Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.47% covered (warning)
89.47%
51 / 57
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
AsyncSigningService
89.47% covered (warning)
89.47%
51 / 57
66.67% covered (warning)
66.67%
4 / 6
13.20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enqueueSigningJob
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 persistQueuedJob
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 compensateFailedEnqueue
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
6.20
 invalidateQueuedStatus
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 storeCredentials
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use OCA\Libresign\BackgroundJob\SignFileJob;
12use OCA\Libresign\Db\File as LibreSignFile;
13use OCA\Libresign\Db\SignRequest;
14use OCA\Libresign\Enum\FileStatus;
15use OCA\Libresign\Service\Worker\WorkerActivationService;
16use OCP\BackgroundJob\IJobList;
17use OCP\IDBConnection;
18use OCP\IUser;
19use OCP\Security\ICredentialsManager;
20use OCP\Security\ISecureRandom;
21use Psr\Log\LoggerInterface;
22
23class AsyncSigningService {
24    public function __construct(
25        private IJobList $jobList,
26        private ICredentialsManager $credentialsManager,
27        private ISecureRandom $secureRandom,
28        private FileStatusService $fileStatusService,
29        private IDBConnection $db,
30        private WorkerActivationService $workerActivationService,
31        private LoggerInterface $logger,
32        private SessionService $sessionService,
33    ) {
34    }
35
36    /**
37     * @return array{credentialsId: string, jobAdded: bool}
38     */
39    public function enqueueSigningJob(
40        LibreSignFile $libreSignFile,
41        SignRequest $signRequest,
42        ?IUser $user,
43        string $userUniqueIdentifier,
44        bool $signWithoutPassword,
45        ?string $password,
46        ?string $signatureMethod,
47        array $visibleElements,
48        array $metadata,
49    ): array {
50        $credentialsId = $this->storeCredentials($signRequest, $user, $signWithoutPassword, $password);
51        $jobArguments = [
52            'fileId' => $libreSignFile->getId(),
53            'signRequestId' => $signRequest->getId(),
54            'signRequestUuid' => $signRequest->getUuid(),
55            'userId' => $user?->getUID() ?? $libreSignFile->getUserId(),
56            'credentialsId' => $credentialsId,
57            'isExternalSigner' => $user === null,
58            'signatureSessionId' => $user === null ? $this->sessionService->getSessionId() : null,
59            'userUniqueIdentifier' => $userUniqueIdentifier,
60            'friendlyName' => $signRequest->getDisplayName(),
61            'signatureMethod' => $signatureMethod,
62            'visibleElements' => $visibleElements,
63            'metadata' => $metadata,
64        ];
65        $this->persistQueuedJob($libreSignFile, $jobArguments, $user?->getUID() ?? '', $credentialsId);
66        $this->invalidateQueuedStatus($libreSignFile);
67
68        // The queued job is durable even if this best-effort launch fails.
69        $this->workerActivationService->ensureWorkerRunning();
70        return ['credentialsId' => $credentialsId, 'jobAdded' => true];
71    }
72
73    /** @param array<string, mixed> $jobArguments */
74    private function persistQueuedJob(LibreSignFile $file, array $jobArguments, string $credentialOwner, string $credentialsId): void {
75        $previousStatus = $file->getStatus();
76        $previousMetadata = $file->getMetadata();
77        $transactionOpen = false;
78        try {
79            // The job and its signing status must become visible together.
80            $this->db->beginTransaction();
81            $transactionOpen = true;
82            $file->setStatus(FileStatus::SIGNING_IN_PROGRESS->value);
83            $this->fileStatusService->updateForQueuedJob($file);
84            $this->jobList->add(SignFileJob::class, $jobArguments);
85            $this->db->commit();
86        } catch (\Throwable $error) {
87            // Compensate and rethrow even programming errors; never report that a
88            // failed enqueue succeeded or discard a possibly committed job.
89            $this->compensateFailedEnqueue($file, $previousStatus, $previousMetadata, $credentialOwner, $credentialsId, $transactionOpen);
90            throw $error;
91        }
92    }
93
94    private function compensateFailedEnqueue(
95        LibreSignFile $file,
96        int $previousStatus,
97        ?array $previousMetadata,
98        string $credentialOwner,
99        string $credentialsId,
100        bool $transactionOpen,
101    ): void {
102        if ($transactionOpen) {
103            try {
104                $this->db->rollBack();
105            } catch (\Exception $e) {
106                // After an ambiguous commit, these credentials may still be needed.
107                $this->logger->error('Cannot roll back the signing enqueue', ['exception' => $e]);
108                return;
109            }
110        }
111        try {
112            $this->fileStatusService->discardQueuedJobStatus($file, $previousStatus, $previousMetadata);
113        } catch (\Exception $e) {
114            $this->logger->warning('Cannot discard uncommitted signing status', ['exception' => $e]);
115        }
116        try {
117            $this->credentialsManager->delete($credentialOwner, $credentialsId);
118        } catch (\Exception $e) {
119            $this->logger->warning('Cannot remove unused signing credentials', ['exception' => $e]);
120        }
121    }
122
123    private function invalidateQueuedStatus(LibreSignFile $file): void {
124        try {
125            $this->fileStatusService->onQueuedJobCommitted($file);
126        } catch (\Exception $e) {
127            // A cache failure after commit must not initiate a second signing.
128            $this->logger->warning('Unable to invalidate queued signing status', ['exception' => $e]);
129        }
130    }
131
132    private function storeCredentials(
133        SignRequest $signRequest,
134        ?IUser $user,
135        bool $signWithoutPassword,
136        ?string $password,
137    ): string {
138        $credentialsId = 'libresign_sign_' . $signRequest->getId() . '_' . $this->secureRandom->generate(16, ISecureRandom::CHAR_ALPHANUMERIC);
139
140        $this->credentialsManager->store(
141            $user?->getUID() ?? '',
142            $credentialsId,
143            [
144                'signWithoutPassword' => $signWithoutPassword,
145                'password' => $password,
146                'timestamp' => time(),
147            ]
148        );
149
150        return $credentialsId;
151    }
152}