Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.53% covered (success)
91.53%
108 / 118
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
NotificationEnvelopeCodec
91.53% covered (success)
91.53%
108 / 118
60.00% covered (warning)
60.00%
3 / 5
21.27
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
 encode
95.56% covered (success)
95.56%
43 / 45
0.00% covered (danger)
0.00%
0 / 1
2
 snapshotNotification
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 decode
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
13
 restorePayload
70.37% covered (warning)
70.37%
19 / 27
0.00% covered (danger)
0.00%
0 / 1
4.42
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\Notification;
10
11use OCA\Libresign\Db\File;
12use OCA\Libresign\Db\NotificationDelivery;
13use OCA\Libresign\Db\SignRequest;
14use OCP\Notification\IManager;
15use OCP\Security\ICrypto;
16
17/**
18 * Versioned, encrypted transport handoff. Never serialize PHP objects, mail
19 * credentials, verification codes, or arbitrary payload classes into the queue.
20 * The encrypted snapshot also supports cancellation after a signer is removed.
21 */
22final class NotificationEnvelopeCodec {
23    private const MAX_BODY_BYTES = 16384;
24
25    public function __construct(
26        private ICrypto $crypto,
27        private IManager $notifications,
28    ) {
29    }
30
31    public function encode(NotificationMessage $message): string {
32        $payload = match (true) {
33            $message->payload instanceof MailPayload => [
34                'type' => 'mail',
35                'displayName' => $message->payload->displayName,
36                'firstNotification' => $message->payload->firstNotification,
37            ],
38            $message->payload instanceof GatewayPayload => [
39                'type' => 'gateway',
40                'gatewayName' => $message->payload->gatewayName,
41                'body' => $message->payload->body,
42                'extra' => $message->payload->extra,
43            ],
44            $message->payload instanceof InAppPayload => $this->snapshotNotification($message->payload),
45            $message->payload instanceof DataPayload => [
46                'type' => 'data',
47                'data' => $message->payload->data,
48            ],
49            default => throw new \LogicException('Notification payload cannot be queued'),
50        };
51        $encoded = json_encode([
52            'v' => 1,
53            'sourceEventId' => $message->sourceEventId,
54            'eventType' => $message->eventType,
55            'channel' => $message->channel,
56            'historyMethod' => $message->historyMethod,
57            'file' => [
58                'id' => $message->file->getId(),
59                'uuid' => $message->file->getUuid(),
60                'name' => $message->file->getName(),
61                'userId' => $message->file->getUserId(),
62                'nodeId' => $message->file->getNodeId(),
63            ],
64            'signRequest' => [
65                'id' => $message->signRequest->getId(),
66                'fileId' => $message->signRequest->getFileId(),
67                'uuid' => $message->signRequest->getUuid(),
68                'displayName' => $message->signRequest->getDisplayName(),
69                'description' => $message->signRequest->getDescription(),
70                'participantRole' => $message->signRequest->getParticipantRole(),
71            ],
72            'payload' => $payload,
73        ], JSON_THROW_ON_ERROR);
74        if (strlen($encoded) > self::MAX_BODY_BYTES) {
75            throw new \LogicException('Notification handoff exceeds the allowed size');
76        }
77        return $this->crypto->encrypt($encoded);
78    }
79
80    private function snapshotNotification(InAppPayload $payload): array {
81        $notification = $payload->notification;
82        return [
83            'type' => 'nextcloud',
84            'app' => $notification->getApp(),
85            'user' => $notification->getUser(),
86            'objectType' => $notification->getObjectType(),
87            'objectId' => $notification->getObjectId(),
88            'dateTime' => $notification->getDateTime()->format(DATE_ATOM),
89            'subject' => $notification->getSubject(),
90            'subjectParameters' => $notification->getSubjectParameters(),
91        ];
92    }
93
94    public function decode(NotificationDelivery $delivery): NotificationMessage {
95        $encrypted = $delivery->getMessageEnc();
96        if ($encrypted === null || $encrypted === '' || strlen($encrypted) > 131072) {
97            throw new \UnexpectedValueException('Notification handoff is unavailable');
98        }
99        $data = json_decode($this->crypto->decrypt($encrypted), true, 32, JSON_THROW_ON_ERROR);
100        if (!is_array($data) || ($data['v'] ?? null) !== 1
101            || ($data['channel'] ?? null) !== $delivery->getChannel()
102            || ($data['eventType'] ?? null) !== $delivery->getEventType()
103            || ($data['file']['id'] ?? null) !== $delivery->getFileId()
104            || ($data['signRequest']['id'] ?? null) !== $delivery->getSignRequestId()
105        ) {
106            throw new \UnexpectedValueException('Notification handoff does not match its delivery');
107        }
108        $file = new File();
109        $file->setId((int)$data['file']['id']);
110        $file->setUuid((string)$data['file']['uuid']);
111        $file->setName((string)$data['file']['name']);
112        $file->setUserId((string)$data['file']['userId']);
113        $file->setNodeId($data['file']['nodeId'] === null ? null : (int)$data['file']['nodeId']);
114
115        $request = new SignRequest();
116        $request->setId((int)$data['signRequest']['id']);
117        $request->setFileId((int)$data['signRequest']['fileId']);
118        $request->setUuid((string)$data['signRequest']['uuid']);
119        $request->setDisplayName((string)$data['signRequest']['displayName']);
120        $request->setDescription($data['signRequest']['description'] === null ? null : (string)$data['signRequest']['description']);
121        $request->setParticipantRole((string)$data['signRequest']['participantRole']);
122
123        $payload = $this->restorePayload($data['payload']);
124        return new NotificationMessage(
125            (string)$data['sourceEventId'],
126            $delivery->getEventType(),
127            $delivery->getChannel(),
128            $file,
129            $request,
130            $this->crypto->decrypt($delivery->getRecipientEnc()),
131            $payload,
132            isset($data['historyMethod']) ? (string)$data['historyMethod'] : null,
133        );
134    }
135
136    private function restorePayload(array $payload): NotificationPayload {
137        return match ($payload['type'] ?? null) {
138            'mail' => new MailPayload(
139                isset($payload['displayName']) ? (string)$payload['displayName'] : null,
140                $payload['firstNotification'] === true,
141            ),
142            'gateway' => new GatewayPayload(
143                (string)$payload['gatewayName'],
144                (string)$payload['body'],
145                is_array($payload['extra'] ?? [])
146                    ? $payload['extra'] ?? []
147                    : throw new \UnexpectedValueException('Invalid gateway delivery options'),
148            ),
149            'data' => new DataPayload(
150                is_array($payload['data'] ?? null)
151                    ? $payload['data']
152                    : throw new \UnexpectedValueException('Invalid extension notification data'),
153            ),
154            'nextcloud' => new InAppPayload(
155                $this->notifications->createNotification()
156                    ->setApp((string)$payload['app'])
157                    ->setUser((string)$payload['user'])
158                    ->setObject((string)$payload['objectType'], (string)$payload['objectId'])
159                    ->setDateTime(new \DateTime((string)$payload['dateTime']))
160                    ->setSubject((string)$payload['subject'], $payload['subjectParameters'] ?? []),
161            ),
162            default => throw new \UnexpectedValueException('Unsupported queued notification payload'),
163        };
164    }
165}