• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LibreSign / libresign / 21006909149

14 Jan 2026 07:21PM UTC coverage: 43.837%. First build
21006909149

Pull #6436

github

web-flow
Merge 1b659c916 into 9bd4c65c5
Pull Request #6436: feat: async parallel signing

294 of 825 new or added lines in 35 files covered. (35.64%)

6921 of 15788 relevant lines covered (43.84%)

4.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

14.63
/lib/Controller/SignFileController.php
1
<?php
2

3
declare(strict_types=1);
4
/**
5
 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6
 * SPDX-License-Identifier: AGPL-3.0-or-later
7
 */
8

9
namespace OCA\Libresign\Controller;
10

11
use OCA\Libresign\AppInfo\Application;
12
use OCA\Libresign\BackgroundJob\SignFileJob;
13
use OCA\Libresign\Db\FileMapper;
14
use OCA\Libresign\Db\SignRequest;
15
use OCA\Libresign\Db\SignRequestMapper;
16
use OCA\Libresign\Enum\FileStatus;
17
use OCA\Libresign\Exception\LibresignException;
18
use OCA\Libresign\Helper\JSActions;
19
use OCA\Libresign\Helper\ValidateHelper;
20
use OCA\Libresign\Middleware\Attribute\CanSignRequestUuid;
21
use OCA\Libresign\Middleware\Attribute\RequireManager;
22
use OCA\Libresign\Middleware\Attribute\RequireSigner;
23
use OCA\Libresign\Service\FileService;
24
use OCA\Libresign\Service\IdentifyMethodService;
25
use OCA\Libresign\Service\SignFileService;
26
use OCA\Libresign\Service\WorkerHealthService;
27
use OCP\AppFramework\Http;
28
use OCP\AppFramework\Http\Attribute\ApiRoute;
29
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
30
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
31
use OCP\AppFramework\Http\Attribute\PublicPage;
32
use OCP\AppFramework\Http\DataResponse;
33
use OCP\BackgroundJob\IJobList;
34
use OCP\IAppConfig;
35
use OCP\IL10N;
36
use OCP\IRequest;
37
use OCP\IUserSession;
38
use OCP\Security\ICredentialsManager;
39
use OCP\Security\ISecureRandom;
40
use Psr\Log\LoggerInterface;
41

42
class SignFileController extends AEnvironmentAwareController implements ISignatureUuid {
43
        use LibresignTrait;
44
        public function __construct(
45
                IRequest $request,
46
                protected IL10N $l10n,
47
                private SignRequestMapper $signRequestMapper,
48
                private FileMapper $fileMapper,
49
                protected IUserSession $userSession,
50
                private ValidateHelper $validateHelper,
51
                protected SignFileService $signFileService,
52
                private IdentifyMethodService $identifyMethodService,
53
                private FileService $fileService,
54
                private IJobList $jobList,
55
                private WorkerHealthService $workerHealthService,
56
                private IAppConfig $appConfig,
57
                private ICredentialsManager $credentialsManager,
58
                private ISecureRandom $secureRandom,
59
                protected LoggerInterface $logger,
60
        ) {
61
                parent::__construct(Application::APP_ID, $request);
6✔
62
        }
63

64
        /**
65
         * Sign a file using file Id
66
         *
67
         * @param int $fileId Id of LibreSign file
68
         * @param string $method Signature method
69
         * @param array<string, mixed> $elements List of visible elements
70
         * @param string $identifyValue Identify value
71
         * @param string $token Token, commonly send by email
72
         * @return DataResponse<Http::STATUS_OK, array{action: integer, message?: string, file?: array{uuid: string}, job?: array{status: 'SIGNING_IN_PROGRESS', file: array{uuid: string}}}, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, array{action: integer, errors: list<array{message: string, title?: string}>, redirect?: string}, array{}>
73
         *
74
         * 200: OK
75
         * 404: Invalid data
76
         * 422: Error
77
         */
78
        #[NoAdminRequired]
79
        #[NoCSRFRequired]
80
        #[RequireManager]
81
        #[PublicPage]
82
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/file_id/{fileId}', requirements: ['apiVersion' => '(v1)'])]
83
        public function signUsingFileId(int $fileId, string $method, array $elements = [], string $identifyValue = '', string $token = ''): DataResponse {
84
                return $this->sign($method, $elements, $identifyValue, $token, $fileId, null);
1✔
85
        }
86

87
        /**
88
         * Sign a file using file UUID
89
         *
90
         * @param string $uuid UUID of LibreSign file
91
         * @param string $method Signature method
92
         * @param array<string, mixed> $elements List of visible elements
93
         * @param string $identifyValue Identify value
94
         * @param string $token Token, commonly send by email
95
         * @return DataResponse<Http::STATUS_OK, array{action: integer, message?: string, file?: array{uuid: string}, job?: array{status: 'SIGNING_IN_PROGRESS', file: array{uuid: string}}}, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, array{action: integer, errors: list<array{message: string, title?: string}>, redirect?: string}, array{}>
96
         *
97
         * 200: OK
98
         * 404: Invalid data
99
         * 422: Error
100
         */
101
        #[NoAdminRequired]
102
        #[NoCSRFRequired]
103
        #[RequireSigner]
104
        #[PublicPage]
105
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/uuid/{uuid}', requirements: ['apiVersion' => '(v1)'])]
106
        public function signUsingUuid(string $uuid, string $method, array $elements = [], string $identifyValue = '', string $token = ''): DataResponse {
107
                return $this->sign($method, $elements, $identifyValue, $token, null, $uuid);
2✔
108
        }
109

110
        /**
111
         * @return DataResponse<Http::STATUS_OK, array{action: integer, message?: string, file?: array{uuid: string}, job?: array{status: 'SIGNING_IN_PROGRESS', file: array{uuid: string}}}, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, array{action: integer, errors: list<array{message: string, title?: string}>, redirect?: string}, array{}
112
         */
113
        public function sign(
114
                string $method,
115
                array $elements = [],
116
                string $identifyValue = '',
117
                string $token = '',
118
                ?int $fileId = null,
119
                ?string $signRequestUuid = null,
120
        ): DataResponse {
121
                try {
122
                        $user = $this->userSession->getUser();
3✔
123
                        $this->validateHelper->canSignWithIdentificationDocumentStatus(
3✔
124
                                $user,
3✔
125
                                $this->fileService->getIdentificationDocumentsStatus($user?->getUID() ?? '')
3✔
126
                        );
3✔
127
                        $libreSignFile = $this->signFileService->getLibresignFile($fileId, $signRequestUuid);
3✔
128
                        $signRequest = $this->signFileService->getSignRequestToSign($libreSignFile, $signRequestUuid, $user);
2✔
129
                        $this->validateHelper->validateVisibleElementsRelation($elements, $signRequest, $user);
2✔
130
                        $this->validateHelper->validateCredentials($signRequest, $method, $identifyValue, $token);
2✔
131
                        if ($method === 'password') {
×
132
                                $this->signFileService->setPassword($token);
×
133
                        } else {
134
                                $this->signFileService->setSignWithoutPassword();
×
135
                        }
136
                        $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($signRequest->getId());
×
NEW
137
                        $userIdentifier = $identifyMethod->getEntity()->getIdentifierKey()
×
NEW
138
                                . ':'
×
NEW
139
                                . $identifyMethod->getEntity()->getIdentifierValue();
×
140

NEW
141
                        $metadata = $this->collectRequestMetadata();
×
142

143
                        $this->signFileService
×
144
                                ->setLibreSignFile($libreSignFile)
×
145
                                ->setSignRequest($signRequest)
×
146
                                ->setCurrentUser($user)
×
NEW
147
                                ->setUserUniqueIdentifier($userIdentifier)
×
NEW
148
                                ->setFriendlyName($signRequest->getDisplayName());
×
149

NEW
150
                        $asyncEnabled = $this->workerHealthService->isAsyncLocalEnabled();
×
151

NEW
152
                        if ($asyncEnabled) {
×
153
                                // Validate signing requirements before enqueueing
154
                                // This ensures early error detection for configuration issues (e.g., invalid TSA)
NEW
155
                                $this->signFileService->validateSigningRequirements();
×
156

157
                                // Set file status to SIGNING_IN_PROGRESS before enqueuing
NEW
158
                                $libreSignFile->setStatusEnum(FileStatus::SIGNING_IN_PROGRESS);
×
NEW
159
                                $metadata = $libreSignFile->getMetadata() ?? [];
×
NEW
160
                                $metadata['status_changed_at'] = (new \DateTime())->format(\DateTimeInterface::ATOM);
×
NEW
161
                                $libreSignFile->setMetadata($metadata);
×
NEW
162
                                $this->fileMapper->update($libreSignFile);
×
163

164
                                // Store credentials securely using ICredentialsManager
NEW
165
                                $credentialsId = 'libresign_sign_' . $signRequest->getId() . '_' . $this->secureRandom->generate(16, ISecureRandom::CHAR_ALPHANUMERIC);
×
NEW
166
                                $this->credentialsManager->store(
×
NEW
167
                                        $user?->getUID() ?? '',
×
NEW
168
                                        $credentialsId,
×
NEW
169
                                        [
×
NEW
170
                                                'signWithoutPassword' => $method !== 'password',
×
NEW
171
                                                'password' => $method === 'password' ? $token : null,
×
NEW
172
                                                'timestamp' => time(),
×
NEW
173
                                        ]
×
NEW
174
                                );
×
175

176
                                // Worker available, enqueue the job with credential ID instead of plain text password
NEW
177
                                $this->jobList->add(SignFileJob::class, [
×
NEW
178
                                        'fileId' => $libreSignFile->getId(),
×
NEW
179
                                        'signRequestId' => $signRequest->getId(),
×
NEW
180
                                        'userId' => $user?->getUID(),
×
NEW
181
                                        'credentialsId' => $credentialsId,  // Secure: only ID, not password
×
NEW
182
                                        'userUniqueIdentifier' => $userIdentifier,
×
NEW
183
                                        'friendlyName' => $signRequest->getDisplayName(),
×
NEW
184
                                        'visibleElements' => $elements,
×
NEW
185
                                        'metadata' => $metadata,
×
NEW
186
                                ]);
×
187

188
                                // Start worker after enqueue (order: enqueue then ensure worker)
NEW
189
                                $this->workerHealthService->ensureWorkerRunning();
×
190

191

NEW
192
                                return new DataResponse(
×
NEW
193
                                        [
×
NEW
194
                                                'action' => JSActions::ACTION_DO_NOTHING,
×
NEW
195
                                                'job' => [
×
NEW
196
                                                        'status' => 'SIGNING_IN_PROGRESS',
×
NEW
197
                                                        'file' => [
×
NEW
198
                                                                'uuid' => $libreSignFile->getUuid(),
×
NEW
199
                                                        ],
×
NEW
200
                                                ],
×
NEW
201
                                        ],
×
NEW
202
                                        Http::STATUS_OK
×
NEW
203
                                );
×
204
                        }
205

NEW
206
                        $this->signFileService
×
207
                                ->setVisibleElements($elements)
×
NEW
208
                                ->storeUserMetadata($metadata)
×
209
                                ->sign();
×
210

211

212
                        $validationUuid = $libreSignFile->getUuid();
×
213
                        if ($libreSignFile->hasParent()) {
×
214
                                $parentFile = $this->signFileService->getFile($libreSignFile->getParentFileId());
×
215
                                $validationUuid = $parentFile->getUuid();
×
216
                        }
217

218
                        return new DataResponse(
×
219
                                [
×
220
                                        'action' => JSActions::ACTION_SIGNED,
×
221
                                        'message' => $this->l10n->t('File signed'),
×
222
                                        'file' => [
×
223
                                                'uuid' => $validationUuid
×
224
                                        ]
×
225
                                ],
×
226
                                Http::STATUS_OK
×
227
                        );
×
228
                } catch (LibresignException $e) {
3✔
229
                        $code = $e->getCode();
3✔
230
                        if ($code === 400) {
3✔
231
                                $action = JSActions::ACTION_CREATE_SIGNATURE_PASSWORD;
×
232
                        } else {
233
                                $action = JSActions::ACTION_DO_NOTHING;
3✔
234
                        }
235
                        $data = [
3✔
236
                                'action' => $action,
3✔
237
                                'errors' => [['message' => $e->getMessage()]],
3✔
238
                        ];
3✔
239
                } catch (\Throwable $th) {
×
240
                        $message = $th->getMessage();
×
241
                        $data = [
×
242
                                'action' => JSActions::ACTION_DO_NOTHING,
×
243
                        ];
×
244
                        switch ($message) {
245
                                case 'Host violates local access rules.':
×
246
                                case 'Certificate Password Invalid.':
×
247
                                case 'Certificate Password is Empty.':
×
248
                                        $data['errors'] = [['message' => $this->l10n->t($message)]];
×
249
                                        break;
×
250
                                default:
251
                                        $this->logger->error($message, ['exception' => $th]);
×
252
                                        $data['errors'] = [[
×
253
                                                'message'
×
254
                                                        => sprintf(
×
255
                                                                "The server was unable to complete your request.\n"
×
256
                                                                . "If this happens again, please send the technical details below to the server administrator.\n"
×
257
                                                                . "## Technical details:\n"
×
258
                                                                . "**Remote Address**: %s\n"
×
259
                                                                . "**Request ID**: %s\n"
×
260
                                                                . '**Message**: %s',
×
261
                                                                $this->request->getRemoteAddress(),
×
262
                                                                $this->request->getId(),
×
263
                                                                $message,
×
264
                                                        ),
×
265
                                                'title' => $this->l10n->t('Internal Server Error'),
×
266
                                        ]];
×
267
                        }
268
                }
269
                return new DataResponse(
3✔
270
                        $data,
3✔
271
                        Http::STATUS_UNPROCESSABLE_ENTITY
3✔
272
                );
3✔
273
        }
274

275
        /**
276
         * Renew the signature method
277
         *
278
         * @param string $method Signature method
279
         * @return DataResponse<Http::STATUS_OK, array{message: string}, array{}>
280
         *
281
         * 200: OK
282
         */
283
        #[NoAdminRequired]
284
        #[NoCSRFRequired]
285
        #[PublicPage]
286
        #[CanSignRequestUuid]
287
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}', requirements: ['apiVersion' => '(v1)'])]
288
        public function signRenew(string $method): DataResponse {
289
                $this->signFileService->renew(
×
290
                        $this->getSignRequestEntity(),
×
291
                        $method,
×
292
                );
×
293
                return new DataResponse(
×
294
                        [
×
295
                                // TRANSLATORS Message sent to signer when the sign link was expired and was possible to request to renew. The signer will see this message on the screen and nothing more.
296
                                'message' => $this->l10n->t('Renewed with success. Access the link again.'),
×
297
                        ]
×
298
                );
×
299
        }
300

301
        /**
302
         * Get code to sign the document using UUID
303
         *
304
         * @param string $uuid UUID of LibreSign file
305
         * @param 'account'|'email'|null $identifyMethod Identify signer method
306
         * @param string|null $signMethod Method used to sign the document, i.e. emailToken, account, clickToSign, sms, signal, telegram, whatsapp, xmpp
307
         * @param string|null $identify Identify value, i.e. the signer email, account or phone number
308
         * @return DataResponse<Http::STATUS_OK, array{message: string}, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, array{message: string}, array{}>
309
         *
310
         * 200: OK
311
         * 422: Error
312
         */
313
        #[NoAdminRequired]
314
        #[NoCSRFRequired]
315
        #[RequireSigner]
316
        #[PublicPage]
317
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/uuid/{uuid}/code', requirements: ['apiVersion' => '(v1)'])]
318
        public function getCodeUsingUuid(string $uuid, ?string $identifyMethod, ?string $signMethod, ?string $identify): DataResponse {
319
                try {
320
                        $signRequest = $this->signRequestMapper->getBySignerUuidAndUserId($uuid);
×
321
                } catch (\Throwable) {
×
322
                        throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
×
323
                }
324
                return $this->getCode($signRequest);
×
325
        }
326

327
        /**
328
         * Get code to sign the document using FileID
329
         *
330
         * @param int $fileId Id of LibreSign file
331
         * @param 'account'|'email'|null $identifyMethod Identify signer method
332
         * @param string|null $signMethod Method used to sign the document, i.e. emailToken, account, clickToSign, sms, signal, telegram, whatsapp, xmpp
333
         * @param string|null $identify Identify value, i.e. the signer email, account or phone number
334
         * @return DataResponse<Http::STATUS_OK, array{message: string}, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, array{message: string}, array{}>
335
         *
336
         * 200: OK
337
         * 422: Error
338
         */
339
        #[NoAdminRequired]
340
        #[NoCSRFRequired]
341
        #[RequireSigner]
342
        #[PublicPage]
343
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/file_id/{fileId}/code', requirements: ['apiVersion' => '(v1)'])]
344
        public function getCodeUsingFileId(int $fileId, ?string $identifyMethod, ?string $signMethod, ?string $identify): DataResponse {
345
                try {
346
                        $signRequest = $this->signRequestMapper->getByFileIdAndUserId($fileId);
×
347
                } catch (\Throwable) {
×
348
                        throw new LibresignException($this->l10n->t('Invalid data to sign file'), 1);
×
349
                }
350
                return $this->getCode($signRequest);
×
351
        }
352

353
        /**
354
         * @todo validate if can request code
355
         * @return DataResponse<Http::STATUS_OK|Http::STATUS_UNPROCESSABLE_ENTITY, array{message: string}, array{}>
356
         */
357
        private function getCode(SignRequest $signRequest): DataResponse {
358
                try {
359
                        $libreSignFile = $this->fileMapper->getById($signRequest->getFileId());
×
360
                        $this->validateHelper->fileCanBeSigned($libreSignFile);
×
361
                        $this->signFileService->requestCode(
×
362
                                signRequest: $signRequest,
×
363
                                identifyMethodName: $this->request->getParam('identifyMethod', ''),
×
364
                                signMethodName: $this->request->getParam('signMethod', ''),
×
365
                                identify: $this->request->getParam('identify', ''),
×
366
                        );
×
367
                        $message = $this->l10n->t('The code to sign file was successfully requested.');
×
368
                        $statusCode = Http::STATUS_OK;
×
369
                } catch (\Throwable $th) {
×
370
                        $message = $th->getMessage();
×
371
                        $statusCode = Http::STATUS_UNPROCESSABLE_ENTITY;
×
372
                }
373
                return new DataResponse(
×
374
                        [
×
375
                                'message' => $message,
×
376
                        ],
×
377
                        $statusCode,
×
378
                );
×
379
        }
380

381
        /**
382
         * Collect request metadata used both for immediate persistence and async job payload.
383
         *
384
         * @return array{user-agent: string|null, remote-address: string|null}
385
         */
386
        private function collectRequestMetadata(): array {
NEW
387
                return [
×
NEW
388
                        'user-agent' => $this->request->getHeader('User-Agent'),
×
NEW
389
                        'remote-address' => $this->request->getRemoteAddress(),
×
NEW
390
                ];
×
391
        }
392
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc