Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
25.08% covered (danger)
25.08%
82 / 327
22.22% covered (danger)
22.22%
4 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileController
25.08% covered (danger)
25.08%
82 / 327
22.22% covered (danger)
22.22%
4 / 18
3651.84
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
 validateUuid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateFileId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateBinary
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
12
 validate
62.75% covered (warning)
62.75%
32 / 51
0.00% covered (danger)
0.00%
0 / 1
24.13
 list
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 getThumbnail
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 getThumbnailByFileId
15.38% covered (danger)
15.38%
2 / 13
0.00% covered (danger)
0.00%
0 / 1
46.77
 fetchPreview
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
342
 getMimeFallbackResponse
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 save
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
3.37
 addFileToEnvelope
20.00% covered (danger)
20.00%
9 / 45
0.00% covered (danger)
0.00%
0 / 1
40.77
 prepareFileForSaving
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
42
 prepareFilesForSaving
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
11.62
 processUploadedFiles
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
30
 saveFiles
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 extractFileName
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 deleteFile
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
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\Controller;
10
11use InvalidArgumentException;
12use OCA\Files_Sharing\SharedStorage;
13use OCA\Libresign\AppInfo\Application;
14use OCA\Libresign\Db\FileMapper;
15use OCA\Libresign\Db\SignRequestMapper;
16use OCA\Libresign\Enum\FileStatus;
17use OCA\Libresign\Exception\LibresignException;
18use OCA\Libresign\Helper\JSActions;
19use OCA\Libresign\Middleware\Attribute\PrivateValidation;
20use OCA\Libresign\Middleware\Attribute\RequireFileAccess;
21use OCA\Libresign\Middleware\Attribute\RequireManager;
22use OCA\Libresign\Service\AccountService;
23use OCA\Libresign\Service\File\FileListService;
24use OCA\Libresign\Service\File\SettingsLoader;
25use OCA\Libresign\Service\FileService;
26use OCA\Libresign\Service\Policy\ValidationEffectivePolicyService;
27use OCA\Libresign\Service\RequestSignatureService;
28use OCA\Libresign\Service\SessionService;
29use OCA\Libresign\Service\Validation\FileInputValidator;
30use OCA\Libresign\Service\Validation\SigningRequestValidator;
31use OCP\AppFramework\Db\DoesNotExistException;
32use OCP\AppFramework\Http;
33use OCP\AppFramework\Http\Attribute\ApiRoute;
34use OCP\AppFramework\Http\Attribute\NoAdminRequired;
35use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
36use OCP\AppFramework\Http\Attribute\PublicPage;
37use OCP\AppFramework\Http\DataResponse;
38use OCP\AppFramework\Http\FileDisplayResponse;
39use OCP\AppFramework\Http\RedirectResponse;
40use OCP\Files\File;
41use OCP\Files\Node;
42use OCP\Files\NotFoundException;
43use OCP\IL10N;
44use OCP\IPreview;
45use OCP\IRequest;
46use OCP\IURLGenerator;
47use OCP\IUserSession;
48use OCP\Preview\IMimeIconProvider;
49use Psr\Log\LoggerInterface;
50
51/**
52 * @psalm-import-type LibresignFile from \OCA\Libresign\ResponseDefinitions
53 * @psalm-import-type LibresignDetailedFile from \OCA\Libresign\ResponseDefinitions
54 * @psalm-import-type LibresignDetailedFileResponse from \OCA\Libresign\ResponseDefinitions
55 * @psalm-import-type LibresignActionErrorResponse from \OCA\Libresign\ResponseDefinitions
56 * @psalm-import-type LibresignFileListResponse from \OCA\Libresign\ResponseDefinitions
57 * @psalm-import-type LibresignMessageResponse from \OCA\Libresign\ResponseDefinitions
58 * @psalm-import-type LibresignFileSummary from \OCA\Libresign\ResponseDefinitions
59 * @psalm-import-type LibresignFolderSettings from \OCA\Libresign\ResponseDefinitions
60 * @psalm-import-type LibresignNewFile from \OCA\Libresign\ResponseDefinitions
61 * @psalm-import-type LibresignPagination from \OCA\Libresign\ResponseDefinitions
62 * @psalm-import-type LibresignSettings from \OCA\Libresign\ResponseDefinitions
63 * @psalm-import-type LibresignValidatedFile from \OCA\Libresign\ResponseDefinitions
64 * @psalm-import-type LibresignValidatedFileResponse from \OCA\Libresign\ResponseDefinitions
65 * @psalm-import-type LibresignEffectivePolicyState from \OCA\Libresign\ResponseDefinitions
66 * @psalm-import-type LibresignValidateMetadata from \OCA\Libresign\ResponseDefinitions
67 * @psalm-import-type LibresignVisibleElement from \OCA\Libresign\ResponseDefinitions
68 */
69class FileController extends AEnvironmentAwareController {
70    public function __construct(
71        IRequest $request,
72        private IL10N $l10n,
73        private LoggerInterface $logger,
74        private IUserSession $userSession,
75        private SessionService $sessionService,
76        private SignRequestMapper $signRequestMapper,
77        private FileMapper $fileMapper,
78        private RequestSignatureService $requestSignatureService,
79        private AccountService $accountService,
80        private ValidationEffectivePolicyService $validationEffectivePolicyService,
81        private IPreview $preview,
82        private IMimeIconProvider $mimeIconProvider,
83        private FileService $fileService,
84        private FileListService $fileListService,
85        private SigningRequestValidator $signingRequestValidator,
86        private FileInputValidator $fileInputValidator,
87        private SettingsLoader $settingsLoader,
88        private IURLGenerator $urlGenerator,
89    ) {
90        parent::__construct(Application::APP_ID, $request);
91    }
92
93    /**
94     * Validate a file using Uuid
95     *
96     * Validate a file returning file data.
97     * The response always includes `filesCount` and `files`.
98     * For `nodeType=file`, `filesCount=1` and `files` contains the current file.
99     * For `nodeType=envelope`, `files` contains envelope child files.
100     *
101     * @param string $uuid The UUID of the LibreSign file
102     * @param bool $showVisibleElements Whether to include visible elements in the response
103     * @param bool $showMessages Whether to include validation messages in the response
104     * @param bool $showValidateFile Whether to include the file payload in the response
105     * @return DataResponse<Http::STATUS_OK, LibresignValidatedFileResponse, array{}>|DataResponse<Http::STATUS_NOT_FOUND, LibresignActionErrorResponse, array{}>
106     *
107     * 200: OK
108     * 404: Request failed
109     * 422: Request failed
110     */
111    #[PrivateValidation(allowValidSignRequestUuid: true)]
112    #[NoAdminRequired]
113    #[NoCSRFRequired]
114    #[PublicPage]
115    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/file/validate/uuid/{uuid}', requirements: ['apiVersion' => '(v1)'])]
116    public function validateUuid(
117        string $uuid,
118        bool $showVisibleElements = true,
119        bool $showMessages = true,
120        bool $showValidateFile = true,
121    ): DataResponse {
122        return $this->validate('Uuid', $uuid, $showVisibleElements, $showMessages, $showValidateFile);
123    }
124
125    /**
126     * Validate a file using FileId
127     *
128     * Validate a file returning file data.
129     * The response always includes `filesCount` and `files`.
130     * For `nodeType=file`, `filesCount=1` and `files` contains the current file.
131     * For `nodeType=envelope`, `files` contains envelope child files.
132     *
133     * @param int $fileId The identifier value of the LibreSign file
134     * @param bool $showVisibleElements Whether to include visible elements in the response
135     * @param bool $showMessages Whether to include validation messages in the response
136     * @param bool $showValidateFile Whether to include the file payload in the response
137     * @return DataResponse<Http::STATUS_OK, LibresignValidatedFileResponse, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array<empty>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, LibresignActionErrorResponse, array{}>
138     *
139     * 200: OK
140     * 403: Forbidden
141     * 404: Request failed
142     */
143    #[PrivateValidation]
144    #[NoAdminRequired]
145    #[NoCSRFRequired]
146    #[PublicPage]
147    #[RequireFileAccess('fileId')]
148    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/file/validate/file_id/{fileId}', requirements: ['apiVersion' => '(v1)'])]
149    public function validateFileId(
150        int $fileId,
151        bool $showVisibleElements = true,
152        bool $showMessages = true,
153        bool $showValidateFile = true,
154    ): DataResponse {
155        return $this->validate('FileId', $fileId, $showVisibleElements, $showMessages, $showValidateFile);
156    }
157
158    /**
159     * Validate a binary file
160     *
161     * Validate a binary file returning file data.
162     * Use field 'file' for the file upload.
163     * The response always includes `filesCount` and `files`.
164     * For `nodeType=file`, `filesCount=1` and `files` contains the current file.
165     * For `nodeType=envelope`, `files` contains envelope child files.
166     *
167     * @return DataResponse<Http::STATUS_OK, LibresignValidatedFileResponse, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_BAD_REQUEST, LibresignActionErrorResponse, array{}>
168     *
169     * 200: OK
170     * 404: Request failed
171     * 400: Request failed
172     */
173    #[PrivateValidation]
174    #[NoAdminRequired]
175    #[NoCSRFRequired]
176    #[PublicPage]
177    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/file/validate/', requirements: ['apiVersion' => '(v1)'])]
178    public function validateBinary(): DataResponse {
179        try {
180            $file = $this->request->getUploadedFile('file');
181            /** @var LibresignValidatedFile $validatedFile */
182            $validatedFile = $this->fileService
183                ->setMe($this->userSession->getUser())
184                ->setFileFromRequest($file)
185                ->setHost($this->request->getServerHost())
186                ->showVisibleElements()
187                ->showSigners()
188                ->showSettings()
189                ->showMessages()
190                ->showValidateFile()
191                ->toArray();
192
193            /** @var LibresignValidatedFileResponse $response */
194            $response = $this->validationEffectivePolicyService->appendEffectivePolicies($validatedFile);
195
196            return new DataResponse($response, Http::STATUS_OK);
197        } catch (InvalidArgumentException $e) {
198            $message = $e->getMessage();
199            /** @var LibresignActionErrorResponse $response */
200            $response = [
201                'action' => JSActions::ACTION_DO_NOTHING,
202                'errors' => [['message' => $message]]
203            ];
204
205            return new DataResponse($response, Http::STATUS_NOT_FOUND);
206        } catch (\Exception $e) {
207            $this->logger->error('Failed to post file to validate', [
208                'exception' => $e,
209            ]);
210
211            /** @var LibresignActionErrorResponse $response */
212            $response = [
213                'action' => JSActions::ACTION_DO_NOTHING,
214                // TRANSLATORS Error shown when validating a signed document fails unexpectedly; ask the user to contact the server administrator.
215                'errors' => [['message' => $this->l10n->t('Internal error. Contact admin.')]],
216            ];
217
218            return new DataResponse($response, Http::STATUS_BAD_REQUEST);
219        }
220    }
221
222    /**
223     * Validate a file
224     *
225     * @param string|null $type The type of identifier could be Uuid or FileId
226     * @param string|int $identifier The identifier value, could be string or integer, if UUID will be a string, if FileId will be an integer
227     * @return DataResponse<Http::STATUS_OK, LibresignValidatedFileResponse, array{}>|DataResponse<Http::STATUS_NOT_FOUND, LibresignActionErrorResponse, array{}>
228     */
229    private function validate(
230        ?string $type = null,
231        $identifier = null,
232        bool $showVisibleElements = true,
233        bool $showMessages = true,
234        bool $showValidateFile = true,
235    ): DataResponse {
236        try {
237            $signRequest = null;
238            if ($type === 'Uuid' && !empty($identifier)) {
239                try {
240                    $this->fileService->setFileByUuid((string)$identifier);
241                } catch (LibresignException) {
242                    $this->fileService->setFileBySignerUuid((string)$identifier);
243                    $signRequest = $this->signRequestMapper->getBySignerUuidAndUserId((string)$identifier);
244                }
245            } elseif ($type === 'SignerUuid' && !empty($identifier)) {
246                $this->fileService->setFileBySignerUuid((string)$identifier);
247                $signRequest = $this->signRequestMapper->getBySignerUuidAndUserId((string)$identifier);
248            } elseif ($type === 'FileId' && !empty($identifier)) {
249                $this->fileService->setFileById((int)$identifier);
250            } elseif ($this->request->getParam('fileId')) {
251                $this->fileService->setFileById((int)$this->request->getParam('fileId'));
252            } elseif ($this->request->getParam('uuid')) {
253                try {
254                    $this->fileService->setFileByUuid((string)$this->request->getParam('uuid'));
255                } catch (LibresignException) {
256                    $this->fileService->setFileBySignerUuid((string)$this->request->getParam('uuid'));
257                    $signRequest = $this->signRequestMapper->getBySignerUuidAndUserId((string)$this->request->getParam('uuid'));
258                }
259            }
260
261            if ($signRequest) {
262                $this->fileService->setSignRequest($signRequest);
263            }
264
265            /** @var LibresignValidatedFile $validatedFile */
266            $validatedFile = $this->fileService
267                ->setMe($this->userSession->getUser())
268                ->setIdentifyMethodId($this->sessionService->getIdentifyMethodId())
269                ->setHost($this->request->getServerHost())
270                ->showVisibleElements($showVisibleElements)
271                ->showSigners()
272                ->showSettings()
273                ->showMessages($showMessages)
274                ->showValidateFile($showValidateFile)
275                ->toArray();
276
277            $validatedFile = $this->fileListService->enrichValidatedFileSignersGeolocationMetadata(
278                $validatedFile,
279                $this->userSession->getUser(),
280            );
281
282            /** @var LibresignValidatedFileResponse $response */
283            $response = $this->validationEffectivePolicyService->appendEffectivePolicies($validatedFile);
284
285            return new DataResponse($response, Http::STATUS_OK);
286        } catch (LibresignException $e) {
287            $message = $e->getMessage();
288            /** @var LibresignActionErrorResponse $response */
289            $response = [
290                'action' => JSActions::ACTION_DO_NOTHING,
291                'errors' => [['message' => $message]]
292            ];
293
294            return new DataResponse($response, Http::STATUS_NOT_FOUND);
295        } catch (\Throwable $th) {
296            $this->logger->error($th->getMessage(), ['exception' => $th]);
297            // TRANSLATORS Error shown when validating a signed document fails unexpectedly; ask the user to contact the server administrator.
298            $message = $this->l10n->t('Internal error. Contact admin.');
299            /** @var LibresignActionErrorResponse $response */
300            $response = [
301                'action' => JSActions::ACTION_DO_NOTHING,
302                'errors' => [['message' => $message]]
303            ];
304
305            return new DataResponse($response, Http::STATUS_NOT_FOUND);
306        }
307    }
308
309    /**
310     * List identification documents that need to be approved
311     *
312     * @param string|null $signer_uuid Signer UUID
313     * @param list<int>|null $fileIds The list of fileIds (database file IDs). It's the ids of LibreSign files
314     * @param list<int>|null $nodeIds The list of nodeIds. It's the ids of files at Nextcloud
315     * @param list<int>|null $status Status could be none or many of 0 = draft, 1 = able to sign, 2 = partial signed, 3 = signed, 4 = deleted.
316     * @param int|null $page the number of page to return
317     * @param int|null $length Total of elements to return
318     * @param int|null $start Start date of signature request (UNIX timestamp)
319     * @param int|null $end End date of signature request (UNIX timestamp)
320     * @param string|null $sortBy Name of the column to sort by
321     * @param string|null $sortDirection Ascending or descending order
322     * @param int|null $parentFileId Filter files by parent envelope file ID
323     * @param bool $details Whether to return the detailed payload instead of the lightweight summary payload
324     * @return DataResponse<Http::STATUS_OK, LibresignFileListResponse, array{}>
325     *
326     * 200: OK
327     */
328    #[NoAdminRequired]
329    #[NoCSRFRequired]
330    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/file/list', requirements: ['apiVersion' => '(v1)'])]
331    public function list(
332        ?int $page = null,
333        ?int $length = null,
334        ?string $signer_uuid = null,
335        ?array $fileIds = null,
336        ?array $nodeIds = null,
337        ?array $status = null,
338        ?int $start = null,
339        ?int $end = null,
340        ?string $sortBy = null,
341        ?string $sortDirection = null,
342        ?int $parentFileId = null,
343        bool $details = false,
344    ): DataResponse {
345        $filter = array_filter([
346            'signer_uuid' => $signer_uuid,
347            'fileIds' => $fileIds,
348            'nodeIds' => $nodeIds,
349            'status' => $status,
350            'start' => $start,
351            'end' => $end,
352            'parentFileId' => $parentFileId,
353        ], static fn ($var) => $var !== null);
354        $sort = [
355            'sortBy' => $sortBy,
356            'sortDirection' => $sortDirection,
357        ];
358
359        $user = $this->userSession->getUser();
360        $return = $this->fileListService->listAssociatedFilesOfSignFlow($user, $page, $length, $filter, $sort, $details);
361
362        if ($user) {
363            $return['settings'] = $this->settingsLoader->getUserIdentificationSettings($user);
364        }
365
366        return new DataResponse($return, Http::STATUS_OK);
367    }
368
369    /**
370     * Return the thumbnail of a LibreSign file
371     *
372     * @param integer $nodeId The nodeId of document
373     * @param integer $x Width of generated file
374     * @param integer $y Height of generated file
375     * @param boolean $a Crop, boolean value, default false
376     * @param boolean $forceIcon Force to generate a new thumbnail
377     * @param string $mode To force a given mimetype for the file
378     * @param boolean $mimeFallback If we have no preview enabled, we can redirect to the mime icon if any
379     * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
380     *
381     * 200: OK
382     * 303: Redirect
383     * 400: Bad request
384     * 403: Forbidden
385     * 404: Not found
386     */
387    #[NoAdminRequired]
388    #[NoCSRFRequired]
389    #[RequireFileAccess('nodeId')]
390    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/file/thumbnail/{nodeId}', requirements: ['apiVersion' => '(v1)'])]
391    public function getThumbnail(
392        int $nodeId = -1,
393        int $x = 32,
394        int $y = 32,
395        bool $a = false,
396        bool $forceIcon = true,
397        string $mode = 'fill',
398        bool $mimeFallback = false,
399    ) {
400        if ($nodeId === -1 || $x === 0 || $y === 0) {
401            return new DataResponse([], Http::STATUS_BAD_REQUEST);
402        }
403
404        try {
405            $libreSignFile = $this->fileMapper->getByNodeId($nodeId);
406
407            if ($libreSignFile->getNodeType() === 'envelope') {
408                if ($mimeFallback) {
409                    $url = $this->mimeIconProvider->getMimeIconUrl('folder');
410                    if ($url) {
411                        return new RedirectResponse($url);
412                    }
413                }
414                return new DataResponse([], Http::STATUS_NOT_FOUND);
415            }
416
417            $node = $this->accountService->getPdfByUuid($libreSignFile->getUuid());
418        } catch (DoesNotExistException) {
419            return new DataResponse([], Http::STATUS_NOT_FOUND);
420        }
421
422        return $this->fetchPreview($node, $x, $y, $a, $forceIcon, $mode, $mimeFallback);
423    }
424
425    /**
426     * Return the thumbnail of a LibreSign file by fileId
427     *
428     * @param integer $fileId The LibreSign fileId (database id)
429     * @param integer $x Width of generated file
430     * @param integer $y Height of generated file
431     * @param boolean $a Crop, boolean value, default false
432     * @param boolean $forceIcon Force to generate a new thumbnail
433     * @param string $mode To force a given mimetype for the file
434     * @param boolean $mimeFallback If we have no preview enabled, we can redirect to the mime icon if any
435     * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
436     *
437     * 200: OK
438     * 303: Redirect
439     * 400: Bad request
440     * 403: Forbidden
441     * 404: Not found
442     */
443    #[NoAdminRequired]
444    #[NoCSRFRequired]
445    #[RequireFileAccess('fileId')]
446    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/file/thumbnail/file_id/{fileId}', requirements: ['apiVersion' => '(v1)'])]
447    public function getThumbnailByFileId(
448        int $fileId = -1,
449        int $x = 32,
450        int $y = 32,
451        bool $a = false,
452        bool $forceIcon = true,
453        string $mode = 'fill',
454        bool $mimeFallback = false,
455    ) {
456        if ($fileId === -1 || $x === 0 || $y === 0) {
457            return new DataResponse([], Http::STATUS_BAD_REQUEST);
458        }
459
460        try {
461            $libreSignFile = $this->fileMapper->getById($fileId);
462
463            if ($libreSignFile->getNodeType() === 'envelope') {
464                if ($mimeFallback) {
465                    $url = $this->mimeIconProvider->getMimeIconUrl('folder');
466                    if ($url) {
467                        return new RedirectResponse($url);
468                    }
469                }
470                return new DataResponse([], Http::STATUS_NOT_FOUND);
471            }
472
473            $node = $this->accountService->getPdfByUuid($libreSignFile->getUuid());
474        } catch (DoesNotExistException) {
475            return new DataResponse([], Http::STATUS_NOT_FOUND);
476        }
477
478        return $this->fetchPreview($node, $x, $y, $a, $forceIcon, $mode, $mimeFallback);
479    }
480
481    /**
482     * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
483     */
484    private function fetchPreview(
485        Node $node,
486        int $x,
487        int $y,
488        bool $a,
489        bool $forceIcon,
490        string $mode,
491        bool $mimeFallback = false,
492    ): Http\Response {
493        if (!($node instanceof File) || (!$forceIcon && !$this->preview->isAvailable($node))) {
494            return new DataResponse([], Http::STATUS_NOT_FOUND);
495        }
496        if (!$node->isReadable()) {
497            return new DataResponse([], Http::STATUS_FORBIDDEN);
498        }
499
500        // Avoid expensive external preview generators for PDFs when a mime fallback is explicitly requested.
501        if ($mimeFallback && $node->getMimeType() === 'application/pdf') {
502            $mimeFallbackResponse = $this->getMimeFallbackResponse($node->getMimeType());
503            if ($mimeFallbackResponse !== null) {
504                /** @var Http\RedirectResponse<Http::STATUS_SEE_OTHER, array{}> $mimeFallbackResponse */
505                return $mimeFallbackResponse;
506            }
507        }
508
509        $storage = $node->getStorage();
510        if ($storage->instanceOfStorage(SharedStorage::class)) {
511            /** @var SharedStorage $storage */
512            $share = $storage->getShare();
513            $attributes = $share->getAttributes();
514            if ($attributes !== null && $attributes->getAttribute('permissions', 'download') === false) {
515                return new DataResponse([], Http::STATUS_FORBIDDEN);
516            }
517        }
518
519        try {
520            $f = $this->preview->getPreview($node, $x, $y, !$a, $mode);
521            $response = new FileDisplayResponse($f, Http::STATUS_OK, [
522                'Content-Type' => $f->getMimeType(),
523            ]);
524            $response->cacheFor(3600 * 24, false, true);
525            return $response;
526        } catch (NotFoundException) {
527            $mimeFallbackResponse = $mimeFallback ? $this->getMimeFallbackResponse($node->getMimeType()) : null;
528            if ($mimeFallbackResponse !== null) {
529                /** @var Http\RedirectResponse<Http::STATUS_SEE_OTHER, array{}> $mimeFallbackResponse */
530                return $mimeFallbackResponse;
531            }
532
533            return new DataResponse([], Http::STATUS_NOT_FOUND);
534        } catch (\InvalidArgumentException) {
535            return new DataResponse([], Http::STATUS_BAD_REQUEST);
536        } catch (\Throwable $e) {
537            $this->logger->warning('Failed to generate LibreSign thumbnail preview', [
538                'nodeId' => $node->getId(),
539                'mimeType' => $node->getMimeType(),
540                'exception' => $e,
541            ]);
542
543            $mimeFallbackResponse = $mimeFallback ? $this->getMimeFallbackResponse($node->getMimeType()) : null;
544            if ($mimeFallbackResponse !== null) {
545                /** @var Http\RedirectResponse<Http::STATUS_SEE_OTHER, array{}> $mimeFallbackResponse */
546                return $mimeFallbackResponse;
547            }
548
549            return new DataResponse([], Http::STATUS_NOT_FOUND);
550        }
551    }
552
553    private function getMimeFallbackResponse(string $mimeType): ?\OCP\AppFramework\Http\RedirectResponse {
554        $url = $this->mimeIconProvider->getMimeIconUrl($mimeType);
555        if ($url) {
556            /** @var \OCP\AppFramework\Http\RedirectResponse<Http::STATUS_SEE_OTHER, array{}> $response */
557            $response = new RedirectResponse($url, Http::STATUS_SEE_OTHER);
558            return $response;
559        }
560
561        return null;
562    }
563
564    /**
565     * Send a file
566     *
567     * Send a new file to Nextcloud and return the fileId used to create a signature request.
568     * Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'.
569     *
570     * **Note on multiple file uploads:**
571     * PHP has a limit on the number of files that can be uploaded in a single request (max_file_uploads directive, default 20).
572     * When uploading many files (more than 20), consider uploading them sequentially in multiple requests
573     * or use individual file uploads like the Files app does.
574     *
575     * @param LibresignNewFile $file File to save
576     * @param string $name The name of file to sign
577     * @param LibresignFolderSettings $settings Settings to define how and where the file should be stored
578     * @param list<LibresignNewFile> $files Multiple files to create an envelope (optional, use either file or files)
579     * @return DataResponse<Http::STATUS_OK, LibresignDetailedFileResponse, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignMessageResponse, array{}>
580     *
581     * 200: OK
582     * 422: Failed to save data
583     */
584    #[NoAdminRequired]
585    #[NoCSRFRequired]
586    #[RequireManager]
587    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/file', requirements: ['apiVersion' => '(v1)'])]
588    public function save(
589        array $file = [],
590        string $name = '',
591        array $settings = [],
592        array $files = [],
593    ): DataResponse {
594        try {
595            $this->signingRequestValidator->canRequestSign($this->userSession->getUser());
596
597            $normalizedFiles = $this->prepareFilesForSaving($file, $files, $settings);
598
599            return $this->saveFiles($normalizedFiles, $name, $settings);
600        } catch (LibresignException $e) {
601            return new DataResponse(
602                [
603                    'message' => $e->getMessage(),
604                ],
605                Http::STATUS_UNPROCESSABLE_ENTITY,
606            );
607        }
608    }
609
610    /**
611     * Add file to envelope
612     *
613     * Add one or more files to an existing envelope that is in DRAFT status.
614     * Files must be uploaded as multipart/form-data with field name 'files[]'.
615     *
616     * @param string $uuid The UUID of the envelope
617     * @return DataResponse<Http::STATUS_OK, LibresignDetailedFileResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_UNPROCESSABLE_ENTITY, LibresignMessageResponse, array{}>
618     *
619     * 200: Files added successfully
620     * 400: Invalid request
621     * 404: Envelope not found
622     * 422: Cannot add files (envelope not in DRAFT status or validation failed)
623     */
624    #[NoAdminRequired]
625    #[NoCSRFRequired]
626    #[RequireManager]
627    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/file/{uuid}/add-file', requirements: ['apiVersion' => '(v1)'])]
628    public function addFileToEnvelope(string $uuid): DataResponse {
629        try {
630            $this->signingRequestValidator->canRequestSign($this->userSession->getUser());
631
632            $envelope = $this->fileMapper->getByUuid($uuid);
633            $this->signingRequestValidator->iRequestedSignThisFile($this->userSession->getUser(), $envelope->getId());
634
635            if ($envelope->getNodeType() !== 'envelope') {
636                // TRANSLATORS Error shown when adding files to a signature envelope but the given UUID is not an envelope container.
637                throw new LibresignException($this->l10n->t('This is not an envelope'));
638            }
639
640            if ($envelope->getStatus() !== FileStatus::DRAFT->value) {
641                // TRANSLATORS Error shown when trying to add documents to a signature envelope that is no longer in draft and already in the signing flow.
642                throw new LibresignException($this->l10n->t('Cannot add files to an envelope that is not in draft status'));
643            }
644
645            $settings = $envelope->getMetadata()['settings'] ?? [];
646
647            $uploadedFiles = $this->request->getUploadedFile('files');
648            if (!$uploadedFiles) {
649                // TRANSLATORS Error shown when adding documents to a signature envelope and the request contains no uploaded files.
650                throw new LibresignException($this->l10n->t('No files uploaded'));
651            }
652
653            $normalizedFiles = $this->processUploadedFiles($uploadedFiles);
654
655            $addedFiles = [];
656            foreach ($normalizedFiles as $fileData) {
657                $prepared = $this->prepareFileForSaving($fileData, '', $settings);
658
659                $childFile = $this->requestSignatureService->save([
660                    'file' => ['fileNode' => $prepared['node']],
661                    'name' => $prepared['name'],
662                    'userManager' => $this->userSession->getUser(),
663                    'status' => FileStatus::DRAFT->value,
664                    'parentFileId' => $envelope->getId(),
665                ]);
666
667                $addedFiles[] = $childFile;
668            }
669
670            $this->fileService->updateEnvelopeFilesCount($envelope, count($addedFiles));
671
672            $envelope = $this->fileMapper->getById($envelope->getId());
673            $response = $this->fileListService->formatFileWithChildren($envelope, $addedFiles, $this->userSession->getUser());
674            return new DataResponse($response, Http::STATUS_OK);
675        } catch (DoesNotExistException) {
676            return new DataResponse(
677                // TRANSLATORS Error shown when the signature envelope cannot be found.
678                ['message' => $this->l10n->t('Envelope not found')],
679                Http::STATUS_NOT_FOUND,
680            );
681        } catch (LibresignException $e) {
682            return new DataResponse(
683                ['message' => $e->getMessage()],
684                Http::STATUS_UNPROCESSABLE_ENTITY,
685            );
686        } catch (\Exception $e) {
687            $this->logger->error('Failed to add file to envelope', [
688                'exception' => $e,
689            ]);
690            return new DataResponse(
691                // TRANSLATORS Error shown when adding a document into a signature envelope fails for an unexpected reason.
692                ['message' => $this->l10n->t('Failed to add file to envelope')],
693                Http::STATUS_BAD_REQUEST,
694            );
695        }
696    }
697
698    /**
699     * @return array{node: Node, name: string}
700     */
701    private function prepareFileForSaving(array $fileData, string $name, array $settings): array {
702        if (empty($name)) {
703            $name = $this->extractFileName($fileData);
704        }
705        if (empty($name)) {
706            // TRANSLATORS Error shown when saving a document for signing without a file name.
707            throw new LibresignException($this->l10n->t('File name is required'));
708        }
709
710        if (isset($fileData['fileNode']) && $fileData['fileNode'] instanceof Node) {
711            $node = $fileData['fileNode'];
712            $name = $fileData['name'] ?? $name;
713        } elseif (isset($fileData['uploadedFile'])) {
714            $this->fileService->validateUploadedFile($fileData['uploadedFile']);
715
716            $node = $this->fileService->getNodeFromData([
717                'userManager' => $this->userSession->getUser(),
718                'name' => $name,
719                'uploadedFile' => $fileData['uploadedFile'],
720                'settings' => $settings
721            ]);
722        } else {
723            $this->fileInputValidator->validateNewFile([
724                'file' => $fileData,
725                'userManager' => $this->userSession->getUser(),
726            ]);
727
728            $node = $this->fileService->getNodeFromData([
729                'userManager' => $this->userSession->getUser(),
730                'name' => $name,
731                'file' => $fileData,
732                'settings' => $settings
733            ]);
734        }
735
736        return [
737            'node' => $node,
738            'name' => $name,
739        ];
740    }
741
742    /**
743     * @return list<array{fileNode?: Node, name?: string, uploadedFile?: array}> Normalized files array
744     */
745    private function prepareFilesForSaving(array $file, array $files, array $settings): array {
746        $uploadedFiles = $this->request->getUploadedFile('files') ?: $this->request->getUploadedFile('file');
747
748        if ($uploadedFiles) {
749            return $this->processUploadedFiles($uploadedFiles);
750        }
751
752        if (!empty($files)) {
753            /** @var list<array{fileNode?: Node, name?: string}> $normalizedFiles */
754            $normalizedFiles = array_map(
755                fn (mixed $each): mixed => is_array($each) ? $this->fileInputValidator->normalizeNodeId($each) : $each,
756                $files,
757            );
758            return $normalizedFiles;
759        }
760
761        if (!empty($file)) {
762            /** @var array{fileNode?: Node, name?: string} $normalizedFile */
763            $normalizedFile = $this->fileInputValidator->normalizeNodeId($file);
764            return [$normalizedFile];
765        }
766
767        // TRANSLATORS Error shown when creating or updating a signature request without a file.
768        throw new LibresignException($this->l10n->t('File or files parameter is required'));
769    }
770
771    /**
772     * @return list<array{uploadedFile: array, name: string}>
773     */
774    private function processUploadedFiles(array $uploadedFiles): array {
775        $filesArray = [];
776
777        if (isset($uploadedFiles['tmp_name'])) {
778            if (is_array($uploadedFiles['tmp_name'])) {
779                $count = count($uploadedFiles['tmp_name']);
780                for ($i = 0; $i < $count; $i++) {
781                    $uploadedFile = [
782                        'tmp_name' => $uploadedFiles['tmp_name'][$i],
783                        'name' => $uploadedFiles['name'][$i],
784                        'type' => $uploadedFiles['type'][$i],
785                        'size' => $uploadedFiles['size'][$i],
786                        'error' => $uploadedFiles['error'][$i],
787                    ];
788                    $this->fileService->validateUploadedFile($uploadedFile);
789                    $filesArray[] = [
790                        'uploadedFile' => $uploadedFile,
791                        'name' => pathinfo((string)$uploadedFile['name'], PATHINFO_FILENAME),
792                    ];
793                }
794            } else {
795                $this->fileService->validateUploadedFile($uploadedFiles);
796                $filesArray[] = [
797                    'uploadedFile' => $uploadedFiles,
798                    'name' => pathinfo((string)$uploadedFiles['name'], PATHINFO_FILENAME),
799                ];
800            }
801        }
802
803        if (empty($filesArray)) {
804            // TRANSLATORS Error shown when uploading files for a signature request and no usable files were received.
805            throw new LibresignException($this->l10n->t('No files uploaded'));
806        }
807
808        return $filesArray;
809    }
810
811    /**
812     * @return DataResponse<Http::STATUS_OK, LibresignDetailedFileResponse, array{}>
813     */
814    private function saveFiles(array $files, string $name, array $settings): DataResponse {
815        if (empty($files)) {
816            // TRANSLATORS Error shown when saving documents for a signature request and no files were provided.
817            throw new LibresignException($this->l10n->t('File or files parameter is required'));
818        }
819
820        $result = $this->requestSignatureService->saveFiles([
821            'files' => $files,
822            'name' => $name,
823            'userManager' => $this->userSession->getUser(),
824            'settings' => $settings,
825        ]);
826
827        $response = $this->fileListService->formatFileWithChildren($result['file'], $result['children'], $this->userSession->getUser());
828        return new DataResponse($response, Http::STATUS_OK);
829    }
830
831    private function extractFileName(array $fileData): string {
832        if (!empty($fileData['name'])) {
833            return $fileData['name'];
834        }
835        if (!empty($fileData['url'])) {
836            return rawurldecode(pathinfo((string)$fileData['url'], PATHINFO_FILENAME));
837        }
838        return '';
839    }
840
841    /**
842     * Delete File
843     *
844     * This will delete the file and all data
845     *
846     * @param integer $fileId LibreSign file ID
847     * @param boolean $deleteFile Whether to delete the physical file from Nextcloud (default: true)
848     * @return DataResponse<Http::STATUS_OK, LibresignMessageResponse, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, LibresignMessageResponse, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignActionErrorResponse, array{}>
849     *
850     * 200: OK
851     * 401: Failed
852     * 422: Failed
853     */
854    #[NoAdminRequired]
855    #[NoCSRFRequired]
856    #[RequireManager]
857    #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/file/file_id/{fileId}', requirements: ['apiVersion' => '(v1)'])]
858    public function deleteFile(int $fileId, bool $deleteFile = true): DataResponse {
859        try {
860            $data = [
861                'userManager' => $this->userSession->getUser(),
862                'file' => [
863                    'fileId' => $fileId
864                ]
865            ];
866            $this->signingRequestValidator->validateExistingFile($data);
867            $this->fileService->delete($fileId, $deleteFile);
868        } catch (\Throwable $th) {
869            return new DataResponse(
870                [
871                    'message' => $th->getMessage(),
872                ],
873                Http::STATUS_UNAUTHORIZED
874            );
875        }
876        return new DataResponse(
877            [
878                // TRANSLATORS Success message shown after a manager deletes a LibreSign document and its related signing data.
879                'message' => $this->l10n->t('Success')
880            ],
881            Http::STATUS_OK
882        );
883    }
884}