Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.17% covered (success)
98.17%
107 / 109
81.82% covered (success)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
PushSubscriptionRepository
98.17% covered (success)
98.17%
107 / 109
81.82% covered (success)
81.82%
9 / 11
24
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
 save
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
4
 deleteByEndpointHash
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 deleteByEndpointHashAndUserId
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 deleteByUserId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 deleteByEndpoint
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getByUserId
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 getByUserIds
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 getAll
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 hasSubscription
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 mapRowToEntity
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Repository for push notification subscriptions.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ\Push
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-02
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Push;
21
22use DateTimeImmutable;
23use phpMyFAQ\Configuration;
24use phpMyFAQ\Database;
25use phpMyFAQ\Entity\PushSubscriptionEntity;
26
27readonly class PushSubscriptionRepository
28{
29    private string $table;
30
31    public function __construct(
32        private Configuration $configuration,
33    ) {
34        $this->table = Database::getTablePrefix() . 'faqpush_subscriptions';
35    }
36
37    /**
38     * Saves a push subscription (upsert by endpoint_hash).
39     *
40     * Uses an atomic approach: attempts INSERT first, and if a duplicate key
41     * constraint violation occurs, falls back to UPDATE to avoid race conditions.
42     */
43    public function save(PushSubscriptionEntity $entity): bool
44    {
45        $db = $this->configuration->getDb();
46        $endpointHash = hash('sha256', $entity->getEndpoint());
47
48        // Try to insert first (atomic approach to avoid race conditions)
49        $nextId = $db->nextId($this->table, 'id');
50        $insertQuery = sprintf(
51            'INSERT INTO %s (id, user_id, endpoint, endpoint_hash, public_key, auth_token, content_encoding, created_at)'
52            . " VALUES (%d, %d, '%s', '%s', '%s', '%s', '%s', %s)",
53            $this->table,
54            $nextId,
55            $entity->getUserId(),
56            $db->escape($entity->getEndpoint()),
57            $db->escape($endpointHash),
58            $db->escape($entity->getPublicKey()),
59            $db->escape($entity->getAuthToken()),
60            $db->escape($entity->getContentEncoding() ?? 'aesgcm'),
61            $db->now(),
62        );
63
64        try {
65            $result = $db->query($insertQuery);
66            if ($result !== false) {
67                return true;
68            }
69        } catch (\Throwable $throwable) {
70            // Likely a duplicate key constraint violation, fall through to update.
71            // Keep a reference to avoid an empty catch block while intentionally ignoring the exception.
72            $ignoredInsertException = $throwable;
73        }
74
75        // INSERT failed (duplicate key constraint), perform UPDATE instead
76        $updateQuery = sprintf(
77            "UPDATE %s SET user_id = %d, endpoint = '%s', public_key = '%s', auth_token = '%s', "
78            . "content_encoding = '%s' WHERE endpoint_hash = '%s'",
79            $this->table,
80            $entity->getUserId(),
81            $db->escape($entity->getEndpoint()),
82            $db->escape($entity->getPublicKey()),
83            $db->escape($entity->getAuthToken()),
84            $db->escape($entity->getContentEncoding() ?? 'aesgcm'),
85            $db->escape($endpointHash),
86        );
87
88        try {
89            return (bool) $db->query($updateQuery);
90        } catch (\Throwable) {
91            return false;
92        }
93    }
94
95    /**
96     * Deletes a subscription by endpoint hash.
97     */
98    public function deleteByEndpointHash(string $endpointHash): bool
99    {
100        $db = $this->configuration->getDb();
101        $query = sprintf("DELETE FROM %s WHERE endpoint_hash = '%s'", $this->table, $db->escape($endpointHash));
102
103        return (bool) $db->query($query);
104    }
105
106    /**
107     * Deletes a subscription by endpoint hash scoped to a specific user.
108     * This ensures users can only delete their own subscriptions.
109     */
110    public function deleteByEndpointHashAndUserId(string $endpointHash, int $userId): bool
111    {
112        $db = $this->configuration->getDb();
113        $query = sprintf(
114            "DELETE FROM %s WHERE endpoint_hash = '%s' AND user_id = %d",
115            $this->table,
116            $db->escape($endpointHash),
117            $userId,
118        );
119
120        return (bool) $db->query($query);
121    }
122
123    /**
124     * Deletes all subscriptions for a user.
125     */
126    public function deleteByUserId(int $userId): bool
127    {
128        $db = $this->configuration->getDb();
129        $query = sprintf('DELETE FROM %s WHERE user_id = %d', $this->table, $userId);
130
131        return (bool) $db->query($query);
132    }
133
134    /**
135     * Deletes a subscription by its endpoint URL.
136     */
137    public function deleteByEndpoint(string $endpoint): bool
138    {
139        $endpointHash = hash('sha256', $endpoint);
140        return $this->deleteByEndpointHash($endpointHash);
141    }
142
143    /**
144     * Gets all subscriptions for a specific user.
145     *
146     * @return PushSubscriptionEntity[]
147     */
148    public function getByUserId(int $userId): array
149    {
150        $db = $this->configuration->getDb();
151        $query = sprintf('SELECT * FROM %s WHERE user_id = %d ORDER BY created_at DESC', $this->table, $userId);
152
153        $result = $db->query($query);
154        if ($result === false) {
155            return [];
156        }
157
158        $subscriptions = [];
159
160        $row = $db->fetchObject($result);
161        while ($row) {
162            $subscriptions[] = $this->mapRowToEntity($row);
163            $row = $db->fetchObject($result);
164        }
165
166        return $subscriptions;
167    }
168
169    /**
170     * Gets all subscriptions for multiple users.
171     *
172     * @param int[] $userIds
173     * @return PushSubscriptionEntity[]
174     */
175    public function getByUserIds(array $userIds): array
176    {
177        if ($userIds === []) {
178            return [];
179        }
180
181        $db = $this->configuration->getDb();
182        $ids = implode(',', array_map('intval', $userIds));
183        $query = sprintf('SELECT * FROM %s WHERE user_id IN (%s) ORDER BY created_at DESC', $this->table, $ids);
184
185        $result = $db->query($query);
186        if ($result === false) {
187            return [];
188        }
189
190        $subscriptions = [];
191
192        $row = $db->fetchObject($result);
193        while ($row) {
194            $subscriptions[] = $this->mapRowToEntity($row);
195            $row = $db->fetchObject($result);
196        }
197
198        return $subscriptions;
199    }
200
201    /**
202     * Gets all subscriptions.
203     *
204     * @return PushSubscriptionEntity[]
205     */
206    public function getAll(): array
207    {
208        $db = $this->configuration->getDb();
209        $query = sprintf('SELECT * FROM %s ORDER BY created_at DESC', $this->table);
210
211        $result = $db->query($query);
212        if ($result === false) {
213            return [];
214        }
215
216        $subscriptions = [];
217
218        $row = $db->fetchObject($result);
219        while ($row) {
220            $subscriptions[] = $this->mapRowToEntity($row);
221            $row = $db->fetchObject($result);
222        }
223
224        return $subscriptions;
225    }
226
227    /**
228     * Checks if a user has any active subscriptions.
229     */
230    public function hasSubscription(int $userId): bool
231    {
232        $db = $this->configuration->getDb();
233        $query = sprintf('SELECT id FROM %s WHERE user_id = %d', $this->table, $userId);
234
235        $result = $db->query($query);
236        if ($result === false) {
237            return false;
238        }
239
240        return (bool) $db->fetchObject($result);
241    }
242
243    private function mapRowToEntity(\stdClass $row): PushSubscriptionEntity
244    {
245        $entity = new PushSubscriptionEntity();
246
247        try {
248            $createdAt = new DateTimeImmutable((string) $row->created_at);
249        } catch (\Exception) {
250            // Fallback to current time if created_at is malformed
251            $createdAt = new DateTimeImmutable();
252        }
253
254        $contentEncoding = $row->content_encoding ?? null;
255
256        $entity
257            ->setId((int) $row->id)
258            ->setUserId((int) $row->user_id)
259            ->setEndpoint((string) $row->endpoint)
260            ->setEndpointHash((string) $row->endpoint_hash)
261            ->setPublicKey((string) $row->public_key)
262            ->setAuthToken((string) $row->auth_token)
263            ->setContentEncoding($contentEncoding === null ? null : (string) $contentEncoding)
264            ->setCreatedAt($createdAt);
265
266        return $entity;
267    }
268}