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

LibreSign / libresign / 24083625942

07 Apr 2026 01:22PM UTC coverage: 55.6%. First build
24083625942

Pull #7450

github

web-flow
Merge b1c2ec824 into 1112b1165
Pull Request #7450: chore(rector): apply safe test-only batch and php82 baseline

25 of 50 new or added lines in 15 files covered. (50.0%)

10231 of 18401 relevant lines covered (55.6%)

6.61 hits per line

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

66.22
/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\IdentifyMethodMapper;
27
use OCA\Libresign\Db\SignRequest as SignRequestEntity;
28
use OCA\Libresign\Db\SignRequestMapper;
29
use OCA\Libresign\Db\UserElementMapper;
30
use OCA\Libresign\Enum\FileStatus;
31
use OCA\Libresign\Events\SignedEventFactory;
32
use OCA\Libresign\Exception\LibresignException;
33
use OCA\Libresign\Handler\DocMdpHandler;
34
use OCA\Libresign\Handler\FooterHandler;
35
use OCA\Libresign\Handler\PdfTk\Pdf;
36
use OCA\Libresign\Handler\SignEngine\Pkcs12Handler;
37
use OCA\Libresign\Handler\SignEngine\SignEngineFactory;
38
use OCA\Libresign\Handler\SignEngine\SignEngineHandler;
39
use OCA\Libresign\Helper\JSActions;
40
use OCA\Libresign\Helper\ValidateHelper;
41
use OCA\Libresign\Service\Envelope\EnvelopeStatusDeterminer;
42
use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
43
use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\IToken;
44
use OCA\Libresign\Service\SignRequest\SignRequestService;
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
                private SubjectAlternativeNameService $subjectAlternativeNameService,
123
                private SignRequestService $signRequestService,
124
        ) {
125
        }
198✔
126

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

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

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

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

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

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

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

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

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

231
        public function setCurrentUser(?IUser $user): self {
232
                $this->user = $user;
34✔
233
                return $this;
34✔
234
        }
235

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

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

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

269
                if (empty($list) && ($fileId === null || $signRequestId === null)) {
37✔
270
                        return $this;
20✔
271
                }
272

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

277
                $fileElements = $this->fileElementMapper->getByFileIdAndSignRequestId($fileId, $signRequestId);
14✔
278
                $canCreateSignature = $this->signerElementsService->canCreateSignature();
14✔
279
                $newElements = [];
14✔
280

281
                foreach ($fileElements as $fileElement) {
14✔
282
                        $fileElementId = $fileElement->getId();
12✔
283
                        if (!$canCreateSignature) {
12✔
284
                                $newElements[$fileElementId] = new VisibleElementAssoc($fileElement);
1✔
285
                                continue;
1✔
286
                        }
287
                        $element = $this->array_find($list, fn (array $element): bool => ($element['documentElementId'] ?? '') === $fileElementId);
11✔
288
                        if (!$element) {
11✔
289
                                // No user-submitted image for this element (e.g. clickToSign).
290
                                // Still include the file element so the admin background image (n0 layer)
291
                                // is rendered in the signature stamp on the document.
292
                                $newElements[$fileElementId] = new VisibleElementAssoc($fileElement);
1✔
293
                                continue;
1✔
294
                        }
295
                        $nodeId = $this->getNodeId($element, $fileElement);
10✔
296

297
                        $existing = $this->elements[$fileElementId] ?? null;
8✔
298
                        if ($existing instanceof VisibleElementAssoc && $this->isTempFileValid($existing)) {
8✔
299
                                $newElements[$fileElementId] = $existing;
×
300
                                continue;
×
301
                        }
302

303
                        $newElements[$fileElementId] = $this->bindFileElementWithTempFile($fileElement, $nodeId);
8✔
304
                }
305

306
                $this->elements = $newElements;
7✔
307

308
                return $this;
7✔
309
        }
310

311
        private function isTempFileValid(VisibleElementAssoc $elementAssoc): bool {
312
                $tempFile = $elementAssoc->getTempFile();
×
313
                return $tempFile !== '' && is_file($tempFile);
×
314
        }
315

316
        private function getNodeId(?array $element, FileElement $fileElement): int {
317
                if ($this->isValidElement($element)) {
10✔
318
                        return (int)$element['profileNodeId'];
8✔
319
                }
320

321
                return $this->retrieveUserElement($fileElement);
×
322
        }
323

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

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

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

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

367
        private function getNode(int $nodeId): ?File {
368
                try {
369
                        return $this->folderService->getFileByNodeId($nodeId);
8✔
370
                } catch (\Throwable) {
4✔
371
                        $filesOfElementes = $this->signerElementsService->getElementsFromSession();
4✔
372
                        return $this->array_find($filesOfElementes, fn ($file) => $file->getId() === $nodeId);
4✔
373
                }
374
        }
375

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

391
                return null;
5✔
392
        }
393

394
        public function getVisibleElements(): array {
395
                return $this->elements;
7✔
396
        }
397

398
        public function getJobArgumentsWithoutCredentials(): array {
399
                $args = [];
2✔
400

401
                if (!empty($this->userUniqueIdentifier)) {
2✔
402
                        $args['userUniqueIdentifier'] = $this->userUniqueIdentifier;
2✔
403
                }
404

405
                if (!empty($this->friendlyName)) {
2✔
406
                        $args['friendlyName'] = $this->friendlyName;
2✔
407
                }
408

409
                if (!empty($this->elements)) {
2✔
410
                        $args['visibleElements'] = $this->elements;
1✔
411
                }
412

413
                if ($this->signRequest instanceof SignRequestEntity && $this->signRequest->getMetadata()) {
2✔
414
                        $args['metadata'] = $this->signRequest->getMetadata();
2✔
415
                }
416

417
                if ($this->user instanceof IUser) {
2✔
418
                        $args['userId'] = $this->user->getUID();
2✔
419
                }
420

421
                return $args;
2✔
422
        }
423

424
        public function validateSigningRequirements(): void {
425
                $this->tsaValidationService->validateConfiguration();
1✔
426
        }
427

428
        public function sign(): void {
429
                $signRequests = $this->getSignRequestsToSign();
18✔
430

431
                if (empty($signRequests)) {
18✔
432
                        throw new LibresignException('No sign requests found to process');
×
433
                }
434

435
                $this->executeSigningStrategy($signRequests);
18✔
436
        }
437

438
        private function executeSigningStrategy(array $signRequests): ?DateTimeInterface {
439
                if ($this->signingCoordinatorService->shouldUseParallelProcessing(count($signRequests))) {
18✔
440
                        return $this->processParallelSigning($signRequests);
×
441
                }
442
                return $this->signSequentially($signRequests);
18✔
443
        }
444

445
        private function processParallelSigning(array $signRequests): ?DateTimeInterface {
446
                $this->enqueueParallelSigningJobs($signRequests, $this->getJobArgumentsWithoutCredentials());
×
447
                return $this->getLatestSignedDate($signRequests);
×
448
        }
449

450
        private function getLatestSignedDate(array $signRequests): ?DateTimeInterface {
451
                $latestSignedDate = null;
×
452

453
                foreach ($signRequests as $signRequestData) {
×
454
                        try {
455
                                $signRequest = $this->signRequestMapper->getById($signRequestData['signRequest']->getId());
×
456
                                if ($signRequest->getSigned()) {
×
457
                                        $latestSignedDate = $signRequest->getSigned();
×
458
                                }
459
                        } catch (DoesNotExistException) {
×
460
                        }
461
                }
462

463
                return $latestSignedDate;
×
464
        }
465

466
        public function signSingleFile(FileEntity $libreSignFile, SignRequestEntity $signRequest): void {
467
                $previousState = $this->saveCachedState();
×
468
                $this->resetCachedState();
×
469

470
                if ($libreSignFile->getSignedHash()) {
×
471
                        $this->restoreCachedState($previousState);
×
472
                        return;
×
473
                }
474

475
                $previousLibreSignFile = $this->libreSignFile;
×
476
                $previousSignRequest = $this->signRequest;
×
477
                $this->libreSignFile = $libreSignFile;
×
478
                $this->signRequest = $signRequest;
×
479
                $this->setVisibleElements($this->elementsInput);
×
480

481
                try {
482
                        $this->validateDocMdpAllowsSignatures();
×
483

484
                        try {
485
                                $signedFile = $this->getEngine()->sign();
×
486
                        } catch (LibresignException|Exception $e) {
×
487
                                $this->cleanupUnsignedSignedFile();
×
488
                                $this->recordSignatureAttempt($e);
×
489
                                throw $e;
×
490
                        }
491

492
                        $hash = $this->computeHash($signedFile);
×
493
                        $this->updateSignRequest($hash);
×
494
                        $this->updateLibreSignFile($libreSignFile, $signedFile->getId(), $hash);
×
495

496
                        $this->dispatchSignedEvent();
×
497

498
                        $envelopeContext = $this->getEnvelopeContext();
×
499
                        if ($envelopeContext['envelope'] instanceof FileEntity) {
×
500
                                $this->updateEnvelopeStatus(
×
501
                                        $envelopeContext['envelope'],
×
502
                                        $envelopeContext['envelopeSignRequest'] ?? null,
×
503
                                        $signRequest->getSigned()
×
504
                                );
×
505
                        }
506
                } finally {
507
                        $this->libreSignFile = $previousLibreSignFile;
×
508
                        $this->signRequest = $previousSignRequest;
×
509
                        $this->restoreCachedState($previousState);
×
510
                }
511
        }
512

513
        private function saveCachedState(): array {
514
                return [
×
515
                        'fileToSign' => $this->fileToSign,
×
516
                        'createdSignedFile' => $this->createdSignedFile,
×
517
                        'engine' => $this->engine,
×
518
                ];
×
519
        }
520

521
        private function resetCachedState(): void {
522
                $this->fileToSign = null;
×
523
                $this->createdSignedFile = null;
×
524
                $this->engine = null;
×
525
        }
526

527
        private function restoreCachedState(array $state): void {
528
                $this->fileToSign = $state['fileToSign'];
×
529
                $this->createdSignedFile = $state['createdSignedFile'];
×
530
                $this->engine = $state['engine'];
×
531
        }
532

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

535
                if (empty($signRequests)) {
3✔
536
                        throw new LibresignException('No sign requests found to process');
×
537
                }
538

539
                $enqueued = 0;
3✔
540
                foreach ($signRequests as $signRequestData) {
3✔
541
                        $file = $signRequestData['file'];
3✔
542
                        $signRequest = $signRequestData['signRequest'];
3✔
543

544
                        if ($file->getSignedHash()) {
3✔
545
                                continue;
1✔
546
                        }
547

548
                        $nodeId = $file->getNodeId();
3✔
549
                        $userId = $file->getUserId() ?? $signRequest->getUserId();
3✔
550

551
                        if ($nodeId === null || !$this->verifyFileExists($userId, $nodeId)) {
3✔
552
                                continue;
×
553
                        }
554

555
                        $this->enqueueSigningJobForFile($signRequest, $file, $jobArguments);
3✔
556
                        $enqueued++;
3✔
557
                }
558

559
                return $enqueued;
3✔
560
        }
561

562
        private function enqueueSigningJobForFile(SignRequestEntity $signRequest, FileEntity $file, array $jobArguments): void {
563
                $args = $jobArguments;
3✔
564
                $args = $this->addCredentialsToJobArgs($args, $signRequest, $file);
3✔
565
                $args = array_merge($args, [
3✔
566
                        'fileId' => $file->getId(),
3✔
567
                        'signRequestId' => $signRequest->getId(),
3✔
568
                        'signRequestUuid' => $signRequest->getUuid(),
3✔
569
                        'userId' => $file->getUserId(),
3✔
570
                        'isExternalSigner' => !str_starts_with($args['userUniqueIdentifier'] ?? '', 'account:'),
3✔
571
                ]);
3✔
572

573
                $this->jobList->add(SignSingleFileJob::class, $args);
3✔
574
        }
575

576
        private function addCredentialsToJobArgs(array $args, SignRequestEntity $signRequest, FileEntity $file): array {
577
                if (!($this->signWithoutPassword || !empty($this->password))) {
3✔
578
                        return $args;
1✔
579
                }
580

581
                $credentialsId = 'libresign_sign_' . $signRequest->getId() . '_' . $file->getId() . '_' . $this->secureRandom->generate(8, ISecureRandom::CHAR_ALPHANUMERIC);
2✔
582
                $this->credentialsManager->store(
2✔
583
                        $this->user?->getUID() ?? '',
2✔
584
                        $credentialsId,
2✔
585
                        [
2✔
586
                                'signWithoutPassword' => $this->signWithoutPassword,
2✔
587
                                'password' => $this->password,
2✔
588
                                'timestamp' => time(),
2✔
589
                                'expires' => time() + 3600,
2✔
590
                        ]
2✔
591
                );
2✔
592
                $args['credentialsId'] = $credentialsId;
2✔
593

594
                return $args;
2✔
595
        }
596

597
        /**
598
         * @return DateTimeInterface|null Last signed date
599
         */
600
        private function signSequentially(array $signRequests): ?DateTimeInterface {
601
                $envelopeLastSignedDate = null;
18✔
602
                $envelopeContext = $this->getEnvelopeContext();
18✔
603

604
                foreach ($signRequests as $index => $signRequestData) {
18✔
605
                        $this->libreSignFile = $signRequestData['file'];
18✔
606
                        if ($this->libreSignFile->getStatus() === FileStatus::SIGNED->value) {
18✔
607
                                continue;
1✔
608
                        }
609
                        $this->signRequest = $signRequestData['signRequest'];
17✔
610
                        $this->engine = null;
17✔
611
                        $this->setVisibleElements($this->elementsInput);
17✔
612
                        $this->fileToSign = null;
17✔
613

614
                        $this->validateDocMdpAllowsSignatures();
17✔
615

616
                        try {
617
                                $signedFile = $this->getEngine()->sign();
15✔
618
                        } catch (LibresignException|Exception $e) {
×
619
                                $this->cleanupUnsignedSignedFile();
×
620
                                $this->recordSignatureAttempt($e);
×
621

622
                                $isEnvelope = $this->libreSignFile->isEnvelope() || $this->libreSignFile->hasParent();
×
623
                                if (!$isEnvelope) {
×
624
                                        throw $e;
×
625
                                }
626
                                continue;
×
627
                        }
628

629
                        $hash = $this->computeHash($signedFile);
15✔
630
                        $envelopeLastSignedDate = $this->getEngine()->getLastSignedDate();
15✔
631

632
                        $this->updateSignRequest($hash);
15✔
633
                        $this->updateLibreSignFile($this->libreSignFile, $signedFile->getId(), $hash);
15✔
634

635
                        $this->dispatchSignedEvent();
15✔
636
                }
637

638
                if ($envelopeContext['envelope'] instanceof FileEntity) {
16✔
639
                        $this->updateEnvelopeStatus(
×
640
                                $envelopeContext['envelope'],
×
641
                                $envelopeContext['envelopeSignRequest'] ?? null,
×
642
                                $envelopeLastSignedDate
×
643
                        );
×
644
                }
645

646
                return $envelopeLastSignedDate;
16✔
647
        }
648

649
        /**
650
         * @return array Array of sign request data with 'file' => FileEntity, 'signRequest' => SignRequestEntity
651
         */
652
        private function getSignRequestsToSign(): array {
653
                if (!$this->libreSignFile->isEnvelope()
23✔
654
                        && !$this->libreSignFile->hasParent()
23✔
655
                ) {
656
                        return [[
19✔
657
                                'file' => $this->libreSignFile,
19✔
658
                                'signRequest' => $this->signRequest,
19✔
659
                        ]];
19✔
660
                }
661

662
                return $this->buildEnvelopeSignRequests();
4✔
663
        }
664

665
        /**
666
         * @return array Array of sign request data with 'file' => FileEntity, 'signRequest' => SignRequestEntity
667
         */
668
        private function buildEnvelopeSignRequests(): array {
669
                $envelopeId = $this->libreSignFile->isEnvelope()
4✔
670
                        ? $this->libreSignFile->getId()
3✔
671
                        : $this->libreSignFile->getParentFileId();
1✔
672

673
                $childFiles = $this->fileMapper->getChildrenFiles($envelopeId);
4✔
674
                if (empty($childFiles)) {
4✔
675
                        throw new LibresignException('No files found in envelope');
1✔
676
                }
677

678
                $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod(
3✔
679
                        $envelopeId,
3✔
680
                        $this->signRequest->getId()
3✔
681
                );
3✔
682

683
                if (empty($childSignRequests)) {
3✔
684
                        throw new LibresignException('No sign requests found for envelope files');
1✔
685
                }
686

687
                $signRequestsData = [];
2✔
688
                foreach ($childSignRequests as $childSignRequest) {
2✔
689
                        $childFile = $this->array_find(
2✔
690
                                $childFiles,
2✔
691
                                fn (FileEntity $file) => $file->getId() === $childSignRequest->getFileId()
2✔
692
                        );
2✔
693

694
                        if ($childFile) {
2✔
695
                                $signRequestsData[] = [
2✔
696
                                        'file' => $childFile,
2✔
697
                                        'signRequest' => $childSignRequest,
2✔
698
                                ];
2✔
699
                        }
700
                }
701

702
                return $signRequestsData;
2✔
703
        }
704

705
        /**
706
         * @return array Array with 'envelope' => FileEntity or null, 'envelopeSignRequest' => SignRequestEntity or null
707
         */
708
        private function getEnvelopeContext(): array {
709
                $result = [
22✔
710
                        'envelope' => null,
22✔
711
                        'envelopeSignRequest' => null,
22✔
712
                ];
22✔
713

714
                if (!$this->libreSignFile->isEnvelope() && !$this->libreSignFile->hasParent()) {
22✔
715
                        return $result;
19✔
716
                }
717

718
                if ($this->libreSignFile->isEnvelope()) {
3✔
719
                        $result['envelope'] = $this->libreSignFile;
1✔
720
                        $result['envelopeSignRequest'] = $this->signRequest;
1✔
721
                        return $result;
1✔
722
                }
723

724
                try {
725
                        $envelopeId = $this->libreSignFile->isEnvelope()
2✔
726
                                ? $this->libreSignFile->getId()
×
727
                                : $this->libreSignFile->getParentFileId();
2✔
728
                        $result['envelope'] = $this->fileMapper->getById($envelopeId);
2✔
729
                        $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($this->signRequest->getId());
1✔
730
                        $result['envelopeSignRequest'] = $this->signRequestMapper->getByIdentifyMethodAndFileId(
1✔
731
                                $identifyMethod,
1✔
732
                                $result['envelope']->getId()
1✔
733
                        );
1✔
734
                } catch (DoesNotExistException) {
1✔
735
                }
736

737
                return $result;
2✔
738
        }
739

740
        private function updateEnvelopeStatus(
741
                FileEntity $envelope,
742
                ?SignRequestEntity $envelopeSignRequest = null,
743
                ?DateTimeInterface $signedDate = null,
744
        ): void {
745
                $childFiles = $this->fileMapper->getChildrenFiles($envelope->getId());
1✔
746
                $signRequestsMap = $this->buildSignRequestsMap($childFiles);
1✔
747

748
                $status = $this->envelopeStatusDeterminer->determineStatus($childFiles, $signRequestsMap);
1✔
749
                $envelope->setStatus($status);
1✔
750

751
                $this->handleSignedEnvelopeSignRequest($envelope, $envelopeSignRequest, $signedDate, $status);
1✔
752

753
                $this->updateEnvelopeMetadata($envelope);
1✔
754
                $this->fileMapper->update($envelope);
1✔
755
                $this->updateEntityCacheAfterDbSave($envelope);
1✔
756
        }
757

758
        private function buildSignRequestsMap(array $childFiles): array {
759
                $signRequestsMap = [];
2✔
760
                foreach ($childFiles as $childFile) {
2✔
761
                        $signRequestsMap[$childFile->getId()] = $this->signRequestMapper->getByFileId($childFile->getId());
2✔
762
                }
763
                return $signRequestsMap;
2✔
764
        }
765

766
        private function handleSignedEnvelopeSignRequest(
767
                FileEntity $envelope,
768
                ?SignRequestEntity $envelopeSignRequest,
769
                ?DateTimeInterface $signedDate,
770
                int $status,
771
        ): void {
772
                if (!($envelopeSignRequest instanceof SignRequestEntity)) {
3✔
773
                        return;
1✔
774
                }
775

776
                $envelopeSignRequest->setSigned($signedDate ?: new DateTime());
2✔
777
                $envelopeSignRequest->setStatusEnum(\OCA\Libresign\Enum\SignRequestStatus::SIGNED);
2✔
778
                $this->signRequestMapper->update($envelopeSignRequest);
2✔
779
                $this->sequentialSigningService
2✔
780
                        ->setFile($envelope)
2✔
781
                        ->releaseNextOrder(
2✔
782
                                $envelopeSignRequest->getFileId(),
2✔
783
                                $envelopeSignRequest->getSigningOrder()
2✔
784
                        );
2✔
785
        }
786

787
        private function updateEnvelopeMetadata(FileEntity $envelope): void {
788
                $meta = $envelope->getMetadata() ?? [];
2✔
789
                $meta['status_changed_at'] = (new DateTime())->format(DateTimeInterface::ATOM);
2✔
790
                $envelope->setMetadata($meta);
2✔
791
        }
792

793
        /**
794
         * @throws LibresignException If the document has DocMDP level 1 (no changes allowed)
795
         */
796
        protected function validateDocMdpAllowsSignatures(): void {
797
                $resource = $this->getLibreSignFileAsResource();
18✔
798

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

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

831
        protected function computeHash(File $file): string {
832
                return hash('sha256', $file->getContent());
2✔
833
        }
834

835
        protected function updateSignRequest(string $hash): void {
836
                $lastSignedDate = $this->getEngine()->getLastSignedDate();
13✔
837
                $this->signRequest->setSigned($lastSignedDate);
13✔
838
                $this->signRequest->setSignedHash($hash);
13✔
839
                $this->signRequest->setStatusEnum(\OCA\Libresign\Enum\SignRequestStatus::SIGNED);
13✔
840

841
                $certificateInfo = $this->getEngine()->readCertificate();
13✔
842
                $this->storeCertificateInfoInMetadata($certificateInfo);
13✔
843

844
                $this->signRequestMapper->update($this->signRequest);
13✔
845

846
                $this->sequentialSigningService
13✔
847
                        ->setFile($this->libreSignFile)
13✔
848
                        ->releaseNextOrder(
13✔
849
                                $this->signRequest->getFileId(),
13✔
850
                                $this->signRequest->getSigningOrder()
13✔
851
                        );
13✔
852
        }
853

854
        private function storeCertificateInfoInMetadata(array $certificateInfo): void {
855
                $metadata = $this->signRequest->getMetadata() ?? [];
15✔
856

857
                $certificateData = [];
15✔
858

859
                if (isset($certificateInfo['serialNumber'])) {
15✔
860
                        $certificateData['serialNumber'] = $certificateInfo['serialNumber'];
2✔
861
                }
862
                if (isset($certificateInfo['serialNumberHex'])) {
15✔
863
                        $certificateData['serialNumberHex'] = $certificateInfo['serialNumberHex'];
1✔
864
                }
865
                if (isset($certificateInfo['hash'])) {
15✔
866
                        $certificateData['hash'] = $certificateInfo['hash'];
1✔
867
                }
868
                if (isset($certificateInfo['subject'])) {
15✔
869
                        $certificateData['subject'] = $certificateInfo['subject'];
1✔
870
                }
871

872
                if (!empty($certificateData)) {
15✔
873
                        $metadata['certificate_info'] = $certificateData;
2✔
874
                        $this->signRequest->setMetadata($metadata);
2✔
875
                }
876
        }
877

878
        protected function updateLibreSignFile(FileEntity $libreSignFile, int $nodeId, string $hash): void {
879
                $libreSignFile->setSignedNodeId($nodeId);
13✔
880
                $libreSignFile->setSignedHash($hash);
13✔
881
                $this->setNewStatusIfNecessary($libreSignFile);
13✔
882
                $this->fileStatusService->update($libreSignFile);
13✔
883

884
                if ($libreSignFile->hasParent()) {
13✔
885
                        $this->fileStatusService->propagateStatusToParent($libreSignFile->getParentFileId());
×
886
                }
887
        }
888

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

907
                $event = $this->signedEventFactory->make(
13✔
908
                        $this->signRequest,
13✔
909
                        $this->libreSignFile,
13✔
910
                        $this->getEngine()->getInputFile(),
13✔
911
                        $this->signWithoutPassword,
13✔
912
                        $certificateSerialNumber,
13✔
913
                );
13✔
914
                $this->eventDispatcher->dispatchTyped($event);
13✔
915
        }
916

917
        protected function identifyEngine(File $file): SignEngineHandler {
918
                return $this->signEngineFactory->resolve($file->getExtension());
10✔
919
        }
920

921
        protected function getSignatureParams(): array {
922
                $certificateData = $this->readCertificate();
20✔
923
                $signatureParams = $this->buildBaseSignatureParams($certificateData);
20✔
924
                $signatureParams = $this->addEmailToSignatureParams($signatureParams, $certificateData);
20✔
925
                $signatureParams = $this->addMetadataToSignatureParams($signatureParams);
20✔
926
                return $signatureParams;
20✔
927
        }
928

929
        private function buildBaseSignatureParams(array $certificateData): array {
930
                $issuerCommonName = $this->normalizeCertificateFieldToString($certificateData['issuer']['CN'] ?? '');
20✔
931
                $signerCommonName = $this->normalizeCertificateFieldToString($certificateData['subject']['CN'] ?? '');
20✔
932

933
                return [
20✔
934
                        'DocumentUUID' => $this->libreSignFile?->getUuid(),
20✔
935
                        'IssuerCommonName' => $issuerCommonName,
20✔
936
                        'SignerCommonName' => $signerCommonName,
20✔
937
                        'LocalSignerTimezone' => $this->dateTimeZone->getTimeZone()->getName(),
20✔
938
                        'LocalSignerSignatureDateTime' => (new DateTime('now', new \DateTimeZone('UTC')))
20✔
939
                                ->format(DateTimeInterface::ATOM)
20✔
940
                ];
20✔
941
        }
942

943
        private function normalizeCertificateFieldToString(mixed $value): string {
944
                if (is_array($value)) {
20✔
945
                        $flattened = [];
1✔
946
                        array_walk_recursive($value, static function (mixed $item) use (&$flattened): void {
1✔
947
                                if ($item !== null) {
1✔
948
                                        $flattened[] = (string)$item;
1✔
949
                                }
950
                        });
1✔
951
                        return implode(', ', $flattened);
1✔
952
                }
953

954
                return $value === null ? '' : (string)$value;
19✔
955
        }
956

957
        private function addEmailToSignatureParams(array $signatureParams, array $certificateData): array {
958
                $email = $this->subjectAlternativeNameService->extractEmailFromCertificate($certificateData);
20✔
959
                if ($email) {
20✔
960
                        $signatureParams['SignerEmail'] = $email;
6✔
961
                }
962

963
                if (empty($signatureParams['SignerEmail']) && $this->user instanceof IUser) {
20✔
964
                        $signatureParams['SignerEmail'] = $this->user->getEMailAddress();
×
965
                }
966

967
                if (empty($signatureParams['SignerEmail']) && $this->signRequest instanceof SignRequestEntity) {
20✔
968
                        $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($this->signRequest->getId());
14✔
969
                        if ($identifyMethod->getName() === IdentifyMethodService::IDENTIFY_EMAIL) {
14✔
970
                                $signatureParams['SignerEmail'] = $identifyMethod->getEntity()->getIdentifierValue();
1✔
971
                        }
972
                }
973
                return $signatureParams;
20✔
974
        }
975

976
        private function addMetadataToSignatureParams(array $signatureParams): array {
977
                $signRequestMetadata = $this->signRequest->getMetadata();
20✔
978
                if (isset($signRequestMetadata['remote-address'])) {
20✔
979
                        $signatureParams['SignerIP'] = $signRequestMetadata['remote-address'];
2✔
980
                }
981
                if (isset($signRequestMetadata['user-agent'])) {
20✔
982
                        $signatureParams['SignerUserAgent'] = $signRequestMetadata['user-agent'];
2✔
983
                }
984
                if ($this->libreSignFile?->getMetadata()) {
20✔
985
                        $metadata = $this->libreSignFile->getMetadata();
3✔
986
                        if (isset($metadata['d']) && !empty($metadata['d'])) {
3✔
987
                                $signatureParams['PageDimensions'] = $metadata['d'];
1✔
988
                        }
989
                }
990
                return $signatureParams;
20✔
991
        }
992

993
        public function storeUserMetadata(array $metadata = []): self {
994
                $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
18✔
995
                if (!$collectMetadata || !$metadata) {
18✔
996
                        return $this;
7✔
997
                }
998
                $this->signRequest->setMetadata(array_merge(
11✔
999
                        $this->signRequest->getMetadata() ?? [],
11✔
1000
                        $metadata,
11✔
1001
                ));
11✔
1002
                $this->signRequestMapper->update($this->signRequest);
11✔
1003
                return $this;
11✔
1004
        }
1005

1006
        /**
1007
         * @return SignRequestEntity[]
1008
         */
1009
        protected function getSigners(): array {
1010
                return $this->signRequestMapper->getByFileId($this->signRequest->getFileId());
×
1011
        }
1012

1013
        protected function setNewStatusIfNecessary(FileEntity $libreSignFile): bool {
1014
                $newStatus = $this->evaluateStatusFromSigners();
9✔
1015

1016
                if ($newStatus === null || $newStatus === $libreSignFile->getStatus()) {
9✔
1017
                        return false;
3✔
1018
                }
1019

1020
                $libreSignFile->setStatus($newStatus);
6✔
1021

1022
                return true;
6✔
1023
        }
1024

1025
        private function updateEntityCacheAfterDbSave(FileEntity $file): void {
1026
                $this->statusService->cacheFileStatus($file);
1✔
1027
        }
1028

1029
        private function evaluateStatusFromSigners(): ?int {
1030
                $signers = $this->getSigners();
9✔
1031

1032
                $total = count($signers);
9✔
1033

1034
                if ($total === 0) {
9✔
1035
                        return null;
1✔
1036
                }
1037

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

1040
                if ($totalSigned === $total) {
8✔
1041
                        return FileStatus::SIGNED->value;
4✔
1042
                }
1043

1044
                if ($totalSigned > 0) {
4✔
1045
                        return FileStatus::PARTIAL_SIGNED->value;
3✔
1046
                }
1047

1048
                return null;
1✔
1049
        }
1050

1051
        private function getOrGeneratePfxContent(SignEngineHandler $engine): string {
1052
                $result = $this->pfxProvider->getOrGeneratePfx(
13✔
1053
                        $engine,
13✔
1054
                        $this->signWithoutPassword,
13✔
1055
                        $this->signatureMethodName,
13✔
1056
                        $this->userUniqueIdentifier,
13✔
1057
                        $this->friendlyName,
13✔
1058
                        $this->password,
13✔
1059
                );
13✔
1060
                if ($result['password'] !== null) {
13✔
1061
                        $this->setPassword($result['password']);
2✔
1062
                }
1063
                return $result['pfx'];
13✔
1064
        }
1065

1066
        protected function readCertificate(): array {
1067
                return $this->getEngine()
×
1068
                        ->readCertificate();
×
1069
        }
1070

1071
        /**
1072
         * Get file to sign
1073
         *
1074
         * @throws LibresignException
1075
         */
1076
        protected function getFileToSign(): File {
1077
                if ($this->fileToSign instanceof File) {
×
1078
                        return $this->fileToSign;
×
1079
                }
1080

1081
                $userId = $this->libreSignFile->getUserId()
×
1082
                        ?? $this->user?->getUID()
×
1083
                        ?? ($this->signRequest?->getUserId() ?? null);
×
1084
                $nodeId = $this->libreSignFile->getNodeId();
×
1085

1086
                if ($userId === null) {
×
1087
                        throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
×
1088
                }
1089

1090
                try {
1091
                        $originalFile = $this->getNodeByIdUsingUid($userId, $nodeId);
×
1092
                } catch (\Throwable $e) {
×
1093
                        $this->logger->error('[file-access] FAILED to find file - userId={userId} nodeId={nodeId} error={error}', [
×
1094
                                'userId' => $userId,
×
1095
                                'nodeId' => $nodeId,
×
1096
                                'error' => $e->getMessage(),
×
1097
                        ]);
×
1098
                        throw $e;
×
1099
                }
1100

1101
                if ($originalFile->getOwner()->getUID() !== $userId) {
×
1102
                        $originalFile = $this->getNodeByIdUsingUid($originalFile->getOwner()->getUID(), $nodeId);
×
1103
                }
1104
                if ($this->isPdf($originalFile)) {
×
1105
                        $this->fileToSign = $this->getPdfToSign($originalFile);
×
1106
                } else {
1107
                        $this->fileToSign = $originalFile;
×
1108
                }
1109
                return $this->fileToSign;
×
1110
        }
1111

1112
        private function isPdf(File $file): bool {
1113
                return strcasecmp($file->getExtension(), 'pdf') === 0;
×
1114
        }
1115

1116
        protected function getEngine(): SignEngineHandler {
1117
                if (!$this->engine) {
12✔
1118
                        $originalFile = $this->getFileToSign();
12✔
1119
                        $this->engine = $this->identifyEngine($originalFile);
12✔
1120

1121
                        $this->configureEngine();
12✔
1122
                }
1123
                return $this->engine;
12✔
1124
        }
1125

1126
        private function configureEngine(): void {
1127
                $this->engine
12✔
1128
                        ->setInputFile($this->getFileToSign())
12✔
1129
                        ->setCertificate($this->getOrGeneratePfxContent($this->engine))
12✔
1130
                        ->setPassword($this->password);
12✔
1131

1132
                if ($this->engine::class === Pkcs12Handler::class) {
12✔
1133
                        $this->engine
×
1134
                                ->setVisibleElements($this->getVisibleElements())
×
1135
                                ->setSignatureParams($this->getSignatureParams());
×
1136
                }
1137
        }
1138

1139
        public function getLibresignFile(?int $fileId, ?string $signRequestUuid = null): FileEntity {
1140
                try {
1141
                        if ($fileId) {
3✔
1142
                                return $this->fileMapper->getById($fileId);
1✔
1143
                        }
1144

1145
                        if ($signRequestUuid) {
2✔
1146
                                $signRequest = $this->signRequestMapper->getByUuid($signRequestUuid);
2✔
1147
                                return $this->fileMapper->getById($signRequest->getFileId());
2✔
1148
                        }
1149

1150
                        throw new \Exception('Invalid arguments');
×
1151

1152
                } catch (DoesNotExistException) {
1✔
1153
                        throw new LibresignException($this->l10n->t('File not found'), 1);
1✔
1154
                }
1155
        }
1156

1157
        public function renew(SignRequestEntity $signRequest, string $method): void {
1158
                $identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
×
1159
                if (empty($identifyMethods[$method])) {
×
1160
                        throw new LibresignException($this->l10n->t('Invalid identification method'));
×
1161
                }
1162

1163
                $signRequest->setUuid(UUIDUtil::getUUID());
×
1164
                $this->signRequestMapper->update($signRequest);
×
1165

1166
                array_map(function (IIdentifyMethod $identifyMethod): void {
×
1167
                        $entity = $identifyMethod->getEntity();
×
1168
                        $entity->setAttempts($entity->getAttempts() + 1);
×
1169
                        $entity->setLastAttemptDate($this->timeFactory->getDateTime());
×
1170
                        $identifyMethod->save();
×
1171
                }, $identifyMethods[$method]);
×
1172
        }
1173

1174
        public function requestCode(
1175
                SignRequestEntity $signRequest,
1176
                string $identifyMethodName,
1177
                string $signMethodName,
1178
                string $identify = '',
1179
        ): void {
1180
                $identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
×
1181
                if (empty($identifyMethods[$identifyMethodName])) {
×
1182
                        throw new LibresignException($this->l10n->t('Invalid identification method'));
×
1183
                }
1184
                foreach ($identifyMethods[$identifyMethodName] as $identifyMethod) {
×
1185
                        try {
1186
                                $signatureMethod = $identifyMethod->getEmptyInstanceOfSignatureMethodByName($signMethodName);
×
1187
                                $signatureMethod->setEntity($identifyMethod->getEntity());
×
1188
                        } catch (InvalidArgumentException) {
×
1189
                                continue;
×
1190
                        }
1191
                        /** @var IToken $signatureMethod */
1192
                        $identifier = $identify ?: $identifyMethod->getEntity()->getIdentifierValue();
×
1193
                        $signatureMethod->requestCode($identifier, $identifyMethod->getEntity()->getIdentifierKey());
×
1194
                        return;
×
1195
                }
1196
                throw new LibresignException($this->l10n->t('Sending authorization code not enabled.'));
×
1197
        }
1198

1199
        private function getOrCreateApproverSignRequest(FileEntity $file, IUser $user): ?SignRequestEntity {
1200
                if (!$this->validateHelper->userCanApproveValidationDocuments($user, false)) {
6✔
1201
                        return null;
4✔
1202
                }
1203

1204
                try {
1205
                        $this->idDocsMapper->getByFileId($file->getId());
2✔
1206
                } catch (\Throwable) {
1✔
1207
                        return null;
1✔
1208
                }
1209

1210
                try {
1211
                        $this->sequentialSigningService->setFile($file);
1✔
1212
                        $signRequest = $this->signRequestService->createOrUpdateSignRequest(
1✔
1213
                                identifyMethods: [IdentifyMethodService::IDENTIFY_ACCOUNT => $user->getUID()],
1✔
1214
                                displayName: $user->getDisplayName(),
1✔
1215
                                description: '',
1✔
1216
                                notify: false,
1✔
1217
                                fileId: $file->getId(),
1✔
1218
                                signingOrder: 0,
1✔
1219
                                fileStatus: FileStatus::ABLE_TO_SIGN->value,
1✔
1220
                        );
1✔
1221

1222
                        return $signRequest;
1✔
1223
                } catch (\Throwable $e) {
×
1224
                        $this->logger->error('Failed to create/get SignRequest for approver', [
×
1225
                                'exception' => $e,
×
1226
                                'fileId' => $file->getId(),
×
1227
                                'userId' => $user->getUID(),
×
1228
                        ]);
×
1229
                        return null;
×
1230
                }
1231
        }
1232

1233
        /**
1234
         * @param SignRequestEntity[] $signRequests
1235
         * @param IUser $user
1236
         * @return SignRequestEntity|null
1237
         */
1238
        private function findSignRequestByIdentifyMethod(array $signRequests, IUser $user): ?SignRequestEntity {
1239
                foreach ($signRequests as $signRequest) {
5✔
1240
                        $identifyMethods = $this->identifyMethodMapper->getIdentifyMethodsFromSignRequestId($signRequest->getId());
5✔
1241
                        foreach ($identifyMethods as $method) {
5✔
1242
                                if ($method->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL
5✔
1243
                                        && ($method->getIdentifierValue() === $user->getUID()
5✔
1244
                                                || $method->getIdentifierValue() === $user->getEMailAddress())
5✔
1245
                                ) {
1246
                                        return $signRequest;
2✔
1247
                                }
1248
                                if ($method->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT
3✔
1249
                                        && $method->getIdentifierValue() === $user->getUID()
3✔
1250
                                ) {
1251
                                        return $signRequest;
2✔
1252
                                }
1253
                        }
1254
                }
1255
                return null;
1✔
1256
        }
1257

1258
        public function getSignRequestToSign(FileEntity $libresignFile, ?string $signRequestUuid, ?IUser $user): SignRequestEntity {
1259
                $this->validateHelper->fileCanBeSigned($libresignFile);
9✔
1260
                try {
1261
                        if (!empty($signRequestUuid)) {
9✔
1262
                                $signRequest = $this->getSignRequestByUuid($signRequestUuid);
3✔
1263
                        } elseif ($user) {
6✔
1264
                                $signRequest = $this->getOrCreateApproverSignRequest($libresignFile, $user);
6✔
1265
                        }
1266

1267
                        if (!isset($signRequest)) {
9✔
1268
                                if ($libresignFile->isEnvelope()) {
5✔
1269
                                        $childFiles = $this->fileMapper->getChildrenFiles($libresignFile->getId());
×
1270
                                        $allSignRequests = [];
×
1271
                                        foreach ($childFiles as $childFile) {
×
1272
                                                $childSignRequests = $this->signRequestMapper->getByFileId($childFile->getId());
×
1273
                                                $allSignRequests = array_merge($allSignRequests, $childSignRequests);
×
1274
                                        }
1275
                                        $signRequests = $allSignRequests;
×
1276
                                } else {
1277
                                        $signRequests = $this->signRequestMapper->getByFileId($libresignFile->getId());
5✔
1278
                                }
1279

1280
                                $signRequest = $this->findSignRequestByIdentifyMethod($signRequests, $user);
5✔
1281
                        }
1282

1283
                        if (!$signRequest) {
9✔
1284
                                throw new DoesNotExistException('Sign request not found');
1✔
1285
                        }
1286
                        $signRequestFile = $libresignFile;
8✔
1287
                        if ($signRequestFile->getId() !== $signRequest->getFileId()) {
8✔
1288
                                $signRequestFile = $this->fileMapper->getById($signRequest->getFileId());
×
1289
                        }
1290
                        $this->sequentialSigningService->setFile($signRequestFile);
8✔
1291
                        if (
1292
                                $this->sequentialSigningService->isOrderedNumericFlow()
8✔
1293
                                && $this->sequentialSigningService->hasPendingLowerOrderSigners(
8✔
1294
                                        $signRequest->getFileId(),
8✔
1295
                                        $signRequest->getSigningOrder()
8✔
1296
                                )
8✔
1297
                        ) {
1298
                                throw new LibresignException(json_encode([
×
1299
                                        'action' => JSActions::ACTION_DO_NOTHING,
×
1300
                                        'errors' => [['message' => $this->l10n->t('You are not allowed to sign this document yet')]],
×
1301
                                ]));
×
1302
                        }
1303
                        if ($signRequest->getSigned()) {
8✔
1304
                                throw new LibresignException($this->l10n->t('File already signed by you'), 1);
×
1305
                        }
1306
                        return $signRequest;
8✔
1307
                } catch (DoesNotExistException) {
1✔
1308
                        throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
1✔
1309
                }
1310
        }
1311

1312
        protected function getPdfToSign(File $originalFile): File {
1313
                $file = $this->getSignedFile();
1✔
1314
                if ($file instanceof File) {
1✔
1315
                        return $file;
×
1316
                }
1317

1318
                $originalContent = $originalFile->getContent();
1✔
1319

1320
                if ($this->pdfSignatureDetectionService->hasSignatures($originalContent)) {
1✔
1321
                        return $this->createSignedFile($originalFile, $originalContent);
×
1322
                }
1323
                $metadata = $this->footerHandler->getMetadata($originalFile, $this->libreSignFile);
1✔
1324
                $footer = $this->footerHandler
1✔
1325
                        ->setTemplateVar('uuid', $this->libreSignFile->getUuid())
1✔
1326
                        ->setTemplateVar('signers', array_map(fn (SignRequestEntity $signer) => [
1✔
1327
                                'displayName' => $signer->getDisplayName(),
1✔
1328
                                'signed' => $signer->getSigned()
1✔
1329
                                        ? $signer->getSigned()->format(DateTimeInterface::ATOM)
×
1330
                                        : null,
1331
                        ], $this->getSigners()))
1✔
1332
                        ->getFooter($metadata['d']);
1✔
1333
                if ($footer) {
1✔
1334
                        $stamp = $this->tempManager->getTemporaryFile('stamp.pdf');
1✔
1335
                        file_put_contents($stamp, $footer);
1✔
1336

1337
                        $input = $this->tempManager->getTemporaryFile('input.pdf');
1✔
1338
                        file_put_contents($input, $originalContent);
1✔
1339

1340
                        try {
1341
                                $pdfContent = $this->pdf->applyStamp($input, $stamp);
1✔
1342
                        } catch (RuntimeException $e) {
1✔
1343
                                throw new LibresignException($e->getMessage());
1✔
1344
                        }
1345
                } else {
1346
                        $pdfContent = $originalContent;
×
1347
                }
1348
                return $this->createSignedFile($originalFile, $pdfContent);
×
1349
        }
1350

1351
        protected function getSignedFile(): ?File {
1352
                $nodeId = $this->libreSignFile->getSignedNodeId();
3✔
1353
                if (!$nodeId) {
3✔
1354
                        return null;
1✔
1355
                }
1356

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

1359
                if ($fileToSign->getOwner()->getUID() !== $this->libreSignFile->getUserId()) {
2✔
1360
                        $fileToSign = $this->getNodeByIdUsingUid($fileToSign->getOwner()->getUID(), $nodeId);
1✔
1361
                }
1362
                return $fileToSign;
2✔
1363
        }
1364

1365
        protected function getNodeByIdUsingUid(string $uid, int $nodeId): File {
1366
                try {
1367
                        $userFolder = $this->root->getUserFolder($uid);
4✔
1368
                } catch (NoUserException $e) {
2✔
1369
                        $this->logger->error('[file-access] NoUserException for uid={uid}', ['uid' => $uid]);
1✔
1370
                        throw new LibresignException($this->l10n->t('User not found.'));
1✔
1371
                } catch (NotPermittedException $e) {
1✔
1372
                        $this->logger->error('[file-access] NotPermittedException for uid={uid}', ['uid' => $uid]);
1✔
1373
                        throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
1✔
1374
                }
1375

1376
                try {
1377
                        $fileToSign = $userFolder->getFirstNodeById($nodeId);
2✔
1378
                } catch (\Throwable $e) {
×
1379
                        $this->logger->error('[file-access] Failed getFirstNodeById - nodeId={nodeId} error={error}', [
×
1380
                                'nodeId' => $nodeId,
×
1381
                                'error' => $e->getMessage(),
×
1382
                        ]);
×
1383
                        throw $e;
×
1384
                }
1385

1386
                if (!$fileToSign instanceof File) {
2✔
1387
                        $this->logger->error('[file-access] Node is not a File - nodeId={nodeId} type={type}', [
1✔
1388
                                'nodeId' => $nodeId,
1✔
1389
                                'type' => $fileToSign ? $fileToSign::class : 'NULL',
1✔
1390
                        ]);
1✔
1391
                        throw new LibresignException($this->l10n->t('File not found'));
1✔
1392
                }
1393
                return $fileToSign;
1✔
1394
        }
1395

1396
        /**
1397
         * Verify if file exists in filesystem before enqueuing background job
1398
         *
1399
         * @param string|null $uid User ID
1400
         * @param int $nodeId File node ID
1401
         * @return bool True if file exists and is accessible
1402
         */
1403
        private function verifyFileExists(?string $uid, int $nodeId): bool {
1404
                if ($uid === null || $nodeId === 0) {
8✔
1405
                        return false;
2✔
1406
                }
1407

1408
                try {
1409
                        $userFolder = $this->root->getUserFolder($uid);
6✔
1410
                        $node = $userFolder->getFirstNodeById($nodeId);
5✔
1411
                        return $node instanceof File;
5✔
1412
                } catch (\Throwable $e) {
1✔
1413
                        $this->logger->warning('[verify-file] File not accessible - nodeId={nodeId} uid={uid} error={error}', [
1✔
1414
                                'nodeId' => $nodeId,
1✔
1415
                                'uid' => $uid,
1✔
1416
                                'error' => $e->getMessage(),
1✔
1417
                        ]);
1✔
1418
                        return false;
1✔
1419
                }
1420
        }
1421

1422
        private function cleanupUnsignedSignedFile(): void {
1423
                if (!$this->createdSignedFile instanceof File) {
3✔
1424
                        return;
1✔
1425
                }
1426

1427
                try {
1428
                        $this->createdSignedFile->delete();
2✔
1429
                } catch (\Throwable $e) {
1✔
1430
                        $this->logger->warning('Failed to delete temporary signed file: ' . $e->getMessage());
1✔
1431
                } finally {
1432
                        $this->createdSignedFile = null;
2✔
1433
                }
1434
        }
1435

1436
        private function createSignedFile(File $originalFile, string $content): File {
1437
                $filename = preg_replace(
×
1438
                        '/' . $originalFile->getExtension() . '$/',
×
1439
                        $this->l10n->t('signed') . '.' . $originalFile->getExtension(),
×
1440
                        basename($originalFile->getPath())
×
1441
                );
×
1442
                $owner = $originalFile->getOwner()->getUID();
×
1443

1444
                $fileId = $this->libreSignFile->getId();
×
1445
                $extension = $originalFile->getExtension();
×
NEW
1446
                $uniqueFilename = substr((string)$filename, 0, -strlen($extension) - 1) . '_' . $fileId . '.' . $extension;
×
1447

1448
                try {
1449
                        /** @var \OCP\Files\Folder */
1450
                        $parentFolder = $this->root->getUserFolder($owner)->getFirstNodeById($originalFile->getParentId());
×
1451

1452
                        $this->createdSignedFile = $parentFolder->newFile($uniqueFilename, $content);
×
1453

1454
                        return $this->createdSignedFile;
×
1455
                } catch (NotPermittedException) {
×
1456
                        throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
×
1457
                } catch (\Exception $e) {
×
1458
                        throw $e;
×
1459
                }
1460
        }
1461

1462
        /**
1463
         * @throws DoesNotExistException
1464
         */
1465
        public function getSignRequestByUuid(string $uuid): SignRequestEntity {
1466
                $this->validateHelper->validateUuidFormat($uuid);
5✔
1467
                return $this->signRequestMapper->getByUuid($uuid);
4✔
1468
        }
1469

1470
        /**
1471
         * @throws DoesNotExistException
1472
         */
1473
        public function getFile(int $signRequestId): FileEntity {
1474
                return $this->fileMapper->getById($signRequestId);
×
1475
        }
1476

1477
        /**
1478
         * @throws DoesNotExistException
1479
         */
1480
        public function getFileByUuid(string $uuid): FileEntity {
1481
                return $this->fileMapper->getByUuid($uuid);
×
1482
        }
1483

1484
        public function getIdDocById(int $fileId): IdDocs {
1485
                return $this->idDocsMapper->getByFileId($fileId);
×
1486
        }
1487

1488
        /**
1489
         * @return File[] Array of files
1490
         */
1491
        public function getNextcloudFiles(FileEntity $fileData): array {
1492
                if ($fileData->getNodeType() === 'envelope') {
1✔
1493
                        $children = $this->fileMapper->getChildrenFiles($fileData->getId());
×
1494
                        $files = [];
×
1495
                        foreach ($children as $child) {
×
1496
                                $nodeId = $child->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
                                $file = $this->root->getUserFolder($child->getUserId())->getFirstNodeById($nodeId);
×
1504
                                if ($file instanceof File) {
×
1505
                                        $files[] = $file;
×
1506
                                }
1507
                        }
1508
                        return $files;
×
1509
                }
1510

1511
                $nodeId = $fileData->getNodeId();
1✔
1512
                if ($nodeId === null) {
1✔
1513
                        throw new LibresignException(json_encode([
1✔
1514
                                'action' => JSActions::ACTION_DO_NOTHING,
1✔
1515
                                'errors' => [['message' => $this->l10n->t('File not found')]],
1✔
1516
                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
1✔
1517
                }
1518
                $storageUserId = $this->fileMapper->getStorageUserIdByUuid($fileData->getUuid());
×
1519
                $this->folderService->setUserId($storageUserId);
×
1520
                $fileToSign = $this->folderService->getFileByNodeId($nodeId);
×
1521
                if (!$fileToSign instanceof File) {
×
1522
                        throw new LibresignException(json_encode([
×
1523
                                'action' => JSActions::ACTION_DO_NOTHING,
×
1524
                                'errors' => [['message' => $this->l10n->t('File not found')]],
×
1525
                        ]), AppFrameworkHttp::STATUS_NOT_FOUND);
×
1526
                }
1527
                return [$fileToSign];
×
1528
        }
1529

1530
        public function validateSigner(string $uuid, ?IUser $user = null): void {
1531
                $this->validateHelper->validateSigner($uuid, $user);
×
1532
        }
1533

1534
        public function validateRenewSigner(string $uuid, ?IUser $user = null): void {
1535
                $this->validateHelper->validateRenewSigner($uuid, $user);
×
1536
        }
1537

1538
        public function getSignerData(?IUser $user, ?SignRequestEntity $signRequest = null): array {
1539
                $return = ['user' => ['name' => null]];
×
1540
                if ($signRequest) {
×
1541
                        $return['user']['name'] = $signRequest->getDisplayName();
×
1542
                } elseif ($user) {
×
1543
                        $return['user']['name'] = $user->getDisplayName();
×
1544
                }
1545
                return $return;
×
1546
        }
1547

1548
        public function getAvailableIdentifyMethodsFromSettings(): array {
1549
                $identifyMethods = $this->identifyMethodService->getIdentifyMethodsSettings();
×
1550
                $return = array_map(fn (array $identifyMethod): array => [
×
1551
                        'mandatory' => $identifyMethod['mandatory'],
×
1552
                        'identifiedAtDate' => null,
×
1553
                        'validateCode' => false,
×
1554
                        'method' => $identifyMethod['name'],
×
1555
                ], $identifyMethods);
×
1556
                return $return;
×
1557
        }
1558

1559
        public function getFileUrl(int $fileId, string $uuid): string {
1560
                try {
1561
                        $this->idDocsMapper->getByFileId($fileId);
×
1562
                        return $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $uuid]);
×
1563
                } catch (DoesNotExistException) {
×
1564
                        return $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $uuid]);
×
1565
                }
1566
        }
1567

1568
        /**
1569
         * Get PDF URLs for signing
1570
         * For envelopes: returns URLs for all child files
1571
         * For regular files: returns URL for the file itself
1572
         *
1573
         * @return string[]
1574
         */
1575
        public function getPdfUrlsForSigning(FileEntity $fileEntity, SignRequestEntity $signRequestEntity): array {
1576
                if (!$fileEntity->isEnvelope()) {
×
1577
                        return [
×
1578
                                $this->getFileUrl($fileEntity->getId(), $signRequestEntity->getUuid())
×
1579
                        ];
×
1580
                }
1581

1582
                $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod(
×
1583
                        $fileEntity->getId(),
×
1584
                        $signRequestEntity->getId()
×
1585
                );
×
1586

1587
                $pdfUrls = [];
×
1588
                foreach ($childSignRequests as $childSignRequest) {
×
1589
                        $pdfUrls[] = $this->getFileUrl(
×
1590
                                $childSignRequest->getFileId(),
×
1591
                                $childSignRequest->getUuid()
×
1592
                        );
×
1593
                }
1594

1595
                return $pdfUrls;
×
1596
        }
1597

1598
        private function recordSignatureAttempt(Exception $exception): void {
1599
                if (!$this->libreSignFile) {
×
1600
                        return;
×
1601
                }
1602

1603
                $metadata = $this->libreSignFile->getMetadata() ?? [];
×
1604

1605
                if (!isset($metadata['signature_attempts'])) {
×
1606
                        $metadata['signature_attempts'] = [];
×
1607
                }
1608

1609
                $attempt = [
×
1610
                        'timestamp' => (new DateTime())->format(\DateTime::ATOM),
×
NEW
1611
                        'engine' => $this->engine ? $this->engine::class : 'unknown',
×
1612
                        'error_message' => $exception->getMessage(),
×
1613
                        'error_code' => $exception->getCode(),
×
1614
                ];
×
1615

1616
                $metadata['signature_attempts'][] = $attempt;
×
1617
                $this->libreSignFile->setMetadata($metadata);
×
1618
                $this->fileMapper->update($this->libreSignFile);
×
1619
        }
1620
}
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