Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
76.09% |
140 / 184 |
|
88.00% |
22 / 25 |
CRAP | |
0.00% |
0 / 1 |
| SignerElementsService | |
76.09% |
140 / 184 |
|
88.00% |
22 / 25 |
114.56 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getUserElementByNodeId | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
6 | |||
| getUserElements | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
12 | |||
| signatureFileExists | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| getSessionStorageId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getElementsFromSession | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| getElementFromSessionByNodeId | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| getElementsFromSessionAsArray | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
| saveVisibleElements | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| saveVisibleElement | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| updateFileOfVisibleElement | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| updateDataOfVisibleElement | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| saveFileOfVisibleElementUsingUser | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| saveFileOfVisibleElementUsingSession | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| updateFileOfVisibleElementUsingSession | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| createFileOfVisibleElementUsingSession | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| createSessionElement | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
| getSessionElementType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| replaceSessionElement | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| insertVisibleElement | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| getFileRaw | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
7 | |||
| deleteSignatureElement | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| deleteSignatureElementFromSession | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| isSignElementsAvailable | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| canCreateSignature | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| 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\Service; |
| 10 | |
| 11 | use OCA\Libresign\Db\UserElement; |
| 12 | use OCA\Libresign\Db\UserElementMapper; |
| 13 | use OCA\Libresign\ResponseDefinitions; |
| 14 | use OCA\Libresign\Service\Validation\FileInputValidator; |
| 15 | use OCP\AppFramework\Db\DoesNotExistException; |
| 16 | use OCP\AppFramework\Utility\ITimeFactory; |
| 17 | use OCP\Files\File; |
| 18 | use OCP\Files\Folder; |
| 19 | use OCP\Files\NotFoundException; |
| 20 | use OCP\Http\Client\IClientService; |
| 21 | use OCP\IL10N; |
| 22 | use OCP\IURLGenerator; |
| 23 | use OCP\IUser; |
| 24 | use Sabre\DAV\UUIDUtil; |
| 25 | |
| 26 | /** |
| 27 | * @psalm-import-type LibresignUserElement from ResponseDefinitions |
| 28 | */ |
| 29 | class 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 | } |