Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.29% covered (warning)
76.29%
666 / 873
59.38% covered (warning)
59.38%
57 / 96
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignFileService
76.29% covered (warning)
76.29%
666 / 873
59.38% covered (warning)
59.38%
57 / 96
1428.67
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canDeleteRequestSignature
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
6.40
 notifyCallback
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
3.02
 setLibreSignFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setUserUniqueIdentifier
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFriendlyName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSignRequest
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSignWithoutPassword
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSignatureMethod
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPassword
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCurrentUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 prepareForSigning
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 setVisibleElements
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
12
 restoreMissingVisibleElementImages
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getNodeId
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 isValidElement
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 retrieveUserElement
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 bindFileElementWithTempFile
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getNode
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 array_find
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getVisibleElements
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSignatureSessionId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getJobArgumentsWithoutCredentials
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 validateSigningRequirements
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sign
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 executeSigningStrategy
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 processParallelSigning
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getLatestSignedDate
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 signSingleFile
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
20
 saveCachedState
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 resetCachedState
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 restoreCachedState
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 enqueueParallelSigningJobs
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
 enqueueSigningJobForFile
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 addCredentialsToJobArgs
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 runWithVolatileActiveUser
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 signSequentially
82.86% covered (warning)
82.86%
29 / 35
0.00% covered (danger)
0.00%
0 / 1
7.25
 getSignRequestsToSign
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 buildEnvelopeSignRequests
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 getEnvelopeContext
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 updateEnvelopeStatus
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 buildSignRequestsMap
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 handleSignedEnvelopeSignRequest
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 updateEnvelopeMetadata
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 validateDocMdpAllowsSignatures
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getLibreSignFileAsResource
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 computeHash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateSignRequest
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 storeCertificateInfoInMetadata
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 updateLibreSignFile
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 dispatchSignedEvent
52.63% covered (warning)
52.63%
10 / 19
0.00% covered (danger)
0.00%
0 / 1
7.66
 identifyEngine
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSignatureParams
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildBaseSignatureParams
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 buildValidationUrl
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 normalizeCertificateFieldToString
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 addEmailToSignatureParams
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 addMetadataToSignatureParams
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 storeUserMetadata
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
8
 isCollectMetadataEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSigners
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setNewStatusIfNecessary
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 updateEntityCacheAfterDbSave
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 evaluateStatusFromSigners
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOrGeneratePfxContent
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getPkcs12Engine
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 readCertificate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFileToSign
50.00% covered (danger)
50.00%
11 / 22
0.00% covered (danger)
0.00%
0 / 1
10.50
 isPdf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEngine
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 configureEngine
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getLibresignFile
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 renew
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 requestCode
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
 getOrCreateApproverSignRequest
69.57% covered (warning)
69.57%
16 / 23
0.00% covered (danger)
0.00%
0 / 1
4.45
 findSignRequestByIdentifyMethod
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 getSignRequestToSign
67.57% covered (warning)
67.57%
25 / 37
0.00% covered (danger)
0.00%
0 / 1
18.77
 getPdfToSign
90.32% covered (success)
90.32%
28 / 31
0.00% covered (danger)
0.00%
0 / 1
7.04
 getFooterPolicyValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getSignedFile
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getNodeByIdUsingUid
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
6.97
 verifyFileExists
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 cleanupUnsignedSignedFile
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 createSignedFile
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
3.07
 getSignRequestByUuid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFileByUuid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getIdDocById
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNextcloudFiles
41.38% covered (danger)
41.38%
12 / 29
0.00% covered (danger)
0.00%
0 / 1
16.87
 validateSigner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateRenewSigner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSignerData
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getAvailableIdentifyMethodsFromSettings
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getFileUrl
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getPdfUrlsForSigning
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 recordSignatureAttempt
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use DateTime;
12use DateTimeInterface;
13use Exception;
14use InvalidArgumentException;
15use OC\AppFramework\Http as AppFrameworkHttp;
16use OCA\Libresign\AppInfo\Application;
17use OCA\Libresign\BackgroundJob\SignSingleFileJob;
18use OCA\Libresign\DataObjects\VisibleElementAssoc;
19use OCA\Libresign\Db\File as FileEntity;
20use OCA\Libresign\Db\FileElement;
21use OCA\Libresign\Db\FileElementMapper;
22use OCA\Libresign\Db\FileMapper;
23use OCA\Libresign\Db\IdDocs;
24use OCA\Libresign\Db\IdDocsMapper;
25use OCA\Libresign\Db\IdentifyMethodMapper;
26use OCA\Libresign\Db\NotificationDeliveryMapper;
27use OCA\Libresign\Db\SignRequest as SignRequestEntity;
28use OCA\Libresign\Db\SignRequestMapper;
29use OCA\Libresign\Db\UserElementMapper;
30use OCA\Libresign\Enum\FileStatus;
31use OCA\Libresign\Enum\IdentifyMethodRequirement;
32use OCA\Libresign\Enum\SignRequestStatus;
33use OCA\Libresign\Events\SignedEventFactory;
34use OCA\Libresign\Exception\FooterStampUnavailableException;
35use OCA\Libresign\Exception\LibresignException;
36use OCA\Libresign\Handler\DocMdpHandler;
37use OCA\Libresign\Handler\FooterHandler;
38use OCA\Libresign\Handler\PdfTk\Pdf;
39use OCA\Libresign\Handler\SignEngine\IPkcs12SignEngineHandler;
40use OCA\Libresign\Handler\SignEngine\ISignEngineHandler;
41use OCA\Libresign\Handler\SignEngine\Pkcs12Handler;
42use OCA\Libresign\Handler\SignEngine\SignEngineFactory;
43use OCA\Libresign\Handler\SignEngine\SignEngineHandler;
44use OCA\Libresign\Helper\JSActions;
45use OCA\Libresign\Service\Envelope\EnvelopeStatusDeterminer;
46use OCA\Libresign\Service\File\SigningWorkflowStatusResolver;
47use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
48use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\IToken;
49use OCA\Libresign\Service\Policy\PolicyService;
50use OCA\Libresign\Service\Policy\Provider\CollectMetadata\CollectMetadataPolicy;
51use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy;
52use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue;
53use OCA\Libresign\Service\SignRequest\SignRequestService;
54use OCA\Libresign\Service\SignRequest\StatusService;
55use OCA\Libresign\Service\Validation\IdentityDocumentValidator;
56use OCA\Libresign\Service\Validation\SignerValidator;
57use OCA\Libresign\Service\Validation\SigningRequestValidator;
58use OCP\AppFramework\Db\DoesNotExistException;
59use OCP\AppFramework\Db\MultipleObjectsReturnedException;
60use OCP\AppFramework\Utility\ITimeFactory;
61use OCP\BackgroundJob\IJobList;
62use OCP\EventDispatcher\IEventDispatcher;
63use OCP\Files\File;
64use OCP\Files\NotPermittedException;
65use OCP\Http\Client\IClientService;
66use OCP\IAppConfig;
67use OCP\IDateTimeZone;
68use OCP\IL10N;
69use OCP\ITempManager;
70use OCP\IURLGenerator;
71use OCP\IUser;
72use OCP\IUserSession;
73use OCP\Security\ICredentialsManager;
74use OCP\Security\ISecureRandom;
75use OCP\User\Exceptions\UserNotFoundException;
76use Psr\Log\LoggerInterface;
77use RuntimeException;
78use Sabre\DAV\UUIDUtil;
79
80class SignFileService {
81    private ?string $signatureSessionId = null;
82    private ?SignRequestEntity $signRequest = null;
83    private string $password = '';
84    private ?FileEntity $libreSignFile = null;
85    /** @var array<int, VisibleElementAssoc> indexed by fileElementId */
86    private $elements = [];
87    private array $elementsInput = [];
88    private bool $signWithoutPassword = false;
89    private ?string $signatureMethodName = null;
90    private ?File $fileToSign = null;
91    private ?File $createdSignedFile = null;
92    private string $userUniqueIdentifier = '';
93    private string $friendlyName = '';
94    private ?IUser $user = null;
95    private ?ISignEngineHandler $engine = null;
96    // The stamp and the validation URL read the footer of the same file.
97    private ?FileEntity $footerPolicyFile = null;
98    private string $footerPolicyValue = '';
99
100    public function __construct(
101        protected IL10N $l10n,
102        private FileMapper $fileMapper,
103        private SignRequestMapper $signRequestMapper,
104        private IdDocsMapper $idDocsMapper,
105        private FooterHandler $footerHandler,
106        protected FolderService $folderService,
107        private IClientService $client,
108        protected LoggerInterface $logger,
109        private IAppConfig $appConfig,
110        protected IdentityDocumentValidator $identityDocumentValidator,
111        protected SigningRequestValidator $signingRequestValidator,
112        protected SignerValidator $signerValidator,
113        private SignerElementsService $signerElementsService,
114        private IUserSession $userSession,
115        private IDateTimeZone $dateTimeZone,
116        private FileElementMapper $fileElementMapper,
117        private UserElementMapper $userElementMapper,
118        private IEventDispatcher $eventDispatcher,
119        protected ISecureRandom $secureRandom,
120        private IURLGenerator $urlGenerator,
121        private IdentifyMethodMapper $identifyMethodMapper,
122        private ITempManager $tempManager,
123        private SigningCoordinatorService $signingCoordinatorService,
124        private IdentifyMethodService $identifyMethodService,
125        private ITimeFactory $timeFactory,
126        protected SignEngineFactory $signEngineFactory,
127        private SignedEventFactory $signedEventFactory,
128        private Pdf $pdf,
129        private DocMdpHandler $docMdpHandler,
130        private PdfSignatureDetectionService $pdfSignatureDetectionService,
131        private SequentialSigningService $sequentialSigningService,
132        private FileStatusService $fileStatusService,
133        private StatusService $statusService,
134        private IJobList $jobList,
135        private ICredentialsManager $credentialsManager,
136        private EnvelopeStatusDeterminer $envelopeStatusDeterminer,
137        private TsaValidationService $tsaValidationService,
138        private PfxProvider $pfxProvider,
139        private SubjectAlternativeNameService $subjectAlternativeNameService,
140        private SignRequestService $signRequestService,
141        private PolicyService $policyService,
142        private TemporarySignatureImageService $temporarySignatureImageService,
143        private NotificationDeliveryMapper $notificationDeliveries,
144        private SigningWorkflowStatusResolver $signingWorkflowStatusResolver,
145    ) {
146    }
147
148    /**
149     * Can delete sing request
150     */
151    public function canDeleteRequestSignature(array $data): void {
152        if (!empty($data['uuid'])) {
153            $signatures = $this->signRequestMapper->getByFileUuid($data['uuid']);
154        } elseif (!empty($data['file']['nodeId'])) {
155            $signatures = $this->signRequestMapper->getByNodeId($data['file']['nodeId']);
156        } else {
157            // TRANSLATORS Error shown when a signing action is missing both the document identifier and the file object.
158            throw new \Exception($this->l10n->t('Please provide either UUID or File object'));
159        }
160        $signed = array_filter($signatures, fn ($s) => $s->getSigned());
161        if ($signed) {
162            // TRANSLATORS Error shown when trying to sign a document that is already fully signed.
163            throw new \Exception($this->l10n->t('Document already signed'));
164        }
165        array_walk($data['signers'], function ($signer) use ($signatures): void {
166            $exists = array_filter($signatures, function (SignRequestEntity $signRequest) use ($signer) {
167                $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($signRequest->getId());
168                if ($identifyMethod->getName() === 'email') {
169                    return $identifyMethod->getEntity()->getIdentifierValue() === $signer['email'];
170                }
171                return false;
172            });
173            if (!$exists) {
174                // TRANSLATORS Error shown when notifying or signing for a person who was never asked to sign. %s is their email.
175                throw new \Exception($this->l10n->t('No signature was requested to %s', $signer['email']));
176            }
177        });
178    }
179
180    public function notifyCallback(File $file): void {
181        $uri = $this->libreSignFile->getCallback();
182        if (!$uri) {
183            $uri = $this->appConfig->getValueString(Application::APP_ID, 'webhook_sign_url');
184            if (!$uri) {
185                return;
186            }
187        }
188        $options = [
189            'multipart' => [
190                [
191                    'name' => 'uuid',
192                    'contents' => $this->libreSignFile->getUuid(),
193                ],
194                [
195                    'name' => 'status',
196                    'contents' => $this->libreSignFile->getStatus(),
197                ],
198                [
199                    'name' => 'file',
200                    'contents' => $file->fopen('r'),
201                    'filename' => $file->getName()
202                ]
203            ]
204        ];
205        $this->client->newClient()->post($uri, $options);
206    }
207
208    /**
209     * @return static
210     */
211    public function setLibreSignFile(FileEntity $libreSignFile): self {
212        $this->libreSignFile = $libreSignFile;
213        return $this;
214    }
215
216    public function setUserUniqueIdentifier(string $identifier): self {
217        $this->userUniqueIdentifier = $identifier;
218        return $this;
219    }
220
221    public function setFriendlyName(string $friendlyName): self {
222        $this->friendlyName = $friendlyName;
223        return $this;
224    }
225
226    /**
227     * @return static
228     */
229    public function setSignRequest(SignRequestEntity $signRequest): self {
230        $this->signRequest = $signRequest;
231        return $this;
232    }
233
234    /**
235     * @return static
236     */
237    public function setSignWithoutPassword(bool $signWithoutPassword = true): self {
238        $this->signWithoutPassword = $signWithoutPassword;
239        return $this;
240    }
241
242    public function setSignatureMethod(?string $signatureMethodName): self {
243        $this->signatureMethodName = $signatureMethodName;
244        return $this;
245    }
246
247    /**
248     * @return static
249     */
250    public function setPassword(?string $password = null): self {
251        $this->password = $password;
252        return $this;
253    }
254
255    public function setCurrentUser(?IUser $user): self {
256        $this->user = $user;
257        return $this;
258    }
259
260    public function prepareForSigning(
261        FileEntity $libreSignFile,
262        SignRequestEntity $signRequest,
263        ?IUser $user,
264        string $userIdentifier,
265        string $displayName,
266        bool $signWithoutPassword,
267        ?string $password = null,
268        ?string $signatureMethodName = null,
269    ): self {
270        if ($signWithoutPassword) {
271            $this->setSignWithoutPassword();
272        } else {
273            $this->setPassword($password);
274        }
275
276        return $this
277            ->setLibreSignFile($libreSignFile)
278            ->setSignRequest($signRequest)
279            ->setCurrentUser($user)
280            ->setUserUniqueIdentifier($userIdentifier)
281            ->setFriendlyName($displayName)
282            ->setSignatureMethod($signatureMethodName);
283    }
284
285    public function setVisibleElements(array $list): self {
286        $this->elementsInput = $list;
287        if (!$this->signRequest instanceof SignRequestEntity) {
288            return $this;
289        }
290        $fileId = $this->signRequest->getFileId();
291        $signRequestId = $this->signRequest->getId();
292
293        if (empty($list) && ($fileId === null || $signRequestId === null)) {
294            return $this;
295        }
296
297        if ($fileId === null || $signRequestId === null) {
298            // TRANSLATORS Error shown when the document to sign cannot be found.
299            throw new LibresignException($this->l10n->t('File not found'));
300        }
301
302        $fileElements = $this->fileElementMapper->getByFileIdAndSignRequestId($fileId, $signRequestId);
303        $canCreateSignature = $this->signerElementsService->canCreateSignature();
304        $newElements = [];
305
306        foreach ($fileElements as $fileElement) {
307            $fileElementId = $fileElement->getId();
308            if (!$canCreateSignature) {
309                $newElements[$fileElementId] = new VisibleElementAssoc($fileElement);
310                continue;
311            }
312            $element = $this->array_find($list, fn (array $element): bool => ($element['documentElementId'] ?? '') === $fileElementId);
313            if (!$element) {
314                // No user-submitted image for this element (e.g. clickToSign).
315                // Still include the file element so the admin background image (n0 layer)
316                // is rendered in the signature stamp on the document.
317                $newElements[$fileElementId] = new VisibleElementAssoc($fileElement);
318                continue;
319            }
320            $nodeId = $this->getNodeId($element, $fileElement);
321
322            $existing = $this->elements[$fileElementId] ?? null;
323            if ($existing instanceof VisibleElementAssoc && $this->temporarySignatureImageService->isAvailable($existing->getTempFile())) {
324                $newElements[$fileElementId] = $existing;
325                continue;
326            }
327
328            $newElements[$fileElementId] = $this->bindFileElementWithTempFile($fileElement, $nodeId);
329        }
330
331        $this->elements = $newElements;
332
333        return $this;
334    }
335
336    private function restoreMissingVisibleElementImages(): void {
337        foreach ($this->elements as $element) {
338            if ($element->getTempFile() !== '' && !$this->temporarySignatureImageService->isAvailable($element->getTempFile())) {
339                // Rebuild from the stored signature, not from a temporary path that may have been cleaned.
340                $this->setVisibleElements($this->elementsInput);
341                return;
342            }
343        }
344    }
345
346    private function getNodeId(?array $element, FileElement $fileElement): int {
347        if ($this->isValidElement($element)) {
348            return (int)$element['profileNodeId'];
349        }
350
351        return $this->retrieveUserElement($fileElement);
352    }
353
354    private function isValidElement(?array $element): bool {
355        if (is_array($element) && !empty($element['profileNodeId']) && is_int($element['profileNodeId'])) {
356            return true;
357        }
358        $this->logger->error('Invalid data provided for signing file.', ['element' => $element]);
359        // TRANSLATORS Error shown when the data required to apply a digital signature is missing or invalid.
360        throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
361    }
362
363    private function retrieveUserElement(FileElement $fileElement): int {
364        try {
365            if (!$this->user instanceof IUser) {
366                throw new Exception('User not set');
367            }
368            $userElement = $this->userElementMapper->findOne([
369                'user_id' => $this->user->getUID(),
370                'type' => $fileElement->getType(),
371            ]);
372        } catch (MultipleObjectsReturnedException|DoesNotExistException|Exception) {
373            // TRANSLATORS Error shown when signing requires a visible signature or initials image and the signer has not defined one yet.
374            throw new LibresignException($this->l10n->t('You need to define a visible signature or initials to sign this document.'));
375        }
376        return $userElement->getNodeId();
377    }
378
379    private function bindFileElementWithTempFile(FileElement $fileElement, int $nodeId): VisibleElementAssoc {
380        try {
381            $node = $this->getNode($nodeId);
382            if (!$node) {
383                throw new \Exception('Node content is empty or unavailable.');
384            }
385        } catch (\Throwable) {
386            // TRANSLATORS Error shown when signing requires a visible signature or initials image and the signer has not defined one yet.
387            throw new LibresignException($this->l10n->t('You need to define a visible signature or initials to sign this document.'));
388        }
389
390        $content = $node->getContent();
391        if (empty($content)) {
392            $this->logger->error('Failed to retrieve content for node.', ['nodeId' => $nodeId, 'fileElement' => $fileElement]);
393            // TRANSLATORS Error shown when signing requires a visible signature or initials image and the signer has not defined one yet.
394            throw new LibresignException($this->l10n->t('You need to define a visible signature or initials to sign this document.'));
395        }
396        $tempFile = $this->temporarySignatureImageService->create($nodeId, $content);
397        return new VisibleElementAssoc($fileElement, $tempFile);
398    }
399
400    private function getNode(int $nodeId): ?File {
401        try {
402            return $this->folderService->getFileByNodeId($nodeId);
403        } catch (\Throwable) {
404            $filesOfElementes = $this->signatureSessionId === null
405                ? $this->signerElementsService->getElementsFromSession()
406                : $this->signerElementsService->getElementsFromSession($this->signatureSessionId);
407            return $this->array_find($filesOfElementes, fn ($file) => $file->getId() === $nodeId);
408        }
409    }
410
411    /**
412     * Fallback to PHP < 8.4
413     *
414     * Reference: https://www.php.net/manual/en/function.array-find.php#130257
415     *
416     * @todo remove this after minor PHP version is >= 8.4
417     * @deprecated This method will be removed once the minimum PHP version is >= 8.4. Use native array_find instead.
418     */
419    private function array_find(array $array, callable $callback): mixed {
420        foreach ($array as $key => $value) {
421            if ($callback($value, $key)) {
422                return $value;
423            }
424        }
425
426        return null;
427    }
428
429    public function getVisibleElements(): array {
430        return $this->elements;
431    }
432
433    /** Use only the storage context captured by a server-created signing job. */
434    public function setSignatureSessionId(?string $sessionId): self {
435        $this->signatureSessionId = $sessionId;
436        return $this;
437    }
438
439    public function getJobArgumentsWithoutCredentials(): array {
440        $args = [];
441        if ($this->signatureSessionId !== null || !$this->user instanceof IUser) {
442            $sessionId = $this->signatureSessionId ?? $this->signerElementsService->getSessionStorageId();
443            if ($sessionId !== '') {
444                $args['signatureSessionId'] = $sessionId;
445            }
446        }
447
448        if (!empty($this->userUniqueIdentifier)) {
449            $args['userUniqueIdentifier'] = $this->userUniqueIdentifier;
450        }
451
452        if (!empty($this->friendlyName)) {
453            $args['friendlyName'] = $this->friendlyName;
454        }
455
456        if (!empty($this->elements)) {
457            $args['visibleElements'] = $this->elements;
458        }
459
460        if ($this->signRequest instanceof SignRequestEntity && $this->signRequest->getMetadata()) {
461            $args['metadata'] = $this->signRequest->getMetadata();
462        }
463
464        if ($this->user instanceof IUser) {
465            $args['userId'] = $this->user->getUID();
466        }
467
468        return $args;
469    }
470
471    public function validateSigningRequirements(): void {
472        $this->tsaValidationService->validateConfiguration();
473    }
474
475    public function sign(): void {
476        $signRequests = $this->getSignRequestsToSign();
477
478        if (empty($signRequests)) {
479            throw new LibresignException('No sign requests found to process');
480        }
481
482        $this->executeSigningStrategy($signRequests);
483    }
484
485    private function executeSigningStrategy(array $signRequests): ?DateTimeInterface {
486        if ($this->signingCoordinatorService->shouldUseParallelProcessing(count($signRequests))) {
487            return $this->processParallelSigning($signRequests);
488        }
489        return $this->signSequentially($signRequests);
490    }
491
492    private function processParallelSigning(array $signRequests): ?DateTimeInterface {
493        $this->enqueueParallelSigningJobs($signRequests, $this->getJobArgumentsWithoutCredentials());
494        return $this->getLatestSignedDate($signRequests);
495    }
496
497    private function getLatestSignedDate(array $signRequests): ?DateTimeInterface {
498        $latestSignedDate = null;
499
500        foreach ($signRequests as $signRequestData) {
501            try {
502                $signRequest = $this->signRequestMapper->getById($signRequestData['signRequest']->getId());
503                if ($signRequest->getSigned()) {
504                    $latestSignedDate = $signRequest->getSigned();
505                }
506            } catch (DoesNotExistException) {
507            }
508        }
509
510        return $latestSignedDate;
511    }
512
513    public function signSingleFile(FileEntity $libreSignFile, SignRequestEntity $signRequest): void {
514        $previousState = $this->saveCachedState();
515        $this->resetCachedState();
516
517        if ($libreSignFile->getSignedHash()) {
518            $this->restoreCachedState($previousState);
519            return;
520        }
521
522        $previousLibreSignFile = $this->libreSignFile;
523        $previousSignRequest = $this->signRequest;
524        $this->libreSignFile = $libreSignFile;
525        $this->signRequest = $signRequest;
526        $this->setVisibleElements($this->elementsInput);
527
528        try {
529            $this->validateDocMdpAllowsSignatures();
530
531            try {
532                $signedFile = $this->getEngine()->sign();
533            } catch (LibresignException|Exception $e) {
534                $this->cleanupUnsignedSignedFile();
535                $this->recordSignatureAttempt($e);
536                throw $e;
537            }
538
539            $hash = $this->computeHash($signedFile);
540            $this->updateSignRequest($hash);
541            $this->updateLibreSignFile($libreSignFile, $signedFile->getId(), $hash);
542
543            $this->dispatchSignedEvent();
544
545            $envelopeContext = $this->getEnvelopeContext();
546            if ($envelopeContext['envelope'] instanceof FileEntity) {
547                $this->updateEnvelopeStatus(
548                    $envelopeContext['envelope'],
549                    $envelopeContext['envelopeSignRequest'] ?? null,
550                    $signRequest->getSigned()
551                );
552            }
553        } finally {
554            $this->libreSignFile = $previousLibreSignFile;
555            $this->signRequest = $previousSignRequest;
556            $this->restoreCachedState($previousState);
557        }
558    }
559
560    private function saveCachedState(): array {
561        return [
562            'fileToSign' => $this->fileToSign,
563            'createdSignedFile' => $this->createdSignedFile,
564            'engine' => $this->engine,
565        ];
566    }
567
568    private function resetCachedState(): void {
569        $this->fileToSign = null;
570        $this->createdSignedFile = null;
571        $this->engine = null;
572    }
573
574    private function restoreCachedState(array $state): void {
575        $this->fileToSign = $state['fileToSign'];
576        $this->createdSignedFile = $state['createdSignedFile'];
577        $this->engine = $state['engine'];
578    }
579
580    public function enqueueParallelSigningJobs(array $signRequests, array $jobArguments = []): int {
581
582        if (empty($signRequests)) {
583            throw new LibresignException('No sign requests found to process');
584        }
585
586        $enqueued = 0;
587        foreach ($signRequests as $signRequestData) {
588            $file = $signRequestData['file'];
589            $signRequest = $signRequestData['signRequest'];
590
591            if ($file->getSignedHash()) {
592                continue;
593            }
594
595            $nodeId = $file->getNodeId();
596            $userId = $file->getUserId() ?? $signRequest->getUserId();
597
598            if ($nodeId === null || !$this->verifyFileExists($userId, $nodeId)) {
599                continue;
600            }
601
602            $this->enqueueSigningJobForFile($signRequest, $file, $jobArguments);
603            $enqueued++;
604        }
605
606        return $enqueued;
607    }
608
609    private function enqueueSigningJobForFile(SignRequestEntity $signRequest, FileEntity $file, array $jobArguments): void {
610        $args = $jobArguments;
611        $args = $this->addCredentialsToJobArgs($args, $signRequest, $file);
612        $args = array_merge($args, [
613            'fileId' => $file->getId(),
614            'signRequestId' => $signRequest->getId(),
615            'signRequestUuid' => $signRequest->getUuid(),
616            'userId' => $file->getUserId(),
617            'isExternalSigner' => !str_starts_with($args['userUniqueIdentifier'] ?? '', 'account:'),
618        ]);
619
620        $this->jobList->add(SignSingleFileJob::class, $args);
621    }
622
623    private function addCredentialsToJobArgs(array $args, SignRequestEntity $signRequest, FileEntity $file): array {
624        if (!($this->signWithoutPassword || !empty($this->password))) {
625            return $args;
626        }
627
628        $credentialsId = 'libresign_sign_' . $signRequest->getId() . '_' . $file->getId() . '_' . $this->secureRandom->generate(8, ISecureRandom::CHAR_ALPHANUMERIC);
629        $this->credentialsManager->store(
630            $this->user?->getUID() ?? '',
631            $credentialsId,
632            [
633                'signWithoutPassword' => $this->signWithoutPassword,
634                'password' => $this->password,
635                'timestamp' => time(),
636                'expires' => time() + 3600,
637            ]
638        );
639        $args['credentialsId'] = $credentialsId;
640
641        return $args;
642    }
643
644    private function runWithVolatileActiveUser(?IUser $user, callable $callback): mixed {
645        $currentUser = $this->userSession->getUser();
646
647        if ($user === null || $currentUser?->getUID() === $user->getUID()) {
648            return $callback();
649        }
650
651        $this->userSession->setVolatileActiveUser($user);
652
653        try {
654            return $callback();
655        } finally {
656            $this->userSession->setVolatileActiveUser($currentUser);
657        }
658    }
659
660    /**
661     * @return DateTimeInterface|null Last signed date
662     */
663    private function signSequentially(array $signRequests): ?DateTimeInterface {
664        $envelopeLastSignedDate = null;
665        $envelopeContext = $this->getEnvelopeContext();
666
667        foreach ($signRequests as $index => $signRequestData) {
668            $this->libreSignFile = $signRequestData['file'];
669            if ($this->libreSignFile->getStatus() === FileStatus::SIGNED->value) {
670                continue;
671            }
672            $this->signRequest = $signRequestData['signRequest'];
673            $this->engine = null;
674            $this->setVisibleElements($this->elementsInput);
675            $this->fileToSign = null;
676
677            $this->validateDocMdpAllowsSignatures();
678
679            try {
680                $engine = $this->getEngine();
681                $signedFile = $this->runWithVolatileActiveUser(
682                    $this->fileToSign?->getOwner(),
683                    fn (): File => $engine->sign(),
684                );
685            } catch (LibresignException|Exception $e) {
686                $this->cleanupUnsignedSignedFile();
687                $this->recordSignatureAttempt($e);
688
689                $isEnvelope = $this->libreSignFile->isEnvelope() || $this->libreSignFile->hasParent();
690                if (!$isEnvelope) {
691                    throw $e;
692                }
693                continue;
694            }
695
696            $hash = $this->computeHash($signedFile);
697            $envelopeLastSignedDate = $this->getEngine()->getLastSignedDate();
698
699            $this->updateSignRequest($hash);
700            $this->updateLibreSignFile($this->libreSignFile, $signedFile->getId(), $hash);
701
702            $this->dispatchSignedEvent();
703        }
704
705        if ($envelopeContext['envelope'] instanceof FileEntity) {
706            $this->updateEnvelopeStatus(
707                $envelopeContext['envelope'],
708                $envelopeContext['envelopeSignRequest'] ?? null,
709                $envelopeLastSignedDate
710            );
711        }
712
713        return $envelopeLastSignedDate;
714    }
715
716    /**
717     * @return array Array of sign request data with 'file' => FileEntity, 'signRequest' => SignRequestEntity
718     */
719    private function getSignRequestsToSign(): array {
720        if (!$this->libreSignFile->isEnvelope()
721            && !$this->libreSignFile->hasParent()
722        ) {
723            return [[
724                'file' => $this->libreSignFile,
725                'signRequest' => $this->signRequest,
726            ]];
727        }
728
729        return $this->buildEnvelopeSignRequests();
730    }
731
732    /**
733     * @return array Array of sign request data with 'file' => FileEntity, 'signRequest' => SignRequestEntity
734     */
735    private function buildEnvelopeSignRequests(): array {
736        $envelopeId = $this->libreSignFile->isEnvelope()
737            ? $this->libreSignFile->getId()
738            : $this->libreSignFile->getParentFileId();
739
740        $childFiles = $this->fileMapper->getChildrenFiles($envelopeId);
741        if (empty($childFiles)) {
742            throw new LibresignException('No files found in envelope');
743        }
744
745        $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod(
746            $envelopeId,
747            $this->signRequest->getId()
748        );
749
750        if (empty($childSignRequests)) {
751            throw new LibresignException('No sign requests found for envelope files');
752        }
753
754        $signRequestsData = [];
755        foreach ($childSignRequests as $childSignRequest) {
756            $childFile = $this->array_find(
757                $childFiles,
758                fn (FileEntity $file) => $file->getId() === $childSignRequest->getFileId()
759            );
760
761            if ($childFile) {
762                $signRequestsData[] = [
763                    'file' => $childFile,
764                    'signRequest' => $childSignRequest,
765                ];
766            }
767        }
768
769        return $signRequestsData;
770    }
771
772    /**
773     * @return array Array with 'envelope' => FileEntity or null, 'envelopeSignRequest' => SignRequestEntity or null
774     */
775    private function getEnvelopeContext(): array {
776        $result = [
777            'envelope' => null,
778            'envelopeSignRequest' => null,
779        ];
780
781        if (!$this->libreSignFile->isEnvelope() && !$this->libreSignFile->hasParent()) {
782            return $result;
783        }
784
785        if ($this->libreSignFile->isEnvelope()) {
786            $result['envelope'] = $this->libreSignFile;
787            $result['envelopeSignRequest'] = $this->signRequest;
788            return $result;
789        }
790
791        try {
792            $envelopeId = $this->libreSignFile->isEnvelope()
793                ? $this->libreSignFile->getId()
794                : $this->libreSignFile->getParentFileId();
795            $result['envelope'] = $this->fileMapper->getById($envelopeId);
796            $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($this->signRequest->getId());
797            $result['envelopeSignRequest'] = $this->signRequestMapper->getByIdentifyMethodAndFileId(
798                $identifyMethod,
799                $result['envelope']->getId()
800            );
801        } catch (DoesNotExistException) {
802        }
803
804        return $result;
805    }
806
807    private function updateEnvelopeStatus(
808        FileEntity $envelope,
809        ?SignRequestEntity $envelopeSignRequest = null,
810        ?DateTimeInterface $signedDate = null,
811    ): void {
812        $childFiles = $this->fileMapper->getChildrenFiles($envelope->getId());
813        $signRequestsMap = $this->buildSignRequestsMap($childFiles);
814
815        $status = $this->envelopeStatusDeterminer->determineStatus($childFiles, $signRequestsMap);
816        $envelope->setStatus($status);
817
818        $this->handleSignedEnvelopeSignRequest($envelope, $envelopeSignRequest, $signedDate, $status);
819
820        $this->updateEnvelopeMetadata($envelope);
821        $this->fileMapper->update($envelope);
822        $this->updateEntityCacheAfterDbSave($envelope);
823    }
824
825    private function buildSignRequestsMap(array $childFiles): array {
826        $signRequestsMap = [];
827        foreach ($childFiles as $childFile) {
828            $signRequestsMap[$childFile->getId()] = $this->signRequestMapper->getByFileId($childFile->getId());
829        }
830        return $signRequestsMap;
831    }
832
833    private function handleSignedEnvelopeSignRequest(
834        FileEntity $envelope,
835        ?SignRequestEntity $envelopeSignRequest,
836        ?DateTimeInterface $signedDate,
837        int $status,
838    ): void {
839        if (!($envelopeSignRequest instanceof SignRequestEntity)) {
840            return;
841        }
842
843        $envelopeSignRequest->setSigned($signedDate ?: new DateTime());
844        $envelopeSignRequest->setStatusEnum(\OCA\Libresign\Enum\SignRequestStatus::SIGNED);
845        $this->signRequestMapper->update($envelopeSignRequest);
846        $this->sequentialSigningService
847            ->setFile($envelope)
848            ->releaseNextOrder(
849                $envelopeSignRequest->getFileId(),
850                $envelopeSignRequest->getSigningOrder()
851            );
852    }
853
854    private function updateEnvelopeMetadata(FileEntity $envelope): void {
855        $meta = $envelope->getMetadata() ?? [];
856        $meta['status_changed_at'] = (new DateTime())->format(DateTimeInterface::ATOM);
857        $envelope->setMetadata($meta);
858    }
859
860    /**
861     * @throws LibresignException If the document has DocMDP level 1 (no changes allowed)
862     */
863    protected function validateDocMdpAllowsSignatures(): void {
864        $resource = $this->getLibreSignFileAsResource();
865
866        try {
867            if (!$this->docMdpHandler->allowsAdditionalSignatures($resource)) {
868                throw new LibresignException(
869                    // TRANSLATORS Error shown when trying to add more signers to a document certified with DocMDP level that forbids further changes.
870                    $this->l10n->t('This document has been certified with no changes allowed. You cannot add more signers to this document.'),
871                    AppFrameworkHttp::STATUS_UNPROCESSABLE_ENTITY
872                );
873            }
874        } finally {
875            fclose($resource);
876        }
877    }
878
879    /**
880     * @return resource
881     * @throws LibresignException
882     */
883    protected function getLibreSignFileAsResource() {
884        $files = $this->getNextcloudFiles($this->libreSignFile);
885        if (empty($files)) {
886            throw new LibresignException('File not found');
887        }
888        $fileToSign = current($files);
889        $content = $fileToSign->getContent();
890        $resource = fopen('php://memory', 'r+');
891        if ($resource === false) {
892            throw new LibresignException('Failed to create temporary resource for PDF validation');
893        }
894        fwrite($resource, $content);
895        rewind($resource);
896        return $resource;
897    }
898
899    protected function computeHash(File $file): string {
900        return hash('sha256', $file->getContent());
901    }
902
903    protected function updateSignRequest(string $hash): void {
904        $lastSignedDate = $this->getEngine()->getLastSignedDate();
905        $this->signRequest->setSigned($lastSignedDate);
906        $this->signRequest->setSignedHash($hash);
907        $this->signRequest->setStatusEnum(\OCA\Libresign\Enum\SignRequestStatus::SIGNED);
908
909        $certificateInfo = $this->getPkcs12Engine()->readCertificate();
910        $this->storeCertificateInfoInMetadata($certificateInfo);
911
912        $this->signRequestMapper->update($this->signRequest);
913        // A queued invitation is no longer actionable once signing completed.
914        // Transient signing fixtures have no persisted request ID to invalidate.
915        if ($this->signRequest->getId() !== null) {
916            $this->notificationDeliveries->cancelPendingInvitations($this->signRequest->getId());
917        }
918
919        $this->sequentialSigningService
920            ->setFile($this->libreSignFile)
921            ->releaseNextOrder(
922                $this->signRequest->getFileId(),
923                $this->signRequest->getSigningOrder()
924            );
925    }
926
927    private function storeCertificateInfoInMetadata(array $certificateInfo): void {
928        $metadata = $this->signRequest->getMetadata() ?? [];
929
930        $certificateData = [];
931
932        if (isset($certificateInfo['serialNumber'])) {
933            $certificateData['serialNumber'] = $certificateInfo['serialNumber'];
934        }
935        if (isset($certificateInfo['serialNumberHex'])) {
936            $certificateData['serialNumberHex'] = $certificateInfo['serialNumberHex'];
937        }
938        if (isset($certificateInfo['hash'])) {
939            $certificateData['hash'] = $certificateInfo['hash'];
940        }
941        if (isset($certificateInfo['subject'])) {
942            $certificateData['subject'] = $certificateInfo['subject'];
943        }
944
945        if (!empty($certificateData)) {
946            $metadata['certificate_info'] = $certificateData;
947            $this->signRequest->setMetadata($metadata);
948        }
949    }
950
951    protected function updateLibreSignFile(FileEntity $libreSignFile, int $nodeId, string $hash): void {
952        $libreSignFile->setSignedNodeId($nodeId);
953        $libreSignFile->setSignedHash($hash);
954        $this->setNewStatusIfNecessary($libreSignFile);
955        $this->fileStatusService->update($libreSignFile);
956
957        if ($libreSignFile->hasParent()) {
958            $this->fileStatusService->propagateStatusToParent($libreSignFile->getParentFileId());
959        }
960    }
961
962    protected function dispatchSignedEvent(): void {
963        $certificateSerialNumber = null;
964        if ($this->signWithoutPassword) {
965            try {
966                $certificateInfo = $this->getPkcs12Engine()->readCertificate();
967                if (isset($certificateInfo['serialNumber']) && is_string($certificateInfo['serialNumber'])) {
968                    $certificateSerialNumber = $certificateInfo['serialNumber'];
969                } else {
970                    $this->logger->warning('Unable to extract certificate serial number for event payload');
971                }
972            } catch (\Throwable $e) {
973                $this->logger->error('Failed to get certificate info for event', [
974                    'exception' => $e,
975                    'signRequestId' => $this->signRequest->getId()
976                ]);
977            }
978        }
979
980        $event = $this->signedEventFactory->make(
981            $this->signRequest,
982            $this->libreSignFile,
983            $this->getEngine()->getInputFile(),
984            $this->signWithoutPassword,
985            $certificateSerialNumber,
986        );
987        $this->eventDispatcher->dispatchTyped($event);
988    }
989
990    protected function identifyEngine(File $file): ISignEngineHandler {
991        return $this->signEngineFactory->resolve($file->getExtension());
992    }
993
994    protected function getSignatureParams(): array {
995        $certificateData = $this->readCertificate();
996        $signatureParams = $this->buildBaseSignatureParams($certificateData);
997        $signatureParams = $this->addEmailToSignatureParams($signatureParams, $certificateData);
998        $signatureParams = $this->addMetadataToSignatureParams($signatureParams);
999        return $signatureParams;
1000    }
1001
1002    private function buildBaseSignatureParams(array $certificateData): array {
1003        $issuerCommonName = $this->normalizeCertificateFieldToString($certificateData['issuer']['CN'] ?? '');
1004        $signerCommonName = $this->normalizeCertificateFieldToString($certificateData['subject']['CN'] ?? '');
1005        $documentUuid = $this->libreSignFile?->getUuid() ?? '';
1006        $validationUrl = $documentUuid ? $this->buildValidationUrl($documentUuid) : '';
1007
1008        return [
1009            'DocumentUUID' => $documentUuid,
1010            'IssuerCommonName' => $issuerCommonName,
1011            'SignerCommonName' => $signerCommonName,
1012            'LocalSignerTimezone' => $this->dateTimeZone->getTimeZone()->getName(),
1013            'ValidationURL' => $validationUrl,
1014            'LocalSignerSignatureDateTime' => (new DateTime('now', new \DateTimeZone('UTC')))
1015                ->format(DateTimeInterface::ATOM)
1016        ];
1017    }
1018
1019    private function buildValidationUrl(string $uuid): string {
1020        $footerPolicy = FooterPolicyValue::normalize($this->getFooterPolicyValue());
1021        $validationSite = trim($footerPolicy['validationSite']);
1022        if ($validationSite !== '') {
1023            return rtrim($validationSite, '/') . '/' . $uuid;
1024        }
1025
1026        return $this->urlGenerator->linkToRouteAbsolute('libresign.page.validationFileWithShortUrl', [
1027            'uuid' => $uuid,
1028        ]);
1029    }
1030
1031    private function normalizeCertificateFieldToString(mixed $value): string {
1032        if (is_array($value)) {
1033            $flattened = [];
1034            array_walk_recursive($value, static function (mixed $item) use (&$flattened): void {
1035                if ($item !== null) {
1036                    $flattened[] = (string)$item;
1037                }
1038            });
1039            $displayValues = array_values(array_filter(
1040                $flattened,
1041                static fn (string $item) => !preg_match('/^account:\s*/i', $item),
1042            ));
1043            return implode(', ', $displayValues);
1044        }
1045
1046        return $value === null ? '' : (string)$value;
1047    }
1048
1049    private function addEmailToSignatureParams(array $signatureParams, array $certificateData): array {
1050        $email = $this->subjectAlternativeNameService->extractEmailFromCertificate($certificateData);
1051        if ($email) {
1052            $signatureParams['SignerEmail'] = $email;
1053        }
1054
1055        if (empty($signatureParams['SignerEmail']) && $this->user instanceof IUser) {
1056            $signatureParams['SignerEmail'] = $this->user->getEMailAddress();
1057        }
1058
1059        if (empty($signatureParams['SignerEmail']) && $this->signRequest instanceof SignRequestEntity) {
1060            $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($this->signRequest->getId());
1061            if ($identifyMethod->getName() === IdentifyMethodService::IDENTIFY_EMAIL) {
1062                $signatureParams['SignerEmail'] = $identifyMethod->getEntity()->getIdentifierValue();
1063            }
1064        }
1065        return $signatureParams;
1066    }
1067
1068    private function addMetadataToSignatureParams(array $signatureParams): array {
1069        $signRequestMetadata = $this->signRequest->getMetadata();
1070        if (isset($signRequestMetadata['remote-address'])) {
1071            $signatureParams['SignerIP'] = $signRequestMetadata['remote-address'];
1072        }
1073        if (isset($signRequestMetadata['user-agent'])) {
1074            $signatureParams['SignerUserAgent'] = $signRequestMetadata['user-agent'];
1075        }
1076        if ($this->libreSignFile?->getMetadata()) {
1077            $metadata = $this->libreSignFile->getMetadata();
1078            if (isset($metadata['d']) && !empty($metadata['d'])) {
1079                $signatureParams['PageDimensions'] = $metadata['d'];
1080            }
1081        }
1082        return $signatureParams;
1083    }
1084
1085    public function storeUserMetadata(array $metadata = []): self {
1086        $patch = [];
1087
1088        if ($this->isCollectMetadataEnabled()) {
1089            if (isset($metadata['user-agent'])) {
1090                $patch['user-agent'] = $metadata['user-agent'];
1091            }
1092            if (isset($metadata['remote-address'])) {
1093                $patch['remote-address'] = $metadata['remote-address'];
1094            }
1095        }
1096
1097        if (isset($metadata['geolocation']) && is_array($metadata['geolocation'])) {
1098            $existingMetadata = $this->signRequest->getMetadata() ?? [];
1099            $existingGeolocation = is_array($existingMetadata['geolocation'] ?? null)
1100                ? $existingMetadata['geolocation']
1101                : [];
1102            $patch['geolocation'] = array_merge($existingGeolocation, $metadata['geolocation']);
1103        }
1104
1105        if ($patch === []) {
1106            return $this;
1107        }
1108
1109        $this->signRequest->setMetadata(array_merge(
1110            $this->signRequest->getMetadata() ?? [],
1111            $patch,
1112        ));
1113        $this->signRequestMapper->update($this->signRequest);
1114        return $this;
1115    }
1116
1117    private function isCollectMetadataEnabled(): bool {
1118        return (bool)$this->policyService->resolve(CollectMetadataPolicy::KEY)->getEffectiveValue();
1119    }
1120
1121    /**
1122     * @return SignRequestEntity[]
1123     */
1124    protected function getSigners(): array {
1125        return $this->signRequestMapper->getByFileId($this->signRequest->getFileId());
1126    }
1127
1128    protected function setNewStatusIfNecessary(FileEntity $libreSignFile): bool {
1129        $newStatus = $this->evaluateStatusFromSigners($libreSignFile);
1130
1131        if ($newStatus === null || $newStatus === $libreSignFile->getStatus()) {
1132            return false;
1133        }
1134
1135        $libreSignFile->setStatus($newStatus);
1136
1137        return true;
1138    }
1139
1140    private function updateEntityCacheAfterDbSave(FileEntity $file): void {
1141        $this->statusService->cacheFileStatus($file);
1142    }
1143
1144    private function evaluateStatusFromSigners(FileEntity $libreSignFile): ?int {
1145        return $this->signingWorkflowStatusResolver->resolve($libreSignFile, $this->getSigners())?->value;
1146    }
1147
1148    private function getOrGeneratePfxContent(SignEngineHandler $engine): string {
1149        $result = $this->pfxProvider->getOrGeneratePfx(
1150            $engine,
1151            $this->signWithoutPassword,
1152            $this->signatureMethodName,
1153            $this->userUniqueIdentifier,
1154            $this->friendlyName,
1155            $this->password,
1156        );
1157        if ($result['password'] !== null) {
1158            $this->setPassword($result['password']);
1159        }
1160        return $result['pfx'];
1161    }
1162
1163    /**
1164     * Return the engine that holds the PKCS#12/PFX credential material.
1165     *
1166     * The current signing workflow receives the PKCS#12/PFX credential inside
1167     * LibreSign, so the engine must implement the PKCS#12 credential contract.
1168     * The common sign engine contract does not require it.
1169     *
1170     * @throws LibresignException When the resolved engine has no PKCS#12 credential contract
1171     */
1172    protected function getPkcs12Engine(): IPkcs12SignEngineHandler {
1173        $engine = $this->getEngine();
1174        if (!$engine instanceof IPkcs12SignEngineHandler) {
1175            throw new LibresignException('The sign engine does not support PKCS#12 credentials.');
1176        }
1177        return $engine;
1178    }
1179
1180    protected function readCertificate(): array {
1181        return $this->getPkcs12Engine()
1182            ->readCertificate();
1183    }
1184
1185    /**
1186     * Get file to sign
1187     *
1188     * @throws LibresignException
1189     */
1190    protected function getFileToSign(): File {
1191        if ($this->fileToSign instanceof File) {
1192            return $this->fileToSign;
1193        }
1194
1195        $userId = $this->libreSignFile->getUserId()
1196            ?? $this->user?->getUID()
1197            ?? null;
1198        $nodeId = $this->libreSignFile->getNodeId();
1199
1200        if ($userId === null) {
1201            // TRANSLATORS Error shown when the data required to apply a digital signature is missing or invalid.
1202            throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
1203        }
1204
1205        try {
1206            $originalFile = $this->getNodeByIdUsingUid($userId, $nodeId);
1207        } catch (\Throwable $e) {
1208            $this->logger->error('[file-access] FAILED to find file - userId={userId} nodeId={nodeId} error={error}', [
1209                'userId' => $userId,
1210                'nodeId' => $nodeId,
1211                'error' => $e->getMessage(),
1212            ]);
1213            throw $e;
1214        }
1215
1216        if ($originalFile->getOwner()->getUID() !== $userId) {
1217            $originalFile = $this->getNodeByIdUsingUid($originalFile->getOwner()->getUID(), $nodeId);
1218        }
1219        if ($this->isPdf($originalFile)) {
1220            $this->fileToSign = $this->getPdfToSign($originalFile);
1221        } else {
1222            $this->fileToSign = $originalFile;
1223        }
1224        return $this->fileToSign;
1225    }
1226
1227    private function isPdf(File $file): bool {
1228        return strcasecmp($file->getExtension(), 'pdf') === 0;
1229    }
1230
1231    protected function getEngine(): ISignEngineHandler {
1232        if (!$this->engine) {
1233            $originalFile = $this->getFileToSign();
1234            $this->engine = $this->identifyEngine($originalFile);
1235
1236            $this->configureEngine();
1237        }
1238        return $this->engine;
1239    }
1240
1241    private function configureEngine(): void {
1242        $engine = $this->getPkcs12Engine();
1243        $engine->setInputFile($this->getFileToSign());
1244        $engine->setCertificate($this->getOrGeneratePfxContent($this->engine));
1245        $engine->setPassword($this->password);
1246
1247        if ($this->engine::class === Pkcs12Handler::class) {
1248            $this->restoreMissingVisibleElementImages();
1249            $this->engine
1250                ->setVisibleElements($this->getVisibleElements())
1251                ->setSignatureParams($this->getSignatureParams());
1252        }
1253    }
1254
1255    public function getLibresignFile(?int $fileId, ?string $signRequestUuid = null): FileEntity {
1256        try {
1257            if ($fileId) {
1258                return $this->fileMapper->getById($fileId);
1259            }
1260
1261            if ($signRequestUuid) {
1262                $signRequest = $this->signRequestMapper->getByUuid($signRequestUuid);
1263                return $this->fileMapper->getById($signRequest->getFileId());
1264            }
1265
1266            throw new \Exception('Invalid arguments');
1267        } catch (DoesNotExistException) {
1268            // TRANSLATORS Error shown when the document to sign cannot be found.
1269            throw new LibresignException($this->l10n->t('File not found'), 1);
1270        }
1271    }
1272
1273    public function renew(SignRequestEntity $signRequest, string $method): void {
1274        $identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
1275        if (empty($identifyMethods[$method])) {
1276            // TRANSLATORS Error shown when the chosen method to identify the signer (account, email, SMS, etc.) is invalid.
1277            throw new LibresignException($this->l10n->t('Invalid identification method'));
1278        }
1279
1280        $signRequest->setUuid(UUIDUtil::getUUID());
1281        $this->signRequestMapper->update($signRequest);
1282
1283        array_map(function (IIdentifyMethod $identifyMethod): void {
1284            $entity = $identifyMethod->getEntity();
1285            $entity->setAttempts($entity->getAttempts() + 1);
1286            $entity->setLastAttemptDate($this->timeFactory->getDateTime());
1287            $identifyMethod->save();
1288        }, $identifyMethods[$method]);
1289    }
1290
1291    public function requestCode(
1292        SignRequestEntity $signRequest,
1293        string $identifyMethodName,
1294        string $signMethodName,
1295    ): void {
1296        $identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
1297        if (empty($identifyMethods[$identifyMethodName])) {
1298            // TRANSLATORS Error shown when the chosen method to identify the signer (account, email, SMS, etc.) is invalid.
1299            throw new LibresignException($this->l10n->t('Invalid identification method'));
1300        }
1301        foreach ($identifyMethods[$identifyMethodName] as $identifyMethod) {
1302            try {
1303                $signatureMethod = $identifyMethod->getEmptyInstanceOfSignatureMethodByName($signMethodName);
1304                $signatureMethod->setEntity($identifyMethod->getEntity());
1305            } catch (InvalidArgumentException) {
1306                continue;
1307            }
1308            /** @var IToken $signatureMethod */
1309            $signatureMethod->requestCode();
1310            return;
1311        }
1312        // TRANSLATORS Error shown when sending a signing verification code is disabled by configuration.
1313        throw new LibresignException($this->l10n->t('Sending authorization code not enabled.'));
1314    }
1315
1316    private function getOrCreateApproverSignRequest(FileEntity $file, IUser $user): ?SignRequestEntity {
1317        if (!$this->identityDocumentValidator->userCanApproveValidationDocuments($user, false)) {
1318            return null;
1319        }
1320
1321        try {
1322            $this->idDocsMapper->getByFileId($file->getId());
1323        } catch (\Throwable) {
1324            return null;
1325        }
1326
1327        try {
1328            $this->sequentialSigningService->setFile($file);
1329            $signRequest = $this->signRequestService->createOrUpdateSignRequest(
1330                identifyMethods: [IdentifyMethodService::IDENTIFY_ACCOUNT => $user->getUID()],
1331                displayName: $user->getDisplayName(),
1332                description: null,
1333                notify: false,
1334                fileId: $file->getId(),
1335                signingOrder: 0,
1336                fileStatus: FileStatus::ABLE_TO_SIGN->value,
1337            );
1338
1339            return $signRequest;
1340        } catch (\Throwable $e) {
1341            $this->logger->error('Failed to create/get SignRequest for approver', [
1342                'exception' => $e,
1343                'fileId' => $file->getId(),
1344                'userId' => $user->getUID(),
1345            ]);
1346            return null;
1347        }
1348    }
1349
1350    /**
1351     * @param SignRequestEntity[] $signRequests
1352     * @param IUser $user
1353     * @return SignRequestEntity|null
1354     */
1355    private function findSignRequestByIdentifyMethod(array $signRequests, IUser $user): ?SignRequestEntity {
1356        foreach ($signRequests as $signRequest) {
1357            $identifyMethods = $this->identifyMethodMapper->getIdentifyMethodsFromSignRequestId($signRequest->getId());
1358            foreach ($identifyMethods as $method) {
1359                if ($method->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL
1360                    && ($method->getIdentifierValue() === $user->getUID()
1361                        || $method->getIdentifierValue() === $user->getEMailAddress())
1362                ) {
1363                    return $signRequest;
1364                }
1365                if ($method->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT
1366                    && $method->getIdentifierValue() === $user->getUID()
1367                ) {
1368                    return $signRequest;
1369                }
1370            }
1371        }
1372        return null;
1373    }
1374
1375    public function getSignRequestToSign(FileEntity $libresignFile, ?string $signRequestUuid, ?IUser $user): SignRequestEntity {
1376        $this->signingRequestValidator->fileCanBeSigned($libresignFile);
1377        try {
1378            if (!empty($signRequestUuid)) {
1379                $signRequest = $this->getSignRequestByUuid($signRequestUuid);
1380            } elseif ($user) {
1381                $signRequest = $this->getOrCreateApproverSignRequest($libresignFile, $user);
1382            }
1383
1384            if (!isset($signRequest)) {
1385                if ($libresignFile->isEnvelope()) {
1386                    $childFiles = $this->fileMapper->getChildrenFiles($libresignFile->getId());
1387                    $allSignRequests = [];
1388                    foreach ($childFiles as $childFile) {
1389                        $childSignRequests = $this->signRequestMapper->getByFileId($childFile->getId());
1390                        $allSignRequests = array_merge($allSignRequests, $childSignRequests);
1391                    }
1392                    $signRequests = $allSignRequests;
1393                } else {
1394                    $signRequests = $this->signRequestMapper->getByFileId($libresignFile->getId());
1395                }
1396
1397                $signRequest = $this->findSignRequestByIdentifyMethod($signRequests, $user);
1398            }
1399
1400            if (!$signRequest) {
1401                throw new DoesNotExistException('Sign request not found');
1402            }
1403            $signRequestFile = $libresignFile;
1404            if ($signRequestFile->getId() !== $signRequest->getFileId()) {
1405                $signRequestFile = $this->fileMapper->getById($signRequest->getFileId());
1406            }
1407            $this->sequentialSigningService->setFile($signRequestFile);
1408            if (
1409                $this->sequentialSigningService->isOrderedNumericFlow()
1410                && $this->sequentialSigningService->hasPendingLowerOrderSigners(
1411                    $signRequest->getFileId(),
1412                    $signRequest->getSigningOrder()
1413                )
1414            ) {
1415                throw new LibresignException(json_encode([
1416                    'action' => JSActions::ACTION_DO_NOTHING,
1417                    // TRANSLATORS Error shown when the signer tries to sign before they are allowed to (for example, earlier steps are incomplete).
1418                    'errors' => [['message' => $this->l10n->t('You are not allowed to sign this document yet')]],
1419                ]));
1420            }
1421            if ($signRequest->getSigned()) {
1422                // TRANSLATORS Error shown when the current user already signed this document.
1423                throw new LibresignException($this->l10n->t('File already signed by you'), 1);
1424            }
1425            if ($signRequest->getStatusEnum() === SignRequestStatus::REJECTED) {
1426                // TRANSLATORS Error shown when someone who refused to sign a document tries to sign it anyway.
1427                throw new LibresignException($this->l10n->t('You rejected this document, so you cannot sign it.'), 1);
1428            }
1429            return $signRequest;
1430        } catch (DoesNotExistException) {
1431            // TRANSLATORS Error shown when the data required to apply a digital signature is missing or invalid.
1432            throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
1433        }
1434    }
1435
1436    protected function getPdfToSign(File $originalFile): File {
1437        $file = $this->getSignedFile();
1438        if ($file instanceof File) {
1439            return $file;
1440        }
1441
1442        $originalContent = $originalFile->getContent();
1443
1444        if ($this->pdfSignatureDetectionService->hasSignatures($originalContent)) {
1445            return $this->createSignedFile($originalFile, $originalContent);
1446        }
1447        $metadata = $this->footerHandler->getMetadata($originalFile, $this->libreSignFile);
1448        $this->footerHandler->setPolicyValue($this->getFooterPolicyValue());
1449        $footer = $this->footerHandler
1450            ->setTemplateVar('uuid', $this->libreSignFile->getUuid())
1451            ->setTemplateVar('signers', array_map(fn (SignRequestEntity $signer) => [
1452                'displayName' => $signer->getDisplayName(),
1453                'signed' => $signer->getSigned()
1454                    ? $signer->getSigned()->format(DateTimeInterface::ATOM)
1455                    : null,
1456            ], $this->getSigners()))
1457            ->getFooter($metadata['d']);
1458        if ($footer) {
1459            $stamp = $this->tempManager->getTemporaryFile('stamp.pdf');
1460            file_put_contents($stamp, $footer);
1461
1462            $input = $this->tempManager->getTemporaryFile('input.pdf');
1463            file_put_contents($input, $originalContent);
1464
1465            try {
1466                $pdfContent = $this->pdf->applyStamp($input, $stamp);
1467            } catch (FooterStampUnavailableException $e) {
1468                $this->logger->warning('Using original PDF because footer stamping is unavailable.', [
1469                    'exception' => $e,
1470                ]);
1471                $pdfContent = $originalContent;
1472            } catch (RuntimeException|LibresignException $e) {
1473                throw new LibresignException($e->getMessage());
1474            }
1475        } else {
1476            $pdfContent = $originalContent;
1477        }
1478        return $this->createSignedFile($originalFile, $pdfContent);
1479    }
1480
1481    /**
1482     * Once frozen, the request signs with the footer it stores: it was resolved
1483     * and validated when stored, so later policy changes do not apply to it.
1484     */
1485    private function getFooterPolicyValue(): string {
1486        if ($this->footerPolicyFile !== $this->libreSignFile) {
1487            $storedFooter = FooterPolicyValue::getSnapshotEffectiveValue($this->libreSignFile);
1488            $this->footerPolicyValue = $storedFooter !== null && FileStatusService::isPolicySnapshotFrozen($this->libreSignFile, $this->fileMapper->findEnvelope($this->libreSignFile))
1489                ? $storedFooter
1490                : (string)$this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue();
1491            $this->footerPolicyFile = $this->libreSignFile;
1492        }
1493
1494        return $this->footerPolicyValue;
1495    }
1496
1497    protected function getSignedFile(): ?File {
1498        $nodeId = $this->libreSignFile->getSignedNodeId();
1499        if (!$nodeId) {
1500            return null;
1501        }
1502
1503        $fileToSign = $this->getNodeByIdUsingUid($this->libreSignFile->getUserId(), $nodeId);
1504
1505        if ($fileToSign->getOwner()->getUID() !== $this->libreSignFile->getUserId()) {
1506            $fileToSign = $this->getNodeByIdUsingUid($fileToSign->getOwner()->getUID(), $nodeId);
1507        }
1508        return $fileToSign;
1509    }
1510
1511    protected function getNodeByIdUsingUid(string $uid, int $nodeId): File {
1512        try {
1513            $fileToSign = $this->folderService->getReadableNodeById($uid, $nodeId);
1514        } catch (UserNotFoundException) {
1515            $this->logger->error('[file-access] UserNotFoundException for uid={uid}', ['uid' => $uid]);
1516            // TRANSLATORS Error shown when the Nextcloud user linked to the signer cannot be found.
1517            throw new LibresignException($this->l10n->t('User not found.'));
1518        } catch (NotPermittedException) {
1519            $this->logger->error('[file-access] NotPermittedException for uid={uid}', ['uid' => $uid]);
1520            // TRANSLATORS Permission error shown when the current user cannot perform the requested signing action.
1521            throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
1522        } catch (\Throwable $e) {
1523            $this->logger->error('[file-access] Failed to resolve node - nodeId={nodeId} error={error}', [
1524                'nodeId' => $nodeId,
1525                'error' => $e->getMessage(),
1526            ]);
1527            throw $e;
1528        }
1529
1530        if (!$fileToSign instanceof File) {
1531            $this->logger->error('[file-access] Node is not a File - nodeId={nodeId} type={type}', [
1532                'nodeId' => $nodeId,
1533                'type' => $fileToSign ? $fileToSign::class : 'NULL',
1534            ]);
1535            // TRANSLATORS Error shown when the document to sign cannot be found.
1536            throw new LibresignException($this->l10n->t('File not found'));
1537        }
1538        return $fileToSign;
1539    }
1540
1541    /**
1542     * Verify if file exists in filesystem before enqueuing background job
1543     *
1544     * @param string|null $uid User ID
1545     * @param int $nodeId File node ID
1546     * @return bool True if file exists and is accessible
1547     */
1548    private function verifyFileExists(?string $uid, int $nodeId): bool {
1549        if ($uid === null || $nodeId === 0) {
1550            return false;
1551        }
1552
1553        try {
1554            return $this->folderService->getReadableNodeById($uid, $nodeId) instanceof File;
1555        } catch (\Throwable $e) {
1556            $this->logger->warning('[verify-file] File not accessible - nodeId={nodeId} uid={uid} error={error}', [
1557                'nodeId' => $nodeId,
1558                'uid' => $uid,
1559                'error' => $e->getMessage(),
1560            ]);
1561            return false;
1562        }
1563    }
1564
1565    private function cleanupUnsignedSignedFile(): void {
1566        if (!$this->createdSignedFile instanceof File) {
1567            return;
1568        }
1569
1570        try {
1571            $this->createdSignedFile->delete();
1572        } catch (\Throwable $e) {
1573            $this->logger->warning('Failed to delete temporary signed file: ' . $e->getMessage());
1574        } finally {
1575            $this->createdSignedFile = null;
1576        }
1577    }
1578
1579    private function createSignedFile(File $originalFile, string $content): File {
1580        $filename = preg_replace(
1581            '/' . $originalFile->getExtension() . '$/',
1582            // TRANSLATORS Filename suffix used when saving the signed document copy. Appears before the original file extension.
1583            $this->l10n->t('signed') . '.' . $originalFile->getExtension(),
1584            basename($originalFile->getPath())
1585        );
1586        $owner = $originalFile->getOwner();
1587        $ownerUid = $owner->getUID();
1588
1589        $fileId = $this->libreSignFile->getId();
1590        $extension = $originalFile->getExtension();
1591        $uniqueFilename = substr((string)$filename, 0, -strlen($extension) - 1) . '_' . $fileId . '.' . $extension;
1592
1593        try {
1594            $parentFolder = $originalFile->getParent();
1595
1596            $this->createdSignedFile = $this->runWithVolatileActiveUser(
1597                $owner,
1598                fn (): File => $parentFolder->newFile($uniqueFilename, $content),
1599            );
1600
1601            return $this->createdSignedFile;
1602        } catch (NotPermittedException) {
1603            // TRANSLATORS Permission error shown when the current user cannot perform the requested signing action.
1604            throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
1605        } catch (\Exception $e) {
1606            throw $e;
1607        }
1608    }
1609
1610    /**
1611     * @throws DoesNotExistException
1612     */
1613    public function getSignRequestByUuid(string $uuid): SignRequestEntity {
1614        $this->signerValidator->validateUuidFormat($uuid);
1615        return $this->signRequestMapper->getByUuid($uuid);
1616    }
1617
1618    /**
1619     * @throws DoesNotExistException
1620     */
1621    public function getFile(int $signRequestId): FileEntity {
1622        return $this->fileMapper->getById($signRequestId);
1623    }
1624
1625    /**
1626     * @throws DoesNotExistException
1627     */
1628    public function getFileByUuid(string $uuid): FileEntity {
1629        return $this->fileMapper->getByUuid($uuid);
1630    }
1631
1632    public function getIdDocById(int $fileId): IdDocs {
1633        return $this->idDocsMapper->getByFileId($fileId);
1634    }
1635
1636    /**
1637     * @return File[] Array of files
1638     */
1639    public function getNextcloudFiles(FileEntity $fileData): array {
1640        if ($fileData->getNodeType() === 'envelope') {
1641            $children = $this->fileMapper->getChildrenFiles($fileData->getId());
1642            $files = [];
1643            foreach ($children as $child) {
1644                $nodeId = $child->getNodeId();
1645                if ($nodeId === null) {
1646                    throw new LibresignException(json_encode([
1647                        'action' => JSActions::ACTION_DO_NOTHING,
1648                        // TRANSLATORS Error shown when the document to sign cannot be found.
1649                        'errors' => [['message' => $this->l10n->t('File not found')]],
1650                    ]), AppFrameworkHttp::STATUS_NOT_FOUND);
1651                }
1652                $file = $this->folderService->getReadableNodeById($child->getUserId(), $nodeId);
1653                if ($file instanceof File) {
1654                    $files[] = $file;
1655                }
1656            }
1657            return $files;
1658        }
1659
1660        $nodeId = $fileData->getNodeId();
1661        if ($nodeId === null) {
1662            throw new LibresignException(json_encode([
1663                'action' => JSActions::ACTION_DO_NOTHING,
1664                // TRANSLATORS Error shown when the document to sign cannot be found.
1665                'errors' => [['message' => $this->l10n->t('File not found')]],
1666            ]), AppFrameworkHttp::STATUS_NOT_FOUND);
1667        }
1668        $storageUserId = $this->fileMapper->getStorageUserIdByUuid($fileData->getUuid());
1669        $this->folderService->setUserId($storageUserId);
1670        $fileToSign = $this->folderService->getFileByNodeId($nodeId);
1671        if (!$fileToSign instanceof File) {
1672            throw new LibresignException(json_encode([
1673                'action' => JSActions::ACTION_DO_NOTHING,
1674                // TRANSLATORS Error shown when the document to sign cannot be found.
1675                'errors' => [['message' => $this->l10n->t('File not found')]],
1676            ]), AppFrameworkHttp::STATUS_NOT_FOUND);
1677        }
1678        return [$fileToSign];
1679    }
1680
1681    public function validateSigner(string $uuid, ?IUser $user = null): void {
1682        $this->signerValidator->validateSigner($uuid, $user);
1683    }
1684
1685    public function validateRenewSigner(string $uuid, ?IUser $user = null): void {
1686        $this->signerValidator->validateRenewSigner($uuid, $user);
1687    }
1688
1689    public function getSignerData(?IUser $user, ?SignRequestEntity $signRequest = null): array {
1690        $return = ['user' => ['name' => null]];
1691        if ($signRequest) {
1692            $return['user']['name'] = $signRequest->getDisplayName();
1693        } elseif ($user) {
1694            $return['user']['name'] = $user->getDisplayName();
1695        }
1696        return $return;
1697    }
1698
1699    public function getAvailableIdentifyMethodsFromSettings(): array {
1700        $identifyMethods = $this->identifyMethodService->getIdentifyMethodsSettings();
1701        $return = array_map(fn (array $identifyMethod): array => [
1702            'requirement' => IdentifyMethodRequirement::tryFrom((string)($identifyMethod['requirement'] ?? ''))?->value
1703                ?? IdentifyMethodRequirement::OPTIONAL->value,
1704            'identifiedAtDate' => null,
1705            'validateCode' => false,
1706            'method' => $identifyMethod['name'],
1707        ], $identifyMethods);
1708        return $return;
1709    }
1710
1711    public function getFileUrl(int $fileId, string $uuid): string {
1712        try {
1713            $this->idDocsMapper->getByFileId($fileId);
1714            return $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $uuid]);
1715        } catch (DoesNotExistException) {
1716            return $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $uuid]);
1717        }
1718    }
1719
1720    /**
1721     * Get PDF URLs for signing
1722     * For envelopes: returns URLs for all child files
1723     * For regular files: returns URL for the file itself
1724     *
1725     * @return string[]
1726     */
1727    public function getPdfUrlsForSigning(FileEntity $fileEntity, SignRequestEntity $signRequestEntity): array {
1728        if (!$fileEntity->isEnvelope()) {
1729            return [
1730                $this->getFileUrl($fileEntity->getId(), $signRequestEntity->getUuid())
1731            ];
1732        }
1733
1734        $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod(
1735            $fileEntity->getId(),
1736            $signRequestEntity->getId()
1737        );
1738
1739        $pdfUrls = [];
1740        foreach ($childSignRequests as $childSignRequest) {
1741            $pdfUrls[] = $this->getFileUrl(
1742                $childSignRequest->getFileId(),
1743                $childSignRequest->getUuid()
1744            );
1745        }
1746
1747        return $pdfUrls;
1748    }
1749
1750    private function recordSignatureAttempt(Exception $exception): void {
1751        if (!$this->libreSignFile) {
1752            return;
1753        }
1754
1755        $metadata = $this->libreSignFile->getMetadata() ?? [];
1756
1757        if (!isset($metadata['signature_attempts'])) {
1758            $metadata['signature_attempts'] = [];
1759        }
1760
1761        $attempt = [
1762            'timestamp' => (new DateTime())->format(\DateTime::ATOM),
1763            'engine' => $this->engine ? $this->engine::class : 'unknown',
1764            'error_message' => $exception->getMessage(),
1765            'error_code' => $exception->getCode(),
1766        ];
1767
1768        $metadata['signature_attempts'][] = $attempt;
1769        $this->libreSignFile->setMetadata($metadata);
1770        $this->fileMapper->update($this->libreSignFile);
1771    }
1772}