Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.02% covered (success)
99.02%
101 / 102
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignJobCoordinator
99.02% covered (success)
99.02%
101 / 102
90.91% covered (success)
90.91%
10 / 11
35
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
 runSignFile
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 runSignSingleFile
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 requireIds
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 loadEntities
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 resolveUser
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 retrieveCredentials
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hydrateSignService
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
12
 markInProgress
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 discardJobCredentials
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteCredentials
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
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\Db\File as FileEntity;
12use OCA\Libresign\Db\FileMapper;
13use OCA\Libresign\Db\SignRequest as SignRequestEntity;
14use OCA\Libresign\Db\SignRequestMapper;
15use OCA\Libresign\Enum\FileStatus;
16use OCA\Libresign\Service\SignRequest\Error\SignRequestErrorReporter;
17use OCA\Libresign\Service\SignRequest\ProgressService;
18use OCP\IUser;
19use OCP\IUserManager;
20use OCP\Security\ICredentialsManager;
21use Psr\Log\LoggerInterface;
22
23class SignJobCoordinator {
24    public function __construct(
25        private FileMapper $fileMapper,
26        private SignRequestMapper $signRequestMapper,
27        private SignFileService $signFileService,
28        private FolderService $folderService,
29        private IUserManager $userManager,
30        private ICredentialsManager $credentialsManager,
31        private ProgressService $progressService,
32        private SignRequestErrorReporter $signRequestErrorReporter,
33        private LoggerInterface $logger,
34        private FileService $fileService,
35    ) {
36    }
37
38    public function runSignFile(array $argument): bool {
39        $file = null;
40        $signRequest = null;
41        try {
42            if (empty($argument)) {
43                throw new \InvalidArgumentException('SignFileJob: Cannot proceed with empty arguments');
44            }
45
46            [$fileId, $signRequestId] = $this->requireIds($argument, 'SignFileJob');
47            [$file, $signRequest] = $this->loadEntities($fileId, $signRequestId);
48            $this->progressService->clearSignRequestError($signRequest->getUuid());
49            $this->hydrateSignService($argument, $file, $signRequest);
50
51            $this->signFileService->sign();
52            return true;
53        } catch (\Exception $e) {
54            $this->signRequestErrorReporter->error('SignFileJob failed', [
55                'exception' => $e,
56                'fileId' => $file?->getId() ?? ($argument['fileId'] ?? null),
57                'signRequestId' => $signRequest?->getId() ?? ($argument['signRequestId'] ?? null),
58                'signRequestUuid' => $signRequest?->getUuid() ?? ($argument['signRequestUuid'] ?? null),
59            ]);
60            return false;
61        } finally {
62            $this->deleteCredentials($argument);
63        }
64    }
65
66    public function runSignSingleFile(array $argument): bool {
67        $fileId = $argument['fileId'] ?? null;
68        $signRequestId = $argument['signRequestId'] ?? null;
69        $file = null;
70        $signRequest = null;
71
72        try {
73            if (empty($argument)) {
74                throw new \InvalidArgumentException('SignSingleFileJob: Cannot proceed with empty arguments');
75            }
76
77            [$fileId, $signRequestId] = $this->requireIds($argument, 'SignSingleFileJob');
78            [$file, $signRequest] = $this->loadEntities($fileId, $signRequestId);
79            $this->progressService->clearSignRequestError($signRequest->getUuid());
80            $this->hydrateSignService($argument, $file, $signRequest);
81
82            $this->markInProgress($file);
83
84            $this->signFileService->signSingleFile($file, $signRequest);
85            return true;
86        } catch (\Exception $e) {
87            $this->signRequestErrorReporter->error('SignSingleFileJob failed for file {fileId}', [
88                'exception' => $e,
89                'fileId' => $file?->getId() ?? ($argument['fileId'] ?? null),
90                'signRequestId' => $signRequest?->getId() ?? ($argument['signRequestId'] ?? null),
91                'signRequestUuid' => $signRequest?->getUuid() ?? ($argument['signRequestUuid'] ?? null),
92            ]);
93            return false;
94        } finally {
95            $this->deleteCredentials($argument);
96        }
97    }
98
99    /**
100     * @return array{0:int,1:int}
101     */
102    private function requireIds(array $argument, string $jobName): array {
103        $fileId = $argument['fileId'] ?? null;
104        $signRequestId = $argument['signRequestId'] ?? null;
105        if ($fileId === null || $signRequestId === null) {
106            $this->logger->error($jobName . ': Background job missing required arguments', [
107                'jobName' => $jobName,
108                'hasFileId' => $fileId !== null,
109                'hasSignRequestId' => $signRequestId !== null,
110                'argumentKeys' => array_keys($argument),
111                'argumentCount' => count($argument),
112            ]);
113            throw new \InvalidArgumentException($jobName . ': Missing fileId or signRequestId');
114        }
115        return [$fileId, $signRequestId];
116    }
117
118    /**
119     * @return array{0:FileEntity,1:SignRequestEntity}
120     */
121    private function loadEntities(int $fileId, int $signRequestId): array {
122        $file = $this->fileMapper->getById($fileId);
123        $signRequest = $this->signRequestMapper->getById($signRequestId);
124        return [$file, $signRequest];
125    }
126
127    private function resolveUser(?string $userId): ?IUser {
128        if (empty($userId)) {
129            return null;
130        }
131        return $this->userManager->get($userId);
132    }
133
134    private function retrieveCredentials(IUser $user, ?string $credentialsId): ?array {
135        if (empty($credentialsId)) {
136            return null;
137        }
138        return $this->credentialsManager->retrieve($user->getUID(), $credentialsId);
139    }
140
141    private function hydrateSignService(array $argument, FileEntity $file, SignRequestEntity $signRequest): void {
142        $user = $this->resolveUser($argument['userId']);
143        // Always reset the context: one worker can process several signers.
144        $this->signFileService->setSignatureSessionId(
145            $argument['isExternalSigner'] ? ($argument['signatureSessionId'] ?? null) : null,
146        );
147
148        $credentials = null;
149        if ($user && !$argument['isExternalSigner']) {
150            $this->folderService->setUserId($user->getUID());
151            $credentials = $this->retrieveCredentials($user, $argument['credentialsId'] ?? null);
152        }
153
154        if ($argument['isExternalSigner']) {
155            $this->signFileService->setSignWithoutPassword(true);
156        } elseif ($credentials === null || ($credentials['signWithoutPassword'] ?? false)) {
157            $this->signFileService->setSignWithoutPassword(true);
158        } elseif ($credentials && !empty($credentials['password'])) {
159            $this->signFileService->setPassword($credentials['password']);
160        }
161
162        if (!empty($argument['userUniqueIdentifier'])) {
163            $this->signFileService->setUserUniqueIdentifier($argument['userUniqueIdentifier']);
164        }
165
166        if (!empty($argument['friendlyName'])) {
167            $this->signFileService->setFriendlyName($argument['friendlyName']);
168        }
169
170        if (!empty($argument['signatureMethod'])) {
171            $this->signFileService->setSignatureMethod($argument['signatureMethod']);
172        }
173
174        $this->signFileService
175            ->setLibreSignFile($file)
176            ->setSignRequest($signRequest)
177            ->setCurrentUser($user)
178            ->storeUserMetadata($argument['metadata'] ?? [])
179            ->setVisibleElements($argument['visibleElements'] ?? []);
180    }
181
182    private function markInProgress(FileEntity $file): void {
183        $statusBefore = $file->getStatus();
184        if ($statusBefore === FileStatus::SIGNING_IN_PROGRESS->value) {
185            return;
186        }
187
188        $file->setStatusEnum(FileStatus::SIGNING_IN_PROGRESS);
189        $this->fileService->update($file);
190    }
191
192    /** Also clean credentials for jobs safely skipped by the recovery guard. */
193    public function discardJobCredentials(array $argument): void {
194        $this->deleteCredentials($argument);
195    }
196
197    private function deleteCredentials(array $argument): void {
198        $credentialsId = $argument['credentialsId'] ?? null;
199        // External signers store their credentials in the anonymous vault, not
200        // under the file owner's user ID used for file resolution in the job.
201        $userId = ($argument['isExternalSigner'] ?? false) ? '' : ($argument['userId'] ?? null);
202        if (empty($credentialsId) || $userId === null) {
203            return;
204        }
205        try {
206            // Anonymous signers store their temporary credentials under userId ''.
207            $this->credentialsManager->delete($userId, $credentialsId);
208        } catch (\Exception $e) {
209            // Cleanup must not turn a completed signature into an uncertain attempt.
210            $this->logger->warning('Unable to delete temporary signing credentials', ['exception' => $e]);
211        }
212    }
213}