Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
31.98% covered (danger)
31.98%
55 / 172
25.00% covered (danger)
25.00%
2 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignatureElementsController
31.98% covered (danger)
31.98%
55 / 172
25.00% covered (danger)
25.00%
2 / 8
397.86
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
 createSignatureElement
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 getSignatureElements
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 useSavedSignatureElements
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
8
 previewSignatureElement
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 getSignatureElement
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 patchSignatureElement
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
42
 deleteSignatureElement
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
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\Controller;
10
11use OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Db\SignRequest;
13use OCA\Libresign\Exception\LibresignException;
14use OCA\Libresign\Middleware\Attribute\RequireSignRequestUuid;
15use OCA\Libresign\Service\AccountService;
16use OCA\Libresign\Service\EmailSignerElementsService;
17use OCA\Libresign\Service\IdentifyMethod\AbstractIdentifyMethod;
18use OCA\Libresign\Service\SessionService;
19use OCA\Libresign\Service\SignatureTextService;
20use OCA\Libresign\Service\SignerElementsService;
21use OCA\Libresign\Service\SignFileService;
22use OCA\Libresign\Service\Validation\FileInputValidator;
23use OCA\Libresign\Service\Validation\VisibleElementValidator;
24use OCP\AppFramework\Db\DoesNotExistException;
25use OCP\AppFramework\Http;
26use OCP\AppFramework\Http\Attribute\AnonRateLimit;
27use OCP\AppFramework\Http\Attribute\ApiRoute;
28use OCP\AppFramework\Http\Attribute\BruteForceProtection;
29use OCP\AppFramework\Http\Attribute\NoAdminRequired;
30use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
31use OCP\AppFramework\Http\Attribute\PublicPage;
32use OCP\AppFramework\Http\Attribute\RequestHeader;
33use OCP\AppFramework\Http\DataResponse;
34use OCP\AppFramework\Http\FileDisplayResponse;
35use OCP\Files\SimpleFS\InMemoryFile;
36use OCP\IL10N;
37use OCP\IPreview;
38use OCP\IRequest;
39use OCP\IURLGenerator;
40use OCP\IUser;
41use OCP\IUserSession;
42use OCP\Preview\IMimeIconProvider;
43use Psr\Log\LoggerInterface;
44
45/**
46 * @psalm-import-type LibresignMessageResponse from \OCA\Libresign\ResponseDefinitions
47 * @psalm-import-type LibresignUserElement from \OCA\Libresign\ResponseDefinitions
48 * @psalm-import-type LibresignUserElementsMessageResponse from \OCA\Libresign\ResponseDefinitions
49 * @psalm-import-type LibresignUserElementsResponse from \OCA\Libresign\ResponseDefinitions
50 */
51class SignatureElementsController extends AEnvironmentAwareController implements ISignatureUuid {
52    use LibresignTrait;
53    public function __construct(
54        IRequest $request,
55        protected IL10N $l10n,
56        private AccountService $accountService,
57        private SignerElementsService $signerElementsService,
58        protected SignatureTextService $signatureTextService,
59        protected IUserSession $userSession,
60        protected SessionService $sessionService,
61        protected SignFileService $signFileService,
62        private IPreview $preview,
63        protected IMimeIconProvider $mimeIconProvider,
64        protected IURLGenerator $urlGenerator,
65        private VisibleElementValidator $visibleElementValidator,
66        private EmailSignerElementsService $emailSignerElementsService,
67        private LoggerInterface $logger,
68    ) {
69        parent::__construct(Application::APP_ID, $request);
70    }
71
72    /**
73     * Create signature element
74     *
75     * @param array<string, mixed> $elements Element object
76     * @return DataResponse<Http::STATUS_OK, LibresignUserElementsMessageResponse, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignMessageResponse, array{}>
77     *
78     * 200: OK
79     * 422: Invalid data
80     */
81    #[NoAdminRequired]
82    #[NoCSRFRequired]
83    #[PublicPage]
84    #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)]
85    #[RequireSignRequestUuid(skipIfAuthenticated: true)]
86    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/signature/elements', requirements: ['apiVersion' => '(v1)'])]
87    public function createSignatureElement(array $elements): DataResponse {
88        try {
89            $this->visibleElementValidator->validateVisibleElements($elements, FileInputValidator::TYPE_VISIBLE_ELEMENT_USER);
90            $this->accountService->saveVisibleElements(
91                elements: $elements,
92                sessionId: $this->sessionService->getSessionId(),
93                user: $this->userSession->getUser(),
94            );
95        } catch (\Throwable $th) {
96            return new DataResponse(
97                [
98                    'message' => $th->getMessage(),
99                ],
100                Http::STATUS_UNPROCESSABLE_ENTITY
101            );
102        }
103        if (count($elements) === 1) {
104            // TRANSLATORS Success message shown after creating one visible signature element (signature image, initials, or text) on a document.
105            $message = $this->l10n->t('Element created with success');
106        } else {
107            // TRANSLATORS Success message shown after creating multiple visible signature elements on a document in one request.
108            $message = $this->l10n->t('Elements created with success');
109        }
110        return new DataResponse(
111            [
112                'message' => $message,
113                'elements'
114                    => (
115                        $this->userSession->getUser() instanceof IUser
116                        ? $this->signerElementsService->getUserElements($this->userSession->getUser()->getUID())
117                        : $this->signerElementsService->getElementsFromSessionAsArray()
118                    ),
119            ],
120            Http::STATUS_OK
121        );
122    }
123
124    /**
125     * Get signature elements
126     *
127     * @return DataResponse<Http::STATUS_OK, LibresignUserElementsResponse, array{}>|DataResponse<Http::STATUS_NOT_FOUND, LibresignMessageResponse, array{}>
128     *
129     * 200: OK
130     * 404: Invalid data
131     */
132    #[NoAdminRequired]
133    #[NoCSRFRequired]
134    #[PublicPage]
135    #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)]
136    #[RequireSignRequestUuid(skipIfAuthenticated: true)]
137    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/signature/elements', requirements: ['apiVersion' => '(v1)'])]
138    public function getSignatureElements(): DataResponse {
139        $userId = $this->userSession->getUser()?->getUID();
140        try {
141            $elements = (
142                $userId
143                ? $this->signerElementsService->getUserElements($userId)
144                : $this->signerElementsService->getElementsFromSessionAsArray()
145            );
146            return new DataResponse(
147                [
148                    'elements' => $elements,
149                ],
150                Http::STATUS_OK
151            );
152        } catch (\Throwable) {
153            return new DataResponse(
154                [
155                    // TRANSLATORS Error shown when requested visible signature elements cannot be found for the document.
156                    'message' => $this->l10n->t('Elements not found')
157                ],
158                Http::STATUS_NOT_FOUND
159            );
160        }
161    }
162
163    /**
164     * Use the saved signature elements of a signer identified by email
165     *
166     * The saved elements are copied into the current signing session only
167     * after the signer satisfies the confirm method configured for the Email
168     * identification factor.
169     *
170     * @param string $token Verification code, required when the confirm method is the email token
171     * @return DataResponse<Http::STATUS_OK, LibresignUserElementsResponse, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignMessageResponse, array{}>
172     *
173     * 200: OK
174     * 422: The saved elements cannot be used
175     */
176    #[NoAdminRequired]
177    #[NoCSRFRequired]
178    #[PublicPage]
179    #[AnonRateLimit(limit: 10, period: 60)]
180    #[BruteForceProtection(action: 'libresign_email_signer_elements')]
181    #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)]
182    #[RequireSignRequestUuid]
183    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/signature/elements/saved', requirements: ['apiVersion' => '(v1)'])]
184    public function useSavedSignatureElements(string $token = ''): DataResponse {
185        $signRequest = $this->getSignRequestEntity();
186        if ($this->userSession->getUser() instanceof IUser || !$signRequest instanceof SignRequest) {
187            return new DataResponse(
188                [
189                    // TRANSLATORS Error shown when a signer tries to reuse a saved signature on a request that was not sent by email.
190                    'message' => $this->l10n->t('Saved signatures are only available to signers identified by email'),
191                ],
192                Http::STATUS_UNPROCESSABLE_ENTITY
193            );
194        }
195        try {
196            $elements = $this->emailSignerElementsService->unlock($signRequest, $token);
197        } catch (LibresignException $e) {
198            $message = $e->getMessage();
199            $decoded = json_decode($message, true);
200            if (is_array($decoded) && isset($decoded['errors'][0]['message'])) {
201                $message = (string)$decoded['errors'][0]['message'];
202            }
203            $response = new DataResponse(
204                [
205                    'message' => $message,
206                ],
207                Http::STATUS_UNPROCESSABLE_ENTITY
208            );
209            if ($e->getCode() === AbstractIdentifyMethod::CODE_INVALID_TOKEN) {
210                $response->throttle();
211            }
212            return $response;
213        } catch (\Exception $e) {
214            // Unexpected failures must not be reported as invalid credentials.
215            $this->logger->error('Unable to load saved email signature elements', ['exception' => $e]);
216            throw $e;
217        }
218        return new DataResponse(
219            [
220                'elements' => $elements,
221            ],
222            Http::STATUS_OK
223        );
224    }
225
226    /**
227     * Get preview of signature elements of
228     *
229     * @param int $nodeId Node id of a Nextcloud file
230     * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_NOT_FOUND, array{}, array{}>
231     *
232     * 200: OK
233     * 404: Invalid data
234     */
235    #[NoAdminRequired]
236    #[PublicPage]
237    #[NoCSRFRequired]
238    #[RequireSignRequestUuid(skipIfAuthenticated: true)]
239    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/signature/elements/preview/{nodeId}', requirements: ['apiVersion' => '(v1)'])]
240    public function previewSignatureElement(int $nodeId) {
241        try {
242            $node = $this->userSession->getUser() instanceof IUser
243                ? $this->accountService->getFileByNodeId($nodeId)
244                : $this->signerElementsService->getElementFromSessionByNodeId($nodeId);
245            if ($this->preview->isAvailable($node)) {
246                $preview = $this->preview->getPreview(
247                    file: $node,
248                    width: (int)$this->signatureTextService->getSignatureWidth(),
249                    height: (int)$this->signatureTextService->getSignatureHeight(),
250                );
251            } else {
252                // When the preview is disabled, use the icon image of mimetype
253                // as fallback
254                $url = $this->mimeIconProvider->getMimeIconUrl($node->getMimeType());
255                $baseUrl = $this->urlGenerator->getBaseUrl();
256                if (!str_starts_with((string)$url, $baseUrl)) {
257                    throw new DoesNotExistException('Preview disabled');
258                }
259                $path = \OC::$SERVERROOT . str_replace($baseUrl, '', $url);
260                if (!file_exists($path)) {
261                    throw new DoesNotExistException('Preview disabled');
262                }
263                $extension = pathinfo($path, PATHINFO_EXTENSION);
264                $preview = new InMemoryFile(implode('.', ['signature', $extension]), file_get_contents($path));
265            }
266        } catch (DoesNotExistException) {
267            return new DataResponse([], Http::STATUS_NOT_FOUND);
268        }
269        $response = new FileDisplayResponse($preview, Http::STATUS_OK, [
270            'Content-Type' => $preview->getMimeType(),
271        ]);
272        return $response;
273    }
274
275    /**
276     * Get signature element of signer
277     *
278     * @param int $nodeId Node id of a Nextcloud file
279     * @return DataResponse<Http::STATUS_OK, LibresignUserElement, array{}>|DataResponse<Http::STATUS_NOT_FOUND, LibresignMessageResponse, array{}>
280     *
281     * 200: OK
282     * 404: Invalid data
283     */
284    #[NoAdminRequired]
285    #[NoCSRFRequired]
286    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/signature/elements/{nodeId}', requirements: ['apiVersion' => '(v1)'])]
287    public function getSignatureElement(int $nodeId): DataResponse {
288        $userId = $this->userSession->getUser()->getUID();
289        try {
290            return new DataResponse(
291                $this->signerElementsService->getUserElementByNodeId($userId, $nodeId),
292                Http::STATUS_OK
293            );
294        } catch (\Throwable) {
295            return new DataResponse(
296                [
297                    // TRANSLATORS Error shown when a visible signature element cannot be found for update or inspection.
298                    'message' => $this->l10n->t('Element not found')
299                ],
300                Http::STATUS_NOT_FOUND
301            );
302        }
303    }
304
305    /**
306     * Update signature element
307     *
308     * @param int $nodeId Node id of a Nextcloud file
309     * @param string $type The type of signature element
310     * @param array<string, mixed> $file Element object
311     * @return DataResponse<Http::STATUS_OK, LibresignUserElementsMessageResponse, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignMessageResponse, array{}>
312     *
313     * 200: OK
314     * 422: Error
315     */
316    #[NoAdminRequired]
317    #[PublicPage]
318    #[NoCSRFRequired]
319    #[RequireSignRequestUuid(skipIfAuthenticated: true)]
320    #[ApiRoute(verb: 'PATCH', url: '/api/{apiVersion}/signature/elements/{nodeId}', requirements: ['apiVersion' => '(v1)'])]
321    public function patchSignatureElement(int $nodeId, string $type = '', array $file = []): DataResponse {
322        try {
323            $element['nodeId'] = $nodeId;
324            if ($type) {
325                $element['type'] = $type;
326            }
327            if ($file) {
328                $element['file'] = $file;
329            }
330            $this->visibleElementValidator->validateVisibleElement($element, FileInputValidator::TYPE_VISIBLE_ELEMENT_USER);
331            $user = $this->userSession->getUser();
332            if ($user instanceof IUser) {
333                $userElement = $this->signerElementsService->getUserElementByNodeId(
334                    $user->getUID(),
335                    $nodeId,
336                );
337                $element['elementId'] = $userElement['id'];
338            }
339            $this->accountService->saveVisibleElement($element, $this->sessionService->getSessionId(), $user);
340            /** @var LibresignUserElement[] $elements */
341            $elements = (
342                $this->userSession->getUser() instanceof IUser
343                ? $this->signerElementsService->getUserElements($this->userSession->getUser()->getUID())
344                : $this->signerElementsService->getElementsFromSessionAsArray()
345            );
346            return new DataResponse(
347                [
348                    // TRANSLATORS Success message shown after updating the position or content of a visible signature element on the document.
349                    'message' => $this->l10n->t('Element updated with success'),
350                    'elements' => $elements,
351                ],
352                Http::STATUS_OK
353            );
354        } catch (\Throwable $th) {
355            return new DataResponse(
356                [
357                    'message' => $th->getMessage()
358                ],
359                Http::STATUS_UNPROCESSABLE_ENTITY
360            );
361        }
362    }
363
364    /**
365     * Delete signature element
366     *
367     * @param int $nodeId Node id of a Nextcloud file
368     * @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, LibresignMessageResponse, array{}>
369     *
370     * 200: OK
371     * 404: Not found
372     */
373    #[NoAdminRequired]
374    #[NoCSRFRequired]
375    #[PublicPage]
376    #[RequireSignRequestUuid(skipIfAuthenticated: true)]
377    #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/signature/elements/{nodeId}', requirements: ['apiVersion' => '(v1)'])]
378    public function deleteSignatureElement(int $nodeId): DataResponse {
379        try {
380            if (!$this->userSession->getUser() instanceof IUser) {
381                // Keep the session element available for retry when forgetting its copy fails.
382                $this->emailSignerElementsService->forgetSessionElement($nodeId);
383            }
384            $this->accountService->deleteSignatureElement(
385                user: $this->userSession->getUser(),
386                nodeId: $nodeId,
387                sessionId: $this->sessionService->getSessionId(),
388            );
389        } catch (DoesNotExistException|\OCP\Files\NotFoundException) {
390            return new DataResponse(
391                [
392                    // TRANSLATORS Error shown when deleting a visible signature element that cannot be found.
393                    'message' => $this->l10n->t('Element not found')
394                ],
395                Http::STATUS_NOT_FOUND
396            );
397        } catch (\Exception $e) {
398            $this->logger->error('Unable to delete signature element', ['exception' => $e]);
399            throw $e;
400        }
401        return new DataResponse(
402            [
403                // TRANSLATORS Message shown after deleting the stored visible signature element.
404                'message' => $this->l10n->t('Visible element deleted')
405            ],
406            Http::STATUS_OK
407        );
408    }
409}