Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.81% covered (warning)
85.81%
133 / 155
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignersLoader
85.81% covered (warning)
85.81%
133 / 155
60.00% covered (warning)
60.00%
3 / 5
61.03
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
 loadLibreSignSigners
83.72% covered (warning)
83.72%
108 / 129
0.00% covered (danger)
0.00%
0 / 1
46.90
 isSignersTurn
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
 loadSignersFromCertData
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 reset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\File;
10
11use DateTimeInterface;
12use OCA\Libresign\Db\File;
13use OCA\Libresign\Db\SignRequest;
14use OCA\Libresign\Db\SignRequestMapper;
15use OCA\Libresign\Enum\SignatureFlow;
16use OCA\Libresign\Enum\SignRequestStatus;
17use OCA\Libresign\Service\IdentifyMethodService;
18use OCA\Libresign\Service\SignatureRejection\RejectionViewer;
19use OCA\Libresign\Service\SignatureRejection\SignatureRejectionVisibilityService;
20use OCA\Libresign\Service\SubjectAlternativeNameService;
21use OCP\Accounts\IAccountManager;
22use OCP\IUserManager;
23
24/**
25 * Handles loading signer data for files
26 */
27class SignersLoader {
28    private bool $signersLibreSignLoaded = false;
29    private CertificateSignersMergeService $certificateSignersMergeService;
30
31    public function __construct(
32        private SignRequestMapper $signRequestMapper,
33        private IdentifyMethodService $identifyMethodService,
34        private SubjectAlternativeNameService $subjectAlternativeNameService,
35        private IAccountManager $accountManager,
36        private IUserManager $userManager,
37        private SignatureRejectionVisibilityService $signatureRejectionVisibilityService,
38    ) {
39        $this->certificateSignersMergeService = new CertificateSignersMergeService();
40    }
41
42    public function loadLibreSignSigners(
43        ?File $file,
44        \stdClass $fileData,
45        FileResponseOptions $options,
46        array $certData = [],
47    ): void {
48        if ($this->signersLibreSignLoaded || !$file) {
49            return;
50        }
51        $signers = $this->signRequestMapper->getByFileId($file->getId());
52        if (empty($signers)) {
53            return;
54        }
55        $signRequestIds = array_column(array_map(fn ($s) => ['id' => $s->getId()], $signers), 'id');
56        $identifyMethodsBatch = $this->identifyMethodService
57            ->setIsRequest(false)
58            ->getIdentifyMethodsFromSignRequestIds($signRequestIds);
59
60        // Who the viewer is has to be known for every signer before any of
61        // them is presented: a hidden rejection redacts all unsigned signers.
62        $viewer = new RejectionViewer(
63            $options->getMe() !== null && $options->getMe()->getUID() === $file->getUserId(),
64            array_filter(
65                $signers,
66                fn (SignRequest $candidate): bool => $options->isViewerOfSigner($identifyMethodsBatch[$candidate->getId()] ?? []),
67            ),
68        );
69        $hiddenRejection = $this->signatureRejectionVisibilityService->hasHiddenRejection($file, $signers, $viewer);
70
71        foreach ($signers as $signer) {
72            $identifyMethods = $identifyMethodsBatch[$signer->getId()] ?? [];
73            if (!empty($fileData->signers)) {
74                $found = array_filter($fileData->signers, function (\stdClass $found) use ($identifyMethods) {
75                    if (!isset($found->uid)) {
76                        return false;
77                    }
78                    [$key, $value] = explode(':', (string)$found->uid);
79                    foreach ($identifyMethods as $methods) {
80                        foreach ($methods as $identifyMethod) {
81                            $entity = $identifyMethod->getEntity();
82                            if ($key === $entity->getIdentifierKey() && $value === $entity->getIdentifierValue()) {
83                                return true;
84                            }
85                        }
86                    }
87                    return false;
88                });
89                if (!empty($found)) {
90                    $index = key($found);
91                } else {
92                    $index = count($fileData->signers);
93                }
94            } else {
95                $index = 0;
96            }
97            if (!isset($fileData->signers[$index])) {
98                $fileData->signers[$index] = new \stdClass();
99            }
100            $fileData->signers[$index]->signRequestId = $signer->getId();
101            $fileData->signers[$index]->signed = $signer->getSigned()?->format(DateTimeInterface::ATOM);
102            $this->signatureRejectionVisibilityService->presentSigner(
103                $signer,
104                $file,
105                $viewer,
106                $hiddenRejection,
107            )->applyToObject($fileData->signers[$index]);
108            $fileData->signers[$index]->signingOrder = $signer->getSigningOrder();
109            $fileData->signers[$index]->participantRole = $signer->getParticipantRoleEnum()->value;
110            $fileData->signers[$index]->description = $signer->getDescription();
111            $fileData->signers[$index]->displayName = $signer->getDisplayName();
112            $fileData->signers[$index]->request_sign_date = $signer->getCreatedAt()->format(DateTimeInterface::ATOM);
113            $fileData->signers[$index]->metadata = $signer->getMetadata();
114            $fileData->signers[$index]->identifyMethods = [];
115            $fileData->signers[$index]->visibleElements = [];
116            foreach ($identifyMethods as $type => $methods) {
117                foreach ($methods as $identifyMethod) {
118                    $entity = $identifyMethod->getEntity();
119
120                    $fileData->signers[$index]->identifyMethods[] = [
121                        'method' => $entity->getIdentifierKey(),
122                        'value' => $entity->getIdentifierValue(),
123                        'requirement' => $entity->getRequirement(),
124                    ];
125
126                    switch ($type) {
127                        case 'account':
128                            $fileData->signers[$index]->uid = $entity->getUniqueIdentifier();
129                            $currentDisplayName = $fileData->signers[$index]->displayName ?? '';
130                            if ($currentDisplayName === '' || $currentDisplayName === $entity->getIdentifierValue()) {
131                                $user = $this->userManager->get($entity->getIdentifierValue());
132                                if ($user) {
133                                    $fileData->signers[$index]->displayName = $user->getDisplayName();
134                                } else {
135                                    $fileData->signers[$index]->displayName = $entity->getIdentifierValue();
136                                }
137                            }
138                            if (!isset($fileData->signers[$index]->email)) {
139                                $user = $this->userManager->get($entity->getIdentifierValue());
140                                if (!$user) {
141                                    break;
142                                }
143                                $account = $this->accountManager->getAccount($user);
144                                $fileData->signers[$index]->email = $account->getProperty('email')->getValue();
145                            }
146                            break;
147                        case 'email':
148                            $fileData->signers[$index]->email = $entity->getIdentifierValue();
149                            $fileData->signers[$index]->uid = $entity->getUniqueIdentifier();
150                            if (!isset($fileData->signers[$index]->displayName)) {
151                                $fileData->signers[$index]->displayName = $entity->getIdentifierValue();
152                            }
153                            break;
154                        case 'signatureinit':
155                            $fileData->signers[$index]->signatureMethod = 'password';
156                            if (!isset($fileData->signers[$index]->email)) {
157                                $fileData->signers[$index]->email = '';
158                            }
159                            break;
160                        case 'password':
161                            $fileData->signers[$index]->signatureMethod = 'password';
162                            if (!isset($fileData->signers[$index]->email)) {
163                                $fileData->signers[$index]->email = '';
164                            }
165                            break;
166                    }
167                }
168            }
169            if (isset($fileData->signers[$index]->uid)) {
170                $split = explode(':', $fileData->signers[$index]->uid);
171                $matches = [
172                    'key' => $split[0],
173                    'value' => $split[1],
174                ];
175                if (str_ends_with($matches['value'], $options->getHost())) {
176                    $uid = str_replace('@' . $options->getHost(), '', $matches['value']);
177                    if (!isset($fileData->signers[$index]->displayName) || $fileData->signers[$index]->displayName === '') {
178                        $fileData->signers[$index]->displayName = $uid;
179                    }
180                    $fileData->signers[$index]->uid = 'account:' . $uid;
181                }
182            }
183            $isRouteSigner = $options->getSignRequest()?->getId() === $signer->getId();
184            $fileData->signers[$index]->me = $isRouteSigner || $options->isViewerOfSigner($identifyMethods);
185
186            if ($fileData->signers[$index]->me) {
187                $fileData->signers[$index]->sign_request_uuid = $signer->getUuid();
188                if (isset($fileData->settings) && $this->isSignersTurn($file, $signer, $signers)) {
189                    $fileData->settings['canSign'] = true;
190                }
191                $fileData->signers[$index]->signatureMethods = [];
192                foreach ($identifyMethods as $methods) {
193                    foreach ($methods as $identifyMethod) {
194                        $entity = $identifyMethod->getEntity();
195                        $this->identifyMethodService->setCurrentIdentifyMethod($entity);
196                        $identifyMethodInstance = $this->identifyMethodService
197                            ->setIsRequest(false)
198                            ->getInstanceOfIdentifyMethod(
199                                $entity->getIdentifierKey(),
200                                $entity->getIdentifierValue(),
201                            );
202                        $signatureMethods = $identifyMethodInstance->getSignatureMethods();
203                        foreach ($signatureMethods as $signatureMethod) {
204                            if (!$signatureMethod->isEnabled()) {
205                                continue;
206                            }
207                            $signatureMethod->setEntity($identifyMethodInstance->getEntity());
208                            $signatureMethodData = $signatureMethod->toArray();
209                            $fileData->signers[$index]->signatureMethods[$signatureMethod->getName()] = $signatureMethodData;
210                        }
211                    }
212                }
213            }
214        }
215        ksort($fileData->signers);
216        $this->signersLibreSignLoaded = true;
217    }
218
219    /**
220     * Mirrors the checks made when signing, so the file response does not
221     * offer a signature that the signing endpoint would refuse.
222     *
223     * @param SignRequest[] $signers
224     */
225    private function isSignersTurn(File $file, SignRequest $signer, array $signers): bool {
226        if ($signer->getSigned()
227            || !$signer->getParticipantRoleEnum()->canSign()
228            || $signer->getStatusEnum() !== SignRequestStatus::ABLE_TO_SIGN
229        ) {
230            return false;
231        }
232        if ($file->getSignatureFlowEnum() !== SignatureFlow::ORDERED_NUMERIC) {
233            return true;
234        }
235        foreach ($signers as $other) {
236            if ($other->getParticipantRoleEnum()->canSign()
237                && $other->getSigningOrder() < $signer->getSigningOrder()
238                && !in_array($other->getStatusEnum(), [SignRequestStatus::SIGNED, SignRequestStatus::REJECTED], true)
239            ) {
240                return false;
241            }
242        }
243        return true;
244    }
245
246    public function loadSignersFromCertData(\stdClass $fileData, array $certData, string $host): void {
247        $this->certificateSignersMergeService->merge(
248            $fileData,
249            $certData,
250            $host,
251            $this->signRequestMapper->getTextOfSignerStatus(2),
252            fn (array $certData, string $currentHost): ?string => $this->identifyMethodService->resolveUid($certData, $currentHost),
253            fn (string $method, string $value): string => $this->subjectAlternativeNameService->build($method, $value),
254            function (string $accountId): ?string {
255                $user = $this->userManager->get($accountId);
256                return $user ? $user->getDisplayName() : null;
257            },
258        );
259    }
260
261    public function reset(): void {
262        $this->signersLibreSignLoaded = false;
263    }
264
265}