Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.78% covered (warning)
75.78%
341 / 450
50.88% covered (warning)
50.88%
29 / 57
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileService
75.78% covered (warning)
75.78%
341 / 450
50.88% covered (warning)
50.88%
29 / 57
622.23
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 initializeFileData
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNodeFromData
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
8.09
 validateFileContent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getExtension
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFileRaw
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveFileName
52.63% covered (warning)
52.63%
10 / 19
0.00% covered (danger)
0.00%
0 / 1
17.61
 showSigners
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 showSettings
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 showVisibleElements
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 showMessages
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setMe
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSignRequest
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setSignerIdentified
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setIdentifyMethodId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setHost
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 showValidateFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFileOrFail
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 setFileById
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFileByUuid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFileBySignerUuid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFileByNodeId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateUploadedFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setFileFromRequest
80.00% covered (warning)
80.00%
20 / 25
0.00% covered (danger)
0.00%
0 / 1
5.20
 getFile
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isLibresignFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSignedNodeId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 loadSigners
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
7.05
 loadSignRequestData
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
7.07
 loadFileMetadata
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadSettings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadLibreSignData
79.07% covered (warning)
79.07%
34 / 43
0.00% covered (danger)
0.00%
0 / 1
14.55
 loadVisibleElements
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
8
 getLatestSignedDateFromEnvelope
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 loadEnvelopeFiles
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 buildEnvelopeChildData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadEnvelopeData
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 loadMessages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toArray
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 syncSingleFileCollection
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 mapSignerDetailsToSummary
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
12
 normalizeSignerSummaryStatus
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 normalizeSignerDisplayStatus
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 normalizeSignerIdentifyMethods
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
9.04
 normalizeIdentifyMethodRequirement
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 normalizeNonNegativeInt
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
12.41
 extractValidSignRequestId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 computeEnvelopeSignersProgress
21.74% covered (danger)
21.74%
5 / 23
0.00% covered (danger)
0.00%
0 / 1
30.49
 delete
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
5.76
 deletePhysicalFileIfPossible
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 deleteEmptyFolder
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 processUploadedFilesWithRollback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateEnvelopeFilesCount
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 decrementEnvelopeFilesCountIfNeeded
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
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 DateTimeInterface;
12use InvalidArgumentException;
13use OCA\Libresign\Db\File;
14use OCA\Libresign\Db\FileElementMapper;
15use OCA\Libresign\Db\FileMapper;
16use OCA\Libresign\Db\IdDocsMapper;
17use OCA\Libresign\Db\SignRequestMapper;
18use OCA\Libresign\Enum\FileStatus;
19use OCA\Libresign\Enum\IdentifyMethodRequirement;
20use OCA\Libresign\Enum\SignatureFlow;
21use OCA\Libresign\Enum\SignerDisplayStatus;
22use OCA\Libresign\Enum\SignRequestStatus;
23use OCA\Libresign\Exception\LibresignException;
24use OCA\Libresign\Handler\DocMdpHandler;
25use OCA\Libresign\Handler\SignEngine\Pkcs12Handler;
26use OCA\Libresign\Helper\FileUploadHelper;
27use OCA\Libresign\ResponseDefinitions;
28use OCA\Libresign\Service\Envelope\EnvelopeService;
29use OCA\Libresign\Service\File\CertificateChainService;
30use OCA\Libresign\Service\File\EnvelopeAssembler;
31use OCA\Libresign\Service\File\EnvelopeProgressService;
32use OCA\Libresign\Service\File\FileContentProvider;
33use OCA\Libresign\Service\File\FileResponseOptions;
34use OCA\Libresign\Service\File\MessagesLoader;
35use OCA\Libresign\Service\File\MetadataLoader;
36use OCA\Libresign\Service\File\MimeService;
37use OCA\Libresign\Service\File\Pdf\PdfValidator;
38use OCA\Libresign\Service\File\SettingsLoader;
39use OCA\Libresign\Service\File\SignersLoader;
40use OCA\Libresign\Service\File\UploadProcessor;
41use OCP\AppFramework\Db\DoesNotExistException;
42use OCP\Files\IMimeTypeDetector;
43use OCP\Files\Node;
44use OCP\Files\NotFoundException;
45use OCP\IL10N;
46use OCP\IURLGenerator;
47use OCP\IUser;
48use OCP\IUserManager;
49use Psr\Log\LoggerInterface;
50use stdClass;
51use TypeError;
52
53/**
54 * @psalm-import-type LibresignValidatedFile from ResponseDefinitions
55 * @psalm-import-type LibresignSignerDetail from ResponseDefinitions
56 * @psalm-import-type LibresignValidatedChildSigner from ResponseDefinitions
57 * @psalm-import-type LibresignIdentifyMethod from ResponseDefinitions
58 */
59class FileService {
60    private string $fileContent = '';
61    private ?File $file = null;
62    private array $certData = [];
63    private stdClass $fileData;
64    private FileResponseOptions $options;
65    public const IDENTIFICATION_DOCUMENTS_DISABLED = 0;
66    public const IDENTIFICATION_DOCUMENTS_NEED_SEND = 1;
67    public const IDENTIFICATION_DOCUMENTS_NEED_APPROVAL = 2;
68    public const IDENTIFICATION_DOCUMENTS_APPROVED = 3;
69    public function __construct(
70        protected FileMapper $fileMapper,
71        protected SignRequestMapper $signRequestMapper,
72        protected FileElementMapper $fileElementMapper,
73        protected FileElementService $fileElementService,
74        protected FolderService $folderService,
75        private IdDocsMapper $idDocsMapper,
76        private IdentifyMethodService $identifyMethodService,
77        private IUserManager $userManager,
78        private IURLGenerator $urlGenerator,
79        protected IMimeTypeDetector $mimeTypeDetector,
80        protected Pkcs12Handler $pkcs12Handler,
81        protected DocMdpHandler $docMdpHandler,
82        protected PdfValidator $pdfValidator,
83        protected LoggerInterface $logger,
84        protected IL10N $l10n,
85        private EnvelopeService $envelopeService,
86        private SignersLoader $signersLoader,
87        protected FileUploadHelper $uploadHelper,
88        private EnvelopeAssembler $envelopeAssembler,
89        private EnvelopeProgressService $envelopeProgressService,
90        private CertificateChainService $certificateChainService,
91        private MimeService $mimeService,
92        private FileContentProvider $contentProvider,
93        private UploadProcessor $uploadProcessor,
94        private MetadataLoader $metadataLoader,
95        private SettingsLoader $settingsLoader,
96        private MessagesLoader $messagesLoader,
97        private FileStatusService $fileStatusService,
98    ) {
99        $this->initializeFileData();
100        $this->options = new FileResponseOptions();
101    }
102
103    private function initializeFileData(): void {
104        $this->fileData = new stdClass();
105        $this->fileData->id = 0;
106        $this->fileData->uuid = '';
107        $this->fileData->name = '';
108        $this->fileData->status = FileStatus::DRAFT->value;
109        $this->fileData->statusText = '';
110        $this->fileData->nodeId = 0;
111        $this->fileData->nodeType = 'file';
112        $this->fileData->created_at = '';
113        $this->fileData->metadata = [];
114        $this->fileData->signatureFlow = SignatureFlow::PARALLEL->value;
115        $this->fileData->signers = [];
116        $this->fileData->signersCount = 0;
117        $this->fileData->requested_by = [
118            'userId' => '',
119            'displayName' => '',
120        ];
121        $this->fileData->filesCount = 0;
122        $this->fileData->files = [];
123    }
124
125    public function update(File $file): File {
126        return $this->fileStatusService->update($file);
127    }
128
129    public function getNodeFromData(array $data): Node {
130        $data['userManager'] ??= '';
131        if (!$this->folderService->getUserId() && $data['userManager'] instanceof \OCP\IUser) {
132            $this->folderService->setUserId($data['userManager']->getUID());
133        }
134
135        if (isset($data['uploadedFile'])) {
136            return $this->uploadProcessor->getNodeFromUploadedFile($data);
137        }
138
139        if (isset($data['file']['fileNode']) && $data['file']['fileNode'] instanceof Node) {
140            return $data['file']['fileNode'];
141        }
142        if (isset($data['file']['path'])) {
143            return $this->folderService->getFileByPath($data['file']['path']);
144        }
145        if (isset($data['file']['nodeId'])) {
146            return $this->folderService->getFileByNodeId($data['file']['nodeId']);
147        }
148
149        $content = $this->getFileRaw($data);
150        $extension = $this->getExtension($content);
151
152        $fileName = $data['name'];
153        $this->validateFileContent($content, $fileName, $extension);
154
155        $folderToFile = $this->folderService->getFolderForFile($data, $data['userManager']);
156        $filename = $this->resolveFileName($data, $extension);
157        return $folderToFile->newFile($filename, $content);
158    }
159
160    public function validateFileContent(string $content, string $fileName, string $extension): void {
161        if ($extension === 'pdf') {
162            $this->pdfValidator->validate($content, $fileName);
163        }
164    }
165
166    private function getExtension(string $content): string {
167        return $this->mimeService->getExtension($content);
168    }
169
170    private function getFileRaw(array $data): string {
171        return $this->contentProvider->getContentFromData($data);
172    }
173
174    private function resolveFileName(array $data, string $extension): string {
175        $name = '';
176        if (isset($data['name'])) {
177            $name = trim((string)$data['name']);
178        }
179
180        if ($name === '') {
181            $basename = '';
182            if (!empty($data['file']['url'])) {
183                $path = (string)parse_url((string)$data['file']['url'], PHP_URL_PATH);
184                if ($path !== '') {
185                    $basename = basename($path);
186                }
187            }
188            if ($basename !== '') {
189                $filenameNoExt = pathinfo($basename, PATHINFO_FILENAME);
190                $name = $filenameNoExt !== '' ? $filenameNoExt : $basename;
191            } else {
192                $name = 'document';
193            }
194        }
195
196        $name = preg_replace('/\s+/', '_', $name) ?? $name;
197        $name = $name !== '' ? $name : 'document';
198        $extensionSuffix = '.' . $extension;
199        if (str_ends_with(strtolower((string)$name), strtolower($extensionSuffix))) {
200            return $name;
201        }
202        return $name . $extensionSuffix;
203    }
204
205    /**
206     * @return static
207     */
208    public function showSigners(bool $show = true): self {
209        $this->options->showSigners($show);
210        return $this;
211    }
212
213    /**
214     * @return static
215     */
216    public function showSettings(bool $show = true): self {
217        $this->options->showSettings($show);
218        if ($show) {
219            $this->fileData->settings = [
220                'canSign' => false,
221                'canRequestSign' => false,
222                'phoneNumber' => '',
223            ];
224        } else {
225            unset($this->fileData->settings);
226        }
227        return $this;
228    }
229
230    /**
231     * @return static
232     */
233    public function showVisibleElements(bool $show = true): self {
234        $this->options->showVisibleElements($show);
235        return $this;
236    }
237
238    /**
239     * @return static
240     */
241    public function showMessages(bool $show = true): self {
242        $this->options->showMessages($show);
243        return $this;
244    }
245
246    /**
247     * @return static
248     */
249    public function setMe(?IUser $user): self {
250        $this->options->setMe($user);
251        return $this;
252    }
253
254    public function setSignRequest(?\OCA\Libresign\Db\SignRequest $signRequest): self {
255        $this->options->setSignRequest($signRequest);
256        return $this;
257    }
258
259    public function setSignerIdentified(bool $identified = true): self {
260        $this->options->setSignerIdentified($identified);
261        return $this;
262    }
263
264    public function setIdentifyMethodId(?int $id): self {
265        $this->options->setIdentifyMethodId($id);
266        return $this;
267    }
268
269    public function setHost(string $host): self {
270        $this->options->setHost($host);
271        return $this;
272    }
273
274    /**
275     * @return static
276     */
277    public function setFile(File $file): self {
278        $this->initializeFileData();
279        $this->file = $file;
280        $this->fileData->status = $this->file->getStatus();
281        return $this;
282    }
283
284    public function showValidateFile(bool $validateFile = true): self {
285        $this->options->validateFile($validateFile);
286        return $this;
287    }
288
289    private function setFileOrFail(callable $resolver): self {
290        try {
291            $file = $resolver();
292        } catch (\Throwable) {
293            // TRANSLATORS Error shown when LibreSign cannot validate the uploaded or referenced document for signing.
294            throw new LibresignException($this->l10n->t('Invalid data to validate file'), 404);
295        }
296
297        if (!$file instanceof File) {
298            // TRANSLATORS Error shown when the document identifier used in a signing flow is invalid.
299            throw new LibresignException($this->l10n->t('Invalid file identifier'), 404);
300        }
301
302        return $this->setFile($file);
303    }
304
305    public function setFileById(int $fileId): self {
306        return $this->setFileOrFail(fn () => $this->fileMapper->getById($fileId));
307    }
308
309    public function setFileByUuid(string $uuid): self {
310        return $this->setFileOrFail(fn () => $this->fileMapper->getByUuid($uuid));
311    }
312
313    public function setFileBySignerUuid(string $uuid): self {
314        return $this->setFileOrFail(fn () => $this->fileMapper->getBySignerUuid($uuid));
315    }
316
317    public function setFileByNodeId(int $nodeId): self {
318        return $this->setFileOrFail(fn () => $this->fileMapper->getByNodeId($nodeId));
319    }
320
321    public function validateUploadedFile(array $file): void {
322        $this->uploadHelper->validateUploadedFile($file);
323    }
324
325    public function setFileFromRequest(?array $file): self {
326        if ($file === null) {
327            // TRANSLATORS Error shown when an action expected an uploaded document and none was provided.
328            throw new InvalidArgumentException($this->l10n->t('No file provided'));
329        }
330        $this->initializeFileData();
331        $this->uploadHelper->validateUploadedFile($file);
332
333        $this->fileContent = file_get_contents($file['tmp_name']);
334        $mimeType = $this->mimeService->getMimeType($this->fileContent);
335        if ($mimeType !== 'application/pdf') {
336            $this->fileContent = '';
337            unlink($file['tmp_name']);
338            // TRANSLATORS Error shown when the uploaded document is invalid for LibreSign.
339            throw new InvalidArgumentException($this->l10n->t('Invalid file provided'));
340        }
341        $this->fileData->size = $file['size'];
342
343        $memoryFile = fopen($file['tmp_name'], 'rb');
344        try {
345            $this->certData = $this->pkcs12Handler->getCertificateChain($memoryFile);
346            $this->fileData->status = FileStatus::SIGNED->value;
347        } catch (LibresignException) {
348            $this->fileData->status = FileStatus::DRAFT->value;
349        }
350        fclose($memoryFile);
351        unlink($file['tmp_name']);
352        $this->fileData->hash = hash('sha256', $this->fileContent);
353        try {
354            $libresignFile = $this->fileMapper->getBySignedHash($this->fileData->hash);
355            $this->setFile($libresignFile);
356        } catch (DoesNotExistException) {
357            $this->fileData->status = FileStatus::NOT_LIBRESIGN_FILE->value;
358        }
359        $this->fileData->name = $file['name'];
360        return $this;
361    }
362
363    private function getFile(): \OCP\Files\File {
364        $nodeId = $this->file->getSignedNodeId();
365        if (!$nodeId) {
366            $nodeId = $this->file->getNodeId();
367        }
368        $fileToValidate = $this->folderService->getReadableNodeById($this->file->getUserId(), $nodeId);
369        if (!$fileToValidate instanceof \OCP\Files\File) {
370            // TRANSLATORS Error shown when the requested document cannot be found.
371            throw new LibresignException($this->l10n->t('File not found'), 404);
372        }
373        return $fileToValidate;
374    }
375
376    public function getStatus(): int {
377        return $this->file->getStatus();
378    }
379
380    public function isLibresignFile(int $nodeId): bool {
381        return $this->fileMapper->nodeIdExists($nodeId);
382    }
383
384    public function getSignedNodeId(): ?int {
385        $status = $this->file->getStatus();
386
387        if (!in_array($status, [FileStatus::PARTIAL_SIGNED->value, FileStatus::SIGNED->value])) {
388            return null;
389        }
390        return $this->file->getSignedNodeId();
391    }
392
393    private function loadSigners(): void {
394        if (!$this->options->isShowSigners()) {
395            return;
396        }
397
398        if (!$this->file instanceof File) {
399            if ($this->certData) {
400                $this->signersLoader->loadSignersFromCertData($this->fileData, $this->certData, $this->options->getHost());
401            }
402            return;
403        }
404
405        $this->signersLoader->loadLibreSignSigners($this->file, $this->fileData, $this->options, $this->certData);
406
407        if ($this->file->getSignedNodeId()) {
408            $fileNode = $this->getFile();
409            $certData = $this->certificateChainService->getCertificateChain($fileNode, $this->file, $this->options);
410            if ($certData) {
411                $this->signersLoader->loadSignersFromCertData($this->fileData, $certData, $this->options->getHost());
412            }
413        }
414        $this->loadSignRequestData();
415    }
416
417    private function loadSignRequestData(): void {
418        if (empty($this->fileData->uuid)) {
419            return;
420        }
421
422        $pdfRouteUuid = $this->fileData->uuid;
423
424        if (!empty($this->fileData->signers) && is_array($this->fileData->signers)) {
425            foreach ($this->fileData->signers as $signer) {
426                if (!empty($signer->me) && isset($signer->sign_request_uuid)) {
427                    $pdfRouteUuid = $signer->sign_request_uuid;
428                    break;
429                }
430            }
431        }
432
433        $this->fileData->url = $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $pdfRouteUuid]);
434    }
435
436    private function loadFileMetadata(): void {
437        $this->metadataLoader->loadMetadata($this->file, $this->fileData);
438    }
439
440    private function loadSettings(): void {
441        $this->settingsLoader->loadSettings($this->fileData, $this->options);
442    }
443
444    private function loadLibreSignData(): void {
445        if (!$this->file) {
446            return;
447        }
448        $this->fileData->id = $this->file->getId();
449        $this->fileData->uuid = $this->file->getUuid();
450        $this->fileData->name = $this->file->getName();
451        $this->fileData->status = $this->file->getStatus();
452        $this->fileData->created_at = $this->file->getCreatedAt()->format(DateTimeInterface::ATOM);
453        $this->fileData->statusText = $this->fileMapper->getTextOfStatus($this->file->getStatus());
454        $this->fileData->nodeId = $this->file->getNodeId();
455        $this->fileData->signatureFlow = SignatureFlow::fromNumeric($this->file->getSignatureFlow())->value;
456        $this->fileData->docmdpLevel = $this->file->getDocmdpLevel();
457        $this->fileData->nodeType = $this->file->getNodeType();
458        // Validation is public: only the requester learns about the freeze.
459        if ($this->options->getMe() !== null && $this->options->getMe()->getUID() === $this->file->getUserId()) {
460            $this->fileData->policySnapshotFrozen = FileStatusService::isPolicySnapshotFrozen(
461                $this->file,
462                $this->file->hasParent() ? $this->envelopeService->getEnvelopeByFileId($this->file->getId()) : null,
463            );
464        }
465
466        if ($this->fileData->nodeType !== 'envelope' && !$this->file->getParentFileId()) {
467            $fileId = $this->file->getId();
468
469            $childrenFiles = $this->fileMapper->getChildrenFiles($fileId);
470
471            if (!empty($childrenFiles)) {
472                $this->file->setNodeType('envelope');
473                $this->fileMapper->update($this->file);
474
475                $this->fileData->nodeType = 'envelope';
476                $this->fileData->filesCount = count($childrenFiles);
477                $this->fileData->files = [];
478            }
479        }
480
481        if ($this->fileData->nodeType === 'envelope') {
482            $metadata = $this->file->getMetadata();
483            $this->fileData->filesCount = $metadata['filesCount'] ?? 0;
484            $this->fileData->files = [];
485            $this->loadEnvelopeFiles();
486            if ($this->file->getStatus() === FileStatus::SIGNED->value) {
487                $latestSignedDate = $this->getLatestSignedDateFromEnvelope();
488                if ($latestSignedDate) {
489                    $this->fileData->signedDate = $latestSignedDate->format(DateTimeInterface::ATOM);
490                }
491            }
492        }
493
494        $this->fileData->requested_by = [
495            'userId' => $this->file->getUserId(),
496            'displayName' => $this->userManager->get($this->file->getUserId())->getDisplayName(),
497        ];
498        $this->fileData->file = $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $this->file->getUuid()]);
499
500        $this->loadEnvelopeData();
501
502        if (!isset($this->fileData->files) || !is_array($this->fileData->files)) {
503            $this->fileData->files = [];
504        }
505    }
506
507    private function loadVisibleElements(): void {
508        if (!$this->options->isShowVisibleElements()) {
509            return;
510        }
511        $this->fileData->visibleElements = [];
512
513        if (!$this->file instanceof File) {
514            return;
515        }
516
517        $fileIds = [$this->file->getId()];
518        $childMetadataMap = [];
519        if ($this->file->getNodeType() === 'envelope') {
520            $childrenFiles = $this->fileMapper->getChildrenFiles($this->file->getId());
521            foreach ($childrenFiles as $childFile) {
522                $fileIds[] = $childFile->getId();
523                $childMetadataMap[$childFile->getId()] = $childFile->getMetadata();
524            }
525        }
526
527        $signers = $this->signRequestMapper->getByMultipleFileId($fileIds);
528        $fileMetadata = $this->file->getMetadata();
529        $formattedElementsBySigner = [];
530        foreach ($this->signRequestMapper->getVisibleElementsFromSigners($signers) as $signRequestId => $visibleElements) {
531            if (empty($visibleElements)) {
532                continue;
533            }
534            $elementFileId = $visibleElements[0]->getFileId();
535            $metadata = $childMetadataMap[$elementFileId] ?? $fileMetadata;
536            $formattedElementsBySigner[$signRequestId] = $this->fileElementService->formatVisibleElements($visibleElements, $metadata);
537            $this->fileData->visibleElements = array_merge(
538                $formattedElementsBySigner[$signRequestId],
539                $this->fileData->visibleElements
540            );
541        }
542        foreach ($this->fileData->signers as $signer) {
543            $signer->visibleElements = $formattedElementsBySigner[$signer->signRequestId ?? null] ?? [];
544        }
545    }
546
547    private function getLatestSignedDateFromEnvelope(): ?\DateTime {
548        if (!$this->file || $this->file->getNodeType() !== 'envelope') {
549            return null;
550        }
551
552        $childrenFiles = $this->fileMapper->getChildrenFiles($this->file->getId());
553        $latestDate = null;
554
555        $childFileIds = array_map(fn ($childFile) => $childFile->getId(), $childrenFiles);
556        if (empty($childFileIds)) {
557            return null;
558        }
559
560        $signRequests = $this->signRequestMapper->getByMultipleFileId($childFileIds);
561        foreach ($signRequests as $signRequest) {
562            $signed = $signRequest->getSigned();
563            if ($signed && (!$latestDate || $signed > $latestDate)) {
564                $latestDate = $signed;
565            }
566        }
567
568        return $latestDate;
569    }
570
571    private function loadEnvelopeFiles(): void {
572        if (!$this->file || $this->file->getNodeType() !== 'envelope') {
573            return;
574        }
575
576        $childrenFiles = $this->fileMapper->getChildrenFiles($this->file->getId());
577        foreach ($childrenFiles as $childFile) {
578            $this->fileData->files[] = $this->buildEnvelopeChildData($childFile);
579        }
580    }
581
582    private function buildEnvelopeChildData(File $childFile): stdClass {
583        return $this->envelopeAssembler->buildEnvelopeChildData($childFile, $this->options);
584    }
585
586    private function loadEnvelopeData(): void {
587        if (!$this->file instanceof File || !$this->file->hasParent()) {
588            return;
589        }
590
591        $envelope = $this->envelopeService->getEnvelopeByFileId($this->file->getId());
592        if (!$envelope) {
593            return;
594        }
595
596        $envelopeMetadata = $envelope->getMetadata();
597        $this->fileData->envelope = [
598            'id' => $envelope->getId(),
599            'uuid' => $envelope->getUuid(),
600            'name' => $envelope->getName(),
601            'status' => $envelope->getStatus(),
602            'statusText' => $this->fileMapper->getTextOfStatus($envelope->getStatus()),
603            'filesCount' => $envelopeMetadata['filesCount'] ?? 0,
604            'files' => [],
605        ];
606    }
607
608    private function loadMessages(): void {
609        $this->messagesLoader->loadMessages($this->file, $this->fileData, $this->options, $this->certData);
610    }
611
612    /**
613     * @return LibresignValidatedFile
614     */
615    public function toArray(): array {
616        $this->loadLibreSignData();
617        $this->loadFileMetadata();
618        $this->loadSettings();
619        $this->loadSigners();
620        $this->loadVisibleElements();
621        $this->loadMessages();
622        $this->computeEnvelopeSignersProgress();
623        $this->syncSingleFileCollection();
624
625        $return = json_decode(json_encode($this->fileData), true);
626        ksort($return);
627        return $return;
628    }
629
630    private function syncSingleFileCollection(): void {
631        if ($this->fileData->nodeType === 'envelope') {
632            return;
633        }
634
635        $this->fileData->filesCount = 1;
636        $file = (object)[
637            'id' => $this->fileData->id,
638            'uuid' => $this->fileData->uuid,
639            'name' => $this->fileData->name,
640            'status' => $this->fileData->status,
641            'statusText' => $this->fileData->statusText,
642            'nodeId' => $this->fileData->nodeId,
643            'metadata' => $this->fileData->metadata,
644            'totalPages' => $this->fileData->totalPages ?? 0,
645            'pdfVersion' => $this->fileData->pdfVersion ?? '',
646            'size' => $this->fileData->size ?? 0,
647            'signers' => $this->mapSignerDetailsToSummary($this->fileData->signers ?? []),
648        ];
649        if ($this->fileData->uuid !== '') {
650            $file->file = $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $this->fileData->uuid]);
651        }
652        $this->fileData->files = [$file];
653    }
654
655    /**
656     * @param LibresignSignerDetail[] $signers
657     * @return LibresignValidatedChildSigner[]
658     */
659    private function mapSignerDetailsToSummary(array $signers): array {
660        $summaries = [];
661
662        foreach ($signers as $signer) {
663            $signerData = is_object($signer) ? (array)$signer : $signer;
664            if (!is_array($signerData)) {
665                continue;
666            }
667
668            $signRequestId = $this->extractValidSignRequestId($signerData);
669            if ($signRequestId === null) {
670                continue;
671            }
672
673            $email = $signerData['email'] ?? null;
674            $signed = $signerData['signed'] ?? null;
675            $identifyMethods = $this->normalizeSignerIdentifyMethods($signerData['identifyMethods'] ?? null);
676
677            // The signer entries were already presented for the current viewer
678            // (see SignersLoader): the summary keeps that decision as it is,
679            // including the absence of the real status of a redacted signer.
680            $summary = [
681                'signRequestId' => $signRequestId,
682                'displayName' => isset($signerData['displayName']) ? (string)$signerData['displayName'] : '',
683                'email' => is_string($email) ? $email : null,
684                'signed' => is_string($signed) ? $signed : null,
685                'displayStatus' => $this->normalizeSignerDisplayStatus($signerData['displayStatus'] ?? null, $signerData['status'] ?? null),
686                'statusText' => isset($signerData['statusText']) ? (string)$signerData['statusText'] : '',
687            ];
688            $status = $this->normalizeSignerSummaryStatus($signerData['status'] ?? null);
689            if ($status !== null) {
690                $summary['status'] = $status;
691            }
692
693            if ($identifyMethods !== null) {
694                $summary['identifyMethods'] = $identifyMethods;
695            }
696            if (isset($signerData['visibleElements'])) {
697                $summary['visibleElements'] = $signerData['visibleElements'];
698            }
699
700            /** @var LibresignValidatedChildSigner $summary */
701            $summaries[] = $summary;
702        }
703
704        return $summaries;
705    }
706
707    /**
708     * @psalm-return 0|1|2|3|4|null
709     */
710    private function normalizeSignerSummaryStatus(mixed $status): ?int {
711        if ($status === null || $status === '') {
712            return null;
713        }
714        return SignRequestStatus::tryFrom((int)$status)?->value;
715    }
716
717    /**
718     * @psalm-return 'draft'|'ready_to_sign'|'signed'|'rejected'|'observing'|'not_signed'
719     */
720    private function normalizeSignerDisplayStatus(mixed $displayStatus, mixed $status): string {
721        if (is_string($displayStatus) && ($known = SignerDisplayStatus::tryFrom($displayStatus)) !== null) {
722            return $known->value;
723        }
724        $normalizedStatus = $this->normalizeSignerSummaryStatus($status);
725        if ($normalizedStatus === null) {
726            return SignerDisplayStatus::NOT_SIGNED->value;
727        }
728        return SignerDisplayStatus::fromSignRequestStatus(SignRequestStatus::from($normalizedStatus))->value;
729    }
730
731    /**
732     * @return LibresignIdentifyMethod[]|null
733     * @psalm-return list<LibresignIdentifyMethod>|null
734     */
735    private function normalizeSignerIdentifyMethods(mixed $identifyMethods): ?array {
736        if (!is_array($identifyMethods)) {
737            return null;
738        }
739
740        /** @var list<LibresignIdentifyMethod> $normalizedMethods */
741        $normalizedMethods = [];
742
743        foreach ($identifyMethods as $identifyMethod) {
744            $identifyMethodData = is_object($identifyMethod) ? (array)$identifyMethod : $identifyMethod;
745            if (!is_array($identifyMethodData)) {
746                continue;
747            }
748
749            $method = match ($identifyMethodData['method'] ?? null) {
750                'account', 'email', 'signal', 'sms', 'telegram', 'whatsapp', 'whatsappbusiness', 'xmpp' => $identifyMethodData['method'],
751                default => null,
752            };
753
754            $value = $identifyMethodData['value'] ?? null;
755            $requirement = $this->normalizeIdentifyMethodRequirement(
756                $identifyMethodData['requirement'] ?? null,
757                $identifyMethodData['mandatory'] ?? null,
758            );
759
760            if ($method === null || !is_string($value) || $requirement === null) {
761                continue;
762            }
763
764            /** @var LibresignIdentifyMethod $normalizedMethod */
765            $normalizedMethod = [
766                'method' => $method,
767                'value' => $value,
768                'requirement' => $requirement,
769            ];
770
771            $normalizedMethods[] = $normalizedMethod;
772        }
773
774        return $normalizedMethods === [] ? null : $normalizedMethods;
775    }
776
777    private function normalizeIdentifyMethodRequirement(mixed $requirement, mixed $mandatory): ?string {
778        if (is_string($requirement)) {
779            $normalizedRequirement = IdentifyMethodRequirement::tryFrom(trim($requirement));
780            if ($normalizedRequirement !== null) {
781                return $normalizedRequirement->value;
782            }
783        }
784
785        $mandatoryValue = $this->normalizeNonNegativeInt($mandatory);
786
787        return match ($mandatoryValue) {
788            0 => IdentifyMethodRequirement::OPTIONAL->value,
789            1 => IdentifyMethodRequirement::REQUIRED->value,
790            default => null,
791        };
792    }
793
794    /**
795     * @psalm-return non-negative-int|null
796     */
797    private function normalizeNonNegativeInt(mixed $value): ?int {
798        if (is_int($value) && $value >= 0) {
799            return $value;
800        }
801
802        if (is_string($value) && ctype_digit($value)) {
803            $normalizedValue = (int)$value;
804            /** @var non-negative-int $normalizedValue */
805            return $normalizedValue;
806        }
807
808        return null;
809    }
810
811    private function extractValidSignRequestId(array $signerData): ?int {
812        $signRequestId = $signerData['signRequestId'] ?? null;
813
814        if (is_int($signRequestId)) {
815            return $signRequestId;
816        }
817
818        if (is_string($signRequestId) && ctype_digit($signRequestId)) {
819            return (int)$signRequestId;
820        }
821
822        return null;
823    }
824
825    private function computeEnvelopeSignersProgress(): void {
826        if (!$this->file || $this->file->getParentFileId() || empty($this->fileData->signers)) {
827            return;
828        }
829
830        $childrenFiles = $this->fileMapper->getChildrenFiles($this->file->getId());
831        if (empty($childrenFiles)) {
832            return;
833        }
834
835        $childFileIds = array_map(fn ($childFile) => $childFile->getId(), $childrenFiles);
836        $allSignRequests = $this->signRequestMapper->getByMultipleFileId($childFileIds);
837
838        $signRequestsByFileId = array_fill_keys($childFileIds, []);
839        foreach ($allSignRequests as $signRequest) {
840            $signRequestsByFileId[$signRequest->getFileId()][] = $signRequest;
841        }
842
843        $identifyMethodsBySignRequest = [];
844        if (!empty($allSignRequests)) {
845            $allSignRequestIds = array_map(fn ($sr) => $sr->getId(), $allSignRequests);
846            $identifyMethodsBySignRequest = $this->identifyMethodService
847                ->setIsRequest(false)
848                ->getIdentifyMethodsFromSignRequestIds($allSignRequestIds);
849        }
850
851        $this->envelopeProgressService->computeProgress(
852            $this->fileData,
853            $this->file,
854            $childrenFiles,
855            $signRequestsByFileId,
856            $identifyMethodsBySignRequest
857        );
858    }
859
860    public function delete(int $fileId, bool $deleteFile = true): void {
861        $file = $this->fileMapper->getById($fileId);
862
863        $this->decrementEnvelopeFilesCountIfNeeded($file);
864
865        if ($file->getNodeType() === 'envelope') {
866            $childrenFiles = $this->fileMapper->getChildrenFiles($file->getId());
867            foreach ($childrenFiles as $childFile) {
868                $this->delete($childFile->getId(), $deleteFile);
869            }
870        }
871
872        $this->fileElementService->deleteVisibleElements($file->getId());
873        $list = $this->signRequestMapper->getByFileId($file->getId());
874        foreach ($list as $signRequest) {
875            $this->identifyMethodService->deleteBySignRequestId($signRequest->getId());
876            $this->signRequestMapper->delete($signRequest);
877        }
878        $this->idDocsMapper->deleteByFileId($file->getId());
879        $this->fileMapper->delete($file);
880        if ($deleteFile) {
881            $this->deletePhysicalFileIfPossible($file->getSignedNodeId());
882            $this->deletePhysicalFileIfPossible($file->getNodeId());
883        }
884    }
885
886    private function deletePhysicalFileIfPossible(?int $nodeId): void {
887        if ($nodeId === null) {
888            return;
889        }
890
891        try {
892            $nextcloudFile = $this->folderService->getFileByNodeId($nodeId);
893            $parentFolder = $nextcloudFile->getParent();
894            $nextcloudFile->delete();
895            $this->deleteEmptyFolder($parentFolder);
896        } catch (NotFoundException|TypeError) {
897        }
898    }
899
900    private function deleteEmptyFolder(\OCP\Files\Folder $folder): void {
901        try {
902            $contents = $folder->getDirectoryListing();
903            if (count($contents) === 0) {
904                $folder->delete();
905            }
906        } catch (\Exception $e) {
907            $this->logger->debug('Could not delete empty folder: ' . $e->getMessage(), [
908                'exception' => $e,
909            ]);
910        }
911    }
912
913    public function processUploadedFilesWithRollback(array $filesArray, IUser $user, array $settings): array {
914        return $this->uploadProcessor->processUploadedFilesWithRollback($filesArray, $user, $settings);
915    }
916
917    public function updateEnvelopeFilesCount(File $envelope, int $delta = 0): void {
918        $metadata = $envelope->getMetadata();
919        $currentCount = $metadata['filesCount'] ?? 0;
920        $metadata['filesCount'] = max(0, $currentCount + $delta);
921        $envelope->setMetadata($metadata);
922        $this->fileMapper->update($envelope);
923    }
924
925    private function decrementEnvelopeFilesCountIfNeeded(File $file): void {
926        if ($file->getParentFileId() === null) {
927            return;
928        }
929
930        $parentEnvelope = $this->fileMapper->getById($file->getParentFileId());
931        if ($parentEnvelope->getNodeType() === 'envelope') {
932            $this->updateEnvelopeFilesCount($parentEnvelope, -1);
933        }
934    }
935}