Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
91.82% |
146 / 159 |
|
78.57% |
11 / 14 |
CRAP | |
0.00% |
0 / 1 |
| NotificationDeliveryMapper | |
91.82% |
146 / 159 |
|
78.57% |
11 / 14 |
31.53 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register | |
87.50% |
28 / 32 |
|
0.00% |
0 / 1 |
12.28 | |||
| findByEventKey | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| findForEvent | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| claim | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
| finish | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
6 | |||
| cancelPendingInvitations | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| findById | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| findPending | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| findDue | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| failPending | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| findStaleSending | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| markStaleUnknown | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| redactOlderThan | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | /** |
| 5 | * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors |
| 6 | * SPDX-License-Identifier: AGPL-3.0-or-later |
| 7 | */ |
| 8 | |
| 9 | namespace OCA\Libresign\Db; |
| 10 | |
| 11 | use OCA\Libresign\Enum\NotificationDeliveryState; |
| 12 | use OCP\AppFramework\Db\DoesNotExistException; |
| 13 | use OCP\AppFramework\Db\QBMapper; |
| 14 | use OCP\DB\Exception as DatabaseException; |
| 15 | use OCP\DB\QueryBuilder\IQueryBuilder; |
| 16 | use OCP\IDBConnection; |
| 17 | |
| 18 | /** @template-extends QBMapper<NotificationDelivery> */ |
| 19 | class NotificationDeliveryMapper extends QBMapper { |
| 20 | public const MAX_ATTEMPTS = 5; |
| 21 | public function __construct(IDBConnection $db) { |
| 22 | parent::__construct($db, 'libresign_notification_delivery', NotificationDelivery::class); |
| 23 | } |
| 24 | |
| 25 | /** |
| 26 | * Registers one delivery for an event, channel and recipient. |
| 27 | * A duplicate is never an instruction to resubmit an uncertain delivery. |
| 28 | * |
| 29 | * @return NotificationDelivery The new or existing record. |
| 30 | * A previously committed PENDING event may be safely claimed by a later |
| 31 | * dispatch when a process died before transport submission. |
| 32 | */ |
| 33 | public function register( |
| 34 | string $eventKey, |
| 35 | string $correlationId, |
| 36 | string $eventType, |
| 37 | string $channel, |
| 38 | int $fileId, |
| 39 | ?int $signRequestId, |
| 40 | string $ownerId, |
| 41 | string $encryptedRecipient, |
| 42 | ?string $encryptedMessage = null, |
| 43 | ): NotificationDelivery { |
| 44 | if (strlen($eventKey) !== 64 || !ctype_xdigit($eventKey) |
| 45 | || strlen($correlationId) !== 64 || !ctype_xdigit($correlationId)) { |
| 46 | throw new \InvalidArgumentException('A 64-character event hash is required'); |
| 47 | } |
| 48 | $delivery = new NotificationDelivery(); |
| 49 | $delivery->setEventKey($eventKey); |
| 50 | $delivery->setCorrelationId($correlationId); |
| 51 | $delivery->setEventType($eventType); |
| 52 | $delivery->setChannel($channel); |
| 53 | $delivery->setFileId($fileId); |
| 54 | $delivery->setSignRequestId($signRequestId); |
| 55 | $delivery->setOwnerId($ownerId); |
| 56 | $delivery->setRecipientEnc($encryptedRecipient); |
| 57 | $delivery->setMessageEnc($encryptedMessage); |
| 58 | $delivery->setState(NotificationDeliveryState::PENDING->value); |
| 59 | $delivery->setAttemptCount(0); |
| 60 | $now = new \DateTime('now', new \DateTimeZone('UTC')); |
| 61 | $delivery->setCreatedAt($now); |
| 62 | $delivery->setUpdatedAt($now); |
| 63 | |
| 64 | // PostgreSQL aborts the entire surrounding transaction on a duplicate |
| 65 | // key error. A savepoint keeps the sign request transaction usable while |
| 66 | // still allowing the unique index to arbitrate concurrent registrations. |
| 67 | $savepoint = $this->db->inTransaction() ? 'ls_notify_' . bin2hex(random_bytes(8)) : null; |
| 68 | if ($savepoint !== null) { |
| 69 | $this->db->executeStatement('SAVEPOINT ' . $savepoint); |
| 70 | } |
| 71 | try { |
| 72 | try { |
| 73 | return $this->insert($delivery); |
| 74 | } catch (DatabaseException $e) { |
| 75 | if ($savepoint !== null) { |
| 76 | $this->db->executeStatement('ROLLBACK TO SAVEPOINT ' . $savepoint); |
| 77 | } |
| 78 | if ($e->getReason() !== DatabaseException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { |
| 79 | throw $e; |
| 80 | } |
| 81 | // A second writer raced us. Verify the event key; the exception |
| 82 | // could also be an unrelated constraint violation. |
| 83 | try { |
| 84 | return $this->findByEventKey($eventKey); |
| 85 | } catch (DoesNotExistException) { |
| 86 | throw $e; |
| 87 | } |
| 88 | } |
| 89 | } finally { |
| 90 | if ($savepoint !== null) { |
| 91 | $this->db->executeStatement('RELEASE SAVEPOINT ' . $savepoint); |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | public function findByEventKey(string $eventKey): NotificationDelivery { |
| 97 | $qb = $this->db->getQueryBuilder(); |
| 98 | $qb->select('*')->from($this->getTableName()) |
| 99 | ->where($qb->expr()->eq('event_key', $qb->createNamedParameter($eventKey))); |
| 100 | /** @var NotificationDelivery */ |
| 101 | return $this->findEntity($qb); |
| 102 | } |
| 103 | |
| 104 | /** @return list<NotificationDelivery> */ |
| 105 | public function findForEvent(string $correlationId): array { |
| 106 | $qb = $this->db->getQueryBuilder(); |
| 107 | $qb->select('*')->from($this->getTableName()) |
| 108 | ->where($qb->expr()->eq('correlation_id', $qb->createNamedParameter($correlationId))); |
| 109 | return $this->findEntities($qb); |
| 110 | } |
| 111 | |
| 112 | /** Claim is a compare-and-swap, so a second dispatcher cannot send again. */ |
| 113 | public function claim(int $deliveryId): bool { |
| 114 | $qb = $this->db->getQueryBuilder(); |
| 115 | $now = new \DateTime('now', new \DateTimeZone('UTC')); |
| 116 | return $qb->update($this->getTableName()) |
| 117 | ->set('state', $qb->createNamedParameter(NotificationDeliveryState::SENDING->value)) |
| 118 | ->set('attempt_count', $qb->createFunction('attempt_count + 1')) |
| 119 | ->set('updated_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE)) |
| 120 | ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT))) |
| 121 | ->andWhere($qb->expr()->lt('attempt_count', $qb->createNamedParameter(self::MAX_ATTEMPTS, IQueryBuilder::PARAM_INT))) |
| 122 | ->andWhere($qb->expr()->orX( |
| 123 | $qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::PENDING->value)), |
| 124 | $qb->expr()->andX( |
| 125 | $qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::TEMPORARY_FAILURE->value)), |
| 126 | $qb->expr()->lte('next_attempt_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE)), |
| 127 | ), |
| 128 | )) |
| 129 | ->executeStatement() === 1; |
| 130 | } |
| 131 | |
| 132 | public function finish(int $deliveryId, NotificationDeliveryState $state, ?\DateTime $retryAt = null): void { |
| 133 | if (!in_array($state, [ |
| 134 | NotificationDeliveryState::ACCEPTED, |
| 135 | NotificationDeliveryState::TEMPORARY_FAILURE, |
| 136 | NotificationDeliveryState::PERMANENT_FAILURE, |
| 137 | NotificationDeliveryState::FAILED, |
| 138 | NotificationDeliveryState::UNKNOWN, |
| 139 | ], true)) { |
| 140 | throw new \InvalidArgumentException('Invalid terminal notification state'); |
| 141 | } |
| 142 | if (($state === NotificationDeliveryState::TEMPORARY_FAILURE) !== ($retryAt !== null)) { |
| 143 | throw new \InvalidArgumentException('Only a scheduled temporary failure can retain a notification handoff'); |
| 144 | } |
| 145 | $qb = $this->db->getQueryBuilder(); |
| 146 | $now = new \DateTime('now', new \DateTimeZone('UTC')); |
| 147 | $update = $qb->update($this->getTableName()); |
| 148 | if ($retryAt === null) { |
| 149 | $update->set('message_enc', $qb->createNamedParameter(null)); |
| 150 | } |
| 151 | $updated = $update |
| 152 | ->set('state', $qb->createNamedParameter($state->value)) |
| 153 | ->set('updated_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE)) |
| 154 | ->set('next_attempt_at', $qb->createNamedParameter($retryAt, $retryAt === null ? IQueryBuilder::PARAM_NULL : IQueryBuilder::PARAM_DATETIME_MUTABLE)) |
| 155 | ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT))) |
| 156 | ->andWhere($qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::SENDING->value))) |
| 157 | ->executeStatement(); |
| 158 | if ($updated !== 1) { |
| 159 | throw new \LogicException('Notification delivery state changed during dispatch'); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * Invalidate obsolete invitations while the signing-state mutation is |
| 165 | * protected by its database transaction. A claimed submission is never |
| 166 | * rewritten because the provider may already have accepted it. |
| 167 | */ |
| 168 | public function cancelPendingInvitations(int $signRequestId): int { |
| 169 | $qb = $this->db->getQueryBuilder(); |
| 170 | return $qb->update($this->getTableName()) |
| 171 | ->set('state', $qb->createNamedParameter(NotificationDeliveryState::CANCELED->value)) |
| 172 | ->set('message_enc', $qb->createNamedParameter(null)) |
| 173 | ->set('updated_at', $qb->createNamedParameter(new \DateTime('now', new \DateTimeZone('UTC')), IQueryBuilder::PARAM_DATETIME_MUTABLE)) |
| 174 | ->where($qb->expr()->eq('sign_request_id', $qb->createNamedParameter($signRequestId, IQueryBuilder::PARAM_INT))) |
| 175 | ->andWhere($qb->expr()->eq('event_type', $qb->createNamedParameter('sign_request'))) |
| 176 | ->andWhere($qb->expr()->in('state', $qb->createNamedParameter([NotificationDeliveryState::PENDING->value, NotificationDeliveryState::TEMPORARY_FAILURE->value], IQueryBuilder::PARAM_STR_ARRAY))) |
| 177 | ->executeStatement(); |
| 178 | } |
| 179 | |
| 180 | public function findById(int $deliveryId): NotificationDelivery { |
| 181 | $qb = $this->db->getQueryBuilder(); |
| 182 | $qb->select('*')->from($this->getTableName()) |
| 183 | ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT))); |
| 184 | /** @var NotificationDelivery */ |
| 185 | return $this->findEntity($qb); |
| 186 | } |
| 187 | |
| 188 | /** @return list<NotificationDelivery> */ |
| 189 | public function findPending(int $limit = 50): array { |
| 190 | $qb = $this->db->getQueryBuilder(); |
| 191 | $qb->select('*')->from($this->getTableName()) |
| 192 | ->where($qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::PENDING->value))) |
| 193 | ->orderBy('created_at', 'ASC')->addOrderBy('id', 'ASC') |
| 194 | ->setMaxResults($limit); |
| 195 | return $this->findEntities($qb); |
| 196 | } |
| 197 | |
| 198 | /** |
| 199 | * Work due now, after the last scanned ID. Keyset pagination stays stable |
| 200 | * when other workers change delivery states while recovery is scanning. |
| 201 | * |
| 202 | * @return list<NotificationDelivery> |
| 203 | */ |
| 204 | public function findDue(int $limit = 50, int $afterId = 0): array { |
| 205 | $qb = $this->db->getQueryBuilder(); |
| 206 | $now = new \DateTime('now', new \DateTimeZone('UTC')); |
| 207 | $qb->select('*')->from($this->getTableName()) |
| 208 | ->where($qb->expr()->gt('id', $qb->createNamedParameter($afterId, IQueryBuilder::PARAM_INT))) |
| 209 | ->andWhere($qb->expr()->orX( |
| 210 | $qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::PENDING->value)), |
| 211 | $qb->expr()->andX( |
| 212 | $qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::TEMPORARY_FAILURE->value)), |
| 213 | $qb->expr()->lte('next_attempt_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE)), |
| 214 | ), |
| 215 | )) |
| 216 | ->orderBy('id', 'ASC')->setMaxResults($limit); |
| 217 | return $this->findEntities($qb); |
| 218 | } |
| 219 | |
| 220 | /** A corrupt handoff cannot safely enter a transport attempt. */ |
| 221 | public function failPending(int $deliveryId): bool { |
| 222 | $qb = $this->db->getQueryBuilder(); |
| 223 | return $qb->update($this->getTableName()) |
| 224 | ->set('state', $qb->createNamedParameter(NotificationDeliveryState::FAILED->value)) |
| 225 | ->set('message_enc', $qb->createNamedParameter(null)) |
| 226 | ->set('updated_at', $qb->createNamedParameter(new \DateTime('now', new \DateTimeZone('UTC')), IQueryBuilder::PARAM_DATETIME_MUTABLE)) |
| 227 | ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT))) |
| 228 | ->andWhere($qb->expr()->in('state', $qb->createNamedParameter([NotificationDeliveryState::PENDING->value, NotificationDeliveryState::TEMPORARY_FAILURE->value], IQueryBuilder::PARAM_STR_ARRAY))) |
| 229 | ->executeStatement() === 1; |
| 230 | } |
| 231 | |
| 232 | /** @return list<NotificationDelivery> */ |
| 233 | public function findStaleSending(\DateTime $before, int $limit = 200): array { |
| 234 | $qb = $this->db->getQueryBuilder(); |
| 235 | $qb->select('*')->from($this->getTableName()) |
| 236 | ->where($qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::SENDING->value))) |
| 237 | ->andWhere($qb->expr()->lt('updated_at', $qb->createNamedParameter($before, IQueryBuilder::PARAM_DATETIME_MUTABLE))) |
| 238 | ->setMaxResults($limit); |
| 239 | return $this->findEntities($qb); |
| 240 | } |
| 241 | |
| 242 | /** |
| 243 | * An interrupted submission has no reliable acceptance result. |
| 244 | * Never move a stale sending record back to pending. |
| 245 | */ |
| 246 | public function markStaleUnknown(int $deliveryId, \DateTime $before): bool { |
| 247 | $qb = $this->db->getQueryBuilder(); |
| 248 | return $qb->update($this->getTableName()) |
| 249 | ->set('state', $qb->createNamedParameter(NotificationDeliveryState::UNKNOWN->value)) |
| 250 | ->set('message_enc', $qb->createNamedParameter(null)) |
| 251 | ->set('updated_at', $qb->createNamedParameter(new \DateTime('now', new \DateTimeZone('UTC')), IQueryBuilder::PARAM_DATETIME_MUTABLE)) |
| 252 | ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT))) |
| 253 | ->andWhere($qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::SENDING->value))) |
| 254 | ->andWhere($qb->expr()->lt('updated_at', $qb->createNamedParameter($before, IQueryBuilder::PARAM_DATETIME_MUTABLE))) |
| 255 | ->executeStatement() === 1; |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * Redact recipient and owner data after an explicitly configured retention |
| 260 | * period, but KEEP the event key and terminal state as a deduplication |
| 261 | * tombstone. Deleting the delivery would allow a replay to submit again. |
| 262 | */ |
| 263 | public function redactOlderThan(\DateTime $before, int $limit = 500): int { |
| 264 | $qb = $this->db->getQueryBuilder(); |
| 265 | $rows = $qb->select('id')->from($this->getTableName()) |
| 266 | ->where($qb->expr()->lt('updated_at', $qb->createNamedParameter($before, IQueryBuilder::PARAM_DATETIME_MUTABLE))) |
| 267 | ->andWhere($qb->expr()->neq('recipient_enc', $qb->createNamedParameter(''))) |
| 268 | ->andWhere($qb->expr()->in('state', $qb->createNamedParameter([ |
| 269 | NotificationDeliveryState::ACCEPTED->value, |
| 270 | NotificationDeliveryState::PERMANENT_FAILURE->value, |
| 271 | NotificationDeliveryState::FAILED->value, |
| 272 | NotificationDeliveryState::UNKNOWN->value, |
| 273 | NotificationDeliveryState::CANCELED->value, |
| 274 | ], IQueryBuilder::PARAM_STR_ARRAY))) |
| 275 | ->setMaxResults($limit) |
| 276 | ->executeQuery()->fetchAllAssociative(); |
| 277 | if ($rows === []) { |
| 278 | return 0; |
| 279 | } |
| 280 | $ids = array_map(static fn (array $row): int => (int)$row['id'], $rows); |
| 281 | $update = $this->db->getQueryBuilder(); |
| 282 | return $update->update($this->getTableName()) |
| 283 | ->set('recipient_enc', $update->createNamedParameter('')) |
| 284 | ->set('owner_id', $update->createNamedParameter('')) |
| 285 | ->set('message_enc', $update->createNamedParameter(null)) |
| 286 | ->where($update->expr()->in('id', $update->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))) |
| 287 | ->executeStatement(); |
| 288 | } |
| 289 | } |