Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.39% covered (warning)
63.39%
71 / 112
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileInputValidator
63.39% covered (warning)
63.39%
71 / 112
50.00% covered (danger)
50.00%
8 / 16
279.69
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
 normalizeNodeId
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 validateNewFile
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 validateFile
42.11% covered (danger)
42.11%
16 / 38
0.00% covered (danger)
0.00%
0 / 1
65.68
 validateBase64
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
13.12
 validateContent
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 validateNotRequestedSign
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 validateIfNodeIdExists
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
6.00
 validateMimeTypeAcceptedByNodeId
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 validateMimeTypeAcceptedByMime
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 validateLibreSignFileId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolveUserId
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 resolveStoredFileUserId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 elementNeedFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTypeOfFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 throwInvalidBase64
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Service\Validation;
10
11use InvalidArgumentException;
12use OCA\Libresign\Db\FileMapper;
13use OCA\Libresign\Db\SignRequestMapper;
14use OCA\Libresign\Exception\LibresignException;
15use OCA\Libresign\Service\FolderService;
16use OCP\Files\IMimeTypeDetector;
17use OCP\Files\NotPermittedException;
18use OCP\IL10N;
19use OCP\IUser;
20use OCP\User\Exceptions\UserNotFoundException;
21
22class FileInputValidator {
23    public const TYPE_TO_SIGN = 1;
24    public const TYPE_VISIBLE_ELEMENT_PDF = 2;
25    public const TYPE_VISIBLE_ELEMENT_USER = 3;
26    public const TYPE_ACCOUNT_DOCUMENT = 4;
27    public const VALID_MIMETIPE = [
28        'application/pdf',
29        'image/png',
30    ];
31
32    public function __construct(
33        private IL10N $l10n,
34        private SignRequestMapper $signRequestMapper,
35        private FileMapper $fileMapper,
36        private IMimeTypeDetector $mimeTypeDetector,
37        private FolderService $folderService,
38    ) {
39    }
40
41    /**
42     * Normalize the node id of a file payload received over HTTP.
43     *
44     * The API accepts `nodeId` as a non-negative int or as its canonical
45     * decimal string (digits only, no sign, no leading zeros, within the int
46     * range): the Files app exposes node ids as strings (`Node.id` of
47     * `@nextcloud/files`) and, since Nextcloud 33, they can exceed what a
48     * JavaScript number holds. Call it once at the boundary: past it,
49     * `nodeId` is either absent or the non-negative `int` the Nextcloud Files
50     * API works with, and anything else is rejected here instead of reaching
51     * a later cast.
52     *
53     * @param array<string, mixed> $file
54     * @return array<string, mixed>
55     * @throws LibresignException when `nodeId` is present and is neither a non-negative int nor its canonical decimal string
56     */
57    public function normalizeNodeId(array $file, int $type = self::TYPE_TO_SIGN): array {
58        $nodeId = $file['nodeId'] ?? null;
59        if ($nodeId === null) {
60            return $file;
61        }
62        if (is_string($nodeId) && ctype_digit($nodeId)) {
63            // FILTER_VALIDATE_INT also rejects leading zeros and overflow.
64            $nodeId = filter_var($nodeId, FILTER_VALIDATE_INT);
65        }
66        if (is_int($nodeId) && $nodeId >= 0) {
67            $file['nodeId'] = $nodeId;
68            return $file;
69        }
70        throw new LibresignException($this->l10n->t('File type: %s. Invalid fileID.', [$this->getTypeOfFile($type)]));
71    }
72
73    public function validateNewFile(array $data, int $type = self::TYPE_TO_SIGN, ?IUser $user = null): void {
74        $this->validateFile($data, $type, $user);
75        if (!empty($data['file']['nodeId'])) {
76            $this->validateNotRequestedSign((int)$data['file']['nodeId']);
77        } elseif (!empty($data['file']['path'])) {
78            $uid = $this->resolveUserId($data, $user);
79            $node = $this->folderService->getFileByPath($data['file']['path'], $uid);
80            $this->validateNotRequestedSign($node->getId());
81        }
82    }
83
84    public function validateFile(array $data, int $type = self::TYPE_TO_SIGN, ?IUser $user = null): void {
85        if (empty($data['file'])) {
86            if (!empty($data['files'])) {
87                foreach ($data['files'] as $fileItem) {
88                    $this->validateFile([
89                        'file' => $fileItem,
90                        'userManager' => $data['userManager'] ?? null,
91                        'type' => $data['type'] ?? null,
92                    ], $type, $user);
93                }
94                return;
95            }
96            if ($type === self::TYPE_TO_SIGN) {
97                throw new LibresignException($this->l10n->t('File type: %s. Empty file.', [$this->getTypeOfFile($type)]));
98            }
99            if ($type === self::TYPE_VISIBLE_ELEMENT_USER && $this->elementNeedFile($data)) {
100                throw new LibresignException($this->l10n->t('Elements of type %s need file.', [$data['type']]));
101            }
102            return;
103        }
104
105        if (!empty($data['file']['url'])) {
106            if (!filter_var($data['file']['url'], FILTER_VALIDATE_URL)) {
107                throw new LibresignException($this->l10n->t('File type: %s. Specify a URL, a Base64 string or a fileID.', [$this->getTypeOfFile($type)]));
108            }
109            return;
110        }
111
112        if (!empty($data['file']['nodeId'])) {
113            if (!is_numeric($data['file']['nodeId'])) {
114                throw new LibresignException($this->l10n->t('File type: %s. Invalid fileID.', [$this->getTypeOfFile($type)]));
115            }
116            $uid = $this->resolveUserId($data, $user);
117            $this->validateIfNodeIdExists((int)$data['file']['nodeId'], $uid, $type);
118            $this->validateMimeTypeAcceptedByNodeId((int)$data['file']['nodeId'], $uid, $type);
119            return;
120        }
121
122        if (!empty($data['file']['fileId']) && $type === self::TYPE_VISIBLE_ELEMENT_PDF) {
123            if (!is_numeric($data['file']['fileId'])) {
124                throw new LibresignException($this->l10n->t('File type: %s. Invalid fileID.', [$this->getTypeOfFile($type)]));
125            }
126            $this->validateLibreSignFileId((int)$data['file']['fileId']);
127            return;
128        }
129
130        if (!empty($data['file']['base64'])) {
131            $this->validateBase64($data['file']['base64'], $type);
132            return;
133        }
134
135        if (!empty($data['file']['path'])) {
136            $uid = $this->resolveUserId($data, $user);
137            $this->folderService->getFileByPath($data['file']['path'], $uid);
138            return;
139        }
140
141        throw new LibresignException($this->l10n->t('File type: %s. Specify a URL, Base64 string, path or a fileID.', [$this->getTypeOfFile($type)]));
142    }
143
144    public function validateBase64(string $base64, int $type = self::TYPE_TO_SIGN): void {
145        $withMime = explode(',', $base64);
146        if (count($withMime) === 2) {
147            $withMime[0] = explode(';', $withMime[0]);
148            if (count($withMime[0]) !== 2 || $withMime[0][1] !== 'base64') {
149                $this->throwInvalidBase64($type);
150            }
151            if ($type === self::TYPE_TO_SIGN && $withMime[0][0] !== 'data:application/pdf') {
152                $this->throwInvalidBase64($type);
153            }
154            $base64 = $withMime[1];
155        }
156
157        $string = base64_decode($base64);
158        if (base64_encode($string) !== $base64) {
159            $this->throwInvalidBase64($type);
160        }
161
162        $this->validateContent($string, $type);
163    }
164
165    public function validateContent(string $content, int $type = self::TYPE_TO_SIGN): void {
166        if (in_array($type, [self::TYPE_VISIBLE_ELEMENT_USER, self::TYPE_VISIBLE_ELEMENT_PDF], true) && strlen($content) > 5000 * 1024) {
167            throw new InvalidArgumentException($this->l10n->t('File is too big'));
168        }
169
170        $mimeType = $this->mimeTypeDetector->detectString($content);
171        if ($type === self::TYPE_TO_SIGN && $mimeType !== 'application/pdf') {
172            $this->throwInvalidBase64($type);
173        }
174        if (in_array($type, [self::TYPE_VISIBLE_ELEMENT_USER, self::TYPE_VISIBLE_ELEMENT_PDF], true) && $mimeType !== 'image/png') {
175            $this->throwInvalidBase64($type);
176        }
177    }
178
179    public function validateNotRequestedSign(int $nodeId): void {
180        try {
181            $signRequests = $this->signRequestMapper->getByNodeId($nodeId);
182        } catch (\Throwable) {
183            $signRequests = [];
184        }
185        if (!empty($signRequests)) {
186            throw new LibresignException($this->l10n->t('Already asked to sign this document'));
187        }
188    }
189
190    public function validateIfNodeIdExists(int $nodeId, string $userId = '', int $type = self::TYPE_TO_SIGN): void {
191        $userId = $this->resolveStoredFileUserId($nodeId, $userId);
192        try {
193            $node = $this->folderService->getReadableNodeById($userId, $nodeId);
194        } catch (UserNotFoundException) {
195            throw new LibresignException($this->l10n->t('User not found.'));
196        } catch (NotPermittedException) {
197            throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
198        }
199        if ($node === null) {
200            throw new LibresignException($this->l10n->t('File type: %s. Invalid fileID.', [$this->getTypeOfFile($type)]));
201        }
202    }
203
204    public function validateMimeTypeAcceptedByNodeId(int $nodeId, string $userId = '', int $type = self::TYPE_TO_SIGN): void {
205        $userId = $this->resolveStoredFileUserId($nodeId, $userId);
206        $file = $this->folderService->getReadableNodeById($userId, $nodeId);
207        if ($file === null) {
208            throw new LibresignException($this->l10n->t('File type: %s. Invalid fileID.', [$this->getTypeOfFile($type)]));
209        }
210        $this->validateMimeTypeAcceptedByMime($file->getMimeType(), $type);
211    }
212
213    public function validateMimeTypeAcceptedByMime(string $mimetype, int $type = self::TYPE_TO_SIGN): void {
214        if ($type === self::TYPE_TO_SIGN && $mimetype !== 'application/pdf') {
215            throw new LibresignException($this->l10n->t('File type: %s. Must be a fileID of %s format.', [$this->getTypeOfFile($type), 'PDF']));
216        }
217        if (in_array($type, [self::TYPE_VISIBLE_ELEMENT_PDF, self::TYPE_VISIBLE_ELEMENT_USER], true) && $mimetype !== 'image/png') {
218            throw new LibresignException($this->l10n->t('File type: %s. Must be a fileID of %s format.', [$this->getTypeOfFile($type), 'png']));
219        }
220    }
221
222    public function validateLibreSignFileId(int $fileId): void {
223        try {
224            $this->fileMapper->getById($fileId);
225        } catch (\Throwable) {
226            throw new LibresignException($this->l10n->t('Invalid fileID'));
227        }
228    }
229
230    private function resolveUserId(array $data, ?IUser $user): string {
231        if ($user instanceof IUser) {
232            return $user->getUID();
233        }
234        if (isset($data['userManager']) && $data['userManager'] instanceof IUser) {
235            return $data['userManager']->getUID();
236        }
237        throw new LibresignException($this->l10n->t('User not found.'));
238    }
239
240    private function resolveStoredFileUserId(int $nodeId, string $userId): string {
241        if ($userId !== '') {
242            return $userId;
243        }
244        return $this->fileMapper->getByNodeId($nodeId)->getUserId();
245    }
246
247    private function elementNeedFile(array $data): bool {
248        return in_array($data['type'], ['signature', 'initial'], true);
249    }
250
251    private function getTypeOfFile(int $type): string {
252        return $type === self::TYPE_TO_SIGN
253            ? $this->l10n->t('document to sign')
254            : $this->l10n->t('visible signature element');
255    }
256
257    private function throwInvalidBase64(int $type): never {
258        throw new LibresignException($this->l10n->t('File type: %s. Invalid Base64 file.', [$this->getTypeOfFile($type)]));
259    }
260}