Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.51% covered (success)
90.51%
124 / 137
63.16% covered (warning)
63.16%
12 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
FolderService
90.51% covered (success)
90.51%
124 / 137
63.16% covered (warning)
63.16%
12 / 19
68.61
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserFolder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReadableNodeById
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getCreatableFolderById
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getUserRootFolder
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getFolder
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 getUnauthenticatedFolder
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFileByNodeId
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
7.05
 getContainerFolder
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 getAppDataContainerFolder
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 ensureFolderPathExists
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getLibreSignDefaultPath
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 getFolderForFile
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getFolderName
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
 getFileByPath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getOrCreateFolderByAbsolutePath
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
8.14
 prepareUserFilesystemForWrite
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use OCA\Libresign\Exception\LibresignException;
12use OCP\AppFramework\Services\IAppConfig;
13use OCP\Files\AppData\IAppDataFactory;
14use OCP\Files\File;
15use OCP\Files\Folder;
16use OCP\Files\IAppData;
17use OCP\Files\IRootFolder;
18use OCP\Files\ISetupManager;
19use OCP\Files\IUserFolder;
20use OCP\Files\Node;
21use OCP\Files\NotFoundException;
22use OCP\Files\NotPermittedException;
23use OCP\IGroupManager;
24use OCP\IL10N;
25use OCP\IUser;
26use OCP\IUserManager;
27use OCP\Lock\LockedException;
28
29class FolderService {
30    protected IAppData $appData;
31    public function __construct(
32        private IRootFolder $root,
33        protected IAppDataFactory $appDataFactory,
34        protected IGroupManager $groupManager,
35        private IAppConfig $appConfig,
36        private IL10N $l10n,
37        private ISetupManager $setupManager,
38        private IUserManager $userManager,
39        private ?string $userId,
40    ) {
41        $this->userId = $userId;
42        $this->appData = $appDataFactory->get('libresign');
43    }
44
45    public function setUserId(?string $userId): void {
46        $this->userId = $userId;
47    }
48
49    public function getUserId(): ?string {
50        return $this->userId;
51    }
52
53    public function getUserFolder(string $userId): IUserFolder {
54        return $this->root->getUserFolder($userId);
55    }
56
57    public function getReadableNodeById(string $userId, int $nodeId): ?Node {
58        foreach ($this->getUserFolder($userId)->getById($nodeId) as $node) {
59            if ($node->isReadable()) {
60                return $node;
61            }
62        }
63        return null;
64    }
65
66    public function getCreatableFolderById(string $userId, int $nodeId): ?Folder {
67        foreach ($this->getUserFolder($userId)->getById($nodeId) as $node) {
68            if ($node instanceof Folder && $node->isCreatable()) {
69                return $node;
70            }
71        }
72        return null;
73    }
74
75    /**
76     * Get the root of the user's visible files, not the LibreSign container.
77     *
78     * @throws LibresignException
79     */
80    public function getUserRootFolder(): IUserFolder {
81        if (!$this->userId) {
82            throw new LibresignException('Invalid user to resolve folder');
83        }
84
85        return $this->getUserFolder($this->userId);
86    }
87
88    /**
89     * Get folder for user and creates it if non-existent
90     *
91     * @psalm-suppress MixedReturnStatement
92     * @throws NotFoundException
93     * @throws NotPermittedException
94     * @throws LockedException
95     */
96    public function getFolder(): Folder {
97        $path = $this->getLibreSignDefaultPath();
98        $containerFolder = $this->getContainerFolder();
99
100        try {
101            return $this->ensureFolderPathExists($containerFolder, $path);
102        } catch (NotFoundException) {
103            return $this->ensureFolderPathExists($this->getAppDataContainerFolder(), $path);
104        }
105    }
106
107    /**
108     * Resolve guest signature storage independently of a document owner's context.
109     */
110    public function getUnauthenticatedFolder(): Folder {
111        $service = clone $this;
112        $service->setUserId(null);
113        return $service->getFolder();
114    }
115
116    /**
117     * @throws NotFoundException
118     */
119    public function getFileByNodeId(int $nodeId): File {
120        // For guests, files are stored in appdata, not in user folder
121        // Skip getUserFolder search for guests to avoid false positives
122        if ($this->getUserId() && !$this->groupManager->isInGroup($this->getUserId(), 'guest_app')) {
123            $file = $this->getReadableNodeById($this->getUserId(), $nodeId);
124            if ($file instanceof File) {
125                return $file;
126            }
127        }
128        $path = $this->getLibreSignDefaultPath();
129        $containerFolder = $this->getContainerFolder();
130        try {
131            /** @var Folder $folder */
132            $folder = $containerFolder->get($path);
133        } catch (NotFoundException) {
134            throw new NotFoundException('Invalid node');
135        }
136        $file = $folder->getFirstNodeById($nodeId);
137        if (!$file instanceof File) {
138            throw new NotFoundException('Invalid node');
139        }
140        return $file;
141    }
142
143    protected function getContainerFolder(): Folder {
144        if ($this->getUserId() && !$this->groupManager->isInGroup($this->getUserId(), 'guest_app')) {
145            $this->prepareUserFilesystemForWrite($this->getUserId());
146            try {
147                $containerFolder = $this->getUserFolder($this->getUserId());
148                if ($containerFolder->isUpdateable()) {
149                    return $containerFolder;
150                }
151            } catch (NotFoundException) {
152                // Users provisioned in tests may not have a home folder yet.
153            }
154        }
155        return $this->getAppDataContainerFolder();
156    }
157
158    private function getAppDataContainerFolder(): Folder {
159        $containerFolder = $this->appData->getFolder('/');
160        $reflection = new \ReflectionClass($containerFolder);
161        $reflectionProperty = $reflection->getProperty('folder');
162        return $reflectionProperty->getValue($containerFolder);
163    }
164
165    private function ensureFolderPathExists(Folder $folder, string $path): Folder {
166        $cleanPath = trim($path, '/');
167
168        if ($cleanPath === '') {
169            return $folder;
170        }
171
172        $segments = array_filter(explode('/', $cleanPath), static fn (string $segment): bool => $segment !== '');
173        $currentFolder = $folder;
174
175        foreach ($segments as $segment) {
176            $currentFolder = $currentFolder->getOrCreateFolder($segment);
177        }
178
179        return $currentFolder;
180    }
181
182    private function getLibreSignDefaultPath(): string {
183        if (!$this->userId) {
184            return 'unauthenticated';
185        }
186        // TODO: retrieve guest group name from app once exposed
187        if ($this->groupManager->isInGroup($this->getUserId(), 'guest_app')) {
188            return 'guest_app/' . $this->getUserId();
189        }
190        $path = $this->appConfig->getUserValue($this->userId, 'folder');
191
192        if (empty($path)) {
193            $defaultFolder = $this->appConfig->getAppValueString('default_user_folder', 'LibreSign');
194            $path = '/' . $defaultFolder;
195            $this->appConfig->setUserValue($this->userId, 'folder', $path);
196        }
197
198        return $path;
199    }
200
201    /**
202     * Get or create the folder where a file should be stored
203     *
204     * @param array $data Must contain 'settings' and optionally 'name', 'userManager'
205     * @param mixed $identifier User or string identifier
206     * @return Folder The folder where files should be created
207     * @throws LibresignException
208     */
209    public function getFolderForFile(array $data, $identifier): Folder {
210        $userFolder = $this->getFolder();
211
212        if (isset($data['settings']['envelopeFolderId'])) {
213            foreach ($userFolder->getById($data['settings']['envelopeFolderId']) as $node) {
214                if ($node instanceof Folder && $node->isCreatable()) {
215                    return $node;
216                }
217            }
218
219            // TRANSLATORS Error shown when the Nextcloud folder that stores envelope documents cannot be found or is not writable.
220            throw new LibresignException($this->l10n->t('Envelope folder not found'));
221        }
222
223        $folderName = $this->getFolderName($data, $identifier);
224        return $userFolder->newFolder($folderName);
225    }
226
227    /**
228     * @param array{settings: array, name: string} $data
229     * @param IUser $owner
230     */
231    public function getFolderName(array $data, $identifier): string {
232        if (isset($data['settings']['folderName'])) {
233            return $data['settings']['folderName'];
234        }
235
236        if (!isset($data['settings']['folderPatterns'])) {
237            $data['settings']['separator'] = '_';
238            $data['settings']['folderPatterns'][] = [
239                'name' => 'date',
240                'setting' => 'Y-m-d\TH-i-s-u'
241            ];
242            $data['settings']['folderPatterns'][] = [
243                'name' => 'name'
244            ];
245            $data['settings']['folderPatterns'][] = [
246                'name' => 'userId'
247            ];
248        }
249        $folderName = [];
250        foreach ($data['settings']['folderPatterns'] as $pattern) {
251            switch ($pattern['name']) {
252                case 'date':
253                    $folderName[] = (new \DateTime('now', new \DateTimeZone('UTC')))->format($pattern['setting']);
254                    break;
255                case 'name':
256                    if (!empty($data['name'])) {
257                        $folderName[] = $data['name'];
258                    }
259                    break;
260                case 'userId':
261                    if ($identifier instanceof \OCP\IUser) {
262                        $folderName[] = $identifier->getUID();
263                    } elseif (!empty($identifier) && is_string($identifier)) {
264                        $folderName[] = $identifier;
265                    }
266                    break;
267            }
268        }
269        return implode($data['settings']['separator'], $folderName);
270    }
271
272    public function getFileByPath(string $path, ?string $userId = null): Node {
273        $userFolder = $userId !== null ? $this->getUserFolder($userId) : $this->getUserRootFolder();
274        try {
275            return $userFolder->get($path);
276        } catch (NotFoundException) {
277            // TRANSLATORS Error shown when LibreSign cannot validate the uploaded or referenced document for signing.
278            throw new LibresignException($this->l10n->t('Invalid data to validate file'), 404);
279        }
280    }
281
282    /**
283     * Ensure a folder exists at a given absolute user path, creating missing segments.
284     * If the final folder already exists, it must be empty.
285     *
286     * @throws LibresignException
287     */
288    public function getOrCreateFolderByAbsolutePath(string $path): Folder {
289        if (!$this->userId) {
290            throw new LibresignException('Invalid user to create envelope folder');
291        }
292
293        $cleanPath = ltrim($path, '/');
294        $this->prepareUserFilesystemForWrite($this->userId);
295        $userFolder = $this->getUserFolder($this->userId);
296
297        if ($cleanPath === '') {
298            return $userFolder;
299        }
300
301        $segments = array_filter(explode('/', $cleanPath), static fn (string $segment) => $segment !== '');
302        $folder = $userFolder;
303        $isLastSegment = false;
304
305        foreach ($segments as $index => $segment) {
306            $isLastSegment = ($index === count($segments) - 1);
307
308            try {
309                $node = $folder->get($segment);
310                if (!$node instanceof Folder) {
311                    throw new LibresignException('Invalid folder path');
312                }
313                $folder = $node;
314
315                if ($isLastSegment) {
316                    $contents = $folder->getDirectoryListing();
317                    if (count($contents) > 0) {
318                        // TRANSLATORS Error shown when creating a folder for LibreSign documents and the path already exists with content. %s is the folder path.
319                        throw new LibresignException($this->l10n->t('Folder already exists and is not empty: %s', [$path]));
320                    }
321                }
322            } catch (NotFoundException) {
323                $folder = $folder->newFolder($segment);
324            }
325        }
326
327        return $folder;
328    }
329
330    private function prepareUserFilesystemForWrite(string $userId): void {
331        $user = $this->userManager->get($userId);
332        if (!$user instanceof IUser || $this->setupManager->isSetupComplete($user)) {
333            return;
334        }
335
336        $this->setupManager->tearDown();
337        $this->setupManager->setupForUser($user);
338    }
339
340}