Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
14.36% covered (danger)
14.36%
26 / 181
17.65% covered (danger)
17.65%
3 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
AdminController
14.36% covered (danger)
14.36%
26 / 181
17.65% covered (danger)
17.65%
3 / 17
896.73
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 generateCertificateCfssl
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 generateCertificateOpenSsl
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
2.04
 setCertificateEngine
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 generateCertificate
18.75% covered (danger)
18.75%
3 / 16
0.00% covered (danger)
0.00%
0 / 1
12.58
 loadCertificate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 configureCheck
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 installAndValidate
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
56
 signatureBackgroundSave
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 signatureBackgroundGet
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 signatureBackgroundDelete
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 saveCertificatePolicy
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 deleteCertificatePolicy
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 updateOid
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getActiveSignings
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getGeoIpConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 saveGeoIpConfig
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Controller;
10
11use OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Controller\Traits\UploadValidator;
13use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
14use OCA\Libresign\Handler\CertificateEngine\IEngineHandler;
15use OCA\Libresign\Service\ActiveSigningsService;
16use OCA\Libresign\Service\Certificate\ValidateService;
17use OCA\Libresign\Service\CertificatePolicyService;
18use OCA\Libresign\Service\GeoIp\GeoIpConfigService;
19use OCA\Libresign\Service\GeoIp\GeoIpDatabaseStatusService;
20use OCA\Libresign\Service\IdentifyMethodService;
21use OCA\Libresign\Service\Install\InstallService;
22use OCA\Libresign\Service\SetupCheckResultService;
23use OCA\Libresign\Service\SignatureBackgroundService;
24use OCP\AppFramework\Http;
25use OCP\AppFramework\Http\Attribute\ApiRoute;
26use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
27use OCP\AppFramework\Http\ContentSecurityPolicy;
28use OCP\AppFramework\Http\DataResponse;
29use OCP\AppFramework\Http\FileDisplayResponse;
30use OCP\IAppConfig;
31use OCP\IEventSource;
32use OCP\IEventSourceFactory;
33use OCP\IL10N;
34use OCP\IRequest;
35use OCP\ISession;
36use UnexpectedValueException;
37
38/**
39 * @psalm-import-type LibresignCertificateDataGenerated from \OCA\Libresign\ResponseDefinitions
40 * @psalm-import-type LibresignCertificateEngineConfigResponse from \OCA\Libresign\ResponseDefinitions
41 * @psalm-import-type LibresignCertificatePolicyResponse from \OCA\Libresign\ResponseDefinitions
42 * @psalm-import-type LibresignConfigureCheck from \OCA\Libresign\ResponseDefinitions
43 * @psalm-import-type LibresignConfigureChecksResponse from \OCA\Libresign\ResponseDefinitions
44 * @psalm-import-type LibresignEngineHandlerResponse from \OCA\Libresign\ResponseDefinitions
45 * @psalm-import-type LibresignErrorResponse from \OCA\Libresign\ResponseDefinitions
46 * @psalm-import-type LibresignErrorStatusResponse from \OCA\Libresign\ResponseDefinitions
47 * @psalm-import-type LibresignEngineHandler from \OCA\Libresign\ResponseDefinitions
48 * @psalm-import-type LibresignIdentifyMethodSetting from \OCA\Libresign\ResponseDefinitions
49 * @psalm-import-type LibresignMessageResponse from \OCA\Libresign\ResponseDefinitions
50 * @psalm-import-type LibresignSuccessStatusResponse from \OCA\Libresign\ResponseDefinitions
51 * @psalm-import-type LibresignFailureStatusResponse from \OCA\Libresign\ResponseDefinitions
52 * @psalm-import-type LibresignActiveSigningsResponse from \OCA\Libresign\ResponseDefinitions
53 * @psalm-import-type LibresignRootCertificate from \OCA\Libresign\ResponseDefinitions
54 * @psalm-import-type LibresignGeoIpConfig from \OCA\Libresign\ResponseDefinitions
55 */
56class AdminController extends AEnvironmentAwareController {
57    use UploadValidator;
58
59    private IEventSource $eventSource;
60    public function __construct(
61        IRequest $request,
62        private IAppConfig $appConfig,
63        private InstallService $installService,
64        private CertificateEngineFactory $certificateEngineFactory,
65        private IEventSourceFactory $eventSourceFactory,
66        protected IL10N $l10n,
67        protected ISession $session,
68        private SignatureBackgroundService $signatureBackgroundService,
69        private CertificatePolicyService $certificatePolicyService,
70        private ValidateService $validateService,
71        private IdentifyMethodService $identifyMethodService,
72        private ActiveSigningsService $activeSigningsService,
73        private SetupCheckResultService $setupCheckResultService,
74        private GeoIpConfigService $geoIpConfigService,
75        private GeoIpDatabaseStatusService $geoIpDatabaseStatusService,
76    ) {
77        parent::__construct(Application::APP_ID, $request);
78        $this->eventSource = $this->eventSourceFactory->create();
79    }
80
81    /**
82     * Generate certificate using CFSSL engine
83     *
84     * @param array{commonName: string, names: array<string, array{value:string|array<string>}>} $rootCert fields of root certificate
85     * @param string $cfsslUri URI of CFSSL API
86     * @param string $configPath Path of config files of CFSSL
87     * @return DataResponse<Http::STATUS_OK, LibresignEngineHandlerResponse, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, LibresignMessageResponse, array{}>
88     *
89     * 200: OK
90     * 401: Account not found
91     */
92    #[NoCSRFRequired]
93    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate/cfssl', requirements: ['apiVersion' => '(v1)'])]
94    public function generateCertificateCfssl(
95        array $rootCert,
96        string $cfsslUri = '',
97        string $configPath = '',
98    ): DataResponse {
99        try {
100            $engineHandler = $this->generateCertificate($rootCert, [
101                'engine' => 'cfssl',
102                'configPath' => trim($configPath),
103                'cfsslUri' => trim($cfsslUri),
104            ])->toArray();
105            return new DataResponse([
106                'data' => $engineHandler,
107            ]);
108        } catch (\Exception $exception) {
109            return new DataResponse(
110                [
111                    'message' => $exception->getMessage()
112                ],
113                Http::STATUS_UNAUTHORIZED
114            );
115        }
116    }
117
118    /**
119     * Generate certificate using OpenSSL engine
120     *
121     * @param array{commonName: string, names: array<string, array{value:string|array<string>}>} $rootCert fields of root certificate
122     * @param string $configPath Path of config files of CFSSL
123     * @return DataResponse<Http::STATUS_OK, LibresignEngineHandlerResponse, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, LibresignMessageResponse, array{}>
124     *
125     * 200: OK
126     * 401: Account not found
127     */
128    #[NoCSRFRequired]
129    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate/openssl', requirements: ['apiVersion' => '(v1)'])]
130    public function generateCertificateOpenSsl(
131        array $rootCert,
132        string $configPath = '',
133    ): DataResponse {
134        try {
135            $engineHandler = $this->generateCertificate($rootCert, [
136                'engine' => 'openssl',
137                'configPath' => trim($configPath),
138            ])->toArray();
139            return new DataResponse([
140                'data' => $engineHandler,
141            ]);
142        } catch (\Exception $exception) {
143            return new DataResponse(
144                [
145                    'message' => $exception->getMessage()
146                ],
147                Http::STATUS_UNAUTHORIZED
148            );
149        }
150    }
151
152    /**
153     * Set certificate engine
154     *
155     * Sets the certificate engine (openssl, cfssl, or none) and automatically configures identify_methods when needed
156     *
157     * @param string $engine The certificate engine to use (openssl, cfssl, or none)
158     * @return DataResponse<Http::STATUS_OK, LibresignCertificateEngineConfigResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignMessageResponse, array{}>
159     *
160     * 200: OK
161     * 400: Invalid engine
162     */
163    #[NoCSRFRequired]
164    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate/engine', requirements: ['apiVersion' => '(v1)'])]
165    public function setCertificateEngine(string $engine): DataResponse {
166        $validEngines = ['openssl', 'cfssl', 'none'];
167        if (!in_array($engine, $validEngines, true)) {
168            return new DataResponse(
169                ['message' => 'Invalid engine. Must be one of: ' . implode(', ', $validEngines)],
170                Http::STATUS_BAD_REQUEST
171            );
172        }
173
174        $this->certificateEngineFactory->setEngine($engine);
175        $identifyMethods = $this->identifyMethodService->getIdentifyMethodsSettings();
176        /** @var LibresignCertificateEngineConfigResponse $response */
177        $response = [
178            'engine' => $engine,
179            'identify_methods' => $identifyMethods,
180        ];
181
182        return new DataResponse($response);
183    }
184
185    private function generateCertificate(
186        array $rootCert,
187        array $properties = [],
188    ): IEngineHandler {
189        $names = [];
190        if (isset($rootCert['names'])) {
191            $this->validateService->validateNames($rootCert['names']);
192            foreach ($rootCert['names'] as $item) {
193                if (is_array($item['value'])) {
194                    $trimmedValues = array_map(trim(...), $item['value']);
195                    $names[$item['id']]['value'] = array_filter($trimmedValues, fn ($val) => $val !== '');
196                } else {
197                    $names[$item['id']]['value'] = trim((string)$item['value']);
198                }
199            }
200        }
201        $this->validateService->validate('CN', $rootCert['commonName']);
202        $this->installService->generate(
203            trim((string)$rootCert['commonName']),
204            $properties['engine'],
205            $names,
206            $properties,
207        );
208
209        return $this->certificateEngineFactory->getEngine();
210    }
211
212    /**
213     * Load certificate data
214     *
215     * Return all data of root certificate and a field called `generated` with a boolean value.
216     *
217     * @return DataResponse<Http::STATUS_OK, LibresignCertificateDataGenerated, array{}>
218     *
219     * 200: OK
220     */
221    #[NoCSRFRequired]
222    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/certificate', requirements: ['apiVersion' => '(v1)'])]
223    public function loadCertificate(): DataResponse {
224        $engine = $this->certificateEngineFactory->getEngine();
225        /** @var LibresignCertificateDataGenerated */
226        $certificate = $engine->toArray();
227
228        return new DataResponse($certificate);
229    }
230
231    /**
232     * Check the configuration of LibreSign
233     *
234     * Return the status of necessary configuration and tips to fix the problems.
235     *
236     * @return DataResponse<Http::STATUS_OK, LibresignConfigureChecksResponse, array{}>
237     *
238     * 200: OK
239     */
240    #[NoCSRFRequired]
241    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/configure-check', requirements: ['apiVersion' => '(v1)'])]
242    public function configureCheck(): DataResponse {
243        /** @var LibresignConfigureChecksResponse $checks */
244        $checks = $this->setupCheckResultService->getFormattedChecks();
245        return new DataResponse($checks);
246    }
247
248    /**
249     * @IgnoreOpenAPI
250     */
251    #[NoCSRFRequired]
252    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/install-and-validate', requirements: ['apiVersion' => '(v1)'])]
253    public function installAndValidate(): void {
254        try {
255            $async = \function_exists('proc_open');
256            $this->installService->installJava($async);
257            $this->installService->installJSignPdf($async);
258            $this->installService->installPdftk($async);
259            if ($this->appConfig->getValueString(Application::APP_ID, 'certificate_engine') === 'cfssl') {
260                $this->installService->installCfssl($async);
261            }
262
263            $this->eventSource->send('configure_check', $this->setupCheckResultService->getFormattedChecks());
264            $seconds = 0;
265            while ($this->installService->isDownloadWip()) {
266                $totalSize = $this->installService->getTotalSize();
267                $this->eventSource->send('total_size', json_encode($totalSize));
268                if ($errors = $this->installService->getErrorMessages()) {
269                    $this->eventSource->send('errors', json_encode($errors));
270                }
271                usleep(200000); // 0.2 seconds
272                $seconds += 0.2;
273                if ($seconds === 5.0) {
274                    $this->eventSource->send('configure_check', $this->setupCheckResultService->getFormattedChecks());
275                    $seconds = 0;
276                }
277            }
278            if ($errors = $this->installService->getErrorMessages()) {
279                $this->eventSource->send('errors', json_encode($errors));
280            }
281        } catch (\Exception $exception) {
282            $this->eventSource->send('errors', json_encode([
283                // TRANSLATORS Error shown to an administrator when LibreSign cannot download required components.
284                $this->l10n->t('Could not download binaries.'),
285                $exception->getMessage(),
286            ]));
287        }
288
289        $this->eventSource->send('configure_check', $this->setupCheckResultService->getFormattedChecks());
290        $this->eventSource->send('done', '');
291        $this->eventSource->close();
292        // Nextcloud inject a lot of headers that is incompatible with SSE
293        exit();
294    }
295
296    /**
297     * Add custom background image
298     *
299     * @return DataResponse<Http::STATUS_OK, LibresignSuccessStatusResponse, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignFailureStatusResponse, array{}>
300     *
301     * 200: OK
302     * 422: Error
303     */
304    #[NoCSRFRequired]
305    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signature-background', requirements: ['apiVersion' => '(v1)'])]
306    public function signatureBackgroundSave(): DataResponse {
307        $image = $this->request->getUploadedFile('image');
308        $uploadError = $this->validateUploadedFile($image, 'image');
309        if ($uploadError !== null) {
310            return $uploadError;
311        }
312        try {
313            $this->signatureBackgroundService->updateImage($image['tmp_name']);
314        } catch (\Exception $e) {
315            return new DataResponse(
316                [
317                    'message' => $e->getMessage(),
318                    'status' => 'failure',
319                ],
320                Http::STATUS_UNPROCESSABLE_ENTITY
321            );
322        }
323
324        return new DataResponse(
325            [
326                'status' => 'success',
327            ]
328        );
329    }
330
331    /**
332     * Get custom background image
333     *
334     * @return FileDisplayResponse<Http::STATUS_OK, array{}>
335     *
336     * 200: Image returned
337     */
338    #[NoCSRFRequired]
339    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/signature-background', requirements: ['apiVersion' => '(v1)'])]
340    public function signatureBackgroundGet(): FileDisplayResponse {
341        $file = $this->signatureBackgroundService->getImage();
342
343        $response = new FileDisplayResponse($file);
344        $csp = new ContentSecurityPolicy();
345        $csp->allowInlineStyle();
346        $response->setContentSecurityPolicy($csp);
347        $response->cacheFor(3600);
348        $response->addHeader('Content-Type', 'image/png');
349        $response->addHeader('Content-Disposition', 'attachment; filename="background.png"');
350        $response->addHeader('Content-Type', 'image/png');
351        return $response;
352    }
353
354    /**
355     * Delete background image
356     *
357     * @return DataResponse<Http::STATUS_OK, LibresignSuccessStatusResponse, array{}>
358     *
359     * 200: Deleted with success
360     */
361    #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/admin/signature-background', requirements: ['apiVersion' => '(v1)'])]
362    public function signatureBackgroundDelete(): DataResponse {
363        $this->signatureBackgroundService->delete();
364        return new DataResponse(
365            [
366                'status' => 'success',
367            ]
368        );
369    }
370
371    /**
372     * Upload new certificate policy PDF for this instance
373     *
374     * @return DataResponse<Http::STATUS_OK, LibresignCertificatePolicyResponse, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignFailureStatusResponse, array{}>
375     *
376     * 200: OK
377     * 422: Upload or validation error
378     */
379    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate-policy', requirements: ['apiVersion' => '(v1)'])]
380    public function saveCertificatePolicy(): DataResponse {
381        // Handle POST method - upload PDF
382        $pdf = $this->request->getUploadedFile('pdf');
383        $uploadError = $this->validateUploadedFile($pdf, 'pdf');
384        if ($uploadError !== null) {
385            return $uploadError;
386        }
387        try {
388            $cps = $this->certificatePolicyService->updateFile($pdf['tmp_name']);
389        } catch (UnexpectedValueException $e) {
390            return new DataResponse(
391                [
392                    'message' => $e->getMessage(),
393                    'status' => 'failure',
394                ],
395                Http::STATUS_UNPROCESSABLE_ENTITY
396            );
397        }
398        return new DataResponse(
399            [
400                'CPS' => $cps,
401                'status' => 'success',
402            ]
403        );
404    }
405
406    /**
407     * Delete certificate policy of this instance
408     *
409     * @return DataResponse<Http::STATUS_OK, array{status: 'success'}, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignFailureStatusResponse, array{}>
410     *
411     * 200: OK
412     * 422: Error
413     */
414    #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/admin/certificate-policy', requirements: ['apiVersion' => '(v1)'])]
415    public function deleteCertificatePolicy(): DataResponse {
416        try {
417            $this->certificatePolicyService->deleteFile();
418        } catch (\Exception $e) {
419            return new DataResponse(
420                [
421                    'message' => $e->getMessage(),
422                    'status' => 'failure',
423                ],
424                Http::STATUS_UNPROCESSABLE_ENTITY
425            );
426        }
427        return new DataResponse(['status' => 'success']);
428    }
429
430    /**
431     * Update OID
432     *
433     * @param string $oid OID is a unique numeric identifier for certificate policies in digital certificates.
434     * @return DataResponse<Http::STATUS_OK, LibresignSuccessStatusResponse, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignFailureStatusResponse, array{}>
435     *
436     * 200: OK
437     * 422: Validation error
438     */
439    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate-policy/oid', requirements: ['apiVersion' => '(v1)'])]
440    public function updateOid(string $oid): DataResponse {
441        try {
442            $this->certificatePolicyService->updateOid($oid);
443            return new DataResponse(
444                [
445                    'status' => 'success',
446                ]
447            );
448        } catch (\Exception $e) {
449            return new DataResponse(
450                [
451                    'message' => $e->getMessage(),
452                    'status' => 'failure',
453                ],
454                Http::STATUS_UNPROCESSABLE_ENTITY
455            );
456        }
457    }
458
459    /**
460     * Get list of files currently being signed (status = SIGNING_IN_PROGRESS)
461     *
462     * @return DataResponse<Http::STATUS_OK, LibresignActiveSigningsResponse, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, LibresignErrorResponse, array{}>
463     *
464     * 200: List of active signings
465     */
466    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/active-signings', requirements: ['apiVersion' => '(v1)'])]
467    public function getActiveSignings(): DataResponse {
468        try {
469            return new DataResponse([
470                'data' => $this->activeSigningsService->getActiveSignings(),
471            ]);
472        } catch (\Exception $e) {
473            return new DataResponse([
474                'error' => $e->getMessage(),
475            ], Http::STATUS_INTERNAL_SERVER_ERROR);
476        }
477    }
478
479    /**
480     * Get GeoIP database configuration and status
481     *
482     * @return DataResponse<Http::STATUS_OK, LibresignGeoIpConfig, array{}>
483     *
484     * 200: GeoIP configuration
485     */
486    #[NoCSRFRequired]
487    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/geoip', requirements: ['apiVersion' => '(v1)'])]
488    public function getGeoIpConfig(): DataResponse {
489        return new DataResponse($this->geoIpDatabaseStatusService->getStatus());
490    }
491
492    /**
493     * Save GeoIP database path
494     *
495     * An empty path clears the configuration. The path may be saved even when the
496     * database file is not available yet; the returned status describes the current
497     * filesystem state.
498     *
499     * @param string $path Absolute path to a MaxMind City database, or empty to clear
500     * @return DataResponse<Http::STATUS_OK, LibresignGeoIpConfig, array{}>
501     *
502     * 200: GeoIP configuration saved
503     */
504    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/geoip', requirements: ['apiVersion' => '(v1)'])]
505    public function saveGeoIpConfig(string $path = ''): DataResponse {
506        $this->geoIpConfigService->setDatabasePath($path === '' ? null : $path);
507        return new DataResponse($this->geoIpDatabaseStatusService->getStatus());
508    }
509}