Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
PushController
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
5 / 5
15
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVapidPublicKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 subscribe
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 unsubscribe
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 status
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The Push Notification Controller for the Frontend API.
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
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\Controller\Frontend\Api;
21
22use phpMyFAQ\Controller\AbstractController;
23use phpMyFAQ\Entity\PushSubscriptionEntity;
24use phpMyFAQ\Filter;
25use phpMyFAQ\Push\PushSubscriptionRepository;
26use phpMyFAQ\Push\WebPushService;
27use Symfony\Component\HttpFoundation\JsonResponse;
28use Symfony\Component\HttpFoundation\Request;
29use Symfony\Component\HttpFoundation\Response;
30use Symfony\Component\Routing\Attribute\Route;
31
32final class PushController extends AbstractController
33{
34    public function __construct(
35        private readonly WebPushService $webPushService,
36        private readonly PushSubscriptionRepository $pushSubscriptionRepository,
37    ) {
38        parent::__construct();
39    }
40
41    /**
42     * Returns the VAPID public key and whether push is enabled.
43     */
44    #[Route(path: 'push/vapid-public-key', name: 'api.private.push.vapid-public-key', methods: ['GET'])]
45    public function getVapidPublicKey(): JsonResponse
46    {
47        return $this->json([
48            'enabled' => $this->webPushService->isEnabled(),
49            'vapidPublicKey' => $this->webPushService->getVapidPublicKey(),
50        ], Response::HTTP_OK);
51    }
52
53    /**
54     * Subscribes the current user to push notifications.
55     */
56    #[Route(path: 'push/subscribe', name: 'api.private.push.subscribe', methods: ['POST'])]
57    public function subscribe(Request $request): JsonResponse
58    {
59        $this->userIsAuthenticated();
60
61        try {
62            $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
63        } catch (\JsonException) {
64            return $this->json(['error' => 'Invalid JSON payload'], Response::HTTP_BAD_REQUEST);
65        }
66
67        $filteredEndpoint = Filter::filterVar($data->endpoint ?? '', FILTER_SANITIZE_URL);
68        $endpoint = is_string($filteredEndpoint) ? $filteredEndpoint : '';
69        $publicKey = Filter::filterVar($data->publicKey ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
70        $authToken = Filter::filterVar($data->authToken ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
71        $contentEncoding = Filter::filterVar($data->contentEncoding ?? 'aesgcm', FILTER_SANITIZE_SPECIAL_CHARS);
72
73        if ($endpoint === '' || $publicKey === '' || $authToken === '') {
74            return $this->json(['error' => 'Missing required subscription data'], Response::HTTP_BAD_REQUEST);
75        }
76
77        $entity = new PushSubscriptionEntity();
78        $entity
79            ->setUserId($this->currentUser->getUserId())
80            ->setEndpoint($endpoint)
81            ->setEndpointHash(hash('sha256', $endpoint))
82            ->setPublicKey($publicKey)
83            ->setAuthToken($authToken)
84            ->setContentEncoding($contentEncoding);
85
86        if ($this->pushSubscriptionRepository->save($entity)) {
87            return $this->json(['success' => true], Response::HTTP_CREATED);
88        }
89
90        return $this->json(['error' => 'Failed to save subscription'], Response::HTTP_BAD_REQUEST);
91    }
92
93    /**
94     * Unsubscribes the current user from push notifications.
95     */
96    #[Route(path: 'push/unsubscribe', name: 'api.private.push.unsubscribe', methods: ['POST'])]
97    public function unsubscribe(Request $request): JsonResponse
98    {
99        $this->userIsAuthenticated();
100
101        try {
102            $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
103        } catch (\JsonException) {
104            return $this->json(['error' => 'Invalid JSON payload'], Response::HTTP_BAD_REQUEST);
105        }
106
107        $filteredEndpoint = Filter::filterVar($data->endpoint ?? '', FILTER_SANITIZE_URL);
108        $endpoint = is_string($filteredEndpoint) ? $filteredEndpoint : '';
109
110        if ($endpoint === '') {
111            return $this->json(['error' => 'Missing endpoint'], Response::HTTP_BAD_REQUEST);
112        }
113
114        $endpointHash = hash('sha256', $endpoint);
115        $userId = $this->currentUser->getUserId();
116
117        if ($this->pushSubscriptionRepository->deleteByEndpointHashAndUserId($endpointHash, $userId)) {
118            return $this->json(['success' => true], Response::HTTP_OK);
119        }
120
121        return $this->json(['error' => 'Failed to remove subscription'], Response::HTTP_BAD_REQUEST);
122    }
123
124    /**
125     * Returns the subscription status for the current user.
126     */
127    #[Route(path: 'push/status', name: 'api.private.push.status', methods: ['GET'])]
128    public function status(): JsonResponse
129    {
130        $this->userIsAuthenticated();
131
132        return $this->json([
133            'subscribed' => $this->pushSubscriptionRepository->hasSubscription($this->currentUser->getUserId()),
134        ], Response::HTTP_OK);
135    }
136}