Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.39% covered (warning)
61.39%
124 / 202
47.37% covered (danger)
47.37%
9 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileMapper
61.39% covered (warning)
61.39%
124 / 202
47.37% covered (danger)
47.37%
9 / 19
207.68
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
 update
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 cacheEntity
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getByIdForUpdate
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 getById
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getBySignedHash
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 getByUuid
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getStorageUserIdByUuid
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 getBySignerUuid
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
1.01
 getByNodeId
20.00% covered (danger)
20.00%
3 / 15
0.00% covered (danger)
0.00%
0 / 1
12.19
 nodeIdExists
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getTextOfStatus
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 neutralizeDeletedUser
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getChildrenFiles
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 getParentEnvelope
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 findEnvelope
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 countChildrenFiles
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 findByStatus
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 findStaleSigningInProgress
81.82% covered (warning)
81.82%
18 / 22
0.00% covered (danger)
0.00%
0 / 1
9.49
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\Db;
10
11use OCA\Libresign\Enum\FileStatus;
12use OCA\Libresign\Enum\NodeType;
13use OCP\AppFramework\Db\DoesNotExistException;
14use OCP\AppFramework\Db\Entity;
15use OCP\Comments\ICommentsManager;
16use OCP\DB\QueryBuilder\IQueryBuilder;
17use OCP\ICacheFactory;
18use OCP\IDBConnection;
19use OCP\IL10N;
20
21/**
22 * Class FileMapper
23 *
24 * @package OCA\Libresign\DB
25 * @template-extends CachedQBMapper<File>
26 */
27class FileMapper extends CachedQBMapper {
28    public function __construct(
29        IDBConnection $db,
30        private IL10N $l,
31        ICacheFactory $cacheFactory,
32    ) {
33        parent::__construct($db, $cacheFactory, 'libresign_file');
34    }
35
36    #[\Override]
37    public function update(Entity $entity): File {
38        $entityId = $entity->getId();
39        if ($entityId !== null) {
40            $cached = $this->cacheGet('id:' . $entityId);
41            if ($cached instanceof File) {
42                $nodeId = $cached->getNodeId();
43                if ($nodeId !== null) {
44                    $this->cacheRemove('node_id:' . $nodeId);
45                }
46                $uuid = $cached->getUuid();
47                if ($uuid !== '') {
48                    $this->cacheRemove('uuid:' . $uuid);
49                }
50            }
51        }
52        /** @var File */
53        return parent::update($entity);
54    }
55
56    #[\Override]
57    protected function cacheEntity(Entity $entity): void {
58        parent::cacheEntity($entity);
59        if ($entity instanceof File) {
60            $nodeId = $entity->getNodeId();
61            if ($nodeId !== null) {
62                $this->cacheSet('node_id:' . $nodeId, $entity->getId());
63            }
64            $uuid = $entity->getUuid();
65            if ($uuid !== '') {
66                $this->cacheSet('uuid:' . $uuid, $entity->getId());
67            }
68        }
69    }
70
71    /**
72     * Lock and reload the current file state, independently of the entity cache.
73     */
74    public function getByIdForUpdate(int $id): File {
75        $qb = $this->db->getQueryBuilder();
76        $qb->select('*')
77            ->from($this->getTableName())
78            ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
79        if ($this->db->getDatabaseProvider() !== IDBConnection::PLATFORM_SQLITE) {
80            $qb->forUpdate();
81        }
82
83        /** @var File */
84        return $this->findEntity($qb);
85    }
86
87    /**
88     * Return LibreSign file by ID
89     *
90     * @throws DoesNotExistException
91     * @return File Row of table libresign_file
92     */
93    public function getById(int $id): File {
94        $cached = $this->cacheGet('id:' . $id);
95        if ($cached instanceof File) {
96            return $cached;
97        }
98        $qb = $this->db->getQueryBuilder();
99
100        $qb->select('*')
101            ->from($this->getTableName())
102            ->where(
103                $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
104            );
105
106        /** @var File */
107        $file = $this->findEntity($qb);
108        $this->cacheEntity($file);
109
110        return $file;
111    }
112
113    /**
114     * Return LibreSign file by signed hash
115     *
116     * @throws DoesNotExistException
117     * @return File Row of table libresign_file
118     */
119    public function getBySignedHash(string $hash): File {
120        $qb = $this->db->getQueryBuilder();
121
122        $qb->select('f.*')
123            ->from($this->getTableName(), 'f')
124            ->join('f', 'libresign_sign_request', 'sr', $qb->expr()->eq('f.id', 'sr.file_id'))
125            ->where(
126                $qb->expr()->orX(
127                    $qb->expr()->eq('f.signed_hash', $qb->createNamedParameter($hash)),
128                    $qb->expr()->eq('sr.signed_hash', $qb->createNamedParameter($hash))
129                )
130            )
131            ->setMaxResults(1);
132
133        /** @var File */
134        $file = $this->findEntity($qb);
135        $this->cacheEntity($file);
136        return $file;
137    }
138
139    /**
140     * Return LibreSign file by file UUID
141     */
142    public function getByUuid(string $uuid): File {
143        $cachedId = $this->cacheGet('uuid:' . $uuid);
144        if (is_int($cachedId) || (is_string($cachedId) && ctype_digit($cachedId))) {
145            return $this->getById((int)$cachedId);
146        }
147        $qb = $this->db->getQueryBuilder();
148
149        $qb->select('*')
150            ->from($this->getTableName())
151            ->where(
152                $qb->expr()->eq('uuid', $qb->createNamedParameter($uuid))
153            );
154
155        /** @var File */
156        $file = $this->findEntity($qb);
157        $this->cacheEntity($file);
158
159        return $file;
160    }
161
162    /**
163     * @return ?string userId for storage lookup (null for appdata)
164     * @throws DoesNotExistException
165     */
166    public function getStorageUserIdByUuid(string $uuid): ?string {
167        $qb = $this->db->getQueryBuilder();
168
169        $qb->select('f.user_id')
170            ->from($this->getTableName(), 'f')
171            ->where($qb->expr()->eq('f.uuid', $qb->createNamedParameter($uuid)));
172
173        $result = $qb->executeQuery();
174        $row = $result->fetch();
175        $result->closeCursor();
176
177        if (!$row) {
178            throw new DoesNotExistException('File not found');
179        }
180
181        return $row['user_id'];
182    }
183
184    /**
185     * Return LibreSign file by signer UUID
186     */
187    public function getBySignerUuid(?string $uuid = null): File {
188        $qb = $this->db->getQueryBuilder();
189
190        $qb->select('f.*')
191            ->from($this->getTableName(), 'f')
192            ->join('f', 'libresign_sign_request', 'sr', $qb->expr()->eq('f.id', 'sr.file_id'))
193            ->where(
194                $qb->expr()->eq('sr.uuid', $qb->createNamedParameter($uuid))
195            );
196
197        /** @var File */
198        $file = $this->findEntity($qb);
199        $this->cacheEntity($file);
200        return $file;
201    }
202
203    /**
204     * Return LibreSign file by nodeId
205     */
206    public function getByNodeId(int $nodeId): File {
207        $cachedId = $this->cacheGet('node_id:' . $nodeId);
208        if (is_int($cachedId) || (is_string($cachedId) && ctype_digit($cachedId))) {
209            return $this->getById((int)$cachedId);
210        }
211        $qb = $this->db->getQueryBuilder();
212
213        $qb->select('*')
214            ->from($this->getTableName())
215            ->where(
216                $qb->expr()->orX(
217                    $qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT)),
218                    $qb->expr()->eq('signed_node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))
219                )
220            );
221
222        /** @var File */
223        $file = $this->findEntity($qb);
224        $this->cacheEntity($file);
225        return $file;
226    }
227
228    public function nodeIdExists(int $nodeId): bool {
229        $qb = $this->db->getQueryBuilder();
230
231        $qb->select('*')
232            ->from($this->getTableName())
233            ->where(
234                $qb->expr()->orX(
235                    $qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT)),
236                    $qb->expr()->eq('signed_node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))
237                )
238            );
239
240        $files = $this->findEntities($qb);
241        if (!empty($files)) {
242            foreach ($files as $file) {
243                $this->cacheEntity($file);
244            }
245            return true;
246        }
247        return false;
248    }
249
250    public function getTextOfStatus(int|FileStatus $status): string {
251        if (is_int($status)) {
252            $status = FileStatus::from($status);
253        }
254        return $status->getLabel($this->l);
255    }
256
257    public function neutralizeDeletedUser(string $userId, string $displayName): void {
258        $update = $this->db->getQueryBuilder();
259        $qb = $this->db->getQueryBuilder();
260        $qb->select('f.id')
261            ->addSelect('f.metadata')
262            ->from($this->getTableName(), 'f')
263            ->where($qb->expr()->eq('f.user_id', $qb->createNamedParameter($userId)));
264        $cursor = $qb->executeQuery();
265        while ($row = $cursor->fetch()) {
266            $row['metadata'] = json_decode((string)$row['metadata'], true);
267            $row['metadata']['deleted_account'] = [
268                'account' => $userId,
269                'display_name' => $displayName,
270            ];
271            $update->update($this->getTableName())
272                ->set('user_id', $update->createNamedParameter(ICommentsManager::DELETED_USER))
273                ->set('metadata', $update->createNamedParameter($row['metadata'], IQueryBuilder::PARAM_JSON))
274                ->where($update->expr()->eq('id', $update->createNamedParameter($row['id'])));
275            $update->executeStatement();
276        }
277    }
278
279    /**
280     * @return list<File>
281     */
282    public function getChildrenFiles(int $parentId): array {
283        $qb = $this->db->getQueryBuilder();
284
285        $qb->select('*')
286            ->from($this->getTableName())
287            ->where(
288                $qb->expr()->eq('parent_file_id', $qb->createNamedParameter($parentId, IQueryBuilder::PARAM_INT))
289            )
290            ->andWhere(
291                $qb->expr()->eq('node_type', $qb->createNamedParameter(NodeType::FILE->value))
292            )
293            ->orderBy('id', 'ASC');
294
295        $children = $this->findEntities($qb);
296
297        foreach ($children as $child) {
298            $this->cacheEntity($child);
299        }
300
301        return array_values($children);
302    }
303
304    public function getParentEnvelope(int $fileId): ?File {
305        $file = $this->getById($fileId);
306
307        if (!$file->hasParent()) {
308            return null;
309        }
310
311        return $this->getById($file->getParentFileId());
312    }
313
314    /**
315     * The envelope of a document, or null when it has none or the envelope no
316     * longer exists.
317     */
318    public function findEnvelope(File $file): ?File {
319        $envelopeId = $file->getParentFileId();
320        if ($envelopeId === null) {
321            return null;
322        }
323
324        try {
325            return $this->getById($envelopeId);
326        } catch (DoesNotExistException) {
327            return null;
328        }
329    }
330
331    public function countChildrenFiles(int $envelopeId): int {
332        $qb = $this->db->getQueryBuilder();
333
334        $qb->select($qb->func()->count('*', 'count'))
335            ->from($this->getTableName())
336            ->where(
337                $qb->expr()->eq('parent_file_id', $qb->createNamedParameter($envelopeId, IQueryBuilder::PARAM_INT))
338            )
339            ->andWhere(
340                $qb->expr()->eq('node_type', $qb->createNamedParameter(NodeType::FILE->value))
341            );
342
343        $cursor = $qb->executeQuery();
344        $row = $cursor->fetch();
345        $cursor->closeCursor();
346
347        return $row ? (int)$row['count'] : 0;
348    }
349
350    /**
351     * Find all files with a specific status
352     *
353     * @param int $status File status
354     * @return File[]
355     */
356    public function findByStatus(int $status): array {
357        $qb = $this->db->getQueryBuilder();
358
359        $qb->select('*')
360            ->from($this->getTableName())
361            ->where(
362                $qb->expr()->eq('status', $qb->createNamedParameter($status, IQueryBuilder::PARAM_INT))
363            );
364
365        return $this->findEntities($qb);
366    }
367
368    /**
369     * Find files stuck in SIGNING_IN_PROGRESS status older than threshold
370     *
371     * @param \DateTime $staleThreshold Files created before this time will be returned
372     * @return File[]
373     */
374    public function findStaleSigningInProgress(\DateTime $staleThreshold): array {
375        $qb = $this->db->getQueryBuilder();
376        $qb->select('*')
377            ->from($this->getTableName())
378            ->where(
379                $qb->expr()->eq('status', $qb->createNamedParameter(FileStatus::SIGNING_IN_PROGRESS->value, IQueryBuilder::PARAM_INT))
380            );
381
382        $files = $this->findEntities($qb);
383
384        $stale = [];
385        foreach ($files as $file) {
386            $meta = $file->getMetadata();
387            $changedAt = is_array($meta) ? ($meta['status_changed_at'] ?? null) : null;
388            if ($changedAt !== null) {
389                // Creation time must not override a recent processing timestamp.
390                // Invalid timestamps require review, not an automatic retry.
391                if (!is_string($changedAt) || $changedAt === '') {
392                    continue;
393                }
394                try {
395                    $isStale = new \DateTime($changedAt) < $staleThreshold;
396                } catch (\Exception) {
397                    continue;
398                }
399            } else {
400                $created = $file->getCreatedAt();
401                $isStale = $created instanceof \DateTime && $created < $staleThreshold;
402            }
403
404            if ($isStale) {
405                $stale[] = $file;
406            }
407        }
408
409        return $stale;
410    }
411}