Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
31.98% |
55 / 172 |
|
25.00% |
2 / 8 |
CRAP | |
0.00% |
0 / 1 |
| SignatureElementsController | |
31.98% |
55 / 172 |
|
25.00% |
2 / 8 |
397.86 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| createSignatureElement | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
20 | |||
| getSignatureElements | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
12 | |||
| useSavedSignatureElements | |
96.88% |
31 / 32 |
|
0.00% |
0 / 1 |
8 | |||
| previewSignatureElement | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
42 | |||
| getSignatureElement | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
| patchSignatureElement | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
42 | |||
| deleteSignatureElement | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | /** |
| 5 | * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors |
| 6 | * SPDX-License-Identifier: AGPL-3.0-or-later |
| 7 | */ |
| 8 | |
| 9 | namespace OCA\Libresign\Controller; |
| 10 | |
| 11 | use OCA\Libresign\AppInfo\Application; |
| 12 | use OCA\Libresign\Db\SignRequest; |
| 13 | use OCA\Libresign\Exception\LibresignException; |
| 14 | use OCA\Libresign\Middleware\Attribute\RequireSignRequestUuid; |
| 15 | use OCA\Libresign\Service\AccountService; |
| 16 | use OCA\Libresign\Service\EmailSignerElementsService; |
| 17 | use OCA\Libresign\Service\IdentifyMethod\AbstractIdentifyMethod; |
| 18 | use OCA\Libresign\Service\SessionService; |
| 19 | use OCA\Libresign\Service\SignatureTextService; |
| 20 | use OCA\Libresign\Service\SignerElementsService; |
| 21 | use OCA\Libresign\Service\SignFileService; |
| 22 | use OCA\Libresign\Service\Validation\FileInputValidator; |
| 23 | use OCA\Libresign\Service\Validation\VisibleElementValidator; |
| 24 | use OCP\AppFramework\Db\DoesNotExistException; |
| 25 | use OCP\AppFramework\Http; |
| 26 | use OCP\AppFramework\Http\Attribute\AnonRateLimit; |
| 27 | use OCP\AppFramework\Http\Attribute\ApiRoute; |
| 28 | use OCP\AppFramework\Http\Attribute\BruteForceProtection; |
| 29 | use OCP\AppFramework\Http\Attribute\NoAdminRequired; |
| 30 | use OCP\AppFramework\Http\Attribute\NoCSRFRequired; |
| 31 | use OCP\AppFramework\Http\Attribute\PublicPage; |
| 32 | use OCP\AppFramework\Http\Attribute\RequestHeader; |
| 33 | use OCP\AppFramework\Http\DataResponse; |
| 34 | use OCP\AppFramework\Http\FileDisplayResponse; |
| 35 | use OCP\Files\SimpleFS\InMemoryFile; |
| 36 | use OCP\IL10N; |
| 37 | use OCP\IPreview; |
| 38 | use OCP\IRequest; |
| 39 | use OCP\IURLGenerator; |
| 40 | use OCP\IUser; |
| 41 | use OCP\IUserSession; |
| 42 | use OCP\Preview\IMimeIconProvider; |
| 43 | use 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 | */ |
| 51 | class 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 | } |