Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.96% covered (success)
97.96%
48 / 49
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileProgressController
97.96% covered (success)
97.96%
48 / 49
75.00% covered (warning)
75.00%
3 / 4
17
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
 checkProgressByUuid
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 buildStatusResponse
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
9
 hasErrorsInFiles
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 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\File as FileEntity;
13use OCA\Libresign\Db\FileMapper;
14use OCA\Libresign\Db\SignRequest as SignRequestEntity;
15use OCA\Libresign\Db\SignRequestMapper;
16use OCA\Libresign\Enum\FileStatus;
17use OCA\Libresign\Middleware\Attribute\RequireSignerUuid;
18use OCA\Libresign\Service\FileService;
19use OCA\Libresign\Service\SessionService;
20use OCA\Libresign\Service\SignRequest\ProgressService;
21use OCA\Libresign\Service\Worker\WorkerActivationService;
22use OCP\AppFramework\Http;
23use OCP\AppFramework\Http\Attribute\ApiRoute;
24use OCP\AppFramework\Http\Attribute\NoAdminRequired;
25use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
26use OCP\AppFramework\Http\Attribute\PublicPage;
27use OCP\AppFramework\Http\DataResponse;
28use OCP\IRequest;
29use OCP\IUserSession;
30
31/**
32 * @psalm-import-type LibresignValidatedFile from \OCA\Libresign\ResponseDefinitions
33 * @psalm-import-type LibresignMessageResponse from \OCA\Libresign\ResponseDefinitions
34 * @psalm-import-type LibresignProgressPayload from \OCA\Libresign\ResponseDefinitions
35 * @psalm-import-type LibresignProgressError from \OCA\Libresign\ResponseDefinitions
36 * @psalm-import-type LibresignProgressResponse from \OCA\Libresign\ResponseDefinitions
37 * @psalm-import-type LibresignProgressFile from \OCA\Libresign\ResponseDefinitions
38 * @psalm-import-type LibresignStatusMessageResponse from \OCA\Libresign\ResponseDefinitions
39 */
40class FileProgressController extends AEnvironmentAwareController {
41    public function __construct(
42        IRequest $request,
43        private FileMapper $fileMapper,
44        private SignRequestMapper $signRequestMapper,
45        private FileService $fileService,
46        private SessionService $sessionService,
47        private IUserSession $userSession,
48        private WorkerActivationService $workerActivationService,
49        private ProgressService $progressService,
50    ) {
51        parent::__construct(Application::APP_ID, $request);
52    }
53
54    /**
55     * Check file progress by sign request UUID with long-polling (similar to Talk)
56     *
57     * Waits up to 30 seconds for status change using cache for efficiency.
58     * Returns progress for the specific sign request, not the global file status.
59     *
60     * @param string $uuid Sign request UUID
61     * @param int $timeout Maximum seconds to wait (default 30)
62     * @return DataResponse<Http::STATUS_OK, LibresignProgressResponse, array{}>|DataResponse<Http::STATUS_NOT_FOUND, LibresignStatusMessageResponse, array{}>
63     *
64     * 200: Status and progress returned
65     * 404: Sign request not found
66     */
67    #[NoAdminRequired]
68    #[NoCSRFRequired]
69    #[RequireSignerUuid]
70    #[PublicPage]
71    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/file/progress/{uuid}', requirements: ['apiVersion' => '(v1)'])]
72    public function checkProgressByUuid(string $uuid, int $timeout = 30): DataResponse {
73        $timeout = max(1, min($timeout, 30));
74        try {
75            $signRequest = $this->signRequestMapper->getByUuid($uuid);
76            $file = $this->fileMapper->getById($signRequest->getFileId());
77            $currentStatus = $this->progressService->getStatusCodeForSignRequest($file, $signRequest);
78
79            if ($timeout > 0) {
80                if ($file->getStatus() === FileStatus::SIGNING_IN_PROGRESS->value) {
81                    $this->workerActivationService->ensureWorkerRunning();
82                }
83                $currentStatus = $this->progressService->pollForStatusOrErrorChange($file, $signRequest, $currentStatus, $timeout);
84            }
85
86            return $this->buildStatusResponse($file, $signRequest, $currentStatus);
87        } catch (\Exception $e) {
88            return new DataResponse([
89                'message' => $e->getMessage(),
90                'status' => 'ERROR',
91            ], Http::STATUS_NOT_FOUND);
92        }
93    }
94
95    /**
96     * Build HTTP response with status and progress information
97     *
98     * @param FileEntity $file The file entity
99     * @param SignRequestEntity $signRequest The sign request entity
100     * @param int $status Current status code
101     * @return DataResponse<Http::STATUS_OK, LibresignProgressResponse, array{}>
102     * @psalm-return DataResponse<Http::STATUS_OK, LibresignProgressResponse, array{}>
103     */
104    private function buildStatusResponse(FileEntity $file, SignRequestEntity $signRequest, int $status): DataResponse {
105        $statusEnum = FileStatus::tryFrom($status);
106        /** @psalm-var LibresignProgressPayload $progress */
107        $progress = $this->progressService->getSignRequestProgress($file, $signRequest);
108        /** @psalm-var LibresignProgressError|null $error */
109        $error = $this->progressService->getSignRequestError($signRequest->getUuid());
110
111        $hasFileErrors = !empty($progress['files']) && $this->hasErrorsInFiles($progress['files']);
112
113        /** @psalm-var LibresignProgressResponse $response */
114        $response = [
115            'status' => $statusEnum?->name ?? 'UNKNOWN',
116            'statusCode' => $status,
117            'statusText' => $this->fileMapper->getTextOfStatus($status),
118            'fileId' => $file->getId(),
119            'progress' => $progress,
120        ];
121
122        if ($error && !$hasFileErrors) {
123            $response['status'] = 'ERROR';
124            if (!empty($error['message'])) {
125                $response['statusText'] = (string)$error['message'];
126            }
127            $response['error'] = $error;
128        }
129
130        $hasAnyError = $error || $hasFileErrors || ($progress['errors'] ?? 0) > 0;
131        if (!$hasAnyError && $this->progressService->isProgressComplete($progress)) {
132            $response['file'] = $this->fileService
133                ->setFile($file)
134                ->setIdentifyMethodId($this->sessionService->getIdentifyMethodId())
135                ->setHost($this->request->getServerHost())
136                ->setMe($this->userSession->getUser())
137                ->showVisibleElements()
138                ->showSigners()
139                ->showSettings()
140                ->showMessages()
141                ->showValidateFile()
142                ->toArray();
143        }
144
145        return new DataResponse($response, Http::STATUS_OK);
146    }
147
148    /**
149     * @param list<LibresignProgressFile> $files
150     */
151    private function hasErrorsInFiles(array $files): bool {
152        foreach ($files as $file) {
153            if (!empty($file['error'])) {
154                return true;
155            }
156        }
157        return false;
158    }
159}