Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.00% covered (danger)
50.00%
136 / 272
24.00% covered (danger)
24.00%
6 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageController
50.00% covered (danger)
50.00%
136 / 272
24.00% covered (danger)
24.00%
6 / 25
448.00
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 index
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
3.01
 indexF
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 incomplete
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 incompleteP
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 renderIncompletePage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isSetupComplete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 indexFPath
8.89% covered (danger)
8.89%
4 / 45
0.00% covered (danger)
0.00%
0 / 1
102.52
 canAccessPoliciesWorkbench
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 isPoliciesWorkbenchPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 signF
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 signFPath
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 signPPath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sign
95.65% covered (success)
95.65%
44 / 46
0.00% covered (danger)
0.00%
0 / 1
3
 provideSignerSignatues
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getPdfUrls
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getEnvelopeChildFiles
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 getPdf
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getPdfFile
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 validation
21.05% covered (danger)
21.05%
4 / 19
0.00% covered (danger)
0.00%
0 / 1
3.97
 validationFileWithShortUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resetPassword
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 validationFilePublic
70.00% covered (warning)
70.00%
28 / 40
0.00% covered (danger)
0.00%
0 / 1
6.97
 registerValidationAssets
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 provideDeferredValidationFileInfo
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
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 OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Db\FileMapper;
13use OCA\Libresign\Db\SignRequest;
14use OCA\Libresign\Db\SignRequestMapper;
15use OCA\Libresign\Exception\LibresignException;
16use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
17use OCA\Libresign\Helper\JSActions;
18use OCA\Libresign\Middleware\Attribute\PrivateValidation;
19use OCA\Libresign\Middleware\Attribute\RequireParticipantUuid;
20use OCA\Libresign\Middleware\Attribute\RequireSetupOk;
21use OCA\Libresign\Middleware\Attribute\RequireSignRequestUuid;
22use OCA\Libresign\Service\AccountService;
23use OCA\Libresign\Service\EmailSignerElementsService;
24use OCA\Libresign\Service\File\FileListService;
25use OCA\Libresign\Service\FileService;
26use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\TokenService;
27use OCA\Libresign\Service\IdentifyMethodService;
28use OCA\Libresign\Service\Policy\PolicyAuthorizationService;
29use OCA\Libresign\Service\Policy\PolicyService;
30use OCA\Libresign\Service\Policy\ValidationEffectivePolicyService;
31use OCA\Libresign\Service\RequestSignatureService;
32use OCA\Libresign\Service\SessionService;
33use OCA\Libresign\Service\SignerElementsService;
34use OCA\Libresign\Service\SignFileService;
35use OCA\Libresign\Service\Validation\SigningRequestValidator;
36use OCA\Viewer\Event\LoadViewer;
37use OCP\AppFramework\Db\DoesNotExistException;
38use OCP\AppFramework\Http;
39use OCP\AppFramework\Http\Attribute\AnonRateLimit;
40use OCP\AppFramework\Http\Attribute\FrontpageRoute;
41use OCP\AppFramework\Http\Attribute\NoAdminRequired;
42use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
43use OCP\AppFramework\Http\Attribute\OpenAPI;
44use OCP\AppFramework\Http\Attribute\PublicPage;
45use OCP\AppFramework\Http\ContentSecurityPolicy;
46use OCP\AppFramework\Http\DataResponse;
47use OCP\AppFramework\Http\FileDisplayResponse;
48use OCP\AppFramework\Http\RedirectResponse;
49use OCP\AppFramework\Http\TemplateResponse;
50use OCP\AppFramework\Services\IInitialState;
51use OCP\EventDispatcher\IEventDispatcher;
52use OCP\IAppConfig;
53use OCP\IL10N;
54use OCP\IRequest;
55use OCP\IURLGenerator;
56use OCP\IUserSession;
57use OCP\Util;
58use Psr\Log\LoggerInterface;
59
60#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
61class PageController extends AEnvironmentPageAwareController {
62    public function __construct(
63        IRequest $request,
64        protected IUserSession $userSession,
65        private SessionService $sessionService,
66        private IInitialState $initialState,
67        private AccountService $accountService,
68        private CertificateEngineFactory $certificateEngineFactory,
69        protected SignFileService $signFileService,
70        protected RequestSignatureService $requestSignatureService,
71        private PolicyService $policyService,
72        private ValidationEffectivePolicyService $validationEffectivePolicyService,
73        private PolicyAuthorizationService $policyAuthorizationService,
74        private SignerElementsService $signerElementsService,
75        protected IL10N $l10n,
76        private IdentifyMethodService $identifyMethodService,
77        private IAppConfig $appConfig,
78        private FileService $fileService,
79        private FileListService $fileListService,
80        private FileMapper $fileMapper,
81        private SignRequestMapper $signRequestMapper,
82        private LoggerInterface $logger,
83        private SigningRequestValidator $signingRequestValidator,
84        private IEventDispatcher $eventDispatcher,
85        private IURLGenerator $urlGenerator,
86        private EmailSignerElementsService $emailSignerElementsService,
87    ) {
88        parent::__construct(
89            request: $request,
90            signFileService: $signFileService,
91            l10n: $l10n,
92            userSession: $userSession,
93        );
94    }
95
96    /**
97     * Index page
98     *
99     * @return TemplateResponse<Http::STATUS_OK, array{}>
100     *
101     * 200: OK
102     */
103    #[NoAdminRequired]
104    #[NoCSRFRequired]
105    #[RequireSetupOk(template: 'main')]
106    #[FrontpageRoute(verb: 'GET', url: '/')]
107    public function index(): TemplateResponse {
108        $this->initialState->provideInitialState('config', $this->accountService->getConfig($this->userSession->getUser()));
109        $this->initialState->provideInitialState('filters', $this->accountService->getConfigFilters($this->userSession->getUser()));
110        $this->initialState->provideInitialState('sorting', $this->accountService->getConfigSorting($this->userSession->getUser()));
111        $this->initialState->provideInitialState('certificate_engine', $this->accountService->getCertificateEngineName());
112
113        try {
114            $this->signingRequestValidator->canRequestSign($this->userSession->getUser());
115            $this->initialState->provideInitialState('can_request_sign', true);
116        } catch (LibresignException) {
117            $this->initialState->provideInitialState('can_request_sign', false);
118        }
119
120        $this->provideSignerSignatues();
121        $this->initialState->provideInitialState('effective_policies', [
122            'policies' => $this->policyService->resolveKnownPolicyStates(),
123        ]);
124
125        Util::addScript(Application::APP_ID, 'libresign-main');
126        Util::addStyle(Application::APP_ID, 'libresign-main');
127
128        if (class_exists(LoadViewer::class)) {
129            $this->eventDispatcher->dispatchTyped(new LoadViewer());
130        }
131
132        $response = new TemplateResponse(Application::APP_ID, 'main');
133
134        $policy = new ContentSecurityPolicy();
135        $policy->addAllowedFrameDomain('\'self\'');
136        $policy->addAllowedWorkerSrcDomain("'self'");
137        $response->setContentSecurityPolicy($policy);
138
139        return $response;
140    }
141
142    /**
143     * Index page to authenticated users
144     *
145     * This router is used to be possible render pages with /f/, is a
146     * workaround at frontend side to identify pages with authenticated accounts
147     *
148     * @return TemplateResponse<Http::STATUS_OK, array{}>
149     *
150     * 200: OK
151     */
152    #[NoAdminRequired]
153    #[NoCSRFRequired]
154    #[RequireSetupOk(template: 'main')]
155    #[FrontpageRoute(verb: 'GET', url: '/f/')]
156    public function indexF(): TemplateResponse {
157        return $this->index();
158    }
159
160    /**
161     * Incomplete page
162     *
163     * @return TemplateResponse|RedirectResponse
164     *
165     * 200: OK
166     * 303: Redirected when setup is already complete
167     */
168    #[NoAdminRequired]
169    #[NoCSRFRequired]
170    #[FrontpageRoute(verb: 'GET', url: '/f/incomplete')]
171    public function incomplete(): TemplateResponse|RedirectResponse {
172        if ($this->isSetupComplete()) {
173            return new RedirectResponse(
174                $this->urlGenerator->linkToRouteAbsolute('libresign.page.indexF'),
175            );
176        }
177
178        return $this->renderIncompletePage();
179    }
180
181    /**
182     * Incomplete page in full screen
183     *
184     * @return TemplateResponse|RedirectResponse
185     *
186     * 200: OK
187     * 303: Redirected when setup is already complete
188     */
189    #[PublicPage]
190    #[NoCSRFRequired]
191    #[FrontpageRoute(verb: 'GET', url: '/p/incomplete')]
192    public function incompleteP(): TemplateResponse|RedirectResponse {
193        if ($this->isSetupComplete()) {
194            return new RedirectResponse(
195                $this->urlGenerator->linkToRouteAbsolute('libresign.page.index'),
196            );
197        }
198
199        return $this->renderIncompletePage(publicPage: true);
200    }
201
202    private function renderIncompletePage(bool $publicPage = false): TemplateResponse {
203        Util::addScript(Application::APP_ID, 'libresign-main');
204        Util::addStyle(Application::APP_ID, 'libresign-main');
205
206        if ($publicPage) {
207            return new TemplateResponse(Application::APP_ID, 'main', [], TemplateResponse::RENDER_AS_BASE);
208        }
209
210        return new TemplateResponse(Application::APP_ID, 'main');
211    }
212
213    private function isSetupComplete(): bool {
214        return $this->certificateEngineFactory->getEngine()->isSetupOk();
215    }
216
217    /**
218     * Main page to authenticated signer with a path
219     *
220     * The path is used only by frontend
221     *
222     * @param string $path The path that was sent from frontend
223     * @return TemplateResponse<Http::STATUS_OK, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
224     *
225     * 200: OK
226     * 303: Redirected when the current actor cannot access the requested internal page
227     */
228    #[NoAdminRequired]
229    #[NoCSRFRequired]
230    #[RequireSetupOk(template: 'main')]
231    #[FrontpageRoute(verb: 'GET', url: '/f/{path}', requirements: ['path' => '.+'])]
232    public function indexFPath(string $path): TemplateResponse|RedirectResponse {
233        if ($this->isPoliciesWorkbenchPath($path) && !$this->canAccessPoliciesWorkbench()) {
234            return new RedirectResponse(
235                $this->urlGenerator->linkToRouteAbsolute('libresign.page.indexF'),
236            );
237        }
238
239        if (preg_match('/validation\/(?<uuid>[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/', $path, $matches)) {
240            $signRequest = null;
241
242            try {
243                $this->fileService->setFileByUuid($matches['uuid']);
244            } catch (LibresignException) {
245                try {
246                    $this->fileService->setFileBySignerUuid($matches['uuid']);
247                    $signRequest = $this->signRequestMapper->getBySignerUuidAndUserId($matches['uuid']);
248                } catch (LibresignException) {
249                    throw new LibresignException(json_encode([
250                        'action' => JSActions::ACTION_DO_NOTHING,
251                        // TRANSLATORS Error shown on the signing page when the document identifier in the URL is invalid.
252                        'errors' => [['message' => $this->l10n->t('Invalid UUID')]],
253                    ]), Http::STATUS_NOT_FOUND);
254                }
255            }
256
257            if ($signRequest) {
258                $this->fileService->setSignRequest($signRequest);
259            }
260
261            $this->provideDeferredValidationFileInfo($matches['uuid']);
262        } elseif (preg_match('/sign\/(?<uuid>[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/', $path, $matches)) {
263            try {
264                $signRequest = $this->signFileService->getSignRequestByUuid($matches['uuid']);
265                if ($signRequest->getStatusEnum() === \OCA\Libresign\Enum\SignRequestStatus::SIGNED) {
266                    $file = $this->signFileService->getFile($signRequest->getFileId());
267                    $redirectUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.page.indexFPath', [
268                        'path' => 'validation/' . $file->getUuid(),
269                    ]);
270                    throw new LibresignException(json_encode([
271                        'action' => JSActions::ACTION_REDIRECT,
272                        'redirect' => $redirectUrl,
273                    ]), Http::STATUS_SEE_OTHER);
274                }
275                $file = $this->fileService
276                    ->setFile($this->signFileService->getFile($signRequest->getFileId()))
277                    ->setSignRequest($signRequest)
278                    ->setMe($this->userSession->getUser())
279                    ->showSettings()
280                    ->toArray();
281                $this->initialState->provideInitialState('needIdentificationDocuments', $file['settings']['needIdentificationDocuments'] ?? false);
282                $this->initialState->provideInitialState('identificationDocumentsWaitingApproval', $file['settings']['identificationDocumentsWaitingApproval'] ?? false);
283            } catch (LibresignException $e) {
284                throw $e;
285            } catch (\Throwable) {
286                throw new LibresignException(json_encode([
287                    'action' => JSActions::ACTION_DO_NOTHING,
288                    // TRANSLATORS Error shown on the signing page when the document identifier in the URL is invalid.
289                    'errors' => [['message' => $this->l10n->t('Invalid UUID')]],
290                ]), Http::STATUS_NOT_FOUND);
291            }
292        }
293        return $this->index();
294    }
295
296    private function canAccessPoliciesWorkbench(): bool {
297        $user = $this->userSession->getUser();
298        if ($user === null) {
299            return false;
300        }
301
302        return $this->policyAuthorizationService->canUserManageGroupPolicies($user);
303    }
304
305    private function isPoliciesWorkbenchPath(string $path): bool {
306        return preg_match('/^policies(?:\/|$)/', $path) === 1;
307    }
308
309    /**
310     * Sign page to authenticated signer
311     *
312     * @param string $uuid Sign request uuid
313     * @return TemplateResponse<Http::STATUS_OK, array{}>
314     *
315     * 200: OK
316     */
317    #[PrivateValidation(allowValidSignRequestUuid: false)]
318    #[NoAdminRequired]
319    #[NoCSRFRequired]
320    #[RequireSetupOk]
321    #[PublicPage]
322    #[RequireSignRequestUuid(redirectIfSignedToValidation: true, allowIdDocs: true)]
323    #[FrontpageRoute(verb: 'GET', url: '/f/sign/{uuid}')]
324    public function signF(string $uuid): TemplateResponse {
325        $this->initialState->provideInitialState('action', JSActions::ACTION_SIGN_INTERNAL);
326        return $this->index();
327    }
328
329    /**
330     * Sign page to authenticated signer with the path of file
331     *
332     * The path is used only by frontend
333     *
334     * @param string $uuid Sign request uuid
335     * @return TemplateResponse<Http::STATUS_OK, array{}>
336     *
337     * 200: OK
338     */
339    #[NoAdminRequired]
340    #[NoCSRFRequired]
341    #[RequireSetupOk]
342    #[PublicPage]
343    #[RequireSignRequestUuid(redirectIfSignedToValidation: true, allowIdDocs: true)]
344    #[FrontpageRoute(verb: 'GET', url: '/f/sign/{uuid}/{path}', requirements: ['path' => '.+'])]
345    public function signFPath(string $uuid): TemplateResponse {
346        $this->initialState->provideInitialState('action', JSActions::ACTION_SIGN_INTERNAL);
347        return $this->index();
348    }
349
350    /**
351     * Sign page to unauthenticated signer
352     *
353     * The path is used only by frontend
354     *
355     * @param string $uuid Sign request uuid
356     * @return TemplateResponse<Http::STATUS_OK, array{}>
357     *
358     * 200: OK
359     */
360    #[NoAdminRequired]
361    #[NoCSRFRequired]
362    #[RequireSetupOk]
363    #[PublicPage]
364    #[PrivateValidation(allowValidSignRequestUuid: true)]
365    #[RequireSignRequestUuid(redirectIfSignedToValidation: true, allowIdDocs: true)]
366    #[FrontpageRoute(verb: 'GET', url: '/p/sign/{uuid}/{path}', requirements: ['path' => '.+'])]
367    public function signPPath(string $uuid): TemplateResponse {
368        return $this->sign($uuid);
369    }
370
371    /**
372     * Sign page to unauthenticated signer
373     *
374     * The path is used only by frontend
375     *
376     * @param string $uuid Sign request uuid
377     * @return TemplateResponse<Http::STATUS_OK, array{}>
378     *
379     * 200: OK
380     */
381    #[NoAdminRequired]
382    #[NoCSRFRequired]
383    #[RequireSetupOk]
384    #[PublicPage]
385    #[PrivateValidation(allowValidSignRequestUuid: true)]
386    #[RequireSignRequestUuid(redirectIfSignedToValidation: true, allowIdDocs: true)]
387    #[FrontpageRoute(verb: 'GET', url: '/p/sign/{uuid}')]
388    public function sign(string $uuid): TemplateResponse {
389        $this->initialState->provideInitialState('action', JSActions::ACTION_SIGN);
390        $config = $this->accountService->getConfig($this->userSession->getUser());
391        $this->initialState->provideInitialState('filename', $this->getFileEntity()->getName());
392        $file = $this->fileService
393            ->setFile($this->getFileEntity())
394            ->setSignRequest($this->getSignRequestEntity())
395            ->setHost($this->request->getServerHost())
396            ->setMe($this->userSession->getUser())
397            ->setSignerIdentified()
398            ->setIdentifyMethodId($this->sessionService->getIdentifyMethodId())
399            ->showVisibleElements()
400            ->showSigners()
401            ->showSettings()
402            ->toArray();
403        $this->initialState->provideInitialState('config', array_merge($config, [
404            'identificationDocumentsFlow' => $file['settings']['needIdentificationDocuments'] ?? false,
405        ]));
406        $this->initialState->provideInitialState('id', $file['id']);
407        $this->initialState->provideInitialState('nodeId', $file['nodeId']);
408        $this->initialState->provideInitialState('needIdentificationDocuments', $file['settings']['needIdentificationDocuments'] ?? false);
409        $this->initialState->provideInitialState('identificationDocumentsWaitingApproval', $file['settings']['identificationDocumentsWaitingApproval'] ?? false);
410        $this->initialState->provideInitialState('status', $file['status']);
411        $this->initialState->provideInitialState('statusText', $file['statusText']);
412        $this->initialState->provideInitialState('signers', $file['signers']);
413        $this->initialState->provideInitialState('canSign', $file['settings']['canSign'] ?? false);
414        $this->initialState->provideInitialState('visibleElements', $file['visibleElements'] ?? []);
415        $this->initialState->provideInitialState('sign_request_uuid', $uuid);
416        $this->provideSignerSignatues();
417        $this->initialState->provideInitialState('token_length', TokenService::TOKEN_LENGTH);
418        $this->initialState->provideInitialState('description', $this->getSignRequestEntity()->getDescription() ?? '');
419        if ($this->getFileEntity()->getNodeType() === 'envelope') {
420            $this->initialState->provideInitialState('pdfs', []);
421            $this->initialState->provideInitialState('envelopeFiles', $this->getEnvelopeChildFiles());
422        } else {
423            $this->initialState->provideInitialState('pdfs', $this->getPdfUrls());
424            $this->initialState->provideInitialState('envelopeFiles', []);
425        }
426        $this->initialState->provideInitialState('nodeId', $this->getFileEntity()->getNodeId());
427        $this->initialState->provideInitialState('nodeType', $this->getFileEntity()->getNodeType());
428
429        Util::addScript(Application::APP_ID, 'libresign-external');
430        Util::addStyle(Application::APP_ID, 'libresign-external');
431        if (class_exists(LoadViewer::class)) {
432            $this->eventDispatcher->dispatchTyped(new LoadViewer());
433        }
434        $response = new TemplateResponse(Application::APP_ID, 'external', [], TemplateResponse::RENDER_AS_BASE);
435
436        $policy = new ContentSecurityPolicy();
437        $policy->addAllowedWorkerSrcDomain("'self'");
438        $response->setContentSecurityPolicy($policy);
439
440        return $response;
441    }
442
443    private function provideSignerSignatues(): void {
444        $signatures = [];
445        if ($this->userSession->getUser()) {
446            $signatures = $this->signerElementsService->getUserElements($this->userSession->getUser()->getUID());
447        } else {
448            $signatures = $this->signerElementsService->getElementsFromSessionAsArray();
449            $signRequest = $this->getSignRequestEntity();
450            if ($signRequest instanceof SignRequest) {
451                $this->initialState->provideInitialState(
452                    'email_signer_signature',
453                    $this->emailSignerElementsService->getSignerState($signRequest),
454                );
455            }
456        }
457        $this->initialState->provideInitialState('user_signatures', $signatures);
458    }
459
460    /**
461     * @return string[] Array of PDF URLs
462     */
463    private function getPdfUrls(): array {
464        return $this->signFileService->getPdfUrlsForSigning(
465            $this->getFileEntity(),
466            $this->getSignRequestEntity()
467        );
468    }
469
470    private function getEnvelopeChildFiles(): array {
471        $parentFileId = $this->getFileEntity()->getId();
472        $currentSignRequest = $this->getSignRequestEntity();
473        $childFiles = $this->fileMapper->getChildrenFiles($parentFileId);
474        $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod(
475            $parentFileId,
476            $currentSignRequest->getId()
477        );
478
479        $signRequestsByFileId = [];
480        foreach ($childSignRequests as $childSignRequest) {
481            $signRequestsByFileId[$childSignRequest->getFileId()] = true;
482        }
483
484        foreach ($childFiles as $childFile) {
485            if (!isset($signRequestsByFileId[$childFile->getId()])) {
486                $this->logger->warning('Missing sign request for envelope child file', [
487                    'parentFileId' => $parentFileId,
488                    'childFileId' => $childFile->getId(),
489                    'signRequestId' => $currentSignRequest->getId(),
490                ]);
491            }
492        }
493
494        return $this->fileListService->formatEnvelopeChildFilesForSignRequest(
495            $childFiles,
496            $childSignRequests,
497            $currentSignRequest,
498        );
499    }
500
501    /**
502     * Use UUID of file to get PDF
503     *
504     * @param string $uuid File uuid
505     * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_NOT_FOUND, array<empty>, array{}>
506     *
507     * 200: OK
508     * 401: Validation page not accessible if unauthenticated
509     * 404: File not found
510     */
511    #[PrivateValidation(allowValidSignRequestUuid: false)]
512    #[NoAdminRequired]
513    #[NoCSRFRequired]
514    #[RequireSetupOk]
515    #[PublicPage]
516    #[AnonRateLimit(limit: 300, period: 60)]
517    #[FrontpageRoute(verb: 'GET', url: '/p/pdf/{uuid}')]
518    public function getPdf($uuid) {
519        try {
520            $file = $this->accountService->getPdfByUuid($uuid);
521        } catch (DoesNotExistException) {
522            return new DataResponse([], Http::STATUS_NOT_FOUND);
523        }
524
525        return new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]);
526    }
527
528    /**
529     * Use UUID of user to get PDF
530     *
531     * @param string $uuid Sign request uuid
532     * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
533     *
534     * 200: OK
535     * 401: Validation page not accessible if unauthenticated without a valid sign request UUID
536     */
537    #[PrivateValidation(allowValidSignRequestUuid: true)]
538    #[NoAdminRequired]
539    #[NoCSRFRequired]
540    #[RequireParticipantUuid(allowIdDocs: true)]
541    #[PublicPage]
542    #[RequireSetupOk]
543    #[AnonRateLimit(limit: 300, period: 60)]
544    #[FrontpageRoute(verb: 'GET', url: '/pdf/{uuid}')]
545    public function getPdfFile($uuid): FileDisplayResponse {
546        $files = $this->getNextcloudFiles();
547        if (empty($files)) {
548            throw new LibresignException(json_encode([
549                'action' => JSActions::ACTION_DO_NOTHING,
550                // TRANSLATORS Error shown on the LibreSign page when the requested document cannot be found.
551                'errors' => [['message' => $this->l10n->t('File not found')]],
552            ]), Http::STATUS_NOT_FOUND);
553        }
554        $file = current($files);
555        return new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]);
556    }
557
558    /**
559     * Show validation page
560     *
561     * @return TemplateResponse<Http::STATUS_OK, array{}>
562     *
563     * 200: OK
564     * 401: Validation page not accessible if unauthenticated
565     */
566    #[PrivateValidation(allowValidSignRequestUuid: false)]
567    #[NoAdminRequired]
568    #[NoCSRFRequired]
569    #[RequireSetupOk(template: 'external')]
570    #[PublicPage]
571    #[AnonRateLimit(limit: 30, period: 60)]
572    #[FrontpageRoute(verb: 'GET', url: '/p/validation')]
573    public function validation(): TemplateResponse {
574        if ($this->getFileEntity()) {
575            $this->initialState->provideInitialState('config',
576                $this->accountService->getConfig($this->userSession->getUser())
577            );
578            $this->initialState->provideInitialState('file', [
579                'uuid' => $this->getFileEntity()?->getUuid(),
580                'description' => $this->getSignRequestEntity()?->getDescription(),
581            ]);
582            $this->initialState->provideInitialState('filename', $this->getFileEntity()?->getName());
583            $this->initialState->provideInitialState('pdfs', $this->getPdfUrls());
584            $this->initialState->provideInitialState('signer',
585                $this->signFileService->getSignerData(
586                    $this->userSession->getUser(),
587                    $this->getSignRequestEntity(),
588                )
589            );
590        }
591
592        $this->registerValidationAssets();
593        $response = new TemplateResponse(Application::APP_ID, 'external', [], TemplateResponse::RENDER_AS_BASE);
594
595        return $response;
596    }
597
598    /**
599     * Show validation page
600     *
601     * The path is used only by frontend
602     *
603     * @param string $uuid Sign request uuid
604     * @return TemplateResponse<Http::STATUS_OK, array{}>
605     *
606     * 200: OK
607     * 303: Redirected to validation page
608     * 401: Validation page not accessible if unauthenticated
609     */
610    #[PrivateValidation(allowValidSignRequestUuid: false)]
611    #[NoAdminRequired]
612    #[NoCSRFRequired]
613    #[RequireSetupOk]
614    #[PublicPage]
615    #[AnonRateLimit(limit: 30, period: 60)]
616    #[FrontpageRoute(verb: 'GET', url: '/validation/{uuid}')]
617    public function validationFileWithShortUrl(): TemplateResponse {
618        return $this->validationFilePublic($this->request->getParam('uuid'));
619    }
620
621    /**
622     * Show validation page
623     *
624     * @param string $uuid Sign request uuid
625     * @return TemplateResponse<Http::STATUS_OK, array{}>
626     *
627     * 200: OK
628     */
629    #[NoAdminRequired]
630    #[NoCSRFRequired]
631    #[RequireSetupOk(template: 'main')]
632    #[PublicPage]
633    #[RequireSignRequestUuid]
634    #[FrontpageRoute(verb: 'GET', url: '/reset-password')]
635    public function resetPassword(): TemplateResponse {
636        $this->initialState->provideInitialState('config',
637            $this->accountService->getConfig($this->userSession->getUser())
638        );
639
640        Util::addScript(Application::APP_ID, 'libresign-main');
641        Util::addStyle(Application::APP_ID, 'libresign-main');
642        $response = new TemplateResponse(Application::APP_ID, 'reset_password');
643
644        return $response;
645    }
646
647    /**
648     * Public page to show validation for a specific file UUID
649     *
650     * @param string $uuid File uuid
651     * @return TemplateResponse<Http::STATUS_OK, array{}>
652     *
653     * 200: OK
654     * 401: Validation page not accessible if unauthenticated
655     */
656    #[PrivateValidation(allowValidSignRequestUuid: false)]
657    #[NoAdminRequired]
658    #[NoCSRFRequired]
659    #[PublicPage]
660    #[AnonRateLimit(limit: 30, period: 60)]
661    #[FrontpageRoute(verb: 'GET', url: '/p/validation/{uuid}')]
662    public function validationFilePublic(string $uuid): TemplateResponse {
663        $signRequest = null;
664        try {
665            $this->signFileService->getFileByUuid($uuid);
666            $this->fileService->setFileByUuid($uuid);
667        } catch (DoesNotExistException) {
668            try {
669                $signRequest = $this->signFileService->getSignRequestByUuid($uuid);
670                $libresignFile = $this->signFileService->getFile($signRequest->getFileId());
671                $this->fileService->setFile($libresignFile);
672            } catch (DoesNotExistException) {
673                $this->initialState->provideInitialState('action', JSActions::ACTION_DO_NOTHING);
674                // TRANSLATORS Error shown when the identification-document request identifier is invalid.
675                $this->initialState->provideInitialState('errors', [['message' => $this->l10n->t('Invalid UUID')]]);
676            }
677        }
678        if ($this->userSession->isLoggedIn()) {
679            $this->initialState->provideInitialState('config',
680                $this->accountService->getConfig($this->userSession->getUser())
681            );
682            $this->fileService->setMe($this->userSession->getUser());
683        } else {
684            $this->initialState->provideInitialState('config',
685                $this->accountService->getConfig()
686            );
687        }
688
689        if ($signRequest) {
690            $this->fileService->setSignRequest($signRequest);
691        }
692
693        $fileInfo = $this->fileService
694            ->setIdentifyMethodId($this->sessionService->getIdentifyMethodId())
695            ->setHost($this->request->getServerHost())
696            ->showVisibleElements()
697            ->showSigners()
698            ->showSettings()
699            ->showMessages()
700            ->showValidateFile()
701            ->toArray();
702
703        $fileInfo = $this->validationEffectivePolicyService->appendEffectivePolicies($fileInfo);
704
705        $this->initialState->provideInitialState(
706            'effective_policies',
707            $fileInfo['effective_policies'],
708        );
709        $this->initialState->provideInitialState('file_info', $fileInfo);
710
711        $this->registerValidationAssets();
712        if (class_exists(LoadViewer::class)) {
713            $this->eventDispatcher->dispatchTyped(new LoadViewer());
714        }
715        $response = new TemplateResponse(Application::APP_ID, 'external', [], TemplateResponse::RENDER_AS_BASE);
716
717        return $response;
718    }
719
720    /**
721     * Register the same scripts and styles on all public validation routes.
722     */
723    private function registerValidationAssets(): void {
724        Util::addScript(Application::APP_ID, 'libresign-validation');
725        Util::addStyle(Application::APP_ID, 'libresign-validation');
726    }
727
728    /**
729     * Provide a lightweight placeholder for file_info initial state.
730     *
731     * The validation page SPA fetches fresh data via the validate API on mount,
732     * so the heavy server-side preload is redundant. This method provides only
733     * the UUID so the frontend can issue its API call with the correct identifier.
734     */
735    private function provideDeferredValidationFileInfo(string $uuid): void {
736        $this->initialState->provideInitialState('file_info', [
737            'uuid' => $uuid,
738            'name' => '',
739            'files' => [],
740            'filesCount' => 0,
741            'signers' => [],
742            'messages' => [],
743        ]);
744    }
745}