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

LibreSign / libresign / 20082918971

10 Dec 2025 12:22AM UTC coverage: 43.855%. First build
20082918971

Pull #6069

github

web-flow
Merge 3dbe84f48 into c646f673a
Pull Request #6069: feat: sequential signing

39 of 116 new or added lines in 7 files covered. (33.62%)

5727 of 13059 relevant lines covered (43.85%)

5.11 hits per line

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

13.77
/lib/Controller/AdminController.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 DateTimeInterface;
12
use OCA\Libresign\AppInfo\Application;
13
use OCA\Libresign\Enum\DocMdpLevel;
14
use OCA\Libresign\Exception\LibresignException;
15
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
16
use OCA\Libresign\Handler\CertificateEngine\IEngineHandler;
17
use OCA\Libresign\Helper\ConfigureCheckHelper;
18
use OCA\Libresign\ResponseDefinitions;
19
use OCA\Libresign\Service\Certificate\ValidateService;
20
use OCA\Libresign\Service\CertificatePolicyService;
21
use OCA\Libresign\Service\DocMdpConfigService;
22
use OCA\Libresign\Service\FooterService;
23
use OCA\Libresign\Service\IdentifyMethodService;
24
use OCA\Libresign\Service\Install\ConfigureCheckService;
25
use OCA\Libresign\Service\Install\InstallService;
26
use OCA\Libresign\Service\ReminderService;
27
use OCA\Libresign\Service\SignatureBackgroundService;
28
use OCA\Libresign\Service\SignatureTextService;
29
use OCA\Libresign\Settings\Admin;
30
use OCP\AppFramework\Http;
31
use OCP\AppFramework\Http\Attribute\ApiRoute;
32
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
33
use OCP\AppFramework\Http\ContentSecurityPolicy;
34
use OCP\AppFramework\Http\DataDownloadResponse;
35
use OCP\AppFramework\Http\DataResponse;
36
use OCP\AppFramework\Http\FileDisplayResponse;
37
use OCP\Files\SimpleFS\InMemoryFile;
38
use OCP\IAppConfig;
39
use OCP\IEventSource;
40
use OCP\IEventSourceFactory;
41
use OCP\IL10N;
42
use OCP\IRequest;
43
use OCP\ISession;
44
use UnexpectedValueException;
45

46
/**
47
 * @psalm-import-type LibresignEngineHandler from ResponseDefinitions
48
 * @psalm-import-type LibresignCetificateDataGenerated from ResponseDefinitions
49
 * @psalm-import-type LibresignConfigureCheck from ResponseDefinitions
50
 * @psalm-import-type LibresignRootCertificate from ResponseDefinitions
51
 * @psalm-import-type LibresignReminderSettings from ResponseDefinitions
52
 */
53
class AdminController extends AEnvironmentAwareController {
54
        private IEventSource $eventSource;
55
        public function __construct(
56
                IRequest $request,
57
                private IAppConfig $appConfig,
58
                private ConfigureCheckService $configureCheckService,
59
                private InstallService $installService,
60
                private CertificateEngineFactory $certificateEngineFactory,
61
                private IEventSourceFactory $eventSourceFactory,
62
                private SignatureTextService $signatureTextService,
63
                private IL10N $l10n,
64
                protected ISession $session,
65
                private SignatureBackgroundService $signatureBackgroundService,
66
                private CertificatePolicyService $certificatePolicyService,
67
                private ValidateService $validateService,
68
                private ReminderService $reminderService,
69
                private FooterService $footerService,
70
                private DocMdpConfigService $docMdpConfigService,
71
                private IdentifyMethodService $identifyMethodService,
72
        ) {
73
                parent::__construct(Application::APP_ID, $request);
5✔
74
                $this->eventSource = $this->eventSourceFactory->create();
5✔
75
        }
76

77
        /**
78
         * Generate certificate using CFSSL engine
79
         *
80
         * @param array{commonName: string, names: array<string, array{value:string|array<string>}>} $rootCert fields of root certificate
81
         * @param string $cfsslUri URI of CFSSL API
82
         * @param string $configPath Path of config files of CFSSL
83
         * @return DataResponse<Http::STATUS_OK, array{data: LibresignEngineHandler}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
84
         *
85
         * 200: OK
86
         * 401: Account not found
87
         */
88
        #[NoCSRFRequired]
89
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate/cfssl', requirements: ['apiVersion' => '(v1)'])]
90
        public function generateCertificateCfssl(
91
                array $rootCert,
92
                string $cfsslUri = '',
93
                string $configPath = '',
94
        ): DataResponse {
95
                try {
96
                        $engineHandler = $this->generateCertificate($rootCert, [
×
97
                                'engine' => 'cfssl',
×
98
                                'configPath' => trim($configPath),
×
99
                                'cfsslUri' => trim($cfsslUri),
×
100
                        ])->toArray();
×
101
                        return new DataResponse([
×
102
                                'data' => $engineHandler,
×
103
                        ]);
×
104
                } catch (\Exception $exception) {
×
105
                        return new DataResponse(
×
106
                                [
×
107
                                        'message' => $exception->getMessage()
×
108
                                ],
×
109
                                Http::STATUS_UNAUTHORIZED
×
110
                        );
×
111
                }
112
        }
113

114
        /**
115
         * Generate certificate using OpenSSL engine
116
         *
117
         * @param array{commonName: string, names: array<string, array{value:string|array<string>}>} $rootCert fields of root certificate
118
         * @param string $configPath Path of config files of CFSSL
119
         * @return DataResponse<Http::STATUS_OK, array{data: LibresignEngineHandler}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
120
         *
121
         * 200: OK
122
         * 401: Account not found
123
         */
124
        #[NoCSRFRequired]
125
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate/openssl', requirements: ['apiVersion' => '(v1)'])]
126
        public function generateCertificateOpenSsl(
127
                array $rootCert,
128
                string $configPath = '',
129
        ): DataResponse {
130
                try {
131
                        $engineHandler = $this->generateCertificate($rootCert, [
1✔
132
                                'engine' => 'openssl',
1✔
133
                                'configPath' => trim($configPath),
1✔
134
                        ])->toArray();
1✔
135
                        return new DataResponse([
×
136
                                'data' => $engineHandler,
×
137
                        ]);
×
138
                } catch (\Exception $exception) {
1✔
139
                        return new DataResponse(
1✔
140
                                [
1✔
141
                                        'message' => $exception->getMessage()
1✔
142
                                ],
1✔
143
                                Http::STATUS_UNAUTHORIZED
1✔
144
                        );
1✔
145
                }
146
        }
147

148
        /**
149
         * Set certificate engine
150
         *
151
         * Sets the certificate engine (openssl, cfssl, or none) and automatically configures identify_methods when needed
152
         *
153
         * @param string $engine The certificate engine to use (openssl, cfssl, or none)
154
         * @return DataResponse<Http::STATUS_OK, array{engine: string, identify_methods: array<array<string, mixed>>}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
155
         *
156
         * 200: OK
157
         * 400: Invalid engine
158
         */
159
        #[NoCSRFRequired]
160
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate/engine', requirements: ['apiVersion' => '(v1)'])]
161
        public function setCertificateEngine(string $engine): DataResponse {
162
                $validEngines = ['openssl', 'cfssl', 'none'];
×
163
                if (!in_array($engine, $validEngines, true)) {
×
164
                        return new DataResponse(
×
165
                                ['message' => 'Invalid engine. Must be one of: ' . implode(', ', $validEngines)],
×
166
                                Http::STATUS_BAD_REQUEST
×
167
                        );
×
168
                }
169

170
                $handler = $this->certificateEngineFactory->getEngine();
×
171
                $handler->setEngine($engine);
×
172
                $identifyMethods = $this->identifyMethodService->getIdentifyMethodsSettings();
×
173

174
                return new DataResponse([
×
175
                        'engine' => $engine,
×
176
                        'identify_methods' => $identifyMethods,
×
177
                ]);
×
178
        }
179

180
        private function generateCertificate(
181
                array $rootCert,
182
                array $properties = [],
183
        ): IEngineHandler {
184
                $names = [];
1✔
185
                if (isset($rootCert['names'])) {
1✔
186
                        $this->validateService->validateNames($rootCert['names']);
1✔
187
                        foreach ($rootCert['names'] as $item) {
×
188
                                if (is_array($item['value'])) {
×
189
                                        $trimmedValues = array_map('trim', $item['value']);
×
190
                                        $names[$item['id']]['value'] = array_filter($trimmedValues, fn ($val) => $val !== '');
×
191
                                } else {
192
                                        $names[$item['id']]['value'] = trim((string)$item['value']);
×
193
                                }
194
                        }
195
                }
196
                $this->validateService->validate('CN', $rootCert['commonName']);
×
197
                $this->installService->generate(
×
198
                        trim((string)$rootCert['commonName']),
×
199
                        $properties['engine'],
×
200
                        $names,
×
201
                        $properties,
×
202
                );
×
203

204
                return $this->certificateEngineFactory->getEngine();
×
205
        }
206

207
        /**
208
         * Load certificate data
209
         *
210
         * Return all data of root certificate and a field called `generated` with a boolean value.
211
         *
212
         * @return DataResponse<Http::STATUS_OK, LibresignCetificateDataGenerated, array{}>
213
         *
214
         * 200: OK
215
         */
216
        #[NoCSRFRequired]
217
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/certificate', requirements: ['apiVersion' => '(v1)'])]
218
        public function loadCertificate(): DataResponse {
219
                $engine = $this->certificateEngineFactory->getEngine();
1✔
220
                /** @var LibresignEngineHandler */
221
                $certificate = $engine->toArray();
1✔
222
                $configureResult = $engine->configureCheck();
1✔
223
                $success = array_filter(
1✔
224
                        $configureResult,
1✔
225
                        fn (ConfigureCheckHelper $config) => $config->getStatus() === 'success'
1✔
226
                );
1✔
227
                $certificate['generated'] = count($success) === count($configureResult);
1✔
228

229
                return new DataResponse($certificate);
1✔
230
        }
231

232
        /**
233
         * Check the configuration of LibreSign
234
         *
235
         * Return the status of necessary configuration and tips to fix the problems.
236
         *
237
         * @return DataResponse<Http::STATUS_OK, LibresignConfigureCheck[], array{}>
238
         *
239
         * 200: OK
240
         */
241
        #[NoCSRFRequired]
242
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/configure-check', requirements: ['apiVersion' => '(v1)'])]
243
        public function configureCheck(): DataResponse {
244
                /** @var LibresignConfigureCheck[] */
245
                $configureCheckList = $this->configureCheckService->checkAll();
×
246
                return new DataResponse(
×
247
                        $configureCheckList
×
248
                );
×
249
        }
250

251
        /**
252
         * Disable hate limit to current session
253
         *
254
         * This will disable hate limit to current session.
255
         *
256
         * @return DataResponse<Http::STATUS_OK, array<empty>, array{}>
257
         *
258
         * 200: OK
259
         */
260
        #[NoCSRFRequired]
261
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/disable-hate-limit', requirements: ['apiVersion' => '(v1)'])]
262
        public function disableHateLimit(): DataResponse {
263
                $this->session->set('app_api', true);
×
264

265
                // TODO: Remove after drop support NC29
266
                // deprecated since AppAPI 2.8.0
267
                $this->session->set('app_api_system', true);
×
268

269
                return new DataResponse();
×
270
        }
271

272
        /**
273
         * Set signature flow configuration
274
         *
275
         * @param string $flow Signature flow mode: 'parallel' or 'ordered_numeric'
276
         * @return DataResponse<Http::STATUS_OK, array{signature_flow: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
277
         *
278
         * 200: OK
279
         * 400: Invalid flow value
280
         */
281
        #[NoCSRFRequired]
282
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signature-flow', requirements: ['apiVersion' => '(v1)'])]
283
        public function setSignatureFlow(string $flow): DataResponse {
284
                try {
NEW
285
                        $signatureFlow = \OCA\Libresign\Service\SignatureFlow::from($flow);
×
NEW
286
                } catch (\ValueError) {
×
NEW
287
                        return new DataResponse(
×
NEW
288
                                ['message' => 'Invalid flow value. Must be "parallel" or "ordered_numeric"'],
×
NEW
289
                                Http::STATUS_BAD_REQUEST
×
NEW
290
                        );
×
291
                }
292

NEW
293
                if ($signatureFlow === \OCA\Libresign\Service\SignatureFlow::PARALLEL) {
×
NEW
294
                        $this->appConfig->deleteKey(Application::APP_ID, 'signature_flow');
×
295
                } else {
NEW
296
                        $this->appConfig->setValueString(
×
NEW
297
                                Application::APP_ID,
×
NEW
298
                                'signature_flow',
×
NEW
299
                                $signatureFlow->value
×
NEW
300
                        );
×
301
                }
302

NEW
303
                return new DataResponse(['signature_flow' => $signatureFlow->value]);
×
304
        }
305

306
        /**
307
         * @IgnoreOpenAPI
308
         */
309
        #[NoCSRFRequired]
310
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/install-and-validate', requirements: ['apiVersion' => '(v1)'])]
311
        public function installAndValidate(): void {
312
                try {
313
                        $async = \function_exists('proc_open');
×
314
                        $this->installService->installJava($async);
×
315
                        $this->installService->installJSignPdf($async);
×
316
                        $this->installService->installPdftk($async);
×
317
                        if ($this->appConfig->getValueString(Application::APP_ID, 'certificate_engine') === 'cfssl') {
×
318
                                $this->installService->installCfssl($async);
×
319
                        }
320

321
                        $this->configureCheckService->disableCache();
×
322
                        $this->eventSource->send('configure_check', $this->configureCheckService->checkAll());
×
323
                        $seconds = 0;
×
324
                        while ($this->installService->isDownloadWip()) {
×
325
                                $totalSize = $this->installService->getTotalSize();
×
326
                                $this->eventSource->send('total_size', json_encode($totalSize));
×
327
                                if ($errors = $this->installService->getErrorMessages()) {
×
328
                                        $this->eventSource->send('errors', json_encode($errors));
×
329
                                }
330
                                usleep(200000); // 0.2 seconds
×
331
                                $seconds += 0.2;
×
332
                                if ($seconds === 5.0) {
×
333
                                        $this->eventSource->send('configure_check', $this->configureCheckService->checkAll());
×
334
                                        $seconds = 0;
×
335
                                }
336
                        }
337
                        if ($errors = $this->installService->getErrorMessages()) {
×
338
                                $this->eventSource->send('errors', json_encode($errors));
×
339
                        }
340
                } catch (\Exception $exception) {
×
341
                        $this->eventSource->send('errors', json_encode([
×
342
                                $this->l10n->t('Could not download binaries.'),
×
343
                                $exception->getMessage(),
×
344
                        ]));
×
345
                }
346

347
                $this->eventSource->send('configure_check', $this->configureCheckService->checkAll());
×
348
                $this->eventSource->send('done', '');
×
349
                $this->eventSource->close();
×
350
                // Nextcloud inject a lot of headers that is incompatible with SSE
351
                exit();
×
352
        }
353

354
        /**
355
         * Add custom background image
356
         *
357
         * @return DataResponse<Http::STATUS_OK, array{status: 'success'}, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, array{status: 'failure', message: string}, array{}>
358
         *
359
         * 200: OK
360
         * 422: Error
361
         */
362
        #[NoCSRFRequired]
363
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signature-background', requirements: ['apiVersion' => '(v1)'])]
364
        public function signatureBackgroundSave(): DataResponse {
365
                $image = $this->request->getUploadedFile('image');
×
366
                $phpFileUploadErrors = [
×
367
                        UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'),
×
368
                        UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
×
369
                        UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
×
370
                        UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'),
×
371
                        UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'),
×
372
                        UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'),
×
373
                        UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'),
×
374
                        UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'),
×
375
                ];
×
376
                if (empty($image)) {
×
377
                        $error = $this->l10n->t('No file uploaded');
×
378
                } elseif (!empty($image) && array_key_exists('error', $image) && $image['error'] !== UPLOAD_ERR_OK) {
×
379
                        $error = $phpFileUploadErrors[$image['error']];
×
380
                }
381
                if ($error !== null) {
×
382
                        return new DataResponse(
×
383
                                [
×
384
                                        'message' => $error,
×
385
                                        'status' => 'failure',
×
386
                                ],
×
387
                                Http::STATUS_UNPROCESSABLE_ENTITY
×
388
                        );
×
389
                }
390
                try {
391
                        $this->signatureBackgroundService->updateImage($image['tmp_name']);
×
392
                } catch (\Exception $e) {
×
393
                        return new DataResponse(
×
394
                                [
×
395
                                        'message' => $e->getMessage(),
×
396
                                        'status' => 'failure',
×
397
                                ],
×
398
                                Http::STATUS_UNPROCESSABLE_ENTITY
×
399
                        );
×
400
                }
401

402
                return new DataResponse(
×
403
                        [
×
404
                                'status' => 'success',
×
405
                        ]
×
406
                );
×
407
        }
408

409
        /**
410
         * Get custom background image
411
         *
412
         * @return FileDisplayResponse<Http::STATUS_OK, array{}>
413
         *
414
         * 200: Image returned
415
         */
416
        #[NoCSRFRequired]
417
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/signature-background', requirements: ['apiVersion' => '(v1)'])]
418
        public function signatureBackgroundGet(): FileDisplayResponse {
419
                $file = $this->signatureBackgroundService->getImage();
×
420

421
                $response = new FileDisplayResponse($file);
×
422
                $csp = new ContentSecurityPolicy();
×
423
                $csp->allowInlineStyle();
×
424
                $response->setContentSecurityPolicy($csp);
×
425
                $response->cacheFor(3600);
×
426
                $response->addHeader('Content-Type', 'image/png');
×
427
                $response->addHeader('Content-Disposition', 'attachment; filename="background.png"');
×
428
                $response->addHeader('Content-Type', 'image/png');
×
429
                return $response;
×
430
        }
431

432
        /**
433
         * Reset the background image to be the default of LibreSign
434
         *
435
         * @return DataResponse<Http::STATUS_OK, array{status: 'success'}, array{}>
436
         *
437
         * 200: Image reseted to default
438
         */
439
        #[ApiRoute(verb: 'PATCH', url: '/api/{apiVersion}/admin/signature-background', requirements: ['apiVersion' => '(v1)'])]
440
        public function signatureBackgroundReset(): DataResponse {
441
                $this->signatureBackgroundService->reset();
×
442
                return new DataResponse(
×
443
                        [
×
444
                                'status' => 'success',
×
445
                        ]
×
446
                );
×
447
        }
448

449
        /**
450
         * Delete background image
451
         *
452
         * @return DataResponse<Http::STATUS_OK, array{status: 'success'}, array{}>
453
         *
454
         * 200: Deleted with success
455
         */
456
        #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/admin/signature-background', requirements: ['apiVersion' => '(v1)'])]
457
        public function signatureBackgroundDelete(): DataResponse {
458
                $this->signatureBackgroundService->delete();
×
459
                return new DataResponse(
×
460
                        [
×
461
                                'status' => 'success',
×
462
                        ]
×
463
                );
×
464
        }
465

466
        /**
467
         * Save signature text service
468
         *
469
         * @param string $template Template to signature text
470
         * @param float $templateFontSize Font size used when print the parsed text of this template at PDF file
471
         * @param float $signatureFontSize Font size used when the signature mode is SIGNAME_AND_DESCRIPTION
472
         * @param float $signatureWidth Signature width
473
         * @param float $signatureHeight Signature height
474
         * @param string $renderMode Signature render mode
475
         * @return DataResponse<Http::STATUS_OK, array{template: string, parsed: string, templateFontSize: float, signatureFontSize: float, signatureWidth: float, signatureHeight: float, renderMode: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
476
         *
477
         * 200: OK
478
         * 400: Bad request
479
         */
480
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signature-text', requirements: ['apiVersion' => '(v1)'])]
481
        public function signatureTextSave(
482
                string $template,
483
                /** @todo openapi package don't evaluate SignatureTextService::TEMPLATE_DEFAULT_FONT_SIZE */
484
                float $templateFontSize = 10,
485
                /** @todo openapi package don't evaluate SignatureTextService::SIGNATURE_DEFAULT_FONT_SIZE */
486
                float $signatureFontSize = 20,
487
                /** @todo openapi package don't evaluate SignatureTextService::DEFAULT_SIGNATURE_WIDTH */
488
                float $signatureWidth = 350,
489
                /** @todo openapi package don't evaluate SignatureTextService::DEFAULT_SIGNATURE_HEIGHT */
490
                float $signatureHeight = 100,
491
                string $renderMode = 'GRAPHIC_AND_DESCRIPTION',
492
        ): DataResponse {
493
                try {
494
                        $return = $this->signatureTextService->save(
×
495
                                $template,
×
496
                                $templateFontSize,
×
497
                                $signatureFontSize,
×
498
                                $signatureWidth,
×
499
                                $signatureHeight,
×
500
                                $renderMode,
×
501
                        );
×
502
                        return new DataResponse(
×
503
                                $return,
×
504
                                Http::STATUS_OK
×
505
                        );
×
506
                } catch (LibresignException $th) {
×
507
                        return new DataResponse(
×
508
                                [
×
509
                                        'error' => $th->getMessage(),
×
510
                                ],
×
511
                                Http::STATUS_BAD_REQUEST
×
512
                        );
×
513
                }
514
        }
515

516
        /**
517
         * Get parsed signature text service
518
         *
519
         * @param string $template Template to signature text
520
         * @param string $context Context for parsing the template
521
         * @return DataResponse<Http::STATUS_OK, array{template: string,parsed: string, templateFontSize: float, signatureFontSize: float, signatureWidth: float, signatureHeight: float, renderMode: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
522
         *
523
         * 200: OK
524
         * 400: Bad request
525
         */
526
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/signature-text', requirements: ['apiVersion' => '(v1)'])]
527
        public function signatureTextGet(string $template = '', string $context = ''): DataResponse {
528
                $context = json_decode($context, true) ?? [];
×
529
                try {
530
                        $return = $this->signatureTextService->parse($template, $context);
×
531
                        return new DataResponse(
×
532
                                $return,
×
533
                                Http::STATUS_OK
×
534
                        );
×
535
                } catch (LibresignException $th) {
×
536
                        return new DataResponse(
×
537
                                [
×
538
                                        'error' => $th->getMessage(),
×
539
                                ],
×
540
                                Http::STATUS_BAD_REQUEST
×
541
                        );
×
542
                }
543
        }
544

545
        /**
546
         * Get signature settings
547
         *
548
         * @return DataResponse<Http::STATUS_OK, array{default_signature_text_template: string, signature_available_variables: array<string, string>}, array{}>
549
         *
550
         * 200: OK
551
         */
552
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/signature-settings', requirements: ['apiVersion' => '(v1)'])]
553
        public function getSignatureSettings(): DataResponse {
554
                $response = [
×
555
                        'signature_available_variables' => $this->signatureTextService->getAvailableVariables(),
×
556
                        'default_signature_text_template' => $this->signatureTextService->getDefaultTemplate(),
×
557
                ];
×
558
                return new DataResponse($response);
×
559
        }
560

561
        /**
562
         * Convert signer name as image
563
         *
564
         * @param int $width Image width,
565
         * @param int $height Image height
566
         * @param string $text Text to be added to image
567
         * @param float $fontSize Font size of text
568
         * @param bool $isDarkTheme Color of text, white if is tark theme and black if not
569
         * @param string $align Align of text: left, center or right
570
         * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Disposition: 'inline; filename="signer-name.png"', Content-Type: 'image/png'}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
571
         *
572
         * 200: OK
573
         * 400: Bad request
574
         */
575
        #[NoCSRFRequired]
576
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/signer-name', requirements: ['apiVersion' => '(v1)'])]
577
        public function signerName(
578
                int $width,
579
                int $height,
580
                string $text,
581
                float $fontSize,
582
                bool $isDarkTheme,
583
                string $align,
584
        ):  FileDisplayResponse|DataResponse {
585
                try {
586
                        $blob = $this->signatureTextService->signerNameImage(
×
587
                                width: $width,
×
588
                                height: $height,
×
589
                                text: $text,
×
590
                                fontSize: $fontSize,
×
591
                                isDarkTheme: $isDarkTheme,
×
592
                                align: $align,
×
593
                        );
×
594
                        $file = new InMemoryFile('signer-name.png', $blob);
×
595
                        return new FileDisplayResponse($file, Http::STATUS_OK, [
×
596
                                'Content-Disposition' => 'inline; filename="signer-name.png"',
×
597
                                'Content-Type' => 'image/png',
×
598
                        ]);
×
599
                } catch (LibresignException $th) {
×
600
                        return new DataResponse(
×
601
                                [
×
602
                                        'error' => $th->getMessage(),
×
603
                                ],
×
604
                                Http::STATUS_BAD_REQUEST
×
605
                        );
×
606
                }
607
        }
608

609
        /**
610
         * Update certificate policy of this instance
611
         *
612
         * @return DataResponse<Http::STATUS_OK, array{status: 'success', CPS: string}, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, array{status: 'failure', message: string}, array{}>
613
         *
614
         * 200: OK
615
         * 422: Not found
616
         */
617
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate-policy', requirements: ['apiVersion' => '(v1)'])]
618
        public function saveCertificatePolicy(): DataResponse {
619
                $pdf = $this->request->getUploadedFile('pdf');
×
620
                $phpFileUploadErrors = [
×
621
                        UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'),
×
622
                        UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
×
623
                        UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
×
624
                        UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'),
×
625
                        UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'),
×
626
                        UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'),
×
627
                        UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'),
×
628
                        UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'),
×
629
                ];
×
630
                if (empty($pdf)) {
×
631
                        $error = $this->l10n->t('No file uploaded');
×
632
                } elseif (!empty($pdf) && array_key_exists('error', $pdf) && $pdf['error'] !== UPLOAD_ERR_OK) {
×
633
                        $error = $phpFileUploadErrors[$pdf['error']];
×
634
                }
635
                if ($error !== null) {
×
636
                        return new DataResponse(
×
637
                                [
×
638
                                        'message' => $error,
×
639
                                        'status' => 'failure',
×
640
                                ],
×
641
                                Http::STATUS_UNPROCESSABLE_ENTITY
×
642
                        );
×
643
                }
644
                try {
645
                        $cps = $this->certificatePolicyService->updateFile($pdf['tmp_name']);
×
646
                } catch (UnexpectedValueException $e) {
×
647
                        return new DataResponse(
×
648
                                [
×
649
                                        'message' => $e->getMessage(),
×
650
                                        'status' => 'failure',
×
651
                                ],
×
652
                                Http::STATUS_UNPROCESSABLE_ENTITY
×
653
                        );
×
654
                }
655
                return new DataResponse(
×
656
                        [
×
657
                                'CPS' => $cps,
×
658
                                'status' => 'success',
×
659
                        ]
×
660
                );
×
661
        }
662

663
        /**
664
         * Delete certificate policy of this instance
665
         *
666
         * @return DataResponse<Http::STATUS_OK, array{}, array{}>
667
         *
668
         * 200: OK
669
         * 404: Not found
670
         */
671
        #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/admin/certificate-policy', requirements: ['apiVersion' => '(v1)'])]
672
        public function deleteCertificatePolicy(): DataResponse {
673
                $this->certificatePolicyService->deleteFile();
×
674
                return new DataResponse();
×
675
        }
676

677
        /**
678
         * Update OID
679
         *
680
         * @param string $oid OID is a unique numeric identifier for certificate policies in digital certificates.
681
         * @return DataResponse<Http::STATUS_OK, array{status: 'success'}, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, array{status: 'failure', message: string}, array{}>
682
         *
683
         * 200: OK
684
         * 422: Validation error
685
         */
686
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate-policy/oid', requirements: ['apiVersion' => '(v1)'])]
687
        public function updateOID(string $oid): DataResponse {
688
                try {
689
                        $this->certificatePolicyService->updateOid($oid);
×
690
                        return new DataResponse(
×
691
                                [
×
692
                                        'status' => 'success',
×
693
                                ]
×
694
                        );
×
695
                } catch (\Exception $e) {
×
696
                        return new DataResponse(
×
697
                                [
×
698
                                        'message' => $e->getMessage(),
×
699
                                        'status' => 'failure',
×
700
                                ],
×
701
                                Http::STATUS_UNPROCESSABLE_ENTITY
×
702
                        );
×
703
                }
704
        }
705

706
        /**
707
         * Get reminder settings
708
         *
709
         * @return DataResponse<Http::STATUS_OK, LibresignReminderSettings, array{}>
710
         *
711
         * 200: OK
712
         */
713
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/reminder', requirements: ['apiVersion' => '(v1)'])]
714
        public function reminderFetch(): DataResponse {
715
                $response = $this->reminderService->getSettings();
×
716
                if ($response['next_run'] instanceof \DateTime) {
×
717
                        $response['next_run'] = $response['next_run']->format(DateTimeInterface::ATOM);
×
718
                }
719
                return new DataResponse($response);
×
720
        }
721

722
        /**
723
         * Save reminder
724
         *
725
         * @param int $daysBefore First reminder after (days)
726
         * @param int $daysBetween Days between reminders
727
         * @param int $max Max reminders per signer
728
         * @param string $sendTimer Send time (HH:mm)
729
         * @return DataResponse<Http::STATUS_OK, LibresignReminderSettings, array{}>
730
         *
731
         * 200: OK
732
         */
733
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/reminder', requirements: ['apiVersion' => '(v1)'])]
734
        public function reminderSave(
735
                int $daysBefore,
736
                int $daysBetween,
737
                int $max,
738
                string $sendTimer,
739
        ): DataResponse {
740
                $response = $this->reminderService->save($daysBefore, $daysBetween, $max, $sendTimer);
×
741
                if ($response['next_run'] instanceof \DateTime) {
×
742
                        $response['next_run'] = $response['next_run']->format(DateTimeInterface::ATOM);
×
743
                }
744
                return new DataResponse($response);
×
745
        }
746

747
        /**
748
         * Set TSA configuration values with proper sensitive data handling
749
         *
750
         * Only saves configuration if tsa_url is provided. Automatically manages
751
         * username/password fields based on authentication type.
752
         *
753
         * @param string|null $tsa_url TSA server URL (required for saving)
754
         * @param string|null $tsa_policy_oid TSA policy OID
755
         * @param string|null $tsa_auth_type Authentication type (none|basic), defaults to 'none'
756
         * @param string|null $tsa_username Username for basic authentication
757
         * @param string|null $tsa_password Password for basic authentication (stored as sensitive data)
758
         * @return DataResponse<Http::STATUS_OK, array{status: 'success'}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{status: 'error', message: string}, array{}>
759
         *
760
         * 200: OK
761
         * 400: Validation error
762
         */
763
        #[NoCSRFRequired]
764
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/tsa', requirements: ['apiVersion' => '(v1)'])]
765
        public function setTsaConfig(
766
                ?string $tsa_url = null,
767
                ?string $tsa_policy_oid = null,
768
                ?string $tsa_auth_type = null,
769
                ?string $tsa_username = null,
770
                ?string $tsa_password = null,
771
        ): DataResponse {
772
                if (empty($tsa_url)) {
2✔
773
                        return $this->deleteTsaConfig();
1✔
774
                }
775

776
                $trimmedUrl = trim($tsa_url);
1✔
777
                if (!filter_var($trimmedUrl, FILTER_VALIDATE_URL)
1✔
778
                        || !in_array(parse_url($trimmedUrl, PHP_URL_SCHEME), ['http', 'https'])) {
1✔
779
                        return new DataResponse([
×
780
                                'status' => 'error',
×
781
                                'message' => 'Invalid URL format'
×
782
                        ], Http::STATUS_BAD_REQUEST);
×
783
                }
784

785
                $this->appConfig->setValueString(Application::APP_ID, 'tsa_url', $trimmedUrl);
1✔
786

787
                if (empty($tsa_policy_oid)) {
1✔
788
                        $this->appConfig->deleteKey(Application::APP_ID, 'tsa_policy_oid');
1✔
789
                } else {
790
                        $trimmedOid = trim($tsa_policy_oid);
×
791
                        if (!preg_match('/^[0-9]+(\.[0-9]+)*$/', $trimmedOid)) {
×
792
                                return new DataResponse([
×
793
                                        'status' => 'error',
×
794
                                        'message' => 'Invalid OID format'
×
795
                                ], Http::STATUS_BAD_REQUEST);
×
796
                        }
797
                        $this->appConfig->setValueString(Application::APP_ID, 'tsa_policy_oid', $trimmedOid);
×
798
                }
799

800
                $authType = $tsa_auth_type ?? 'none';
1✔
801
                $this->appConfig->setValueString(Application::APP_ID, 'tsa_auth_type', $authType);
1✔
802

803
                if ($authType === 'basic') {
1✔
804
                        $hasUsername = !empty($tsa_username);
1✔
805
                        $hasPassword = !empty($tsa_password) && $tsa_password !== Admin::PASSWORD_PLACEHOLDER;
1✔
806

807
                        if (!$hasUsername && !$hasPassword) {
1✔
808
                                return new DataResponse([
×
809
                                        'status' => 'error',
×
810
                                        'message' => 'Username and password are required for basic authentication'
×
811
                                ], Http::STATUS_BAD_REQUEST);
×
812
                        } elseif (!$hasUsername) {
1✔
813
                                return new DataResponse([
×
814
                                        'status' => 'error',
×
815
                                        'message' => 'Username is required'
×
816
                                ], Http::STATUS_BAD_REQUEST);
×
817
                        } elseif (!$hasPassword) {
1✔
818
                                return new DataResponse([
×
819
                                        'status' => 'error',
×
820
                                        'message' => 'Password is required'
×
821
                                ], Http::STATUS_BAD_REQUEST);
×
822
                        }
823

824
                        $this->appConfig->setValueString(Application::APP_ID, 'tsa_username', trim($tsa_username));
1✔
825
                        $this->appConfig->setValueString(
1✔
826
                                Application::APP_ID,
1✔
827
                                key: 'tsa_password',
1✔
828
                                value: $tsa_password,
1✔
829
                                sensitive: true,
1✔
830
                        );
1✔
831
                } else {
832
                        $this->appConfig->deleteKey(Application::APP_ID, 'tsa_username');
×
833
                        $this->appConfig->deleteKey(Application::APP_ID, 'tsa_password');
×
834
                }
835

836
                return new DataResponse(['status' => 'success']);
1✔
837
        }
838

839
        /**
840
         * Delete TSA configuration
841
         *
842
         * Delete all TSA configuration fields from the application settings.
843
         *
844
         * @return DataResponse<Http::STATUS_OK, array{status: 'success'}, array{}>
845
         *
846
         * 200: OK
847
         */
848
        #[NoCSRFRequired]
849
        #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/admin/tsa', requirements: ['apiVersion' => '(v1)'])]
850
        public function deleteTsaConfig(): DataResponse {
851
                $fields = ['tsa_url', 'tsa_policy_oid', 'tsa_auth_type', 'tsa_username', 'tsa_password'];
2✔
852

853
                foreach ($fields as $field) {
2✔
854
                        $this->appConfig->deleteKey(Application::APP_ID, $field);
2✔
855
                }
856

857
                return new DataResponse(['status' => 'success']);
2✔
858
        }
859

860
        /**
861
         * Get footer template
862
         *
863
         * Returns the current footer template if set, otherwise returns the default template.
864
         *
865
         * @return DataResponse<Http::STATUS_OK, array{template: string, isDefault: bool, preview_width: int, preview_height: int}, array{}>
866
         *
867
         * 200: OK
868
         */
869
        #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/footer-template', requirements: ['apiVersion' => '(v1)'])]
870
        public function getFooterTemplate(): DataResponse {
871
                return new DataResponse([
×
872
                        'template' => $this->footerService->getTemplate(),
×
873
                        'isDefault' => $this->footerService->isDefaultTemplate(),
×
874
                        'preview_width' => $this->appConfig->getValueInt(Application::APP_ID, 'footer_preview_width', 595),
×
875
                        'preview_height' => $this->appConfig->getValueInt(Application::APP_ID, 'footer_preview_height', 100),
×
876
                ]);
×
877
        }
878

879
        /**
880
         * Save footer template and render preview
881
         *
882
         * Saves the footer template and returns the rendered PDF preview.
883
         *
884
         * @param string $template The Twig template to save (empty to reset to default)
885
         * @param int $width Width of preview in points (default: 595 - A4 width)
886
         * @param int $height Height of preview in points (default: 50)
887
         * @return DataDownloadResponse<Http::STATUS_OK, 'application/pdf', array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
888
         *
889
         * 200: OK
890
         * 400: Bad request
891
         */
892
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/footer-template', requirements: ['apiVersion' => '(v1)'])]
893
        public function saveFooterTemplate(string $template = '', int $width = 595, int $height = 50) {
894
                try {
895
                        $this->footerService->saveTemplate($template);
×
896
                        $pdf = $this->footerService->renderPreviewPdf('', $width, $height);
×
897

898
                        return new DataDownloadResponse($pdf, 'footer-preview.pdf', 'application/pdf');
×
899
                } catch (\Exception $e) {
×
900
                        return new DataResponse([
×
901
                                'error' => $e->getMessage(),
×
902
                        ], Http::STATUS_BAD_REQUEST);
×
903
                }
904
        }
905

906
        /**
907
         * Preview footer template as PDF
908
         *
909
         * @NoAdminRequired
910
         * @NoCSRFRequired
911
         *
912
         * @param string $template Template to preview
913
         * @param int $width Width of preview in points (default: 595 - A4 width)
914
         * @param int $height Height of preview in points (default: 50)
915
         * @return DataDownloadResponse<Http::STATUS_OK, 'application/pdf', array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
916
         *
917
         * 200: OK
918
         * 400: Bad request
919
         */
920
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/footer-template/preview-pdf', requirements: ['apiVersion' => '(v1)'])]
921
        public function footerTemplatePreviewPdf(string $template = '', int $width = 595, int $height = 50) {
922
                try {
923
                        $pdf = $this->footerService->renderPreviewPdf($template ?: null, $width, $height);
×
924
                        return new DataDownloadResponse($pdf, 'footer-preview.pdf', 'application/pdf');
×
925
                } catch (\Exception $e) {
×
926
                        return new DataResponse([
×
927
                                'error' => $e->getMessage(),
×
928
                        ], Http::STATUS_BAD_REQUEST);
×
929
                }
930
        }
931

932
        /**
933
         * Set DocMDP configuration
934
         *
935
         * @param bool $enabled Enable or disable DocMDP certification
936
         * @param int $defaultLevel Default DocMDP level (0-3): 0=none, 1=no changes, 2=form fill, 3=form fill + annotations
937
         * @return DataResponse<Http::STATUS_OK, array{message: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
938
         *
939
         * 200: Configuration saved successfully
940
         * 400: Invalid DocMDP level provided
941
         * 500: Internal server error
942
         */
943
        #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/docmdp/config', requirements: ['apiVersion' => '(v1)'])]
944
        public function setDocMdpConfig(bool $enabled, int $defaultLevel): DataResponse {
945
                try {
946
                        $this->docMdpConfigService->setEnabled($enabled);
×
947

948
                        if ($enabled) {
×
949
                                $level = DocMdpLevel::tryFrom($defaultLevel);
×
950
                                if ($level === null) {
×
951
                                        return new DataResponse([
×
952
                                                'error' => $this->l10n->t('Invalid DocMDP level'),
×
953
                                        ], Http::STATUS_BAD_REQUEST);
×
954
                                }
955

956
                                $this->docMdpConfigService->setLevel($level);
×
957
                        }
958

959
                        return new DataResponse([
×
960
                                'message' => $this->l10n->t('Settings saved'),
×
961
                        ]);
×
962
                } catch (\Exception $e) {
×
963
                        return new DataResponse([
×
964
                                'error' => $e->getMessage(),
×
965
                        ], Http::STATUS_INTERNAL_SERVER_ERROR);
×
966
                }
967
        }
968
}
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