Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.27% covered (warning)
76.27%
90 / 118
57.14% covered (warning)
57.14%
8 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
BeforeNodeDeletedListener
76.27% covered (warning)
76.27%
90 / 118
57.14% covered (warning)
57.14%
8 / 14
57.29
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
 handle
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
23.81
 deleteAllByNodeId
83.78% covered (warning)
83.78%
31 / 37
0.00% covered (danger)
0.00%
0 / 1
12.61
 handleSignedFileDeleted
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 decodeMetadata
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
8.12
 markOriginalFileAsDeleted
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 detachSignedFile
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 deleteUserElement
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 deleteFileElement
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 deleteSigningData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 deleteIdentifyMethods
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
2.19
 deleteSignRequests
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 deleteIdDocs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 deleteFile
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
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\Listener;
10
11use OCA\Libresign\Enum\FileStatus;
12use OCA\Libresign\Service\FileStatusService;
13use OCA\Libresign\Service\Validation\FileInputValidator;
14use OCP\DB\QueryBuilder\IQueryBuilder;
15use OCP\EventDispatcher\Event;
16use OCP\EventDispatcher\IEventListener;
17use OCP\Files\Cache\CacheEntryRemovedEvent;
18use OCP\Files\Events\Node\BeforeNodeDeletedEvent;
19use OCP\Files\File;
20use OCP\Files\Folder;
21use OCP\IDBConnection;
22
23/**
24 * @template-implements IEventListener<Event|BeforeNodeDeletedEvent|CacheEntryRemovedEvent>
25 */
26class BeforeNodeDeletedListener implements IEventListener {
27    public function __construct(
28        private IDBConnection $db,
29    ) {
30    }
31
32    #[\Override]
33    public function handle(Event $event): void {
34        if ($event instanceof BeforeNodeDeletedEvent) {
35            $node = $event->getNode();
36            if (!$node instanceof File && !$node instanceof Folder) {
37                return;
38            }
39            if ($node instanceof File && !in_array($node->getMimeType(), FileInputValidator::VALID_MIMETIPE)) {
40                return;
41            }
42
43            $this->deleteAllByNodeId($node->getId());
44            return;
45        }
46
47        if ($event instanceof CacheEntryRemovedEvent) {
48            $this->deleteAllByNodeId($event->getFileId());
49        }
50    }
51
52    private function deleteAllByNodeId(int $nodeId): void {
53        if ($this->handleSignedFileDeleted($nodeId)) {
54            return;
55        }
56
57        $fullOuterJoin = $this->db->getQueryBuilder();
58        $fullOuterJoin->select($fullOuterJoin->expr()->literal(1));
59
60        $qb = $this->db->getQueryBuilder();
61        $qb
62            ->selectAlias('current.id', 'file_id')
63            ->selectAlias('current.metadata', 'file_metadata')
64            ->selectAlias('current.signed_node_id', 'current_signed_node_id')
65            ->selectAlias('parent.id', 'parent_id')
66            ->selectAlias('children.id', 'child_id')
67            ->selectAlias('ue.id', 'user_element_id')
68            ->selectAlias('fe.file_id', 'file_element_file_id')
69            ->from($qb->createFunction('(' . $fullOuterJoin->getSQL() . ')'), 'foj')
70            ->leftJoin('foj', 'libresign_file', 'current', $qb->expr()->eq('current.node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT)))
71            ->leftJoin('current', 'libresign_file', 'parent', $qb->expr()->eq('parent.id', 'current.parent_file_id'))
72            ->leftJoin('current', 'libresign_file', 'children', $qb->expr()->eq('children.parent_file_id', 'current.id'))
73            ->leftJoin('foj', 'libresign_user_element', 'ue', $qb->expr()->eq('ue.node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT)))
74            ->leftJoin('foj', 'libresign_file_element', 'fe', $qb->expr()->eq('fe.file_id', 'current.id'));
75
76        $cursor = $qb->executeQuery();
77
78        $deletedFiles = [];
79        $deletedUserElements = false;
80        $deletedFileElements = [];
81
82        while ($row = $cursor->fetch()) {
83            if (!empty($row['user_element_id']) && !$deletedUserElements) {
84                $deletedUserElements = true;
85                $this->deleteUserElement($nodeId);
86            }
87
88            if (!empty($row['file_element_file_id']) && !isset($deletedFileElements[$row['file_element_file_id']])) {
89                $deletedFileElements[(int)$row['file_element_file_id']] = true;
90                $this->deleteFileElement((int)$row['file_element_file_id']);
91            }
92
93            if (!empty($row['file_id']) && !isset($deletedFiles[$row['file_id']])) {
94                $deletedFiles[(int)$row['file_id']] = true;
95
96                if (!empty($row['parent_id']) || !empty($row['child_id'])) {
97                    $this->deleteSigningData((int)$row['file_id']);
98                    $this->deleteFile((int)$row['file_id']);
99                } else {
100                    $this->markOriginalFileAsDeleted((int)$row['file_id'], isset($row['file_metadata']) ? (string)$row['file_metadata'] : null);
101                }
102            }
103        }
104        $cursor->closeCursor();
105    }
106
107    private function handleSignedFileDeleted(int $nodeId): bool {
108        $qb = $this->db->getQueryBuilder();
109        $qb->select('id', 'node_id', 'signed_node_id', 'metadata')
110            ->from('libresign_file')
111            ->where($qb->expr()->eq('signed_node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT)));
112
113        $files = $qb->executeQuery()->fetchAll();
114
115        foreach ($files as $file) {
116            $fileId = (int)$file['id'];
117            $this->deleteSigningData($fileId);
118            $this->detachSignedFile($fileId, isset($file['metadata']) ? (string)$file['metadata'] : null);
119        }
120
121        return !empty($files);
122    }
123
124    private function decodeMetadata(?string $metadataJson): array {
125        if ($metadataJson === null || $metadataJson === '') {
126            return [];
127        }
128
129        try {
130            $decoded = json_decode($metadataJson, true, 512, JSON_THROW_ON_ERROR);
131            return is_array($decoded) ? $decoded : [];
132        } catch (\Throwable) {
133            return [];
134        }
135    }
136
137    private function markOriginalFileAsDeleted(int $fileId, ?string $metadataJson = null): void {
138        $existingMetadata = $this->decodeMetadata($metadataJson);
139        $existingMetadata['original_file_deleted'] = true;
140        $existingMetadata['original_file_deleted_at'] = (new \DateTime('now', new \DateTimeZone('UTC')))->format(\DateTime::ATOM);
141
142        $update = $this->db->getQueryBuilder();
143        $update->update('libresign_file')
144            ->set('node_id', $update->createNamedParameter(null, IQueryBuilder::PARAM_NULL))
145            ->set('metadata', $update->createNamedParameter(json_encode($existingMetadata), IQueryBuilder::PARAM_STR))
146            ->where($update->expr()->eq('id', $update->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
147            ->executeStatement();
148    }
149
150    private function detachSignedFile(int $fileId, ?string $metadataJson): void {
151        // The request was signed, so going back to DRAFT keeps its policy snapshot frozen.
152        $metadata = $this->decodeMetadata($metadataJson);
153        $metadata[FileStatusService::POLICY_SNAPSHOT_FROZEN_AT] ??= (new \DateTime('now', new \DateTimeZone('UTC')))->format(\DateTime::ATOM);
154
155        $qb = $this->db->getQueryBuilder();
156        $qb->update('libresign_file')
157            ->set('signed_node_id', $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL))
158            ->set('status', $qb->createNamedParameter(FileStatus::DRAFT->value, IQueryBuilder::PARAM_INT))
159            ->set('metadata', $qb->createNamedParameter(json_encode($metadata), IQueryBuilder::PARAM_STR))
160            ->set('signed_hash', $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL))
161            ->where($qb->expr()->eq('id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
162            ->executeStatement();
163    }
164
165    private function deleteUserElement(int $nodeId): void {
166        $qb = $this->db->getQueryBuilder();
167        $qb->delete('libresign_user_element')
168            ->where($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT)))
169            ->executeStatement();
170    }
171
172    private function deleteFileElement(int $fileId): void {
173        $qb = $this->db->getQueryBuilder();
174        $qb->delete('libresign_file_element')
175            ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
176            ->executeStatement();
177    }
178
179    private function deleteSigningData(int $fileId): void {
180        $this->deleteIdentifyMethods($fileId);
181        $this->deleteSignRequests($fileId);
182        $this->deleteIdDocs($fileId);
183        $this->deleteFileElement($fileId);
184    }
185
186    private function deleteIdentifyMethods(int $fileId): void {
187        $qb = $this->db->getQueryBuilder();
188        $qb->select('id')
189            ->from('libresign_sign_request')
190            ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
191        $cursor = $qb->executeQuery();
192
193        while ($row = $cursor->fetch()) {
194            $delete = $this->db->getQueryBuilder();
195            $delete->delete('libresign_identify_method')
196                ->where($delete->expr()->eq('sign_request_id', $delete->createNamedParameter($row['id'], IQueryBuilder::PARAM_INT)))
197                ->executeStatement();
198        }
199        $cursor->closeCursor();
200    }
201
202    private function deleteSignRequests(int $fileId): void {
203        $qb = $this->db->getQueryBuilder();
204        $qb->delete('libresign_sign_request')
205            ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
206            ->executeStatement();
207    }
208
209    private function deleteIdDocs(int $fileId): void {
210        $qb = $this->db->getQueryBuilder();
211        $qb->delete('libresign_id_docs')
212            ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
213            ->executeStatement();
214    }
215
216    private function deleteFile(int $fileId): void {
217        $qb = $this->db->getQueryBuilder();
218        $qb->delete('libresign_file')
219            ->where($qb->expr()->eq('id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
220            ->executeStatement();
221    }
222}