Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.09% covered (warning)
76.09%
140 / 184
88.00% covered (warning)
88.00%
22 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignerElementsService
76.09% covered (warning)
76.09%
140 / 184
88.00% covered (warning)
88.00%
22 / 25
114.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserElementByNodeId
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getUserElements
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 signatureFileExists
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getSessionStorageId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getElementsFromSession
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getElementFromSessionByNodeId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getElementsFromSessionAsArray
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 saveVisibleElements
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 saveVisibleElement
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 updateFileOfVisibleElement
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 updateDataOfVisibleElement
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 saveFileOfVisibleElementUsingUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 saveFileOfVisibleElementUsingSession
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 updateFileOfVisibleElementUsingSession
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 createFileOfVisibleElementUsingSession
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createSessionElement
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getSessionElementType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 replaceSessionElement
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 insertVisibleElement
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getFileRaw
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 deleteSignatureElement
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 deleteSignatureElementFromSession
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 isSignElementsAvailable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 canCreateSignature
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
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\Db\UserElement;
12use OCA\Libresign\Db\UserElementMapper;
13use OCA\Libresign\ResponseDefinitions;
14use OCA\Libresign\Service\Validation\FileInputValidator;
15use OCP\AppFramework\Db\DoesNotExistException;
16use OCP\AppFramework\Utility\ITimeFactory;
17use OCP\Files\File;
18use OCP\Files\Folder;
19use OCP\Files\NotFoundException;
20use OCP\Http\Client\IClientService;
21use OCP\IL10N;
22use OCP\IURLGenerator;
23use OCP\IUser;
24use Sabre\DAV\UUIDUtil;
25
26/**
27 * @psalm-import-type LibresignUserElement from ResponseDefinitions
28 */
29class SignerElementsService {
30    public const RENDER_MODE_DESCRIPTION_ONLY = 'DESCRIPTION_ONLY';
31    public const RENDER_MODE_SIGNAME_AND_DESCRIPTION = 'SIGNAME_AND_DESCRIPTION';
32    public const RENDER_MODE_GRAPHIC_AND_DESCRIPTION = 'GRAPHIC_AND_DESCRIPTION';
33    public const RENDER_MODE_GRAPHIC_ONLY = 'GRAPHIC_ONLY';
34    public const RENDER_MODE_DEFAULT = 'GRAPHIC_AND_DESCRIPTION';
35    public function __construct(
36        private FolderService $folderService,
37        private SessionService $sessionService,
38        private IURLGenerator $urlGenerator,
39        private UserElementMapper $userElementMapper,
40        private SignatureBackgroundService $signatureBackgroundService,
41        private SignatureTextService $signatureTextService,
42        private FileInputValidator $fileInputValidator,
43        private IClientService $clientService,
44        private ITimeFactory $timeFactory,
45        private IL10N $l10n,
46    ) {
47    }
48
49    /**
50     * @return LibresignUserElement
51     */
52    public function getUserElementByNodeId(string $userId, int $nodeId): array {
53        $element = $this->userElementMapper->findOne(['node_id' => $nodeId, 'user_id' => $userId]);
54        $exists = $this->signatureFileExists($element);
55        if (!$exists) {
56            throw new NotFoundException();
57        }
58        return [
59            'id' => $element->getId(),
60            'type' => $element->getType(),
61            'file' => [
62                'url' => $this->urlGenerator->linkToRoute('ocs.libresign.SignatureElements.previewSignatureElement', [
63                    'apiVersion' => 'v1',
64                    'nodeId' => $element->getNodeId(),
65                ]),
66                'nodeId' => $element->getNodeId()
67            ],
68            'userId' => $element->getUserId(),
69            'starred' => (bool)$element->getStarred(),
70            'createdAt' => $element->getCreatedAt()->format('Y-m-d H:i:s'),
71        ];
72    }
73
74    /**
75     * @return LibresignUserElement[]
76     */
77    public function getUserElements(string $userId): array {
78        $elements = $this->userElementMapper->findMany(['user_id' => $userId]);
79        $return = [];
80        foreach ($elements as $element) {
81            $exists = $this->signatureFileExists($element);
82            if (!$exists) {
83                continue;
84            }
85            $return[] = [
86                'id' => $element->getId(),
87                'type' => $element->getType(),
88                'file' => [
89                    'url' => $this->urlGenerator->linkToRoute('ocs.libresign.SignatureElements.previewSignatureElement', [
90                        'apiVersion' => 'v1',
91                        'nodeId' => $element->getNodeId(),
92                    ]),
93                    'nodeId' => $element->getNodeId()
94                ],
95                'starred' => (bool)$element->getStarred(),
96                'userId' => $element->getUserId(),
97                'createdAt' => $element->getCreatedAt()->format('Y-m-d H:i:s'),
98            ];
99        }
100        return $return;
101    }
102
103    private function signatureFileExists(UserElement $userElement): bool {
104        try {
105            $this->folderService->getFileByNodeId($userElement->getNodeId());
106        } catch (\Exception) {
107            $this->userElementMapper->delete($userElement);
108            return false;
109        }
110        return true;
111    }
112
113    public function getSessionStorageId(): string {
114        return $this->sessionService->getSessionId();
115    }
116
117    /** The optional storage ID is supplied only by server-created signing jobs. */
118    public function getElementsFromSession(?string $sessionId = null): array {
119        if ($sessionId !== null && !preg_match('/^[a-zA-Z0-9,-]+$/D', $sessionId)) {
120            throw new \InvalidArgumentException('Invalid signature session storage ID');
121        }
122        $folder = $this->folderService->getUnauthenticatedFolder();
123        try {
124            /** @var Folder $signerFolder */
125            $signerFolder = $folder->get($sessionId ?? $this->getSessionStorageId());
126        } catch (NotFoundException) {
127            return [];
128        }
129        $fileList = $signerFolder->getDirectoryListing();
130        return $fileList;
131    }
132
133    /**
134     * Unauthenticated signers may only reach elements stored in their own session folder
135     *
136     * @throws DoesNotExistException
137     */
138    public function getElementFromSessionByNodeId(int $nodeId): File {
139        foreach ($this->getElementsFromSession() as $element) {
140            if ($element instanceof File && $element->getId() === $nodeId) {
141                return $element;
142            }
143        }
144        // TRANSLATORS Error when a visible signature element linked to the user account cannot be found.
145        throw new DoesNotExistException($this->l10n->t('Element not found'));
146    }
147
148    /**
149     * @return LibresignUserElement[]
150     */
151    public function getElementsFromSessionAsArray(): array {
152        $return = [];
153        $fileList = $this->getElementsFromSession();
154        foreach ($fileList as $fileElement) {
155            [$type, $timestamp] = explode('_', pathinfo((string)$fileElement->getName(), PATHINFO_FILENAME));
156            $return[] = [
157                'type' => $type,
158                'file' => [
159                    'url' => $this->urlGenerator->linkToRoute('ocs.libresign.SignatureElements.previewSignatureElement', [
160                        'apiVersion' => 'v1',
161                        'nodeId' => $fileElement->getId(),
162                        'mtime' => $fileElement->getMTime(),
163                    ]),
164                    'nodeId' => $fileElement->getId(),
165                ],
166                'starred' => false,
167                'createdAt' => (new \DateTime())->setTimestamp((int)$timestamp)->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'),
168            ];
169        }
170        return $return;
171    }
172
173    public function saveVisibleElements(array $elements, string $sessionId, ?IUser $user): void {
174        foreach ($elements as $element) {
175            $this->saveVisibleElement($element, $sessionId, $user);
176        }
177    }
178
179    public function saveVisibleElement(array $data, string $sessionId, ?IUser $user): void {
180        if (isset($data['elementId'])) {
181            $this->updateFileOfVisibleElement($data);
182            $this->updateDataOfVisibleElement($data);
183        } elseif ($user instanceof IUser) {
184            $file = $this->saveFileOfVisibleElementUsingUser($data, $user);
185            $this->insertVisibleElement($data, $user, $file);
186        } else {
187            $file = $this->saveFileOfVisibleElementUsingSession($data, $sessionId);
188        }
189    }
190
191    private function updateFileOfVisibleElement(array $data): void {
192        if (!isset($data['file'])) {
193            return;
194        }
195        $userElement = $this->userElementMapper->findOne(['id' => $data['elementId']]);
196        $file = $this->folderService->getFileByNodeId($userElement->getNodeId());
197        $file->putContent($this->getFileRaw($data));
198    }
199
200    private function updateDataOfVisibleElement(array $data): void {
201        if (!isset($data['starred'])) {
202            return;
203        }
204        $userElement = $this->userElementMapper->findOne(['id' => $data['elementId']]);
205        $userElement->setStarred($data['starred'] ? 1 : 0);
206        $this->userElementMapper->update($userElement);
207    }
208
209    private function saveFileOfVisibleElementUsingUser(array $data, IUser $user): File {
210        $rootSignatureFolder = $this->folderService->getFolder();
211        $folderName = $this->folderService->getFolderName($data, $user);
212        $folderToFile = $rootSignatureFolder->newFolder($folderName);
213        return $folderToFile->newFile(UUIDUtil::getUUID() . '.png', $this->getFileRaw($data));
214    }
215
216    private function saveFileOfVisibleElementUsingSession(array $data, string $sessionId): File {
217        if (!empty($data['nodeId'])) {
218            return $this->updateFileOfVisibleElementUsingSession($data, $sessionId);
219        }
220        return $this->createFileOfVisibleElementUsingSession($data, $sessionId);
221    }
222
223    private function updateFileOfVisibleElementUsingSession(array $data, string $sessionId): File {
224        $fileList = $this->getElementsFromSession();
225        $element = array_filter($fileList, fn (File $element) => $element->getId() === $data['nodeId']);
226        $element = current($element);
227        if (!$element instanceof File) {
228            // TRANSLATORS Error when loading a LibreSign file for the account flow and the file cannot be found.
229            throw new \Exception($this->l10n->t('File not found'));
230        }
231        $element->putContent($this->getFileRaw($data));
232        return $element;
233    }
234
235    private function createFileOfVisibleElementUsingSession(array $data, string $sessionId): File {
236        return $this->createSessionElement($data['type'], $this->getFileRaw($data), $sessionId);
237    }
238
239    private function createSessionElement(string $type, string $content, string $sessionId): File {
240        $rootSignatureFolder = $this->folderService->getUnauthenticatedFolder();
241        $folderName = $sessionId;
242        $folderToFile = $rootSignatureFolder->getOrCreateFolder($folderName);
243        $filename = implode(
244            '_',
245            [
246                $type,
247                $this->timeFactory->getDateTime()->getTimestamp(),
248            ]
249        ) . '.png';
250        return $folderToFile->newFile($filename, $content);
251    }
252
253    public function getSessionElementType(File $element): string {
254        return explode('_', pathinfo($element->getName(), PATHINFO_FILENAME))[0];
255    }
256
257    /**
258     * Replace the element of a type stored in the current signing session
259     */
260    public function replaceSessionElement(string $type, string $content): void {
261        foreach ($this->getElementsFromSession() as $element) {
262            if ($element instanceof File && $this->getSessionElementType($element) === $type) {
263                $element->delete();
264            }
265        }
266        $this->createSessionElement($type, $content, $this->sessionService->getSessionId());
267    }
268
269    private function insertVisibleElement(array $data, IUser $user, File $file): void {
270        $userElement = new UserElement();
271        $userElement->setType($data['type']);
272        $userElement->setNodeId($file->getId());
273        $userElement->setUserId($user->getUID());
274        $userElement->setStarred(isset($data['starred']) && $data['starred'] ? 1 : 0);
275        $userElement->setCreatedAt($this->timeFactory->getDateTime());
276        $this->userElementMapper->insert($userElement);
277    }
278
279    private function getFileRaw(array $data): string {
280        if (!empty($data['file']['url'])) {
281            if (!filter_var($data['file']['url'], FILTER_VALIDATE_URL)) {
282                // TRANSLATORS Error when a visible signature element is provided via URL and the URL is invalid.
283                throw new \Exception($this->l10n->t('Invalid URL file'));
284            }
285            $response = $this->clientService->newClient()->get($data['file']['url']);
286            $contentType = $response->getHeader('Content-Type');
287            if ($contentType !== 'image/png') {
288                // TRANSLATORS Error when uploading a visible signature or initials image that is not a PNG file.
289                throw new \Exception($this->l10n->t('Visible element file must be png.'));
290            }
291            $content = (string)$response->getBody();
292            if (empty($content)) {
293                // TRANSLATORS Error when uploading a visible signature element file that is empty.
294                throw new \Exception($this->l10n->t('Empty file'));
295            }
296            $this->fileInputValidator->validateContent($content, FileInputValidator::TYPE_VISIBLE_ELEMENT_USER);
297            return $content;
298        }
299        $this->fileInputValidator->validateBase64($data['file']['base64'], FileInputValidator::TYPE_VISIBLE_ELEMENT_USER);
300        $withMime = explode(',', (string)$data['file']['base64']);
301        if (count($withMime) === 2) {
302            $content = base64_decode($withMime[1]);
303        } else {
304            $content = base64_decode((string)$data['file']['base64']);
305        }
306        if (!$content) {
307            return '';
308        }
309        return $content;
310    }
311
312    public function deleteSignatureElement(?IUser $user, string $sessionId, int $nodeId): void {
313        if ($user instanceof IUser) {
314            $element = $this->userElementMapper->findOne([
315                'node_id' => $nodeId,
316                'user_id' => $user->getUID(),
317            ]);
318            $this->userElementMapper->delete($element);
319            try {
320                $file = $this->folderService->getFileByNodeId($element->getNodeId());
321                $file->delete();
322            } catch (\Throwable) {
323            }
324            return;
325        }
326
327        $this->deleteSignatureElementFromSession($sessionId, $nodeId);
328    }
329
330    private function deleteSignatureElementFromSession(string $sessionId, int $nodeId): void {
331        $rootSignatureFolder = $this->folderService->getUnauthenticatedFolder();
332        try {
333            /** @var \OCP\Files\Folder $sessionFolder */
334            $sessionFolder = $rootSignatureFolder->get($sessionId);
335        } catch (NotFoundException) {
336            // TRANSLATORS Error when a visible signature element linked to the user account cannot be found.
337            throw new DoesNotExistException($this->l10n->t('Element not found'));
338        }
339
340        $element = $sessionFolder->getFirstNodeById($nodeId);
341        if (!$element instanceof File) {
342            // TRANSLATORS Error when a visible signature element linked to the user account cannot be found.
343            throw new DoesNotExistException($this->l10n->t('Element not found'));
344        }
345        $element->delete();
346
347        // Clean up empty session folder
348        if (count($sessionFolder->getDirectoryListing()) === 0) {
349            $sessionFolder->delete();
350        }
351    }
352
353    public function isSignElementsAvailable(): bool {
354        return $this->signatureBackgroundService->isEnabled()
355            || $this->signatureTextService->isEnabled()
356            || $this->signatureTextService->getRenderMode() !== self::RENDER_MODE_DESCRIPTION_ONLY;
357    }
358
359    public function canCreateSignature(): bool {
360        return !in_array(
361            $this->signatureTextService->getRenderMode(),
362            [
363                self::RENDER_MODE_DESCRIPTION_ONLY,
364                self::RENDER_MODE_SIGNAME_AND_DESCRIPTION,
365            ]
366        );
367    }
368}