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

LibreSign / libresign / 21257929909

22 Jan 2026 05:17PM UTC coverage: 44.867%. First build
21257929909

Pull #6520

github

web-flow
Merge 3974c717f into cf0454786
Pull Request #6520: fix: prevent cache race condition workers

108 of 162 new or added lines in 8 files covered. (66.67%)

7264 of 16190 relevant lines covered (44.87%)

4.94 hits per line

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

52.13
/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 OCP\AppFramework\Db\DoesNotExistException;
46
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
47
use OCP\AppFramework\Utility\ITimeFactory;
48
use OCP\BackgroundJob\IJobList;
49
use OCP\EventDispatcher\IEventDispatcher;
50
use OCP\Files\File;
51
use OCP\Files\IRootFolder;
52
use OCP\Files\NotPermittedException;
53
use OCP\Http\Client\IClientService;
54
use OCP\IAppConfig;
55
use OCP\ICache;
56
use OCP\ICacheFactory;
57
use OCP\IDateTimeZone;
58
use OCP\IL10N;
59
use OCP\ITempManager;
60
use OCP\IURLGenerator;
61
use OCP\IUser;
62
use OCP\IUserManager;
63
use OCP\IUserSession;
64
use OCP\Security\Events\GenerateSecurePasswordEvent;
65
use OCP\Security\ICredentialsManager;
66
use OCP\Security\ISecureRandom;
67
use Psr\Log\LoggerInterface;
68
use RuntimeException;
69
use Sabre\DAV\UUIDUtil;
70

71
class SignFileService {
72
        private ?SignRequestEntity $signRequest = null;
73
        private string $password = '';
74
        private ?FileEntity $libreSignFile = null;
75
        /** @var VisibleElementAssoc[] */
76
        private $elements = [];
77
        private bool $signWithoutPassword = false;
78
        private ?File $fileToSign = null;
79
        private ?File $createdSignedFile = null;
80
        private string $userUniqueIdentifier = '';
81
        private string $friendlyName = '';
82
        private ?IUser $user = null;
83
        private ?SignEngineHandler $engine = null;
84
        private ICache $cache;
85

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

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

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

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

193
        public function setUserUniqueIdentifier(string $identifier): self {
194
                $this->userUniqueIdentifier = $identifier;
6✔
195
                return $this;
6✔
196
        }
197

198
        public function setFriendlyName(string $friendlyName): self {
199
                $this->friendlyName = $friendlyName;
6✔
200
                return $this;
6✔
201
        }
202

203
        /**
204
         * @return static
205
         */
206
        public function setSignRequest(SignRequestEntity $signRequest): self {
207
                $this->signRequest = $signRequest;
74✔
208
                return $this;
74✔
209
        }
210

211
        /**
212
         * @return static
213
         */
214
        public function setSignWithoutPassword(bool $signWithoutPassword = true): self {
215
                $this->signWithoutPassword = $signWithoutPassword;
6✔
216
                return $this;
6✔
217
        }
218

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

227
        public function setCurrentUser(?IUser $user): self {
228
                $this->user = $user;
31✔
229
                return $this;
31✔
230
        }
231

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

247
                return $this
6✔
248
                        ->setLibreSignFile($libreSignFile)
6✔
249
                        ->setSignRequest($signRequest)
6✔
250
                        ->setCurrentUser($user)
6✔
251
                        ->setUserUniqueIdentifier($userIdentifier)
6✔
252
                        ->setFriendlyName($displayName);
6✔
253
        }
254

255
        public function setVisibleElements(array $list): self {
256
                if (!$this->signRequest instanceof SignRequestEntity) {
14✔
257
                        return $this;
×
258
                }
259
                $fileElements = $this->fileElementMapper->getByFileIdAndSignRequestId($this->signRequest->getFileId(), $this->signRequest->getId());
14✔
260
                $canCreateSignature = $this->signerElementsService->canCreateSignature();
14✔
261

262
                foreach ($fileElements as $fileElement) {
14✔
263
                        $this->elements[] = $this->buildVisibleElementAssoc($fileElement, $list, $canCreateSignature);
12✔
264
                }
265

266
                return $this;
5✔
267
        }
268

269
        private function buildVisibleElementAssoc(FileElement $fileElement, array $list, bool $canCreateSignature): VisibleElementAssoc {
270
                if (!$canCreateSignature) {
12✔
271
                        return new VisibleElementAssoc($fileElement);
1✔
272
                }
273

274
                $element = $this->array_find($list, fn (array $element): bool => ($element['documentElementId'] ?? '') === $fileElement->getId());
11✔
275
                $nodeId = $this->getNodeId($element, $fileElement);
11✔
276

277
                return $this->bindFileElementWithTempFile($fileElement, $nodeId);
7✔
278
        }
279

280
        private function getNodeId(?array $element, FileElement $fileElement): int {
281
                if ($this->isValidElement($element)) {
11✔
282
                        return (int)$element['profileNodeId'];
7✔
283
                }
284

285
                return $this->retrieveUserElement($fileElement);
×
286
        }
287

288
        private function isValidElement(?array $element): bool {
289
                if (is_array($element) && !empty($element['profileNodeId']) && is_int($element['profileNodeId'])) {
11✔
290
                        return true;
7✔
291
                }
292
                $this->logger->error('Invalid data provided for signing file.', ['element' => $element]);
4✔
293
                throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
4✔
294
        }
295

296
        private function retrieveUserElement(FileElement $fileElement): int {
297
                try {
298
                        if (!$this->user instanceof IUser) {
×
299
                                throw new Exception('User not set');
×
300
                        }
301
                        $userElement = $this->userElementMapper->findOne([
×
302
                                'user_id' => $this->user->getUID(),
×
303
                                'type' => $fileElement->getType(),
×
304
                        ]);
×
305
                } catch (MultipleObjectsReturnedException|DoesNotExistException|Exception) {
×
306
                        throw new LibresignException($this->l10n->t('You need to define a visible signature or initials to sign this document.'));
×
307
                }
308
                return $userElement->getNodeId();
×
309
        }
310

311
        private function bindFileElementWithTempFile(FileElement $fileElement, int $nodeId): VisibleElementAssoc {
312
                try {
313
                        $node = $this->getNode($nodeId);
7✔
314
                        if (!$node) {
4✔
315
                                throw new \Exception('Node content is empty or unavailable.');
4✔
316
                        }
317
                } catch (\Throwable) {
4✔
318
                        throw new LibresignException($this->l10n->t('You need to define a visible signature or initials to sign this document.'));
4✔
319
                }
320

321
                $tempFile = $this->tempManager->getTemporaryFile('_' . $nodeId . '.png');
3✔
322
                $content = $node->getContent();
3✔
323
                if (empty($content)) {
3✔
324
                        $this->logger->error('Failed to retrieve content for node.', ['nodeId' => $nodeId, 'fileElement' => $fileElement]);
1✔
325
                        throw new LibresignException($this->l10n->t('You need to define a visible signature or initials to sign this document.'));
1✔
326
                }
327
                file_put_contents($tempFile, $content);
2✔
328
                return new VisibleElementAssoc($fileElement, $tempFile);
2✔
329
        }
330

331
        private function getNode(int $nodeId): ?File {
332
                if ($this->user instanceof IUser) {
7✔
333
                        return $this->folderService->getFileByNodeId($nodeId);
6✔
334
                }
335

336
                $filesOfElementes = $this->signerElementsService->getElementsFromSession();
1✔
337
                return $this->array_find($filesOfElementes, fn ($file) => $file->getId() === $nodeId);
1✔
338
        }
339

340
        /**
341
         * Fallback to PHP < 8.4
342
         *
343
         * Reference: https://www.php.net/manual/en/function.array-find.php#130257
344
         *
345
         * @todo remove this after minor PHP version is >= 8.4
346
         * @deprecated This method will be removed once the minimum PHP version is >= 8.4. Use native array_find instead.
347
         */
348
        private function array_find(array $array, callable $callback): mixed {
349
                foreach ($array as $key => $value) {
12✔
350
                        if ($callback($value, $key)) {
11✔
351
                                return $value;
10✔
352
                        }
353
                }
354

355
                return null;
3✔
356
        }
357

358
        public function getVisibleElements(): array {
359
                return $this->elements;
7✔
360
        }
361

362
        public function getJobArgumentsWithoutCredentials(): array {
363
                $args = [];
×
364

365
                if (!empty($this->userUniqueIdentifier)) {
×
366
                        $args['userUniqueIdentifier'] = $this->userUniqueIdentifier;
×
367
                }
368

369
                if (!empty($this->friendlyName)) {
×
370
                        $args['friendlyName'] = $this->friendlyName;
×
371
                }
372

373
                if (!empty($this->elements)) {
×
374
                        $args['visibleElements'] = $this->elements;
×
375
                }
376

377
                if ($this->signRequest instanceof SignRequestEntity && $this->signRequest->getMetadata()) {
×
378
                        $args['metadata'] = $this->signRequest->getMetadata();
×
379
                }
380

381
                if ($this->user instanceof IUser) {
×
382
                        $args['userId'] = $this->user->getUID();
×
383
                }
384

385
                return $args;
×
386
        }
387

388
        public function validateSigningRequirements(): void {
389
                $this->tsaValidationService->validateConfiguration();
×
390
        }
391

392
        public function sign(): void {
393
                $signRequests = $this->getSignRequestsToSign();
18✔
394

395
                if (empty($signRequests)) {
18✔
396
                        throw new LibresignException('No sign requests found to process');
×
397
                }
398

399
                $this->executeSigningStrategy($signRequests);
18✔
400
        }
401

402
        private function executeSigningStrategy(array $signRequests): ?DateTimeInterface {
403
                if ($this->signingCoordinatorService->shouldUseParallelProcessing(count($signRequests))) {
18✔
404
                        return $this->processParallelSigning($signRequests);
×
405
                }
406
                return $this->signSequentially($signRequests);
18✔
407
        }
408

409
        private function processParallelSigning(array $signRequests): ?DateTimeInterface {
410
                $this->enqueueParallelSigningJobs($signRequests, $this->getJobArgumentsWithoutCredentials());
×
411
                return $this->getLatestSignedDate($signRequests);
×
412
        }
413

414
        private function getLatestSignedDate(array $signRequests): ?DateTimeInterface {
415
                $latestSignedDate = null;
×
416

417
                foreach ($signRequests as $signRequestData) {
×
418
                        try {
419
                                $signRequest = $this->signRequestMapper->getById($signRequestData['signRequest']->getId());
×
420
                                if ($signRequest->getSigned()) {
×
421
                                        $latestSignedDate = $signRequest->getSigned();
×
422
                                }
423
                        } catch (DoesNotExistException) {
×
424
                        }
425
                }
426

427
                return $latestSignedDate;
×
428
        }
429

430
        public function signSingleFile(FileEntity $libreSignFile, SignRequestEntity $signRequest): void {
431
                $previousState = $this->saveCachedState();
×
432
                $this->resetCachedState();
×
433

434
                if ($libreSignFile->getSignedHash()) {
×
435
                        $this->restoreCachedState($previousState);
×
436
                        return;
×
437
                }
438

439
                $previousLibreSignFile = $this->libreSignFile;
×
440
                $previousSignRequest = $this->signRequest;
×
441
                $this->libreSignFile = $libreSignFile;
×
442
                $this->signRequest = $signRequest;
×
443

444
                try {
445
                        $this->validateDocMdpAllowsSignatures();
×
446

447
                        try {
448
                                $signedFile = $this->getEngine()->sign();
×
449
                        } catch (LibresignException|Exception $e) {
×
450
                                $this->cleanupUnsignedSignedFile();
×
451
                                $this->recordSignatureAttempt($e);
×
452
                                throw $e;
×
453
                        }
454

455
                        $hash = $this->computeHash($signedFile);
×
456
                        $this->updateSignRequest($hash);
×
457
                        $this->updateLibreSignFile($libreSignFile, $signedFile->getId(), $hash);
×
458

459
                        $this->dispatchSignedEvent();
×
460

461
                        $envelopeContext = $this->getEnvelopeContext();
×
462
                        if ($envelopeContext['envelope'] instanceof FileEntity) {
×
463
                                $this->updateEnvelopeStatus(
×
464
                                        $envelopeContext['envelope'],
×
465
                                        $envelopeContext['envelopeSignRequest'] ?? null,
×
466
                                        $signRequest->getSigned()
×
467
                                );
×
468
                        }
469
                } finally {
470
                        $this->libreSignFile = $previousLibreSignFile;
×
471
                        $this->signRequest = $previousSignRequest;
×
472
                        $this->restoreCachedState($previousState);
×
473
                }
474
        }
475

476
        private function saveCachedState(): array {
477
                return [
×
478
                        'fileToSign' => $this->fileToSign,
×
479
                        'createdSignedFile' => $this->createdSignedFile,
×
480
                        'engine' => $this->engine,
×
481
                ];
×
482
        }
483

484
        private function resetCachedState(): void {
485
                $this->fileToSign = null;
×
486
                $this->createdSignedFile = null;
×
487
                $this->engine = null;
×
488
        }
489

490
        private function restoreCachedState(array $state): void {
491
                $this->fileToSign = $state['fileToSign'];
×
492
                $this->createdSignedFile = $state['createdSignedFile'];
×
493
                $this->engine = $state['engine'];
×
494
        }
495

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

498
                if (empty($signRequests)) {
1✔
499
                        throw new LibresignException('No sign requests found to process');
×
500
                }
501

502
                $enqueued = 0;
1✔
503
                foreach ($signRequests as $signRequestData) {
1✔
504
                        $file = $signRequestData['file'];
1✔
505
                        $signRequest = $signRequestData['signRequest'];
1✔
506

507
                        if ($file->getSignedHash()) {
1✔
508
                                continue;
×
509
                        }
510

511
                        $nodeId = $file->getNodeId();
1✔
512
                        $userId = $file->getUserId() ?? $signRequest->getUserId();
1✔
513

514
                        if ($nodeId === null || !$this->verifyFileExists($userId, $nodeId)) {
1✔
515
                                continue;
×
516
                        }
517

518
                        $this->enqueueSigningJobForFile($signRequest, $file, $jobArguments);
1✔
519
                        $enqueued++;
1✔
520
                }
521

522
                return $enqueued;
1✔
523
        }
524

525
        private function enqueueSigningJobForFile(SignRequestEntity $signRequest, FileEntity $file, array $jobArguments): void {
526
                $args = $jobArguments;
1✔
527
                $args = $this->addCredentialsToJobArgs($args, $signRequest, $file);
1✔
528
                $args = array_merge($args, [
1✔
529
                        'fileId' => $file->getId(),
1✔
530
                        'signRequestId' => $signRequest->getId(),
1✔
531
                ]);
1✔
532

533
                $this->jobList->add(SignSingleFileJob::class, $args);
1✔
534
        }
535

536
        private function addCredentialsToJobArgs(array $args, SignRequestEntity $signRequest, FileEntity $file): array {
537
                if (!($this->signWithoutPassword || !empty($this->password))) {
1✔
538
                        return $args;
×
539
                }
540

541
                $credentialsId = 'libresign_sign_' . $signRequest->getId() . '_' . $file->getId() . '_' . $this->secureRandom->generate(8, ISecureRandom::CHAR_ALPHANUMERIC);
1✔
542
                $this->credentialsManager->store(
1✔
543
                        $this->user?->getUID() ?? '',
1✔
544
                        $credentialsId,
1✔
545
                        [
1✔
546
                                'signWithoutPassword' => $this->signWithoutPassword,
1✔
547
                                'password' => $this->password,
1✔
548
                                'timestamp' => time(),
1✔
549
                                'expires' => time() + 3600,
1✔
550
                        ]
1✔
551
                );
1✔
552
                $args['credentialsId'] = $credentialsId;
1✔
553

554
                return $args;
1✔
555
        }
556

557
        /**
558
         * @return DateTimeInterface|null Last signed date
559
         */
560
        private function signSequentially(array $signRequests): ?DateTimeInterface {
561
                $envelopeLastSignedDate = null;
18✔
562
                $envelopeContext = $this->getEnvelopeContext();
18✔
563

564
                foreach ($signRequests as $index => $signRequestData) {
18✔
565
                        $this->libreSignFile = $signRequestData['file'];
18✔
566
                        if ($this->libreSignFile->getSignedHash()) {
18✔
567
                                continue;
×
568
                        }
569
                        $this->signRequest = $signRequestData['signRequest'];
18✔
570
                        $this->engine = null;
18✔
571
                        $this->elements = [];
18✔
572
                        $this->fileToSign = null;
18✔
573

574
                        $this->validateDocMdpAllowsSignatures();
18✔
575

576
                        try {
577
                                $signedFile = $this->getEngine()->sign();
16✔
578
                        } catch (LibresignException|Exception $e) {
×
579
                                $this->cleanupUnsignedSignedFile();
×
580
                                $this->recordSignatureAttempt($e);
×
581

582
                                $isEnvelope = $this->libreSignFile->isEnvelope() || $this->libreSignFile->hasParent();
×
583
                                if (!$isEnvelope) {
×
584
                                        throw $e;
×
585
                                }
586
                                continue;
×
587
                        }
588

589
                        $hash = $this->computeHash($signedFile);
16✔
590
                        $envelopeLastSignedDate = $this->getEngine()->getLastSignedDate();
16✔
591

592
                        $this->updateSignRequest($hash);
16✔
593
                        $this->updateLibreSignFile($this->libreSignFile, $signedFile->getId(), $hash);
16✔
594

595
                        $this->dispatchSignedEvent();
16✔
596
                }
597

598
                if ($envelopeContext['envelope'] instanceof FileEntity) {
16✔
NEW
599
                        $this->updateEnvelopeStatus(
×
NEW
600
                                $envelopeContext['envelope'],
×
NEW
601
                                $envelopeContext['envelopeSignRequest'] ?? null,
×
NEW
602
                                $envelopeLastSignedDate
×
NEW
603
                        );
×
604
                }
605

606
                return $envelopeLastSignedDate;
16✔
607
        }
608

609
        /**
610
         * @return array Array of sign request data with 'file' => FileEntity, 'signRequest' => SignRequestEntity
611
         */
612
        private function getSignRequestsToSign(): array {
613
                if (!$this->libreSignFile->isEnvelope()
19✔
614
                        && !$this->libreSignFile->hasParent()
19✔
615
                ) {
616
                        return [[
18✔
617
                                'file' => $this->libreSignFile,
18✔
618
                                'signRequest' => $this->signRequest,
18✔
619
                        ]];
18✔
620
                }
621

622
                return $this->buildEnvelopeSignRequests();
1✔
623
        }
624

625
        /**
626
         * @return array Array of sign request data with 'file' => FileEntity, 'signRequest' => SignRequestEntity
627
         */
628
        private function buildEnvelopeSignRequests(): array {
629
                $envelopeId = $this->libreSignFile->isEnvelope()
1✔
630
                        ? $this->libreSignFile->getId()
×
631
                        : $this->libreSignFile->getParentFileId();
1✔
632

633
                $childFiles = $this->fileMapper->getChildrenFiles($envelopeId);
1✔
634
                if (empty($childFiles)) {
1✔
635
                        throw new LibresignException('No files found in envelope');
×
636
                }
637

638
                $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod(
1✔
639
                        $envelopeId,
1✔
640
                        $this->signRequest->getId()
1✔
641
                );
1✔
642

643
                if (empty($childSignRequests)) {
1✔
644
                        throw new LibresignException('No sign requests found for envelope files');
×
645
                }
646

647
                $signRequestsData = [];
1✔
648
                foreach ($childSignRequests as $childSignRequest) {
1✔
649
                        $childFile = $this->array_find(
1✔
650
                                $childFiles,
1✔
651
                                fn (FileEntity $file) => $file->getId() === $childSignRequest->getFileId()
1✔
652
                        );
1✔
653

654
                        if ($childFile) {
1✔
655
                                $signRequestsData[] = [
1✔
656
                                        'file' => $childFile,
1✔
657
                                        'signRequest' => $childSignRequest,
1✔
658
                                ];
1✔
659
                        }
660
                }
661

662
                return $signRequestsData;
1✔
663
        }
664

665
        /**
666
         * @return array Array with 'envelope' => FileEntity or null, 'envelopeSignRequest' => SignRequestEntity or null
667
         */
668
        private function getEnvelopeContext(): array {
669
                $result = [
18✔
670
                        'envelope' => null,
18✔
671
                        'envelopeSignRequest' => null,
18✔
672
                ];
18✔
673

674
                if (!$this->libreSignFile->isEnvelope() && !$this->libreSignFile->hasParent()) {
18✔
675
                        return $result;
18✔
676
                }
677

678
                if ($this->libreSignFile->isEnvelope()) {
×
679
                        $result['envelope'] = $this->libreSignFile;
×
680
                        $result['envelopeSignRequest'] = $this->signRequest;
×
681
                        return $result;
×
682
                }
683

684
                try {
685
                        $envelopeId = $this->libreSignFile->isEnvelope()
×
686
                                ? $this->libreSignFile->getId()
×
687
                                : $this->libreSignFile->getParentFileId();
×
688
                        $result['envelope'] = $this->fileMapper->getById($envelopeId);
×
689
                        $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($this->signRequest->getId());
×
690
                        $result['envelopeSignRequest'] = $this->signRequestMapper->getByIdentifyMethodAndFileId(
×
691
                                $identifyMethod,
×
692
                                $result['envelope']->getId()
×
693
                        );
×
694
                } catch (DoesNotExistException $e) {
×
695
                }
696

697
                return $result;
×
698
        }
699

700
        private function updateEnvelopeStatus(
701
                FileEntity $envelope,
702
                ?SignRequestEntity $envelopeSignRequest = null,
703
                ?DateTimeInterface $signedDate = null,
704
        ): void {
705
                $childFiles = $this->fileMapper->getChildrenFiles($envelope->getId());
1✔
706
                $signRequestsMap = $this->buildSignRequestsMap($childFiles);
1✔
707

708
                $status = $this->envelopeStatusDeterminer->determineStatus($childFiles, $signRequestsMap);
1✔
709
                $envelope->setStatus($status);
1✔
710

711
                $this->handleSignedEnvelopeSignRequest($envelope, $envelopeSignRequest, $signedDate, $status);
1✔
712

713
                $this->updateEnvelopeMetadata($envelope);
1✔
714
                $this->fileMapper->update($envelope);
1✔
715
                $this->updateEntityCacheAfterDbSave($envelope);
1✔
716
        }
717

718
        private function buildSignRequestsMap(array $childFiles): array {
719
                $signRequestsMap = [];
1✔
720
                foreach ($childFiles as $childFile) {
1✔
721
                        $signRequestsMap[$childFile->getId()] = $this->signRequestMapper->getByFileId($childFile->getId());
1✔
722
                }
723
                return $signRequestsMap;
1✔
724
        }
725

726
        private function handleSignedEnvelopeSignRequest(
727
                FileEntity $envelope,
728
                ?SignRequestEntity $envelopeSignRequest,
729
                ?DateTimeInterface $signedDate,
730
                int $status,
731
        ): void {
732
                if (!($envelopeSignRequest instanceof SignRequestEntity)) {
1✔
733
                        return;
×
734
                }
735

736
                $envelopeSignRequest->setSigned($signedDate ?: new DateTime());
1✔
737
                $envelopeSignRequest->setStatusEnum(\OCA\Libresign\Enum\SignRequestStatus::SIGNED);
1✔
738
                $this->signRequestMapper->update($envelopeSignRequest);
1✔
739
                $this->sequentialSigningService
1✔
740
                        ->setFile($envelope)
1✔
741
                        ->releaseNextOrder(
1✔
742
                                $envelopeSignRequest->getFileId(),
1✔
743
                                $envelopeSignRequest->getSigningOrder()
1✔
744
                        );
1✔
745
        }
746

747
        private function updateEnvelopeMetadata(FileEntity $envelope): void {
748
                $meta = $envelope->getMetadata() ?? [];
1✔
749
                $meta['status_changed_at'] = (new DateTime())->format(DateTimeInterface::ATOM);
1✔
750
                $envelope->setMetadata($meta);
1✔
751
        }
752

753
        /**
754
         * @throws LibresignException If the document has DocMDP level 1 (no changes allowed)
755
         */
756
        protected function validateDocMdpAllowsSignatures(): void {
757
                $docmdpLevel = $this->libreSignFile->getDocmdpLevelEnum();
18✔
758

759
                if ($docmdpLevel === \OCA\Libresign\Enum\DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED) {
18✔
760
                        throw new LibresignException(
×
761
                                $this->l10n->t('This document has been certified with no changes allowed. You cannot add more signers to this document.'),
×
762
                                AppFrameworkHttp::STATUS_UNPROCESSABLE_ENTITY
×
763
                        );
×
764
                }
765

766
                if ($docmdpLevel === \OCA\Libresign\Enum\DocMdpLevel::NOT_CERTIFIED) {
18✔
767
                        $resource = $this->getLibreSignFileAsResource();
18✔
768

769
                        try {
770
                                if (!$this->docMdpHandler->allowsAdditionalSignatures($resource)) {
17✔
771
                                        throw new LibresignException(
3✔
772
                                                $this->l10n->t('This document has been certified with no changes allowed. You cannot add more signers to this document.'),
3✔
773
                                                AppFrameworkHttp::STATUS_UNPROCESSABLE_ENTITY
3✔
774
                                        );
3✔
775
                                }
776
                        } finally {
777
                                fclose($resource);
17✔
778
                        }
779
                }
780
        }
781

782
        /**
783
         * @return resource
784
         * @throws LibresignException
785
         */
786
        protected function getLibreSignFileAsResource() {
787
                $files = $this->getNextcloudFiles($this->libreSignFile);
12✔
788
                if (empty($files)) {
11✔
789
                        throw new LibresignException('File not found');
×
790
                }
791
                $fileToSign = current($files);
11✔
792
                $content = $fileToSign->getContent();
11✔
793
                $resource = fopen('php://memory', 'r+');
11✔
794
                if ($resource === false) {
11✔
795
                        throw new LibresignException('Failed to create temporary resource for PDF validation');
×
796
                }
797
                fwrite($resource, $content);
11✔
798
                rewind($resource);
11✔
799
                return $resource;
11✔
800
        }
801

802
        protected function computeHash(File $file): string {
803
                return hash('sha256', $file->getContent());
2✔
804
        }
805

806
        protected function updateSignRequest(string $hash): void {
807
                $lastSignedDate = $this->getEngine()->getLastSignedDate();
14✔
808
                $this->signRequest->setSigned($lastSignedDate);
14✔
809
                $this->signRequest->setSignedHash($hash);
14✔
810
                $this->signRequest->setStatusEnum(\OCA\Libresign\Enum\SignRequestStatus::SIGNED);
14✔
811

812
                $this->signRequestMapper->update($this->signRequest);
14✔
813

814
                $this->sequentialSigningService
14✔
815
                        ->setFile($this->libreSignFile)
14✔
816
                        ->releaseNextOrder(
14✔
817
                                $this->signRequest->getFileId(),
14✔
818
                                $this->signRequest->getSigningOrder()
14✔
819
                        );
14✔
820
        }
821

822
        protected function updateLibreSignFile(FileEntity $libreSignFile, int $nodeId, string $hash): void {
823
                $libreSignFile->setSignedNodeId($nodeId);
14✔
824
                $libreSignFile->setSignedHash($hash);
14✔
825
                $this->setNewStatusIfNecessary($libreSignFile);
14✔
826
                $meta = $libreSignFile->getMetadata() ?? [];
14✔
827
                $meta['status_changed_at'] = (new \DateTime())->format(\DateTimeInterface::ATOM);
14✔
828
                $libreSignFile->setMetadata($meta);
14✔
829
                $this->fileMapper->update($libreSignFile);
14✔
830
                $this->updateCacheAfterDbSave($libreSignFile); // Update cache AFTER DB save
14✔
831

832
                if ($libreSignFile->hasParent()) {
14✔
833
                        $this->fileStatusService->propagateStatusToParent($libreSignFile->getParentFileId());
×
834
                }
835
        }
836

837
        protected function dispatchSignedEvent(): void {
838
                $certificateSerialNumber = null;
14✔
839
                if ($this->signWithoutPassword) {
14✔
840
                        try {
841
                                $certificateInfo = $this->getEngine()->readCertificate();
×
842
                                if (isset($certificateInfo['serialNumber']) && is_string($certificateInfo['serialNumber'])) {
×
843
                                        $certificateSerialNumber = $certificateInfo['serialNumber'];
×
844
                                } else {
845
                                        $this->logger->warning('Unable to extract certificate serial number for event payload');
×
846
                                }
847
                        } catch (\Throwable $e) {
×
848
                                $this->logger->error('Failed to get certificate info for event', [
×
849
                                        'exception' => $e,
×
850
                                        'signRequestId' => $this->signRequest->getId()
×
851
                                ]);
×
852
                        }
853
                }
854

855
                $event = $this->signedEventFactory->make(
14✔
856
                        $this->signRequest,
14✔
857
                        $this->libreSignFile,
14✔
858
                        $this->getEngine()->getInputFile(),
14✔
859
                        $this->signWithoutPassword,
14✔
860
                        $certificateSerialNumber,
14✔
861
                );
14✔
862
                $this->eventDispatcher->dispatchTyped($event);
14✔
863
        }
864

865
        protected function identifyEngine(File $file): SignEngineHandler {
866
                return $this->signEngineFactory->resolve($file->getExtension());
10✔
867
        }
868

869
        protected function getSignatureParams(): array {
870
                $certificateData = $this->readCertificate();
15✔
871
                $signatureParams = $this->buildBaseSignatureParams($certificateData);
15✔
872
                $signatureParams = $this->addEmailToSignatureParams($signatureParams, $certificateData);
15✔
873
                $signatureParams = $this->addMetadataToSignatureParams($signatureParams);
15✔
874
                return $signatureParams;
15✔
875
        }
876

877
        private function buildBaseSignatureParams(array $certificateData): array {
878
                return [
15✔
879
                        'DocumentUUID' => $this->libreSignFile?->getUuid(),
15✔
880
                        'IssuerCommonName' => $certificateData['issuer']['CN'] ?? '',
15✔
881
                        'SignerCommonName' => $certificateData['subject']['CN'] ?? '',
15✔
882
                        'LocalSignerTimezone' => $this->dateTimeZone->getTimeZone()->getName(),
15✔
883
                        'LocalSignerSignatureDateTime' => (new DateTime('now', new \DateTimeZone('UTC')))
15✔
884
                                ->format(DateTimeInterface::ATOM)
15✔
885
                ];
15✔
886
        }
887

888
        private function addEmailToSignatureParams(array $signatureParams, array $certificateData): array {
889
                if (isset($certificateData['extensions']['subjectAltName'])) {
15✔
890
                        preg_match('/(?:email:)+(?<email>[^\s,]+)/', $certificateData['extensions']['subjectAltName'], $matches);
6✔
891
                        if ($matches && filter_var($matches['email'], FILTER_VALIDATE_EMAIL)) {
6✔
892
                                $signatureParams['SignerEmail'] = $matches['email'];
4✔
893
                        } elseif (filter_var($certificateData['extensions']['subjectAltName'], FILTER_VALIDATE_EMAIL)) {
2✔
894
                                $signatureParams['SignerEmail'] = $certificateData['extensions']['subjectAltName'];
1✔
895
                        }
896
                }
897
                if (empty($signatureParams['SignerEmail']) && $this->user instanceof IUser) {
15✔
898
                        $signatureParams['SignerEmail'] = $this->user->getEMailAddress();
1✔
899
                }
900
                if (empty($signatureParams['SignerEmail']) && $this->signRequest instanceof SignRequestEntity) {
15✔
901
                        $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($this->signRequest->getId());
9✔
902
                        if ($identifyMethod->getName() === IdentifyMethodService::IDENTIFY_EMAIL) {
9✔
903
                                $signatureParams['SignerEmail'] = $identifyMethod->getEntity()->getIdentifierValue();
1✔
904
                        }
905
                }
906
                return $signatureParams;
15✔
907
        }
908

909
        private function addMetadataToSignatureParams(array $signatureParams): array {
910
                $signRequestMetadata = $this->signRequest->getMetadata();
15✔
911
                if (isset($signRequestMetadata['remote-address'])) {
15✔
912
                        $signatureParams['SignerIP'] = $signRequestMetadata['remote-address'];
2✔
913
                }
914
                if (isset($signRequestMetadata['user-agent'])) {
15✔
915
                        $signatureParams['SignerUserAgent'] = $signRequestMetadata['user-agent'];
2✔
916
                }
917
                return $signatureParams;
15✔
918
        }
919

920
        public function storeUserMetadata(array $metadata = []): self {
921
                $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
18✔
922
                if (!$collectMetadata || !$metadata) {
18✔
923
                        return $this;
7✔
924
                }
925
                $this->signRequest->setMetadata(array_merge(
11✔
926
                        $this->signRequest->getMetadata() ?? [],
11✔
927
                        $metadata,
11✔
928
                ));
11✔
929
                $this->signRequestMapper->update($this->signRequest);
11✔
930
                return $this;
11✔
931
        }
932

933
        /**
934
         * @return SignRequestEntity[]
935
         */
936
        protected function getSigners(): array {
937
                return $this->signRequestMapper->getByFileId($this->signRequest->getFileId());
×
938
        }
939

940
        protected function setNewStatusIfNecessary(FileEntity $libreSignFile): bool {
941
                $newStatus = $this->evaluateStatusFromSigners();
10✔
942

943
                if ($newStatus === null || $newStatus === $libreSignFile->getStatus()) {
10✔
944
                        return false;
4✔
945
                }
946

947
                $libreSignFile->setStatus($newStatus);
6✔
948

949
                return true;
6✔
950
        }
951

952
        private function updateCacheAfterDbSave(FileEntity $libreSignFile): void {
953
                $cacheKey = 'status_' . $libreSignFile->getUuid();
14✔
954
                $status = $libreSignFile->getStatus();
14✔
955
                $this->cache->set($cacheKey, $status, 60); // Cache for 60 seconds
14✔
956
        }
957

958
        private function updateEntityCacheAfterDbSave(FileEntity $file): void {
959
                $cacheKey = 'status_' . $file->getUuid();
1✔
960
                $status = $file->getStatus();
1✔
961
                $this->cache->set($cacheKey, $status, 60);
1✔
962
        }
963

964
        private function evaluateStatusFromSigners(): ?int {
965
                $signers = $this->getSigners();
10✔
966

967
                $total = count($signers);
10✔
968

969
                if ($total === 0) {
10✔
970
                        return null;
1✔
971
                }
972

973
                $totalSigned = count(array_filter($signers, fn ($s) => $s->getSigned() !== null));
9✔
974

975
                if ($totalSigned === $total) {
9✔
976
                        return FileStatus::SIGNED->value;
5✔
977
                }
978

979
                if ($totalSigned > 0) {
4✔
980
                        return FileStatus::PARTIAL_SIGNED->value;
3✔
981
                }
982

983
                return null;
1✔
984
        }
985

986
        private function getOrGeneratePfxContent(SignEngineHandler $engine): string {
987
                if ($certificate = $engine->getCertificate()) {
12✔
988
                        return $certificate;
×
989
                }
990
                if ($this->signWithoutPassword) {
12✔
991
                        $tempPassword = $this->generateTemporaryPassword();
1✔
992
                        $this->setPassword($tempPassword);
1✔
993
                        $engine->generateCertificate(
1✔
994
                                [
1✔
995
                                        'host' => $this->userUniqueIdentifier,
1✔
996
                                        'uid' => $this->userUniqueIdentifier,
1✔
997
                                        'name' => $this->friendlyName,
1✔
998
                                ],
1✔
999
                                $tempPassword,
1✔
1000
                                $this->friendlyName,
1✔
1001
                        );
1✔
1002
                }
1003
                return $engine->getPfxOfCurrentSigner();
12✔
1004
        }
1005

1006
        private function generateTemporaryPassword(): string {
1007
                $passwordEvent = new GenerateSecurePasswordEvent();
1✔
1008
                $this->eventDispatcher->dispatchTyped($passwordEvent);
1✔
1009
                return $passwordEvent->getPassword() ?? $this->secureRandom->generate(20);
1✔
1010
        }
1011

1012
        protected function readCertificate(): array {
1013
                return $this->getEngine()
×
1014
                        ->readCertificate();
×
1015
        }
1016

1017
        /**
1018
         * Get file to sign
1019
         *
1020
         * @throws LibresignException
1021
         */
1022
        protected function getFileToSign(): File {
1023
                if ($this->fileToSign instanceof File) {
×
1024
                        return $this->fileToSign;
×
1025
                }
1026

1027
                $userId = $this->libreSignFile->getUserId()
×
1028
                        ?? $this->user?->getUID()
×
1029
                        ?? ($this->signRequest?->getUserId() ?? null);
×
1030
                $nodeId = $this->libreSignFile->getNodeId();
×
1031

1032
                if ($userId === null) {
×
1033
                        throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
×
1034
                }
1035

1036
                try {
1037
                        $originalFile = $this->getNodeByIdUsingUid($userId, $nodeId);
×
1038
                } catch (\Throwable $e) {
×
1039
                        $this->logger->error('[file-access] FAILED to find file - userId={userId} nodeId={nodeId} error={error}', [
×
1040
                                'userId' => $userId,
×
1041
                                'nodeId' => $nodeId,
×
1042
                                'error' => $e->getMessage(),
×
1043
                        ]);
×
1044
                        throw $e;
×
1045
                }
1046

1047
                if ($originalFile->getOwner()->getUID() !== $userId) {
×
1048
                        $originalFile = $this->getNodeByIdUsingUid($originalFile->getOwner()->getUID(), $nodeId);
×
1049
                }
1050
                if ($this->isPdf($originalFile)) {
×
1051
                        $this->fileToSign = $this->getPdfToSign($originalFile);
×
1052
                } else {
1053
                        $this->fileToSign = $originalFile;
×
1054
                }
1055
                return $this->fileToSign;
×
1056
        }
1057

1058
        private function isPdf(File $file): bool {
1059
                return strcasecmp($file->getExtension(), 'pdf') === 0;
×
1060
        }
1061

1062
        protected function getEngine(): SignEngineHandler {
1063
                if (!$this->engine) {
12✔
1064
                        $originalFile = $this->getFileToSign();
12✔
1065
                        $this->engine = $this->identifyEngine($originalFile);
12✔
1066

1067
                        $this->configureEngine();
12✔
1068
                }
1069
                return $this->engine;
12✔
1070
        }
1071

1072
        private function configureEngine(): void {
1073
                $this->engine
12✔
1074
                        ->setInputFile($this->getFileToSign())
12✔
1075
                        ->setCertificate($this->getOrGeneratePfxContent($this->engine))
12✔
1076
                        ->setPassword($this->password);
12✔
1077

1078
                if ($this->engine::class === Pkcs12Handler::class) {
12✔
1079
                        $this->engine
2✔
1080
                                ->setVisibleElements($this->getVisibleElements())
2✔
1081
                                ->setSignatureParams($this->getSignatureParams());
2✔
1082
                }
1083
        }
1084

1085
        public function getLibresignFile(?int $fileId, ?string $signRequestUuid = null): FileEntity {
1086
                try {
1087
                        if ($fileId) {
3✔
1088
                                return $this->fileMapper->getById($fileId);
1✔
1089
                        }
1090

1091
                        if ($signRequestUuid) {
2✔
1092
                                $signRequest = $this->signRequestMapper->getByUuid($signRequestUuid);
2✔
1093
                                return $this->fileMapper->getById($signRequest->getFileId());
2✔
1094
                        }
1095

1096
                        throw new \Exception('Invalid arguments');
×
1097

1098
                } catch (DoesNotExistException) {
1✔
1099
                        throw new LibresignException($this->l10n->t('File not found'), 1);
1✔
1100
                }
1101
        }
1102

1103
        public function renew(SignRequestEntity $signRequest, string $method): void {
1104
                $identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
×
1105
                if (empty($identifyMethods[$method])) {
×
1106
                        throw new LibresignException($this->l10n->t('Invalid identification method'));
×
1107
                }
1108

1109
                $signRequest->setUuid(UUIDUtil::getUUID());
×
1110
                $this->signRequestMapper->update($signRequest);
×
1111

1112
                array_map(function (IIdentifyMethod $identifyMethod): void {
×
1113
                        $entity = $identifyMethod->getEntity();
×
1114
                        $entity->setAttempts($entity->getAttempts() + 1);
×
1115
                        $entity->setLastAttemptDate($this->timeFactory->getDateTime());
×
1116
                        $identifyMethod->save();
×
1117
                }, $identifyMethods[$method]);
×
1118
        }
1119

1120
        public function requestCode(
1121
                SignRequestEntity $signRequest,
1122
                string $identifyMethodName,
1123
                string $signMethodName,
1124
                string $identify = '',
1125
        ): void {
1126
                $identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
×
1127
                if (empty($identifyMethods[$identifyMethodName])) {
×
1128
                        throw new LibresignException($this->l10n->t('Invalid identification method'));
×
1129
                }
1130
                foreach ($identifyMethods[$identifyMethodName] as $identifyMethod) {
×
1131
                        try {
1132
                                $signatureMethod = $identifyMethod->getEmptyInstanceOfSignatureMethodByName($signMethodName);
×
1133
                                $signatureMethod->setEntity($identifyMethod->getEntity());
×
1134
                        } catch (InvalidArgumentException) {
×
1135
                                continue;
×
1136
                        }
1137
                        /** @var IToken $signatureMethod */
1138
                        $identifier = $identify ?: $identifyMethod->getEntity()->getIdentifierValue();
×
1139
                        $signatureMethod->requestCode($identifier, $identifyMethod->getEntity()->getIdentifierKey());
×
1140
                        return;
×
1141
                }
1142
                throw new LibresignException($this->l10n->t('Sending authorization code not enabled.'));
×
1143
        }
1144

1145
        public function getSignRequestToSign(FileEntity $libresignFile, ?string $signRequestUuid, ?IUser $user): SignRequestEntity {
1146
                $this->validateHelper->fileCanBeSigned($libresignFile);
2✔
1147
                try {
1148
                        if ($libresignFile->isEnvelope()) {
2✔
1149
                                $childFiles = $this->fileMapper->getChildrenFiles($libresignFile->getId());
×
1150
                                $allSignRequests = [];
×
1151
                                foreach ($childFiles as $childFile) {
×
1152
                                        $childSignRequests = $this->signRequestMapper->getByFileId($childFile->getId());
×
1153
                                        $allSignRequests = array_merge($allSignRequests, $childSignRequests);
×
1154
                                }
1155
                                $signRequests = $allSignRequests;
×
1156
                        } else {
1157
                                $signRequests = $this->signRequestMapper->getByFileId($libresignFile->getId());
2✔
1158
                        }
1159

1160
                        if (!empty($signRequestUuid)) {
2✔
1161
                                $signRequest = $this->getSignRequestByUuid($signRequestUuid);
2✔
1162
                        } else {
1163
                                $signRequest = array_reduce($signRequests, function (?SignRequestEntity $carry, SignRequestEntity $signRequest) use ($user): ?SignRequestEntity {
×
1164
                                        $identifyMethods = $this->identifyMethodMapper->getIdentifyMethodsFromSignRequestId($signRequest->getId());
×
1165
                                        $found = array_filter($identifyMethods, function (IdentifyMethod $identifyMethod) use ($user) {
×
1166
                                                if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL
×
1167
                                                        && $user
1168
                                                        && (
1169
                                                                $identifyMethod->getIdentifierValue() === $user->getUID()
×
1170
                                                                || $identifyMethod->getIdentifierValue() === $user->getEMailAddress()
×
1171
                                                        )
1172
                                                ) {
1173
                                                        return true;
×
1174
                                                }
1175
                                                if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT
×
1176
                                                        && $user
1177
                                                        && $identifyMethod->getIdentifierValue() === $user->getUID()
×
1178
                                                ) {
1179
                                                        return true;
×
1180
                                                }
1181
                                                return false;
×
1182
                                        });
×
1183
                                        if (count($found) > 0) {
×
1184
                                                return $signRequest;
×
1185
                                        }
1186
                                        return $carry;
×
1187
                                });
×
1188
                        }
1189

1190
                        if (!$signRequest) {
2✔
1191
                                throw new DoesNotExistException('Sign request not found');
×
1192
                        }
1193
                        if ($signRequest->getSigned()) {
2✔
1194
                                throw new LibresignException($this->l10n->t('File already signed by you'), 1);
×
1195
                        }
1196
                        return $signRequest;
2✔
1197
                } catch (DoesNotExistException) {
×
1198
                        throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
×
1199
                }
1200
        }
1201

1202
        protected function getPdfToSign(File $originalFile): File {
1203
                $file = $this->getSignedFile();
×
1204
                if ($file instanceof File) {
×
1205
                        return $file;
×
1206
                }
1207

1208
                $originalContent = $originalFile->getContent();
×
1209

1210
                if ($this->pdfSignatureDetectionService->hasSignatures($originalContent)) {
×
1211
                        return $this->createSignedFile($originalFile, $originalContent);
×
1212
                }
1213
                $metadata = $this->footerHandler->getMetadata($originalFile, $this->libreSignFile);
×
1214
                $footer = $this->footerHandler
×
1215
                        ->setTemplateVar('uuid', $this->libreSignFile->getUuid())
×
1216
                        ->setTemplateVar('signers', array_map(fn (SignRequestEntity $signer) => [
×
1217
                                'displayName' => $signer->getDisplayName(),
×
1218
                                'signed' => $signer->getSigned()
×
1219
                                        ? $signer->getSigned()->format(DateTimeInterface::ATOM)
×
1220
                                        : null,
1221
                        ], $this->getSigners()))
×
1222
                        ->getFooter($metadata['d']);
×
1223
                if ($footer) {
×
1224
                        $stamp = $this->tempManager->getTemporaryFile('stamp.pdf');
×
1225
                        file_put_contents($stamp, $footer);
×
1226

1227
                        $input = $this->tempManager->getTemporaryFile('input.pdf');
×
1228
                        file_put_contents($input, $originalContent);
×
1229

1230
                        try {
1231
                                $pdfContent = $this->pdf->applyStamp($input, $stamp);
×
1232
                        } catch (RuntimeException $e) {
×
1233
                                throw new LibresignException($e->getMessage());
×
1234
                        }
1235
                } else {
1236
                        $pdfContent = $originalContent;
×
1237
                }
1238
                return $this->createSignedFile($originalFile, $pdfContent);
×
1239
        }
1240

1241
        protected function getSignedFile(): ?File {
1242
                $nodeId = $this->libreSignFile->getSignedNodeId();
3✔
1243
                if (!$nodeId) {
3✔
1244
                        return null;
1✔
1245
                }
1246

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

1249
                if ($fileToSign->getOwner()->getUID() !== $this->libreSignFile->getUserId()) {
2✔
1250
                        $fileToSign = $this->getNodeByIdUsingUid($fileToSign->getOwner()->getUID(), $nodeId);
1✔
1251
                }
1252
                return $fileToSign;
2✔
1253
        }
1254

1255
        protected function getNodeByIdUsingUid(string $uid, int $nodeId): File {
1256
                try {
1257
                        $userFolder = $this->root->getUserFolder($uid);
4✔
1258
                } catch (NoUserException $e) {
2✔
1259
                        $this->logger->error('[file-access] NoUserException for uid={uid}', ['uid' => $uid]);
1✔
1260
                        throw new LibresignException($this->l10n->t('User not found.'));
1✔
1261
                } catch (NotPermittedException $e) {
1✔
1262
                        $this->logger->error('[file-access] NotPermittedException for uid={uid}', ['uid' => $uid]);
1✔
1263
                        throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
1✔
1264
                }
1265

1266
                try {
1267
                        $fileToSign = $userFolder->getFirstNodeById($nodeId);
2✔
1268
                } catch (\Throwable $e) {
×
1269
                        $this->logger->error('[file-access] Failed getFirstNodeById - nodeId={nodeId} error={error}', [
×
1270
                                'nodeId' => $nodeId,
×
1271
                                'error' => $e->getMessage(),
×
1272
                        ]);
×
1273
                        throw $e;
×
1274
                }
1275

1276
                if (!$fileToSign instanceof File) {
2✔
1277
                        $this->logger->error('[file-access] Node is not a File - nodeId={nodeId} type={type}', [
1✔
1278
                                'nodeId' => $nodeId,
1✔
1279
                                'type' => $fileToSign ? get_class($fileToSign) : 'NULL',
1✔
1280
                        ]);
1✔
1281
                        throw new LibresignException($this->l10n->t('File not found'));
1✔
1282
                }
1283
                return $fileToSign;
1✔
1284
        }
1285

1286
        /**
1287
         * Verify if file exists in filesystem before enqueuing background job
1288
         *
1289
         * @param string|null $uid User ID
1290
         * @param int $nodeId File node ID
1291
         * @return bool True if file exists and is accessible
1292
         */
1293
        private function verifyFileExists(?string $uid, int $nodeId): bool {
1294
                if ($uid === null || $nodeId === 0) {
1✔
1295
                        return false;
×
1296
                }
1297

1298
                try {
1299
                        $userFolder = $this->root->getUserFolder($uid);
1✔
1300
                        $node = $userFolder->getFirstNodeById($nodeId);
1✔
1301
                        return $node instanceof File;
1✔
1302
                } catch (\Throwable $e) {
×
1303
                        $this->logger->warning('[verify-file] File not accessible - nodeId={nodeId} uid={uid} error={error}', [
×
1304
                                'nodeId' => $nodeId,
×
1305
                                'uid' => $uid,
×
1306
                                'error' => $e->getMessage(),
×
1307
                        ]);
×
1308
                        return false;
×
1309
                }
1310
        }
1311

1312
        private function cleanupUnsignedSignedFile(): void {
1313
                if (!$this->createdSignedFile instanceof File) {
×
1314
                        return;
×
1315
                }
1316

1317
                try {
1318
                        $this->createdSignedFile->delete();
×
1319
                } catch (\Throwable $e) {
×
1320
                        $this->logger->warning('Failed to delete temporary signed file: ' . $e->getMessage());
×
1321
                } finally {
1322
                        $this->createdSignedFile = null;
×
1323
                }
1324
        }
1325

1326
        private function createSignedFile(File $originalFile, string $content): File {
1327
                $filename = preg_replace(
×
1328
                        '/' . $originalFile->getExtension() . '$/',
×
1329
                        $this->l10n->t('signed') . '.' . $originalFile->getExtension(),
×
1330
                        basename($originalFile->getPath())
×
1331
                );
×
1332
                $owner = $originalFile->getOwner()->getUID();
×
1333

1334
                $fileId = $this->libreSignFile->getId();
×
1335
                $extension = $originalFile->getExtension();
×
1336
                $uniqueFilename = substr($filename, 0, -strlen($extension) - 1) . '_' . $fileId . '.' . $extension;
×
1337

1338
                try {
1339
                        /** @var \OCP\Files\Folder */
1340
                        $parentFolder = $this->root->getUserFolder($owner)->getFirstNodeById($originalFile->getParentId());
×
1341

1342
                        $this->createdSignedFile = $parentFolder->newFile($uniqueFilename, $content);
×
1343

1344
                        return $this->createdSignedFile;
×
1345
                } catch (NotPermittedException) {
×
1346
                        throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
×
1347
                } catch (\Exception $e) {
×
1348
                        throw $e;
×
1349
                }
1350
        }
1351

1352
        /**
1353
         * @throws DoesNotExistException
1354
         */
1355
        public function getSignRequestByUuid(string $uuid): SignRequestEntity {
1356
                $this->validateHelper->validateUuidFormat($uuid);
4✔
1357
                return $this->signRequestMapper->getByUuid($uuid);
3✔
1358
        }
1359

1360
        /**
1361
         * @throws DoesNotExistException
1362
         */
1363
        public function getFile(int $signRequestId): FileEntity {
1364
                return $this->fileMapper->getById($signRequestId);
×
1365
        }
1366

1367
        /**
1368
         * @throws DoesNotExistException
1369
         */
1370
        public function getFileByUuid(string $uuid): FileEntity {
1371
                return $this->fileMapper->getByUuid($uuid);
×
1372
        }
1373

1374
        public function getIdDocById(int $fileId): IdDocs {
1375
                return $this->idDocsMapper->getByFileId($fileId);
×
1376
        }
1377

1378
        /**
1379
         * @return File[] Array of files
1380
         */
1381
        public function getNextcloudFiles(FileEntity $fileData): array {
1382
                if ($fileData->getNodeType() === 'envelope') {
1✔
1383
                        $children = $this->fileMapper->getChildrenFiles($fileData->getId());
×
1384
                        $files = [];
×
1385
                        foreach ($children as $child) {
×
1386
                                $nodeId = $child->getNodeId();
×
1387
                                if ($nodeId === null) {
×
1388
                                        throw new LibresignException(json_encode([
×
1389
                                                'action' => JSActions::ACTION_DO_NOTHING,
×
1390
                                                'errors' => [['message' => $this->l10n->t('File not found')]],
×
1391
                                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
×
1392
                                }
1393
                                $file = $this->root->getUserFolder($child->getUserId())->getFirstNodeById($nodeId);
×
1394
                                if ($file instanceof File) {
×
1395
                                        $files[] = $file;
×
1396
                                }
1397
                        }
1398
                        return $files;
×
1399
                }
1400

1401
                $nodeId = $fileData->getNodeId();
1✔
1402
                if ($nodeId === null) {
1✔
1403
                        throw new LibresignException(json_encode([
1✔
1404
                                'action' => JSActions::ACTION_DO_NOTHING,
1✔
1405
                                'errors' => [['message' => $this->l10n->t('File not found')]],
1✔
1406
                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
1✔
1407
                }
1408
                $fileToSign = $this->root->getUserFolder($fileData->getUserId())->getFirstNodeById($nodeId);
×
1409
                if (!$fileToSign instanceof File) {
×
1410
                        throw new LibresignException(json_encode([
×
1411
                                'action' => JSActions::ACTION_DO_NOTHING,
×
1412
                                'errors' => [['message' => $this->l10n->t('File not found')]],
×
1413
                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
×
1414
                }
1415
                return [$fileToSign];
×
1416
        }
1417

1418
        /**
1419
         * @return array<FileEntity>
1420
         */
1421
        public function getNextcloudFilesWithEntities(FileEntity $fileData): array {
1422
                if ($fileData->getNodeType() === 'envelope') {
×
1423
                        $children = $this->fileMapper->getChildrenFiles($fileData->getId());
×
1424
                        $result = [];
×
1425
                        foreach ($children as $child) {
×
1426
                                $nodeId = $child->getNodeId();
×
1427
                                if ($nodeId === null) {
×
1428
                                        throw new LibresignException(json_encode([
×
1429
                                                'action' => JSActions::ACTION_DO_NOTHING,
×
1430
                                                'errors' => [['message' => $this->l10n->t('File not found')]],
×
1431
                                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
×
1432
                                }
1433
                                $file = $this->root->getUserFolder($child->getUserId())->getFirstNodeById($nodeId);
×
1434
                                if ($file instanceof File) {
×
1435
                                        $result[] = $child;
×
1436
                                }
1437
                        }
1438
                        return $result;
×
1439
                }
1440

1441
                $nodeId = $fileData->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
                $fileToSign = $this->root->getUserFolder($fileData->getUserId())->getFirstNodeById($nodeId);
×
1449
                if (!$fileToSign instanceof File) {
×
1450
                        throw new LibresignException(json_encode([
×
1451
                                'action' => JSActions::ACTION_DO_NOTHING,
×
1452
                                'errors' => [['message' => $this->l10n->t('File not found')]],
×
1453
                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
×
1454
                }
1455
                return [$fileData];
×
1456
        }
1457

1458
        public function validateSigner(string $uuid, ?IUser $user = null): void {
1459
                $this->validateHelper->validateSigner($uuid, $user);
×
1460
        }
1461

1462
        public function validateRenewSigner(string $uuid, ?IUser $user = null): void {
1463
                $this->validateHelper->validateRenewSigner($uuid, $user);
×
1464
        }
1465

1466
        public function getSignerData(?IUser $user, ?SignRequestEntity $signRequest = null): array {
1467
                $return = ['user' => ['name' => null]];
×
1468
                if ($signRequest) {
×
1469
                        $return['user']['name'] = $signRequest->getDisplayName();
×
1470
                } elseif ($user) {
×
1471
                        $return['user']['name'] = $user->getDisplayName();
×
1472
                }
1473
                return $return;
×
1474
        }
1475

1476
        public function getAvailableIdentifyMethodsFromSettings(): array {
1477
                $identifyMethods = $this->identifyMethodService->getIdentifyMethodsSettings();
×
1478
                $return = array_map(fn (array $identifyMethod): array => [
×
1479
                        'mandatory' => $identifyMethod['mandatory'],
×
1480
                        'identifiedAtDate' => null,
×
1481
                        'validateCode' => false,
×
1482
                        'method' => $identifyMethod['name'],
×
1483
                ], $identifyMethods);
×
1484
                return $return;
×
1485
        }
1486

1487
        public function getFileUrl(int $fileId, string $uuid): string {
1488
                try {
1489
                        $this->idDocsMapper->getByFileId($fileId);
×
1490
                        return $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $uuid]);
×
1491
                } catch (DoesNotExistException) {
×
1492
                        return $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $uuid]);
×
1493
                }
1494
        }
1495

1496
        /**
1497
         * Get PDF URLs for signing
1498
         * For envelopes: returns URLs for all child files
1499
         * For regular files: returns URL for the file itself
1500
         *
1501
         * @return string[]
1502
         */
1503
        public function getPdfUrlsForSigning(FileEntity $fileEntity, SignRequestEntity $signRequestEntity): array {
1504
                if (!$fileEntity->isEnvelope()) {
×
1505
                        return [
×
1506
                                $this->getFileUrl($fileEntity->getId(), $signRequestEntity->getUuid())
×
1507
                        ];
×
1508
                }
1509

1510
                $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod(
×
1511
                        $fileEntity->getId(),
×
1512
                        $signRequestEntity->getId()
×
1513
                );
×
1514

1515
                $pdfUrls = [];
×
1516
                foreach ($childSignRequests as $childSignRequest) {
×
1517
                        $pdfUrls[] = $this->getFileUrl(
×
1518
                                $childSignRequest->getFileId(),
×
1519
                                $childSignRequest->getUuid()
×
1520
                        );
×
1521
                }
1522

1523
                return $pdfUrls;
×
1524
        }
1525

1526
        private function recordSignatureAttempt(Exception $exception): void {
1527
                if (!$this->libreSignFile) {
×
1528
                        return;
×
1529
                }
1530

1531
                $metadata = $this->libreSignFile->getMetadata() ?? [];
×
1532

1533
                if (!isset($metadata['signature_attempts'])) {
×
1534
                        $metadata['signature_attempts'] = [];
×
1535
                }
1536

1537
                $attempt = [
×
1538
                        'timestamp' => (new DateTime())->format(\DateTime::ATOM),
×
1539
                        'engine' => $this->engine ? get_class($this->engine) : 'unknown',
×
1540
                        'error_message' => $exception->getMessage(),
×
1541
                        'error_code' => $exception->getCode(),
×
1542
                ];
×
1543

1544
                $metadata['signature_attempts'][] = $attempt;
×
1545
                $this->libreSignFile->setMetadata($metadata);
×
1546
                $this->fileMapper->update($this->libreSignFile);
×
1547
        }
1548
}
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