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

LibreSign / libresign / 20907588007

12 Jan 2026 03:57AM UTC coverage: 43.867%. First build
20907588007

Pull #6436

github

web-flow
Merge 9c5490a63 into 8fe916f99
Pull Request #6436: feat: async parallel signing

242 of 775 new or added lines in 26 files covered. (31.23%)

6920 of 15775 relevant lines covered (43.87%)

4.86 hits per line

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

14.72
/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
                                // Set file status to SIGNING_IN_PROGRESS before enqueuing
NEW
154
                                $libreSignFile->setStatusEnum(FileStatus::SIGNING_IN_PROGRESS);
×
NEW
155
                                $metadata = $libreSignFile->getMetadata() ?? [];
×
NEW
156
                                $metadata['status_changed_at'] = (new \DateTime())->format(\DateTimeInterface::ATOM);
×
NEW
157
                                $libreSignFile->setMetadata($metadata);
×
NEW
158
                                $this->fileMapper->update($libreSignFile);
×
159

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

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

184
                                // Start worker after enqueue (order: enqueue then ensure worker)
NEW
185
                                $this->workerHealthService->ensureWorkerRunning();
×
186

187

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

NEW
202
                        $this->signFileService
×
203
                                ->setVisibleElements($elements)
×
NEW
204
                                ->storeUserMetadata($metadata)
×
205
                                ->sign();
×
206

207

208
                        $validationUuid = $libreSignFile->getUuid();
×
209
                        if ($libreSignFile->hasParent()) {
×
210
                                $parentFile = $this->signFileService->getFile($libreSignFile->getParentFileId());
×
211
                                $validationUuid = $parentFile->getUuid();
×
212
                        }
213

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

271
        /**
272
         * Renew the signature method
273
         *
274
         * @param string $method Signature method
275
         * @return DataResponse<Http::STATUS_OK, array{message: string}, array{}>
276
         *
277
         * 200: OK
278
         */
279
        #[NoAdminRequired]
280
        #[NoCSRFRequired]
281
        #[PublicPage]
282
        #[CanSignRequestUuid]
283
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}', requirements: ['apiVersion' => '(v1)'])]
284
        public function signRenew(string $method): DataResponse {
285
                $this->signFileService->renew(
×
286
                        $this->getSignRequestEntity(),
×
287
                        $method,
×
288
                );
×
289
                return new DataResponse(
×
290
                        [
×
291
                                // 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.
292
                                'message' => $this->l10n->t('Renewed with success. Access the link again.'),
×
293
                        ]
×
294
                );
×
295
        }
296

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

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

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

377
        /**
378
         * Collect request metadata used both for immediate persistence and async job payload.
379
         *
380
         * @return array{user-agent: string|null, remote-address: string|null}
381
         */
382
        private function collectRequestMetadata(): array {
NEW
383
                return [
×
NEW
384
                        'user-agent' => $this->request->getHeader('User-Agent'),
×
NEW
385
                        'remote-address' => $this->request->getRemoteAddress(),
×
NEW
386
                ];
×
387
        }
388
}
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