• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LibreSign / libresign / 21803190065

08 Feb 2026 06:31PM UTC coverage: 47.755%. First build
21803190065

Pull #6779

github

web-flow
Merge 728f1f2b7 into e846efc09
Pull Request #6779: feat: external cert metadata matching

46 of 55 new or added lines in 3 files covered. (83.64%)

8284 of 17347 relevant lines covered (47.75%)

5.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

53.82
/lib/Service/SignFileService.php
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 DateTime;
12
use DateTimeInterface;
13
use Exception;
14
use InvalidArgumentException;
15
use OC\AppFramework\Http as AppFrameworkHttp;
16
use OC\User\NoUserException;
17
use OCA\Libresign\AppInfo\Application;
18
use OCA\Libresign\BackgroundJob\SignSingleFileJob;
19
use OCA\Libresign\DataObjects\VisibleElementAssoc;
20
use OCA\Libresign\Db\File as FileEntity;
21
use OCA\Libresign\Db\FileElement;
22
use OCA\Libresign\Db\FileElementMapper;
23
use OCA\Libresign\Db\FileMapper;
24
use OCA\Libresign\Db\IdDocs;
25
use OCA\Libresign\Db\IdDocsMapper;
26
use OCA\Libresign\Db\IdentifyMethod;
27
use OCA\Libresign\Db\IdentifyMethodMapper;
28
use OCA\Libresign\Db\SignRequest as SignRequestEntity;
29
use OCA\Libresign\Db\SignRequestMapper;
30
use OCA\Libresign\Db\UserElementMapper;
31
use OCA\Libresign\Enum\FileStatus;
32
use OCA\Libresign\Events\SignedEventFactory;
33
use OCA\Libresign\Exception\LibresignException;
34
use OCA\Libresign\Handler\DocMdpHandler;
35
use OCA\Libresign\Handler\FooterHandler;
36
use OCA\Libresign\Handler\PdfTk\Pdf;
37
use OCA\Libresign\Handler\SignEngine\Pkcs12Handler;
38
use OCA\Libresign\Handler\SignEngine\SignEngineFactory;
39
use OCA\Libresign\Handler\SignEngine\SignEngineHandler;
40
use OCA\Libresign\Helper\JSActions;
41
use OCA\Libresign\Helper\ValidateHelper;
42
use OCA\Libresign\Service\Envelope\EnvelopeStatusDeterminer;
43
use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
44
use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\IToken;
45
use OCA\Libresign\Service\SignRequest\StatusService;
46
use OCP\AppFramework\Db\DoesNotExistException;
47
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
48
use OCP\AppFramework\Utility\ITimeFactory;
49
use OCP\BackgroundJob\IJobList;
50
use OCP\EventDispatcher\IEventDispatcher;
51
use OCP\Files\File;
52
use OCP\Files\IRootFolder;
53
use OCP\Files\NotPermittedException;
54
use OCP\Http\Client\IClientService;
55
use OCP\IAppConfig;
56
use OCP\IDateTimeZone;
57
use OCP\IL10N;
58
use OCP\ITempManager;
59
use OCP\IURLGenerator;
60
use OCP\IUser;
61
use OCP\IUserSession;
62
use OCP\Security\ICredentialsManager;
63
use OCP\Security\ISecureRandom;
64
use Psr\Log\LoggerInterface;
65
use RuntimeException;
66
use Sabre\DAV\UUIDUtil;
67

68
class SignFileService {
69
        private ?SignRequestEntity $signRequest = null;
70
        private string $password = '';
71
        private ?FileEntity $libreSignFile = null;
72
        /** @var array<int, VisibleElementAssoc> indexed by fileElementId */
73
        private $elements = [];
74
        private array $elementsInput = [];
75
        private bool $signWithoutPassword = false;
76
        private ?string $signatureMethodName = null;
77
        private ?File $fileToSign = null;
78
        private ?File $createdSignedFile = null;
79
        private string $userUniqueIdentifier = '';
80
        private string $friendlyName = '';
81
        private ?IUser $user = null;
82
        private ?SignEngineHandler $engine = null;
83

84
        public function __construct(
85
                protected IL10N $l10n,
86
                private FileMapper $fileMapper,
87
                private SignRequestMapper $signRequestMapper,
88
                private IdDocsMapper $idDocsMapper,
89
                private FooterHandler $footerHandler,
90
                protected FolderService $folderService,
91
                private IClientService $client,
92
                protected LoggerInterface $logger,
93
                private IAppConfig $appConfig,
94
                protected ValidateHelper $validateHelper,
95
                private SignerElementsService $signerElementsService,
96
                private IRootFolder $root,
97
                private IUserSession $userSession,
98
                private IDateTimeZone $dateTimeZone,
99
                private FileElementMapper $fileElementMapper,
100
                private UserElementMapper $userElementMapper,
101
                private IEventDispatcher $eventDispatcher,
102
                protected ISecureRandom $secureRandom,
103
                private IURLGenerator $urlGenerator,
104
                private IdentifyMethodMapper $identifyMethodMapper,
105
                private ITempManager $tempManager,
106
                private SigningCoordinatorService $signingCoordinatorService,
107
                private IdentifyMethodService $identifyMethodService,
108
                private ITimeFactory $timeFactory,
109
                protected SignEngineFactory $signEngineFactory,
110
                private SignedEventFactory $signedEventFactory,
111
                private Pdf $pdf,
112
                private DocMdpHandler $docMdpHandler,
113
                private PdfSignatureDetectionService $pdfSignatureDetectionService,
114
                private SequentialSigningService $sequentialSigningService,
115
                private FileStatusService $fileStatusService,
116
                private StatusService $statusService,
117
                private IJobList $jobList,
118
                private ICredentialsManager $credentialsManager,
119
                private EnvelopeStatusDeterminer $envelopeStatusDeterminer,
120
                private TsaValidationService $tsaValidationService,
121
                private PfxProvider $pfxProvider,
122
        ) {
123
        }
147✔
124

125
        /**
126
         * Can delete sing request
127
         */
128
        public function canDeleteRequestSignature(array $data): void {
129
                if (!empty($data['uuid'])) {
2✔
130
                        $signatures = $this->signRequestMapper->getByFileUuid($data['uuid']);
2✔
131
                } elseif (!empty($data['file']['fileId'])) {
×
132
                        $signatures = $this->signRequestMapper->getByNodeId($data['file']['fileId']);
×
133
                } else {
134
                        throw new \Exception($this->l10n->t('Please provide either UUID or File object'));
×
135
                }
136
                $signed = array_filter($signatures, fn ($s) => $s->getSigned());
2✔
137
                if ($signed) {
2✔
138
                        throw new \Exception($this->l10n->t('Document already signed'));
1✔
139
                }
140
                array_walk($data['users'], function ($user) use ($signatures): void {
1✔
141
                        $exists = array_filter($signatures, function (SignRequestEntity $signRequest) use ($user) {
1✔
142
                                $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($signRequest->getId());
1✔
143
                                if ($identifyMethod->getName() === 'email') {
1✔
144
                                        return $identifyMethod->getEntity()->getIdentifierValue() === $user['email'];
×
145
                                }
146
                                return false;
1✔
147
                        });
1✔
148
                        if (!$exists) {
1✔
149
                                throw new \Exception($this->l10n->t('No signature was requested to %s', $user['email']));
1✔
150
                        }
151
                });
1✔
152
        }
153

154
        public function notifyCallback(File $file): void {
155
                $uri = $this->libreSignFile->getCallback();
1✔
156
                if (!$uri) {
1✔
157
                        $uri = $this->appConfig->getValueString(Application::APP_ID, 'webhook_sign_url');
×
158
                        if (!$uri) {
×
159
                                return;
×
160
                        }
161
                }
162
                $options = [
1✔
163
                        'multipart' => [
1✔
164
                                [
1✔
165
                                        'name' => 'uuid',
1✔
166
                                        'contents' => $this->libreSignFile->getUuid(),
1✔
167
                                ],
1✔
168
                                [
1✔
169
                                        'name' => 'status',
1✔
170
                                        'contents' => $this->libreSignFile->getStatus(),
1✔
171
                                ],
1✔
172
                                [
1✔
173
                                        'name' => 'file',
1✔
174
                                        'contents' => $file->fopen('r'),
1✔
175
                                        'filename' => $file->getName()
1✔
176
                                ]
1✔
177
                        ]
1✔
178
                ];
1✔
179
                $this->client->newClient()->post($uri, $options);
1✔
180
        }
181

182
        /**
183
         * @return static
184
         */
185
        public function setLibreSignFile(FileEntity $libreSignFile): self {
186
                $this->libreSignFile = $libreSignFile;
47✔
187
                return $this;
47✔
188
        }
189

190
        public function setUserUniqueIdentifier(string $identifier): self {
191
                $this->userUniqueIdentifier = $identifier;
8✔
192
                return $this;
8✔
193
        }
194

195
        public function setFriendlyName(string $friendlyName): self {
196
                $this->friendlyName = $friendlyName;
8✔
197
                return $this;
8✔
198
        }
199

200
        /**
201
         * @return static
202
         */
203
        public function setSignRequest(SignRequestEntity $signRequest): self {
204
                $this->signRequest = $signRequest;
79✔
205
                return $this;
79✔
206
        }
207

208
        /**
209
         * @return static
210
         */
211
        public function setSignWithoutPassword(bool $signWithoutPassword = true): self {
212
                $this->signWithoutPassword = $signWithoutPassword;
7✔
213
                return $this;
7✔
214
        }
215

216
        public function setSignatureMethod(?string $signatureMethodName): self {
217
                $this->signatureMethodName = $signatureMethodName;
7✔
218
                return $this;
7✔
219
        }
220

221
        /**
222
         * @return static
223
         */
224
        public function setPassword(?string $password = null): self {
225
                $this->password = $password;
6✔
226
                return $this;
6✔
227
        }
228

229
        public function setCurrentUser(?IUser $user): self {
230
                $this->user = $user;
30✔
231
                return $this;
30✔
232
        }
233

234
        public function prepareForSigning(
235
                FileEntity $libreSignFile,
236
                SignRequestEntity $signRequest,
237
                ?IUser $user,
238
                string $userIdentifier,
239
                string $displayName,
240
                bool $signWithoutPassword,
241
                ?string $password = null,
242
                ?string $signatureMethodName = null,
243
        ): self {
244
                if ($signWithoutPassword) {
6✔
245
                        $this->setSignWithoutPassword();
4✔
246
                } else {
247
                        $this->setPassword($password);
2✔
248
                }
249

250
                return $this
6✔
251
                        ->setLibreSignFile($libreSignFile)
6✔
252
                        ->setSignRequest($signRequest)
6✔
253
                        ->setCurrentUser($user)
6✔
254
                        ->setUserUniqueIdentifier($userIdentifier)
6✔
255
                        ->setFriendlyName($displayName)
6✔
256
                        ->setSignatureMethod($signatureMethodName);
6✔
257
        }
258

259
        public function setVisibleElements(array $list): self {
260
                $this->elementsInput = $list;
36✔
261
                if (!$this->signRequest instanceof SignRequestEntity) {
36✔
262
                        return $this;
×
263
                }
264
                $fileId = $this->signRequest->getFileId();
36✔
265
                $signRequestId = $this->signRequest->getId();
36✔
266

267
                if (empty($list) && ($fileId === null || $signRequestId === null)) {
36✔
268
                        return $this;
20✔
269
                }
270

271
                if ($fileId === null || $signRequestId === null) {
16✔
272
                        throw new LibresignException($this->l10n->t('File not found'));
3✔
273
                }
274

275
                $fileElements = $this->fileElementMapper->getByFileIdAndSignRequestId($fileId, $signRequestId);
13✔
276
                $canCreateSignature = $this->signerElementsService->canCreateSignature();
13✔
277
                $newElements = [];
13✔
278

279
                foreach ($fileElements as $fileElement) {
13✔
280
                        $fileElementId = $fileElement->getId();
11✔
281
                        if (!$canCreateSignature) {
11✔
282
                                $newElements[$fileElementId] = new VisibleElementAssoc($fileElement);
1✔
283
                                continue;
1✔
284
                        }
285
                        $element = $this->array_find($list, fn (array $element): bool => ($element['documentElementId'] ?? '') === $fileElementId);
10✔
286
                        if (!$element) {
10✔
287
                                continue;
×
288
                        }
289
                        $nodeId = $this->getNodeId($element, $fileElement);
10✔
290

291
                        $existing = $this->elements[$fileElementId] ?? null;
8✔
292
                        if ($existing instanceof VisibleElementAssoc && $this->isTempFileValid($existing)) {
8✔
293
                                $newElements[$fileElementId] = $existing;
×
294
                                continue;
×
295
                        }
296

297
                        $newElements[$fileElementId] = $this->bindFileElementWithTempFile($fileElement, $nodeId);
8✔
298
                }
299

300
                $this->elements = $newElements;
6✔
301

302
                return $this;
6✔
303
        }
304

305
        private function isTempFileValid(VisibleElementAssoc $elementAssoc): bool {
306
                $tempFile = $elementAssoc->getTempFile();
×
307
                return $tempFile !== '' && is_file($tempFile);
×
308
        }
309

310
        private function getNodeId(?array $element, FileElement $fileElement): int {
311
                if ($this->isValidElement($element)) {
10✔
312
                        return (int)$element['profileNodeId'];
8✔
313
                }
314

315
                return $this->retrieveUserElement($fileElement);
×
316
        }
317

318
        private function isValidElement(?array $element): bool {
319
                if (is_array($element) && !empty($element['profileNodeId']) && is_int($element['profileNodeId'])) {
10✔
320
                        return true;
8✔
321
                }
322
                $this->logger->error('Invalid data provided for signing file.', ['element' => $element]);
2✔
323
                throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
2✔
324
        }
325

326
        private function retrieveUserElement(FileElement $fileElement): int {
327
                try {
328
                        if (!$this->user instanceof IUser) {
×
329
                                throw new Exception('User not set');
×
330
                        }
331
                        $userElement = $this->userElementMapper->findOne([
×
332
                                'user_id' => $this->user->getUID(),
×
333
                                'type' => $fileElement->getType(),
×
334
                        ]);
×
335
                } catch (MultipleObjectsReturnedException|DoesNotExistException|Exception) {
×
336
                        throw new LibresignException($this->l10n->t('You need to define a visible signature or initials to sign this document.'));
×
337
                }
338
                return $userElement->getNodeId();
×
339
        }
340

341
        private function bindFileElementWithTempFile(FileElement $fileElement, int $nodeId): VisibleElementAssoc {
342
                try {
343
                        $node = $this->getNode($nodeId);
8✔
344
                        if (!$node) {
8✔
345
                                throw new \Exception('Node content is empty or unavailable.');
8✔
346
                        }
347
                } catch (\Throwable) {
4✔
348
                        throw new LibresignException($this->l10n->t('You need to define a visible signature or initials to sign this document.'));
4✔
349
                }
350

351
                $tempFile = $this->tempManager->getTemporaryFile('_' . $nodeId . '.png');
4✔
352
                $content = $node->getContent();
4✔
353
                if (empty($content)) {
4✔
354
                        $this->logger->error('Failed to retrieve content for node.', ['nodeId' => $nodeId, 'fileElement' => $fileElement]);
1✔
355
                        throw new LibresignException($this->l10n->t('You need to define a visible signature or initials to sign this document.'));
1✔
356
                }
357
                file_put_contents($tempFile, $content);
3✔
358
                return new VisibleElementAssoc($fileElement, $tempFile);
3✔
359
        }
360

361
        private function getNode(int $nodeId): ?File {
362
                try {
363
                        return $this->folderService->getFileByNodeId($nodeId);
8✔
364
                } catch (\Throwable) {
4✔
365
                        $filesOfElementes = $this->signerElementsService->getElementsFromSession();
4✔
366
                        return $this->array_find($filesOfElementes, fn ($file) => $file->getId() === $nodeId);
4✔
367
                }
368
        }
369

370
        /**
371
         * Fallback to PHP < 8.4
372
         *
373
         * Reference: https://www.php.net/manual/en/function.array-find.php#130257
374
         *
375
         * @todo remove this after minor PHP version is >= 8.4
376
         * @deprecated This method will be removed once the minimum PHP version is >= 8.4. Use native array_find instead.
377
         */
378
        private function array_find(array $array, callable $callback): mixed {
379
                foreach ($array as $key => $value) {
11✔
380
                        if ($callback($value, $key)) {
11✔
381
                                return $value;
11✔
382
                        }
383
                }
384

385
                return null;
4✔
386
        }
387

388
        public function getVisibleElements(): array {
389
                return $this->elements;
8✔
390
        }
391

392
        public function getJobArgumentsWithoutCredentials(): array {
393
                $args = [];
1✔
394

395
                if (!empty($this->userUniqueIdentifier)) {
1✔
396
                        $args['userUniqueIdentifier'] = $this->userUniqueIdentifier;
1✔
397
                }
398

399
                if (!empty($this->friendlyName)) {
1✔
400
                        $args['friendlyName'] = $this->friendlyName;
1✔
401
                }
402

403
                if (!empty($this->elements)) {
1✔
404
                        $args['visibleElements'] = $this->elements;
×
405
                }
406

407
                if ($this->signRequest instanceof SignRequestEntity && $this->signRequest->getMetadata()) {
1✔
408
                        $args['metadata'] = $this->signRequest->getMetadata();
1✔
409
                }
410

411
                if ($this->user instanceof IUser) {
1✔
412
                        $args['userId'] = $this->user->getUID();
1✔
413
                }
414

415
                return $args;
1✔
416
        }
417

418
        public function validateSigningRequirements(): void {
419
                $this->tsaValidationService->validateConfiguration();
×
420
        }
421

422
        public function sign(): void {
423
                $signRequests = $this->getSignRequestsToSign();
18✔
424

425
                if (empty($signRequests)) {
18✔
426
                        throw new LibresignException('No sign requests found to process');
×
427
                }
428

429
                $this->executeSigningStrategy($signRequests);
18✔
430
        }
431

432
        private function executeSigningStrategy(array $signRequests): ?DateTimeInterface {
433
                if ($this->signingCoordinatorService->shouldUseParallelProcessing(count($signRequests))) {
18✔
434
                        return $this->processParallelSigning($signRequests);
×
435
                }
436
                return $this->signSequentially($signRequests);
18✔
437
        }
438

439
        private function processParallelSigning(array $signRequests): ?DateTimeInterface {
440
                $this->enqueueParallelSigningJobs($signRequests, $this->getJobArgumentsWithoutCredentials());
×
441
                return $this->getLatestSignedDate($signRequests);
×
442
        }
443

444
        private function getLatestSignedDate(array $signRequests): ?DateTimeInterface {
445
                $latestSignedDate = null;
×
446

447
                foreach ($signRequests as $signRequestData) {
×
448
                        try {
449
                                $signRequest = $this->signRequestMapper->getById($signRequestData['signRequest']->getId());
×
450
                                if ($signRequest->getSigned()) {
×
451
                                        $latestSignedDate = $signRequest->getSigned();
×
452
                                }
453
                        } catch (DoesNotExistException) {
×
454
                        }
455
                }
456

457
                return $latestSignedDate;
×
458
        }
459

460
        public function signSingleFile(FileEntity $libreSignFile, SignRequestEntity $signRequest): void {
461
                $previousState = $this->saveCachedState();
×
462
                $this->resetCachedState();
×
463

464
                if ($libreSignFile->getSignedHash()) {
×
465
                        $this->restoreCachedState($previousState);
×
466
                        return;
×
467
                }
468

469
                $previousLibreSignFile = $this->libreSignFile;
×
470
                $previousSignRequest = $this->signRequest;
×
471
                $this->libreSignFile = $libreSignFile;
×
472
                $this->signRequest = $signRequest;
×
473
                $this->setVisibleElements($this->elementsInput);
×
474

475
                try {
476
                        $this->validateDocMdpAllowsSignatures();
×
477

478
                        try {
479
                                $signedFile = $this->getEngine()->sign();
×
480
                        } catch (LibresignException|Exception $e) {
×
481
                                $this->cleanupUnsignedSignedFile();
×
482
                                $this->recordSignatureAttempt($e);
×
483
                                throw $e;
×
484
                        }
485

486
                        $hash = $this->computeHash($signedFile);
×
487
                        $this->updateSignRequest($hash);
×
488
                        $this->updateLibreSignFile($libreSignFile, $signedFile->getId(), $hash);
×
489

490
                        $this->dispatchSignedEvent();
×
491

492
                        $envelopeContext = $this->getEnvelopeContext();
×
493
                        if ($envelopeContext['envelope'] instanceof FileEntity) {
×
494
                                $this->updateEnvelopeStatus(
×
495
                                        $envelopeContext['envelope'],
×
496
                                        $envelopeContext['envelopeSignRequest'] ?? null,
×
497
                                        $signRequest->getSigned()
×
498
                                );
×
499
                        }
500
                } finally {
501
                        $this->libreSignFile = $previousLibreSignFile;
×
502
                        $this->signRequest = $previousSignRequest;
×
503
                        $this->restoreCachedState($previousState);
×
504
                }
505
        }
506

507
        private function saveCachedState(): array {
508
                return [
×
509
                        'fileToSign' => $this->fileToSign,
×
510
                        'createdSignedFile' => $this->createdSignedFile,
×
511
                        'engine' => $this->engine,
×
512
                ];
×
513
        }
514

515
        private function resetCachedState(): void {
516
                $this->fileToSign = null;
×
517
                $this->createdSignedFile = null;
×
518
                $this->engine = null;
×
519
        }
520

521
        private function restoreCachedState(array $state): void {
522
                $this->fileToSign = $state['fileToSign'];
×
523
                $this->createdSignedFile = $state['createdSignedFile'];
×
524
                $this->engine = $state['engine'];
×
525
        }
526

527
        public function enqueueParallelSigningJobs(array $signRequests, array $jobArguments = []): int {
528

529
                if (empty($signRequests)) {
1✔
530
                        throw new LibresignException('No sign requests found to process');
×
531
                }
532

533
                $enqueued = 0;
1✔
534
                foreach ($signRequests as $signRequestData) {
1✔
535
                        $file = $signRequestData['file'];
1✔
536
                        $signRequest = $signRequestData['signRequest'];
1✔
537

538
                        if ($file->getSignedHash()) {
1✔
539
                                continue;
×
540
                        }
541

542
                        $nodeId = $file->getNodeId();
1✔
543
                        $userId = $file->getUserId() ?? $signRequest->getUserId();
1✔
544

545
                        if ($nodeId === null || !$this->verifyFileExists($userId, $nodeId)) {
1✔
546
                                continue;
×
547
                        }
548

549
                        $this->enqueueSigningJobForFile($signRequest, $file, $jobArguments);
1✔
550
                        $enqueued++;
1✔
551
                }
552

553
                return $enqueued;
1✔
554
        }
555

556
        private function enqueueSigningJobForFile(SignRequestEntity $signRequest, FileEntity $file, array $jobArguments): void {
557
                $args = $jobArguments;
1✔
558
                $args = $this->addCredentialsToJobArgs($args, $signRequest, $file);
1✔
559
                $args = array_merge($args, [
1✔
560
                        'fileId' => $file->getId(),
1✔
561
                        'signRequestId' => $signRequest->getId(),
1✔
562
                        'signRequestUuid' => $signRequest->getUuid(),
1✔
563
                        'userId' => $file->getUserId(),
1✔
564
                        'isExternalSigner' => !str_starts_with($args['userUniqueIdentifier'] ?? '', 'account:'),
1✔
565
                ]);
1✔
566

567
                $this->jobList->add(SignSingleFileJob::class, $args);
1✔
568
        }
569

570
        private function addCredentialsToJobArgs(array $args, SignRequestEntity $signRequest, FileEntity $file): array {
571
                if (!($this->signWithoutPassword || !empty($this->password))) {
1✔
572
                        return $args;
×
573
                }
574

575
                $credentialsId = 'libresign_sign_' . $signRequest->getId() . '_' . $file->getId() . '_' . $this->secureRandom->generate(8, ISecureRandom::CHAR_ALPHANUMERIC);
1✔
576
                $this->credentialsManager->store(
1✔
577
                        $this->user?->getUID() ?? '',
1✔
578
                        $credentialsId,
1✔
579
                        [
1✔
580
                                'signWithoutPassword' => $this->signWithoutPassword,
1✔
581
                                'password' => $this->password,
1✔
582
                                'timestamp' => time(),
1✔
583
                                'expires' => time() + 3600,
1✔
584
                        ]
1✔
585
                );
1✔
586
                $args['credentialsId'] = $credentialsId;
1✔
587

588
                return $args;
1✔
589
        }
590

591
        /**
592
         * @return DateTimeInterface|null Last signed date
593
         */
594
        private function signSequentially(array $signRequests): ?DateTimeInterface {
595
                $envelopeLastSignedDate = null;
18✔
596
                $envelopeContext = $this->getEnvelopeContext();
18✔
597

598
                foreach ($signRequests as $index => $signRequestData) {
18✔
599
                        $this->libreSignFile = $signRequestData['file'];
18✔
600
                        if ($this->libreSignFile->getStatus() === FileStatus::SIGNED->value) {
18✔
601
                                continue;
1✔
602
                        }
603
                        $this->signRequest = $signRequestData['signRequest'];
17✔
604
                        $this->engine = null;
17✔
605
                        $this->setVisibleElements($this->elementsInput);
17✔
606
                        $this->fileToSign = null;
17✔
607

608
                        $this->validateDocMdpAllowsSignatures();
17✔
609

610
                        try {
611
                                $signedFile = $this->getEngine()->sign();
15✔
612
                        } catch (LibresignException|Exception $e) {
×
613
                                $this->cleanupUnsignedSignedFile();
×
614
                                $this->recordSignatureAttempt($e);
×
615

616
                                $isEnvelope = $this->libreSignFile->isEnvelope() || $this->libreSignFile->hasParent();
×
617
                                if (!$isEnvelope) {
×
618
                                        throw $e;
×
619
                                }
620
                                continue;
×
621
                        }
622

623
                        $hash = $this->computeHash($signedFile);
15✔
624
                        $envelopeLastSignedDate = $this->getEngine()->getLastSignedDate();
15✔
625

626
                        $this->updateSignRequest($hash);
15✔
627
                        $this->updateLibreSignFile($this->libreSignFile, $signedFile->getId(), $hash);
15✔
628

629
                        $this->dispatchSignedEvent();
15✔
630
                }
631

632
                if ($envelopeContext['envelope'] instanceof FileEntity) {
16✔
633
                        $this->updateEnvelopeStatus(
×
634
                                $envelopeContext['envelope'],
×
635
                                $envelopeContext['envelopeSignRequest'] ?? null,
×
636
                                $envelopeLastSignedDate
×
637
                        );
×
638
                }
639

640
                return $envelopeLastSignedDate;
16✔
641
        }
642

643
        /**
644
         * @return array Array of sign request data with 'file' => FileEntity, 'signRequest' => SignRequestEntity
645
         */
646
        private function getSignRequestsToSign(): array {
647
                if (!$this->libreSignFile->isEnvelope()
19✔
648
                        && !$this->libreSignFile->hasParent()
19✔
649
                ) {
650
                        return [[
18✔
651
                                'file' => $this->libreSignFile,
18✔
652
                                'signRequest' => $this->signRequest,
18✔
653
                        ]];
18✔
654
                }
655

656
                return $this->buildEnvelopeSignRequests();
1✔
657
        }
658

659
        /**
660
         * @return array Array of sign request data with 'file' => FileEntity, 'signRequest' => SignRequestEntity
661
         */
662
        private function buildEnvelopeSignRequests(): array {
663
                $envelopeId = $this->libreSignFile->isEnvelope()
1✔
664
                        ? $this->libreSignFile->getId()
×
665
                        : $this->libreSignFile->getParentFileId();
1✔
666

667
                $childFiles = $this->fileMapper->getChildrenFiles($envelopeId);
1✔
668
                if (empty($childFiles)) {
1✔
669
                        throw new LibresignException('No files found in envelope');
×
670
                }
671

672
                $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod(
1✔
673
                        $envelopeId,
1✔
674
                        $this->signRequest->getId()
1✔
675
                );
1✔
676

677
                if (empty($childSignRequests)) {
1✔
678
                        throw new LibresignException('No sign requests found for envelope files');
×
679
                }
680

681
                $signRequestsData = [];
1✔
682
                foreach ($childSignRequests as $childSignRequest) {
1✔
683
                        $childFile = $this->array_find(
1✔
684
                                $childFiles,
1✔
685
                                fn (FileEntity $file) => $file->getId() === $childSignRequest->getFileId()
1✔
686
                        );
1✔
687

688
                        if ($childFile) {
1✔
689
                                $signRequestsData[] = [
1✔
690
                                        'file' => $childFile,
1✔
691
                                        'signRequest' => $childSignRequest,
1✔
692
                                ];
1✔
693
                        }
694
                }
695

696
                return $signRequestsData;
1✔
697
        }
698

699
        /**
700
         * @return array Array with 'envelope' => FileEntity or null, 'envelopeSignRequest' => SignRequestEntity or null
701
         */
702
        private function getEnvelopeContext(): array {
703
                $result = [
18✔
704
                        'envelope' => null,
18✔
705
                        'envelopeSignRequest' => null,
18✔
706
                ];
18✔
707

708
                if (!$this->libreSignFile->isEnvelope() && !$this->libreSignFile->hasParent()) {
18✔
709
                        return $result;
18✔
710
                }
711

712
                if ($this->libreSignFile->isEnvelope()) {
×
713
                        $result['envelope'] = $this->libreSignFile;
×
714
                        $result['envelopeSignRequest'] = $this->signRequest;
×
715
                        return $result;
×
716
                }
717

718
                try {
719
                        $envelopeId = $this->libreSignFile->isEnvelope()
×
720
                                ? $this->libreSignFile->getId()
×
721
                                : $this->libreSignFile->getParentFileId();
×
722
                        $result['envelope'] = $this->fileMapper->getById($envelopeId);
×
723
                        $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($this->signRequest->getId());
×
724
                        $result['envelopeSignRequest'] = $this->signRequestMapper->getByIdentifyMethodAndFileId(
×
725
                                $identifyMethod,
×
726
                                $result['envelope']->getId()
×
727
                        );
×
728
                } catch (DoesNotExistException $e) {
×
729
                }
730

731
                return $result;
×
732
        }
733

734
        private function updateEnvelopeStatus(
735
                FileEntity $envelope,
736
                ?SignRequestEntity $envelopeSignRequest = null,
737
                ?DateTimeInterface $signedDate = null,
738
        ): void {
739
                $childFiles = $this->fileMapper->getChildrenFiles($envelope->getId());
1✔
740
                $signRequestsMap = $this->buildSignRequestsMap($childFiles);
1✔
741

742
                $status = $this->envelopeStatusDeterminer->determineStatus($childFiles, $signRequestsMap);
1✔
743
                $envelope->setStatus($status);
1✔
744

745
                $this->handleSignedEnvelopeSignRequest($envelope, $envelopeSignRequest, $signedDate, $status);
1✔
746

747
                $this->updateEnvelopeMetadata($envelope);
1✔
748
                $this->fileMapper->update($envelope);
1✔
749
                $this->updateEntityCacheAfterDbSave($envelope);
1✔
750
        }
751

752
        private function buildSignRequestsMap(array $childFiles): array {
753
                $signRequestsMap = [];
1✔
754
                foreach ($childFiles as $childFile) {
1✔
755
                        $signRequestsMap[$childFile->getId()] = $this->signRequestMapper->getByFileId($childFile->getId());
1✔
756
                }
757
                return $signRequestsMap;
1✔
758
        }
759

760
        private function handleSignedEnvelopeSignRequest(
761
                FileEntity $envelope,
762
                ?SignRequestEntity $envelopeSignRequest,
763
                ?DateTimeInterface $signedDate,
764
                int $status,
765
        ): void {
766
                if (!($envelopeSignRequest instanceof SignRequestEntity)) {
1✔
767
                        return;
×
768
                }
769

770
                $envelopeSignRequest->setSigned($signedDate ?: new DateTime());
1✔
771
                $envelopeSignRequest->setStatusEnum(\OCA\Libresign\Enum\SignRequestStatus::SIGNED);
1✔
772
                $this->signRequestMapper->update($envelopeSignRequest);
1✔
773
                $this->sequentialSigningService
1✔
774
                        ->setFile($envelope)
1✔
775
                        ->releaseNextOrder(
1✔
776
                                $envelopeSignRequest->getFileId(),
1✔
777
                                $envelopeSignRequest->getSigningOrder()
1✔
778
                        );
1✔
779
        }
780

781
        private function updateEnvelopeMetadata(FileEntity $envelope): void {
782
                $meta = $envelope->getMetadata() ?? [];
1✔
783
                $meta['status_changed_at'] = (new DateTime())->format(DateTimeInterface::ATOM);
1✔
784
                $envelope->setMetadata($meta);
1✔
785
        }
786

787
        /**
788
         * @throws LibresignException If the document has DocMDP level 1 (no changes allowed)
789
         */
790
        protected function validateDocMdpAllowsSignatures(): void {
791
                $docmdpLevel = $this->libreSignFile->getDocmdpLevelEnum();
17✔
792

793
                if ($docmdpLevel === \OCA\Libresign\Enum\DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED) {
17✔
794
                        throw new LibresignException(
×
795
                                $this->l10n->t('This document has been certified with no changes allowed. You cannot add more signers to this document.'),
×
796
                                AppFrameworkHttp::STATUS_UNPROCESSABLE_ENTITY
×
797
                        );
×
798
                }
799

800
                if ($docmdpLevel === \OCA\Libresign\Enum\DocMdpLevel::NOT_CERTIFIED) {
17✔
801
                        $resource = $this->getLibreSignFileAsResource();
17✔
802

803
                        try {
804
                                if (!$this->docMdpHandler->allowsAdditionalSignatures($resource)) {
16✔
805
                                        throw new LibresignException(
3✔
806
                                                $this->l10n->t('This document has been certified with no changes allowed. You cannot add more signers to this document.'),
3✔
807
                                                AppFrameworkHttp::STATUS_UNPROCESSABLE_ENTITY
3✔
808
                                        );
3✔
809
                                }
810
                        } finally {
811
                                fclose($resource);
16✔
812
                        }
813
                }
814
        }
815

816
        /**
817
         * @return resource
818
         * @throws LibresignException
819
         */
820
        protected function getLibreSignFileAsResource() {
821
                $files = $this->getNextcloudFiles($this->libreSignFile);
11✔
822
                if (empty($files)) {
10✔
823
                        throw new LibresignException('File not found');
×
824
                }
825
                $fileToSign = current($files);
10✔
826
                $content = $fileToSign->getContent();
10✔
827
                $resource = fopen('php://memory', 'r+');
10✔
828
                if ($resource === false) {
10✔
829
                        throw new LibresignException('Failed to create temporary resource for PDF validation');
×
830
                }
831
                fwrite($resource, $content);
10✔
832
                rewind($resource);
10✔
833
                return $resource;
10✔
834
        }
835

836
        protected function computeHash(File $file): string {
837
                return hash('sha256', $file->getContent());
2✔
838
        }
839

840
        protected function updateSignRequest(string $hash): void {
841
                $lastSignedDate = $this->getEngine()->getLastSignedDate();
13✔
842
                $this->signRequest->setSigned($lastSignedDate);
13✔
843
                $this->signRequest->setSignedHash($hash);
13✔
844
                $this->signRequest->setStatusEnum(\OCA\Libresign\Enum\SignRequestStatus::SIGNED);
13✔
845

846
                $certificateInfo = $this->getEngine()->readCertificate();
13✔
847
                $this->storeCertificateInfoInMetadata($certificateInfo);
13✔
848

849
                $this->signRequestMapper->update($this->signRequest);
13✔
850

851
                $this->sequentialSigningService
13✔
852
                        ->setFile($this->libreSignFile)
13✔
853
                        ->releaseNextOrder(
13✔
854
                                $this->signRequest->getFileId(),
13✔
855
                                $this->signRequest->getSigningOrder()
13✔
856
                        );
13✔
857
        }
858

859
        private function storeCertificateInfoInMetadata(array $certificateInfo): void {
860
                $metadata = $this->signRequest->getMetadata() ?? [];
13✔
861

862
                $certificateData = [];
13✔
863

864
                if (isset($certificateInfo['serialNumber'])) {
13✔
NEW
865
                        $certificateData['serialNumber'] = $certificateInfo['serialNumber'];
×
866
                }
867
                if (isset($certificateInfo['serialNumberHex'])) {
13✔
NEW
868
                        $certificateData['serialNumberHex'] = $certificateInfo['serialNumberHex'];
×
869
                }
870
                if (isset($certificateInfo['hash'])) {
13✔
NEW
871
                        $certificateData['hash'] = $certificateInfo['hash'];
×
872
                }
873
                if (isset($certificateInfo['subject'])) {
13✔
NEW
874
                        $certificateData['subject'] = $certificateInfo['subject'];
×
875
                }
876

877
                if (!empty($certificateData)) {
13✔
NEW
878
                        $metadata['certificate_info'] = $certificateData;
×
NEW
879
                        $this->signRequest->setMetadata($metadata);
×
880
                }
881
        }
882

883
        protected function updateLibreSignFile(FileEntity $libreSignFile, int $nodeId, string $hash): void {
884
                $libreSignFile->setSignedNodeId($nodeId);
13✔
885
                $libreSignFile->setSignedHash($hash);
13✔
886
                $this->setNewStatusIfNecessary($libreSignFile);
13✔
887
                $this->fileStatusService->update($libreSignFile);
13✔
888

889
                if ($libreSignFile->hasParent()) {
13✔
890
                        $this->fileStatusService->propagateStatusToParent($libreSignFile->getParentFileId());
×
891
                }
892
        }
893

894
        protected function dispatchSignedEvent(): void {
895
                $certificateSerialNumber = null;
13✔
896
                if ($this->signWithoutPassword) {
13✔
897
                        try {
898
                                $certificateInfo = $this->getEngine()->readCertificate();
×
899
                                if (isset($certificateInfo['serialNumber']) && is_string($certificateInfo['serialNumber'])) {
×
900
                                        $certificateSerialNumber = $certificateInfo['serialNumber'];
×
901
                                } else {
902
                                        $this->logger->warning('Unable to extract certificate serial number for event payload');
×
903
                                }
904
                        } catch (\Throwable $e) {
×
905
                                $this->logger->error('Failed to get certificate info for event', [
×
906
                                        'exception' => $e,
×
907
                                        'signRequestId' => $this->signRequest->getId()
×
908
                                ]);
×
909
                        }
910
                }
911

912
                $event = $this->signedEventFactory->make(
13✔
913
                        $this->signRequest,
13✔
914
                        $this->libreSignFile,
13✔
915
                        $this->getEngine()->getInputFile(),
13✔
916
                        $this->signWithoutPassword,
13✔
917
                        $certificateSerialNumber,
13✔
918
                );
13✔
919
                $this->eventDispatcher->dispatchTyped($event);
13✔
920
        }
921

922
        protected function identifyEngine(File $file): SignEngineHandler {
923
                return $this->signEngineFactory->resolve($file->getExtension());
10✔
924
        }
925

926
        protected function getSignatureParams(): array {
927
                $certificateData = $this->readCertificate();
15✔
928
                $signatureParams = $this->buildBaseSignatureParams($certificateData);
15✔
929
                $signatureParams = $this->addEmailToSignatureParams($signatureParams, $certificateData);
15✔
930
                $signatureParams = $this->addMetadataToSignatureParams($signatureParams);
15✔
931
                return $signatureParams;
15✔
932
        }
933

934
        private function buildBaseSignatureParams(array $certificateData): array {
935
                return [
15✔
936
                        'DocumentUUID' => $this->libreSignFile?->getUuid(),
15✔
937
                        'IssuerCommonName' => $certificateData['issuer']['CN'] ?? '',
15✔
938
                        'SignerCommonName' => $certificateData['subject']['CN'] ?? '',
15✔
939
                        'LocalSignerTimezone' => $this->dateTimeZone->getTimeZone()->getName(),
15✔
940
                        'LocalSignerSignatureDateTime' => (new DateTime('now', new \DateTimeZone('UTC')))
15✔
941
                                ->format(DateTimeInterface::ATOM)
15✔
942
                ];
15✔
943
        }
944

945
        private function addEmailToSignatureParams(array $signatureParams, array $certificateData): array {
946
                if (isset($certificateData['extensions']['subjectAltName'])) {
15✔
947
                        preg_match('/(?:email:)+(?<email>[^\s,]+)/', $certificateData['extensions']['subjectAltName'], $matches);
6✔
948
                        if ($matches && filter_var($matches['email'], FILTER_VALIDATE_EMAIL)) {
6✔
949
                                $signatureParams['SignerEmail'] = $matches['email'];
4✔
950
                        } elseif (filter_var($certificateData['extensions']['subjectAltName'], FILTER_VALIDATE_EMAIL)) {
2✔
951
                                $signatureParams['SignerEmail'] = $certificateData['extensions']['subjectAltName'];
1✔
952
                        }
953
                }
954
                if (empty($signatureParams['SignerEmail']) && $this->user instanceof IUser) {
15✔
955
                        $signatureParams['SignerEmail'] = $this->user->getEMailAddress();
1✔
956
                }
957
                if (empty($signatureParams['SignerEmail']) && $this->signRequest instanceof SignRequestEntity) {
15✔
958
                        $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($this->signRequest->getId());
9✔
959
                        if ($identifyMethod->getName() === IdentifyMethodService::IDENTIFY_EMAIL) {
9✔
960
                                $signatureParams['SignerEmail'] = $identifyMethod->getEntity()->getIdentifierValue();
1✔
961
                        }
962
                }
963
                return $signatureParams;
15✔
964
        }
965

966
        private function addMetadataToSignatureParams(array $signatureParams): array {
967
                $signRequestMetadata = $this->signRequest->getMetadata();
15✔
968
                if (isset($signRequestMetadata['remote-address'])) {
15✔
969
                        $signatureParams['SignerIP'] = $signRequestMetadata['remote-address'];
2✔
970
                }
971
                if (isset($signRequestMetadata['user-agent'])) {
15✔
972
                        $signatureParams['SignerUserAgent'] = $signRequestMetadata['user-agent'];
2✔
973
                }
974
                return $signatureParams;
15✔
975
        }
976

977
        public function storeUserMetadata(array $metadata = []): self {
978
                $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
18✔
979
                if (!$collectMetadata || !$metadata) {
18✔
980
                        return $this;
7✔
981
                }
982
                $this->signRequest->setMetadata(array_merge(
11✔
983
                        $this->signRequest->getMetadata() ?? [],
11✔
984
                        $metadata,
11✔
985
                ));
11✔
986
                $this->signRequestMapper->update($this->signRequest);
11✔
987
                return $this;
11✔
988
        }
989

990
        /**
991
         * @return SignRequestEntity[]
992
         */
993
        protected function getSigners(): array {
994
                return $this->signRequestMapper->getByFileId($this->signRequest->getFileId());
×
995
        }
996

997
        protected function setNewStatusIfNecessary(FileEntity $libreSignFile): bool {
998
                $newStatus = $this->evaluateStatusFromSigners();
9✔
999

1000
                if ($newStatus === null || $newStatus === $libreSignFile->getStatus()) {
9✔
1001
                        return false;
3✔
1002
                }
1003

1004
                $libreSignFile->setStatus($newStatus);
6✔
1005

1006
                return true;
6✔
1007
        }
1008

1009
        private function updateEntityCacheAfterDbSave(FileEntity $file): void {
1010
                $this->statusService->cacheFileStatus($file);
1✔
1011
        }
1012

1013
        private function evaluateStatusFromSigners(): ?int {
1014
                $signers = $this->getSigners();
9✔
1015

1016
                $total = count($signers);
9✔
1017

1018
                if ($total === 0) {
9✔
1019
                        return null;
1✔
1020
                }
1021

1022
                $totalSigned = count(array_filter($signers, fn ($s) => $s->getSigned() !== null));
8✔
1023

1024
                if ($totalSigned === $total) {
8✔
1025
                        return FileStatus::SIGNED->value;
4✔
1026
                }
1027

1028
                if ($totalSigned > 0) {
4✔
1029
                        return FileStatus::PARTIAL_SIGNED->value;
3✔
1030
                }
1031

1032
                return null;
1✔
1033
        }
1034

1035
        private function getOrGeneratePfxContent(SignEngineHandler $engine): string {
1036
                $result = $this->pfxProvider->getOrGeneratePfx(
13✔
1037
                        $engine,
13✔
1038
                        $this->signWithoutPassword,
13✔
1039
                        $this->signatureMethodName,
13✔
1040
                        $this->userUniqueIdentifier,
13✔
1041
                        $this->friendlyName,
13✔
1042
                        $this->password,
13✔
1043
                );
13✔
1044
                if ($result['password'] !== null) {
13✔
1045
                        $this->setPassword($result['password']);
2✔
1046
                }
1047
                return $result['pfx'];
13✔
1048
        }
1049

1050
        protected function readCertificate(): array {
1051
                return $this->getEngine()
×
1052
                        ->readCertificate();
×
1053
        }
1054

1055
        /**
1056
         * Get file to sign
1057
         *
1058
         * @throws LibresignException
1059
         */
1060
        protected function getFileToSign(): File {
1061
                if ($this->fileToSign instanceof File) {
×
1062
                        return $this->fileToSign;
×
1063
                }
1064

1065
                $userId = $this->libreSignFile->getUserId()
×
1066
                        ?? $this->user?->getUID()
×
1067
                        ?? ($this->signRequest?->getUserId() ?? null);
×
1068
                $nodeId = $this->libreSignFile->getNodeId();
×
1069

1070
                if ($userId === null) {
×
1071
                        throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
×
1072
                }
1073

1074
                try {
1075
                        $originalFile = $this->getNodeByIdUsingUid($userId, $nodeId);
×
1076
                } catch (\Throwable $e) {
×
1077
                        $this->logger->error('[file-access] FAILED to find file - userId={userId} nodeId={nodeId} error={error}', [
×
1078
                                'userId' => $userId,
×
1079
                                'nodeId' => $nodeId,
×
1080
                                'error' => $e->getMessage(),
×
1081
                        ]);
×
1082
                        throw $e;
×
1083
                }
1084

1085
                if ($originalFile->getOwner()->getUID() !== $userId) {
×
1086
                        $originalFile = $this->getNodeByIdUsingUid($originalFile->getOwner()->getUID(), $nodeId);
×
1087
                }
1088
                if ($this->isPdf($originalFile)) {
×
1089
                        $this->fileToSign = $this->getPdfToSign($originalFile);
×
1090
                } else {
1091
                        $this->fileToSign = $originalFile;
×
1092
                }
1093
                return $this->fileToSign;
×
1094
        }
1095

1096
        private function isPdf(File $file): bool {
1097
                return strcasecmp($file->getExtension(), 'pdf') === 0;
×
1098
        }
1099

1100
        protected function getEngine(): SignEngineHandler {
1101
                if (!$this->engine) {
12✔
1102
                        $originalFile = $this->getFileToSign();
12✔
1103
                        $this->engine = $this->identifyEngine($originalFile);
12✔
1104

1105
                        $this->configureEngine();
12✔
1106
                }
1107
                return $this->engine;
12✔
1108
        }
1109

1110
        private function configureEngine(): void {
1111
                $this->engine
12✔
1112
                        ->setInputFile($this->getFileToSign())
12✔
1113
                        ->setCertificate($this->getOrGeneratePfxContent($this->engine))
12✔
1114
                        ->setPassword($this->password);
12✔
1115

1116
                if ($this->engine::class === Pkcs12Handler::class) {
12✔
1117
                        $this->engine
2✔
1118
                                ->setVisibleElements($this->getVisibleElements())
2✔
1119
                                ->setSignatureParams($this->getSignatureParams());
2✔
1120
                }
1121
        }
1122

1123
        public function getLibresignFile(?int $fileId, ?string $signRequestUuid = null): FileEntity {
1124
                try {
1125
                        if ($fileId) {
3✔
1126
                                return $this->fileMapper->getById($fileId);
1✔
1127
                        }
1128

1129
                        if ($signRequestUuid) {
2✔
1130
                                $signRequest = $this->signRequestMapper->getByUuid($signRequestUuid);
2✔
1131
                                return $this->fileMapper->getById($signRequest->getFileId());
2✔
1132
                        }
1133

1134
                        throw new \Exception('Invalid arguments');
×
1135

1136
                } catch (DoesNotExistException) {
1✔
1137
                        throw new LibresignException($this->l10n->t('File not found'), 1);
1✔
1138
                }
1139
        }
1140

1141
        public function renew(SignRequestEntity $signRequest, string $method): void {
1142
                $identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
×
1143
                if (empty($identifyMethods[$method])) {
×
1144
                        throw new LibresignException($this->l10n->t('Invalid identification method'));
×
1145
                }
1146

1147
                $signRequest->setUuid(UUIDUtil::getUUID());
×
1148
                $this->signRequestMapper->update($signRequest);
×
1149

1150
                array_map(function (IIdentifyMethod $identifyMethod): void {
×
1151
                        $entity = $identifyMethod->getEntity();
×
1152
                        $entity->setAttempts($entity->getAttempts() + 1);
×
1153
                        $entity->setLastAttemptDate($this->timeFactory->getDateTime());
×
1154
                        $identifyMethod->save();
×
1155
                }, $identifyMethods[$method]);
×
1156
        }
1157

1158
        public function requestCode(
1159
                SignRequestEntity $signRequest,
1160
                string $identifyMethodName,
1161
                string $signMethodName,
1162
                string $identify = '',
1163
        ): void {
1164
                $identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
×
1165
                if (empty($identifyMethods[$identifyMethodName])) {
×
1166
                        throw new LibresignException($this->l10n->t('Invalid identification method'));
×
1167
                }
1168
                foreach ($identifyMethods[$identifyMethodName] as $identifyMethod) {
×
1169
                        try {
1170
                                $signatureMethod = $identifyMethod->getEmptyInstanceOfSignatureMethodByName($signMethodName);
×
1171
                                $signatureMethod->setEntity($identifyMethod->getEntity());
×
1172
                        } catch (InvalidArgumentException) {
×
1173
                                continue;
×
1174
                        }
1175
                        /** @var IToken $signatureMethod */
1176
                        $identifier = $identify ?: $identifyMethod->getEntity()->getIdentifierValue();
×
1177
                        $signatureMethod->requestCode($identifier, $identifyMethod->getEntity()->getIdentifierKey());
×
1178
                        return;
×
1179
                }
1180
                throw new LibresignException($this->l10n->t('Sending authorization code not enabled.'));
×
1181
        }
1182

1183
        public function getSignRequestToSign(FileEntity $libresignFile, ?string $signRequestUuid, ?IUser $user): SignRequestEntity {
1184
                $this->validateHelper->fileCanBeSigned($libresignFile);
2✔
1185
                try {
1186
                        if ($libresignFile->isEnvelope()) {
2✔
1187
                                $childFiles = $this->fileMapper->getChildrenFiles($libresignFile->getId());
×
1188
                                $allSignRequests = [];
×
1189
                                foreach ($childFiles as $childFile) {
×
1190
                                        $childSignRequests = $this->signRequestMapper->getByFileId($childFile->getId());
×
1191
                                        $allSignRequests = array_merge($allSignRequests, $childSignRequests);
×
1192
                                }
1193
                                $signRequests = $allSignRequests;
×
1194
                        } else {
1195
                                $signRequests = $this->signRequestMapper->getByFileId($libresignFile->getId());
2✔
1196
                        }
1197

1198
                        if (!empty($signRequestUuid)) {
2✔
1199
                                $signRequest = $this->getSignRequestByUuid($signRequestUuid);
2✔
1200
                        } else {
1201
                                $signRequest = array_reduce($signRequests, function (?SignRequestEntity $carry, SignRequestEntity $signRequest) use ($user): ?SignRequestEntity {
×
1202
                                        $identifyMethods = $this->identifyMethodMapper->getIdentifyMethodsFromSignRequestId($signRequest->getId());
×
1203
                                        $found = array_filter($identifyMethods, function (IdentifyMethod $identifyMethod) use ($user) {
×
1204
                                                if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL
×
1205
                                                        && $user
1206
                                                        && (
1207
                                                                $identifyMethod->getIdentifierValue() === $user->getUID()
×
1208
                                                                || $identifyMethod->getIdentifierValue() === $user->getEMailAddress()
×
1209
                                                        )
1210
                                                ) {
1211
                                                        return true;
×
1212
                                                }
1213
                                                if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT
×
1214
                                                        && $user
1215
                                                        && $identifyMethod->getIdentifierValue() === $user->getUID()
×
1216
                                                ) {
1217
                                                        return true;
×
1218
                                                }
1219
                                                return false;
×
1220
                                        });
×
1221
                                        if (count($found) > 0) {
×
1222
                                                return $signRequest;
×
1223
                                        }
1224
                                        return $carry;
×
1225
                                });
×
1226
                        }
1227

1228
                        if (!$signRequest) {
2✔
1229
                                throw new DoesNotExistException('Sign request not found');
×
1230
                        }
1231
                        $signRequestFile = $libresignFile;
2✔
1232
                        if ($signRequestFile->getId() !== $signRequest->getFileId()) {
2✔
1233
                                $signRequestFile = $this->fileMapper->getById($signRequest->getFileId());
×
1234
                        }
1235
                        $this->sequentialSigningService->setFile($signRequestFile);
2✔
1236
                        if (
1237
                                $this->sequentialSigningService->isOrderedNumericFlow()
2✔
1238
                                && $this->sequentialSigningService->hasPendingLowerOrderSigners(
2✔
1239
                                        $signRequest->getFileId(),
2✔
1240
                                        $signRequest->getSigningOrder()
2✔
1241
                                )
2✔
1242
                        ) {
1243
                                throw new LibresignException(json_encode([
×
1244
                                        'action' => JSActions::ACTION_DO_NOTHING,
×
1245
                                        'errors' => [['message' => $this->l10n->t('You are not allowed to sign this document yet')]],
×
1246
                                ]));
×
1247
                        }
1248
                        if ($signRequest->getSigned()) {
2✔
1249
                                throw new LibresignException($this->l10n->t('File already signed by you'), 1);
×
1250
                        }
1251
                        return $signRequest;
2✔
1252
                } catch (DoesNotExistException) {
×
1253
                        throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
×
1254
                }
1255
        }
1256

1257
        protected function getPdfToSign(File $originalFile): File {
1258
                $file = $this->getSignedFile();
×
1259
                if ($file instanceof File) {
×
1260
                        return $file;
×
1261
                }
1262

1263
                $originalContent = $originalFile->getContent();
×
1264

1265
                if ($this->pdfSignatureDetectionService->hasSignatures($originalContent)) {
×
1266
                        return $this->createSignedFile($originalFile, $originalContent);
×
1267
                }
1268
                $metadata = $this->footerHandler->getMetadata($originalFile, $this->libreSignFile);
×
1269
                $footer = $this->footerHandler
×
1270
                        ->setTemplateVar('uuid', $this->libreSignFile->getUuid())
×
1271
                        ->setTemplateVar('signers', array_map(fn (SignRequestEntity $signer) => [
×
1272
                                'displayName' => $signer->getDisplayName(),
×
1273
                                'signed' => $signer->getSigned()
×
1274
                                        ? $signer->getSigned()->format(DateTimeInterface::ATOM)
×
1275
                                        : null,
1276
                        ], $this->getSigners()))
×
1277
                        ->getFooter($metadata['d']);
×
1278
                if ($footer) {
×
1279
                        $stamp = $this->tempManager->getTemporaryFile('stamp.pdf');
×
1280
                        file_put_contents($stamp, $footer);
×
1281

1282
                        $input = $this->tempManager->getTemporaryFile('input.pdf');
×
1283
                        file_put_contents($input, $originalContent);
×
1284

1285
                        try {
1286
                                $pdfContent = $this->pdf->applyStamp($input, $stamp);
×
1287
                        } catch (RuntimeException $e) {
×
1288
                                throw new LibresignException($e->getMessage());
×
1289
                        }
1290
                } else {
1291
                        $pdfContent = $originalContent;
×
1292
                }
1293
                return $this->createSignedFile($originalFile, $pdfContent);
×
1294
        }
1295

1296
        protected function getSignedFile(): ?File {
1297
                $nodeId = $this->libreSignFile->getSignedNodeId();
3✔
1298
                if (!$nodeId) {
3✔
1299
                        return null;
1✔
1300
                }
1301

1302
                $fileToSign = $this->getNodeByIdUsingUid($this->libreSignFile->getUserId(), $nodeId);
2✔
1303

1304
                if ($fileToSign->getOwner()->getUID() !== $this->libreSignFile->getUserId()) {
2✔
1305
                        $fileToSign = $this->getNodeByIdUsingUid($fileToSign->getOwner()->getUID(), $nodeId);
1✔
1306
                }
1307
                return $fileToSign;
2✔
1308
        }
1309

1310
        protected function getNodeByIdUsingUid(string $uid, int $nodeId): File {
1311
                try {
1312
                        $userFolder = $this->root->getUserFolder($uid);
4✔
1313
                } catch (NoUserException $e) {
2✔
1314
                        $this->logger->error('[file-access] NoUserException for uid={uid}', ['uid' => $uid]);
1✔
1315
                        throw new LibresignException($this->l10n->t('User not found.'));
1✔
1316
                } catch (NotPermittedException $e) {
1✔
1317
                        $this->logger->error('[file-access] NotPermittedException for uid={uid}', ['uid' => $uid]);
1✔
1318
                        throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
1✔
1319
                }
1320

1321
                try {
1322
                        $fileToSign = $userFolder->getFirstNodeById($nodeId);
2✔
1323
                } catch (\Throwable $e) {
×
1324
                        $this->logger->error('[file-access] Failed getFirstNodeById - nodeId={nodeId} error={error}', [
×
1325
                                'nodeId' => $nodeId,
×
1326
                                'error' => $e->getMessage(),
×
1327
                        ]);
×
1328
                        throw $e;
×
1329
                }
1330

1331
                if (!$fileToSign instanceof File) {
2✔
1332
                        $this->logger->error('[file-access] Node is not a File - nodeId={nodeId} type={type}', [
1✔
1333
                                'nodeId' => $nodeId,
1✔
1334
                                'type' => $fileToSign ? get_class($fileToSign) : 'NULL',
1✔
1335
                        ]);
1✔
1336
                        throw new LibresignException($this->l10n->t('File not found'));
1✔
1337
                }
1338
                return $fileToSign;
1✔
1339
        }
1340

1341
        /**
1342
         * Verify if file exists in filesystem before enqueuing background job
1343
         *
1344
         * @param string|null $uid User ID
1345
         * @param int $nodeId File node ID
1346
         * @return bool True if file exists and is accessible
1347
         */
1348
        private function verifyFileExists(?string $uid, int $nodeId): bool {
1349
                if ($uid === null || $nodeId === 0) {
1✔
1350
                        return false;
×
1351
                }
1352

1353
                try {
1354
                        $userFolder = $this->root->getUserFolder($uid);
1✔
1355
                        $node = $userFolder->getFirstNodeById($nodeId);
1✔
1356
                        return $node instanceof File;
1✔
1357
                } catch (\Throwable $e) {
×
1358
                        $this->logger->warning('[verify-file] File not accessible - nodeId={nodeId} uid={uid} error={error}', [
×
1359
                                'nodeId' => $nodeId,
×
1360
                                'uid' => $uid,
×
1361
                                'error' => $e->getMessage(),
×
1362
                        ]);
×
1363
                        return false;
×
1364
                }
1365
        }
1366

1367
        private function cleanupUnsignedSignedFile(): void {
1368
                if (!$this->createdSignedFile instanceof File) {
×
1369
                        return;
×
1370
                }
1371

1372
                try {
1373
                        $this->createdSignedFile->delete();
×
1374
                } catch (\Throwable $e) {
×
1375
                        $this->logger->warning('Failed to delete temporary signed file: ' . $e->getMessage());
×
1376
                } finally {
1377
                        $this->createdSignedFile = null;
×
1378
                }
1379
        }
1380

1381
        private function createSignedFile(File $originalFile, string $content): File {
1382
                $filename = preg_replace(
×
1383
                        '/' . $originalFile->getExtension() . '$/',
×
1384
                        $this->l10n->t('signed') . '.' . $originalFile->getExtension(),
×
1385
                        basename($originalFile->getPath())
×
1386
                );
×
1387
                $owner = $originalFile->getOwner()->getUID();
×
1388

1389
                $fileId = $this->libreSignFile->getId();
×
1390
                $extension = $originalFile->getExtension();
×
1391
                $uniqueFilename = substr($filename, 0, -strlen($extension) - 1) . '_' . $fileId . '.' . $extension;
×
1392

1393
                try {
1394
                        /** @var \OCP\Files\Folder */
1395
                        $parentFolder = $this->root->getUserFolder($owner)->getFirstNodeById($originalFile->getParentId());
×
1396

1397
                        $this->createdSignedFile = $parentFolder->newFile($uniqueFilename, $content);
×
1398

1399
                        return $this->createdSignedFile;
×
1400
                } catch (NotPermittedException) {
×
1401
                        throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
×
1402
                } catch (\Exception $e) {
×
1403
                        throw $e;
×
1404
                }
1405
        }
1406

1407
        /**
1408
         * @throws DoesNotExistException
1409
         */
1410
        public function getSignRequestByUuid(string $uuid): SignRequestEntity {
1411
                $this->validateHelper->validateUuidFormat($uuid);
4✔
1412
                return $this->signRequestMapper->getByUuid($uuid);
3✔
1413
        }
1414

1415
        /**
1416
         * @throws DoesNotExistException
1417
         */
1418
        public function getFile(int $signRequestId): FileEntity {
1419
                return $this->fileMapper->getById($signRequestId);
×
1420
        }
1421

1422
        /**
1423
         * @throws DoesNotExistException
1424
         */
1425
        public function getFileByUuid(string $uuid): FileEntity {
1426
                return $this->fileMapper->getByUuid($uuid);
×
1427
        }
1428

1429
        public function getIdDocById(int $fileId): IdDocs {
1430
                return $this->idDocsMapper->getByFileId($fileId);
×
1431
        }
1432

1433
        /**
1434
         * @return File[] Array of files
1435
         */
1436
        public function getNextcloudFiles(FileEntity $fileData): array {
1437
                if ($fileData->getNodeType() === 'envelope') {
1✔
1438
                        $children = $this->fileMapper->getChildrenFiles($fileData->getId());
×
1439
                        $files = [];
×
1440
                        foreach ($children as $child) {
×
1441
                                $nodeId = $child->getNodeId();
×
1442
                                if ($nodeId === null) {
×
1443
                                        throw new LibresignException(json_encode([
×
1444
                                                'action' => JSActions::ACTION_DO_NOTHING,
×
1445
                                                'errors' => [['message' => $this->l10n->t('File not found')]],
×
1446
                                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
×
1447
                                }
1448
                                $file = $this->root->getUserFolder($child->getUserId())->getFirstNodeById($nodeId);
×
1449
                                if ($file instanceof File) {
×
1450
                                        $files[] = $file;
×
1451
                                }
1452
                        }
1453
                        return $files;
×
1454
                }
1455

1456
                $nodeId = $fileData->getNodeId();
1✔
1457
                if ($nodeId === null) {
1✔
1458
                        throw new LibresignException(json_encode([
1✔
1459
                                'action' => JSActions::ACTION_DO_NOTHING,
1✔
1460
                                'errors' => [['message' => $this->l10n->t('File not found')]],
1✔
1461
                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
1✔
1462
                }
1463
                $fileToSign = $this->root->getUserFolder($fileData->getUserId())->getFirstNodeById($nodeId);
×
1464
                if (!$fileToSign instanceof File) {
×
1465
                        throw new LibresignException(json_encode([
×
1466
                                'action' => JSActions::ACTION_DO_NOTHING,
×
1467
                                'errors' => [['message' => $this->l10n->t('File not found')]],
×
1468
                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
×
1469
                }
1470
                return [$fileToSign];
×
1471
        }
1472

1473
        /**
1474
         * @return array<FileEntity>
1475
         */
1476
        public function getNextcloudFilesWithEntities(FileEntity $fileData): array {
1477
                if ($fileData->getNodeType() === 'envelope') {
×
1478
                        $children = $this->fileMapper->getChildrenFiles($fileData->getId());
×
1479
                        $result = [];
×
1480
                        foreach ($children as $child) {
×
1481
                                $nodeId = $child->getNodeId();
×
1482
                                if ($nodeId === null) {
×
1483
                                        throw new LibresignException(json_encode([
×
1484
                                                'action' => JSActions::ACTION_DO_NOTHING,
×
1485
                                                'errors' => [['message' => $this->l10n->t('File not found')]],
×
1486
                                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
×
1487
                                }
1488
                                $file = $this->root->getUserFolder($child->getUserId())->getFirstNodeById($nodeId);
×
1489
                                if ($file instanceof File) {
×
1490
                                        $result[] = $child;
×
1491
                                }
1492
                        }
1493
                        return $result;
×
1494
                }
1495

1496
                $nodeId = $fileData->getNodeId();
×
1497
                if ($nodeId === null) {
×
1498
                        throw new LibresignException(json_encode([
×
1499
                                'action' => JSActions::ACTION_DO_NOTHING,
×
1500
                                'errors' => [['message' => $this->l10n->t('File not found')]],
×
1501
                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
×
1502
                }
1503
                $fileToSign = $this->root->getUserFolder($fileData->getUserId())->getFirstNodeById($nodeId);
×
1504
                if (!$fileToSign instanceof File) {
×
1505
                        throw new LibresignException(json_encode([
×
1506
                                'action' => JSActions::ACTION_DO_NOTHING,
×
1507
                                'errors' => [['message' => $this->l10n->t('File not found')]],
×
1508
                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
×
1509
                }
1510
                return [$fileData];
×
1511
        }
1512

1513
        public function validateSigner(string $uuid, ?IUser $user = null): void {
1514
                $this->validateHelper->validateSigner($uuid, $user);
×
1515
        }
1516

1517
        public function validateRenewSigner(string $uuid, ?IUser $user = null): void {
1518
                $this->validateHelper->validateRenewSigner($uuid, $user);
×
1519
        }
1520

1521
        public function getSignerData(?IUser $user, ?SignRequestEntity $signRequest = null): array {
1522
                $return = ['user' => ['name' => null]];
×
1523
                if ($signRequest) {
×
1524
                        $return['user']['name'] = $signRequest->getDisplayName();
×
1525
                } elseif ($user) {
×
1526
                        $return['user']['name'] = $user->getDisplayName();
×
1527
                }
1528
                return $return;
×
1529
        }
1530

1531
        public function getAvailableIdentifyMethodsFromSettings(): array {
1532
                $identifyMethods = $this->identifyMethodService->getIdentifyMethodsSettings();
×
1533
                $return = array_map(fn (array $identifyMethod): array => [
×
1534
                        'mandatory' => $identifyMethod['mandatory'],
×
1535
                        'identifiedAtDate' => null,
×
1536
                        'validateCode' => false,
×
1537
                        'method' => $identifyMethod['name'],
×
1538
                ], $identifyMethods);
×
1539
                return $return;
×
1540
        }
1541

1542
        public function getFileUrl(int $fileId, string $uuid): string {
1543
                try {
1544
                        $this->idDocsMapper->getByFileId($fileId);
×
1545
                        return $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $uuid]);
×
1546
                } catch (DoesNotExistException) {
×
1547
                        return $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $uuid]);
×
1548
                }
1549
        }
1550

1551
        /**
1552
         * Get PDF URLs for signing
1553
         * For envelopes: returns URLs for all child files
1554
         * For regular files: returns URL for the file itself
1555
         *
1556
         * @return string[]
1557
         */
1558
        public function getPdfUrlsForSigning(FileEntity $fileEntity, SignRequestEntity $signRequestEntity): array {
1559
                if (!$fileEntity->isEnvelope()) {
×
1560
                        return [
×
1561
                                $this->getFileUrl($fileEntity->getId(), $signRequestEntity->getUuid())
×
1562
                        ];
×
1563
                }
1564

1565
                $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod(
×
1566
                        $fileEntity->getId(),
×
1567
                        $signRequestEntity->getId()
×
1568
                );
×
1569

1570
                $pdfUrls = [];
×
1571
                foreach ($childSignRequests as $childSignRequest) {
×
1572
                        $pdfUrls[] = $this->getFileUrl(
×
1573
                                $childSignRequest->getFileId(),
×
1574
                                $childSignRequest->getUuid()
×
1575
                        );
×
1576
                }
1577

1578
                return $pdfUrls;
×
1579
        }
1580

1581
        private function recordSignatureAttempt(Exception $exception): void {
1582
                if (!$this->libreSignFile) {
×
1583
                        return;
×
1584
                }
1585

1586
                $metadata = $this->libreSignFile->getMetadata() ?? [];
×
1587

1588
                if (!isset($metadata['signature_attempts'])) {
×
1589
                        $metadata['signature_attempts'] = [];
×
1590
                }
1591

1592
                $attempt = [
×
1593
                        'timestamp' => (new DateTime())->format(\DateTime::ATOM),
×
1594
                        'engine' => $this->engine ? get_class($this->engine) : 'unknown',
×
1595
                        'error_message' => $exception->getMessage(),
×
1596
                        'error_code' => $exception->getCode(),
×
1597
                ];
×
1598

1599
                $metadata['signature_attempts'][] = $attempt;
×
1600
                $this->libreSignFile->setMetadata($metadata);
×
1601
                $this->fileMapper->update($this->libreSignFile);
×
1602
        }
1603
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc