Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
116 / 116
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
webpush
100.00% covered (success)
100.00%
116 / 116
100.00% covered (success)
100.00%
9 / 9
31
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 notification
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 get_user_notifications
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 get_anonymous_notifications
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
8
 get_notification_data
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 worker
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 check_subscribe_requests
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
7
 subscribe
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 unsubscribe
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 *
4 * This file is part of the phpBB Forum Software package.
5 *
6 * @copyright (c) phpBB Limited <https://www.phpbb.com>
7 * @license GNU General Public License, version 2 (GPL-2.0)
8 *
9 * For full copyright and license information, please see
10 * the docs/CREDITS.txt file.
11 *
12 */
13
14namespace phpbb\ucp\controller;
15
16use phpbb\config\config;
17use phpbb\controller\helper as controller_helper;
18use phpbb\db\driver\driver_interface;
19use phpbb\exception\http_exception;
20use phpbb\form\form_helper;
21use phpbb\json\sanitizer as json_sanitizer;
22use phpbb\language\language;
23use phpbb\notification\manager;
24use phpbb\path_helper;
25use phpbb\request\request_interface;
26use phpbb\symfony_request;
27use phpbb\user;
28use phpbb\user_loader;
29use Symfony\Component\HttpFoundation\JsonResponse;
30use Symfony\Component\HttpFoundation\Response;
31use Twig\Environment;
32use Twig\Error\LoaderError;
33use Twig\Error\RuntimeError;
34use Twig\Error\SyntaxError;
35
36class webpush
37{
38    /** @var string UCP form token name */
39    public const FORM_TOKEN_UCP = 'ucp_webpush';
40
41    /** @var config */
42    protected $config;
43
44    /** @var controller_helper */
45    protected $controller_helper;
46
47    /** @var driver_interface */
48    protected $db;
49
50    /** @var form_helper */
51    protected $form_helper;
52
53    /** @var language */
54    protected $language;
55
56    /** @var manager */
57    protected $notification_manager;
58
59    /** @var path_helper */
60    protected $path_helper;
61
62    /** @var request_interface */
63    protected $request;
64
65    /** @var user_loader */
66    protected $user_loader;
67
68    /** @var user */
69    protected $user;
70
71    /** @var Environment */
72    protected $template;
73
74    /** @var string */
75    protected $notification_webpush_table;
76
77    /** @var string */
78    protected $push_subscriptions_table;
79
80    /**
81     * Constructor for webpush controller
82     *
83     * @param config $config
84     * @param controller_helper $controller_helper
85     * @param driver_interface $db
86     * @param form_helper $form_helper
87     * @param language $language
88     * @param manager $notification_manager
89     * @param path_helper $path_helper
90     * @param request_interface $request
91     * @param user_loader $user_loader
92     * @param user $user
93     * @param Environment $template
94     * @param string $notification_webpush_table
95     * @param string $push_subscriptions_table
96     */
97    public function __construct(config $config, controller_helper $controller_helper, driver_interface $db, form_helper $form_helper, language $language, manager $notification_manager,
98                                path_helper $path_helper, request_interface $request, user_loader $user_loader, user $user, Environment $template, string $notification_webpush_table, string $push_subscriptions_table)
99    {
100        $this->config = $config;
101        $this->controller_helper = $controller_helper;
102        $this->db = $db;
103        $this->form_helper = $form_helper;
104        $this->language = $language;
105        $this->notification_manager = $notification_manager;
106        $this->path_helper = $path_helper;
107        $this->request = $request;
108        $this->user_loader = $user_loader;
109        $this->user = $user;
110        $this->template = $template;
111        $this->notification_webpush_table = $notification_webpush_table;
112        $this->push_subscriptions_table = $push_subscriptions_table;
113    }
114
115    /**
116     * Handle request to retrieve notification data
117     *
118     * @return JsonResponse
119     */
120    public function notification(): JsonResponse
121    {
122        if (!$this->request->is_ajax() || $this->user->data['is_bot'] || $this->user->data['user_type'] == USER_INACTIVE)
123        {
124            throw new http_exception(Response::HTTP_FORBIDDEN, 'NO_AUTH_OPERATION');
125        }
126
127        if ($this->user->id() !== ANONYMOUS)
128        {
129            $notification_data = $this->get_user_notifications();
130        }
131        else
132        {
133            $notification_data = $this->get_anonymous_notifications();
134        }
135
136        return new JsonResponse($notification_data, 200, [], true);
137    }
138
139    /**
140     * Get notification data for logged in user
141     *
142     * @return string Notification data
143     */
144    private function get_user_notifications(): string
145    {
146        // Subscribe should only be available for logged-in "normal" users
147        if ($this->user->data['user_type'] == USER_IGNORE)
148        {
149            throw new http_exception(Response::HTTP_FORBIDDEN, 'NO_AUTH_OPERATION');
150        }
151
152        $item_id = $this->request->variable('item_id', 0);
153        $type_id = $this->request->variable('type_id', 0);
154
155        $sql = 'SELECT push_data
156            FROM ' . $this->notification_webpush_table . '
157            WHERE user_id = ' . (int) $this->user->id() . '
158                AND notification_type_id = ' . (int) $type_id . '
159                AND item_id = ' . (int) $item_id;
160        $result = $this->db->sql_query($sql);
161        $notification_data = $this->db->sql_fetchfield('push_data');
162        $this->db->sql_freeresult($result);
163
164        if (!$notification_data)
165        {
166            throw new http_exception(Response::HTTP_BAD_REQUEST, 'AJAX_ERROR_TEXT');
167        }
168
169        return $this->get_notification_data($notification_data) ?: '';
170    }
171
172    /**
173     * Get notification data for not logged in user via token
174     *
175     * @return string
176     */
177    private function get_anonymous_notifications(): string
178    {
179        $token = $this->request->variable('token', '');
180
181        if ($token)
182        {
183            $item_id = $this->request->variable('item_id', 0);
184            $type_id = $this->request->variable('type_id', 0);
185            $user_id = $this->request->variable('user_id', 0);
186
187            $sql = 'SELECT push_data, push_token
188                FROM ' . $this->notification_webpush_table . '
189                WHERE user_id = ' . (int) $user_id . '
190                    AND notification_type_id = ' . (int) $type_id . '
191                    AND item_id = ' . (int) $item_id;
192            $result = $this->db->sql_query($sql);
193            $notification_row = $this->db->sql_fetchrow($result);
194            $this->db->sql_freeresult($result);
195
196            if (!$notification_row || !isset($notification_row['push_data']) || !isset($notification_row['push_token']))
197            {
198                throw new http_exception(Response::HTTP_BAD_REQUEST, 'AJAX_ERROR_TEXT');
199            }
200
201            $notification_data = $notification_row['push_data'];
202            $push_token = $notification_row['push_token'];
203
204            // Check if passed push token is valid
205            $sql = 'SELECT user_form_salt, user_lang
206                FROM ' . USERS_TABLE . '
207                WHERE user_id = ' . (int) $user_id;
208            $result = $this->db->sql_query($sql);
209            $row = $this->db->sql_fetchrow($result);
210            $this->db->sql_freeresult($result);
211
212            $user_form_token = $row['user_form_salt'];
213            $user_lang = $row['user_lang'];
214
215            $expected_push_token = hash('sha256', $user_form_token . $push_token);
216            if ($expected_push_token === $token)
217            {
218                if ($user_lang !== $this->language->get_used_language())
219                {
220                    $this->language->set_user_language($user_lang, true);
221                }
222                return $this->get_notification_data($notification_data) ?: '';
223            }
224        }
225
226        throw new http_exception(Response::HTTP_FORBIDDEN, 'NO_AUTH_OPERATION');
227    }
228
229    /**
230     * Get notification data for output from json encoded data stored in database
231     *
232     * @param string $notification_data Encoded data stored in database
233     *
234     * @return false|string Data for notification output with javascript
235     */
236    private function get_notification_data(string $notification_data): false|string
237    {
238        $row_data = json_decode($notification_data, true);
239
240        // Old notification data is pre-parsed and just needs to be returned
241        if (isset($row_data['heading']))
242        {
243            return $notification_data;
244        }
245
246        // Get notification from row_data
247        $notification = $this->notification_manager->get_item_type_class($row_data['notification_type_name'], $row_data);
248
249        // Load users for notification
250        $this->user_loader->load_users($notification->users_to_query());
251
252        return json_encode([
253            'heading'    => html_entity_decode($this->config['sitename'], ENT_QUOTES, 'UTF-8'),
254            'title'        => strip_tags(html_entity_decode($notification->get_title(), ENT_NOQUOTES, 'UTF-8')),
255            'text'        => strip_tags(html_entity_decode($notification->get_reference(), ENT_NOQUOTES, 'UTF-8')),
256            'url'        => htmlspecialchars_decode($notification->get_url()),
257            'avatar'    => $notification->get_avatar(),
258        ]);
259    }
260
261    /**
262     * Handle request to push worker javascript
263     *
264     * @return Response
265     * @throws LoaderError
266     * @throws RuntimeError
267     * @throws SyntaxError
268     */
269    public function worker(): Response
270    {
271        $content = $this->template->render('push_worker.js.twig', [
272            'U_WEBPUSH_GET_NOTIFICATION'    => $this->controller_helper->route('phpbb_ucp_push_get_notification_controller'),
273            'ASSETS_VERSION'                => $this->config['assets_version'],
274        ]);
275
276        $response = new Response($content);
277        $response->headers->set('Content-Type', 'text/javascript; charset=UTF-8');
278
279        if (!empty($this->user->data['is_bot']))
280        {
281            // Let reverse proxies know we detected a bot.
282            $response->headers->set('X-PHPBB-IS-BOT', 'yes');
283        }
284
285        return $response;
286    }
287
288    /**
289     * Check (un)subscribe form for valid link hash
290     *
291     * @throws http_exception If form is invalid or user should not request (un)subscription
292     * @return void
293     */
294    protected function check_subscribe_requests(): void
295    {
296        if (!$this->form_helper->check_form_tokens(self::FORM_TOKEN_UCP))
297        {
298            throw new http_exception(Response::HTTP_BAD_REQUEST, 'FORM_INVALID');
299        }
300
301        // Subscribe should only be available for logged-in "normal" users
302        if (!$this->request->is_ajax() || $this->user->id() === ANONYMOUS || $this->user->data['is_bot']
303            || $this->user->data['user_type'] == USER_IGNORE || $this->user->data['user_type'] == USER_INACTIVE)
304        {
305            throw new http_exception(Response::HTTP_FORBIDDEN, 'NO_AUTH_OPERATION');
306        }
307    }
308
309    /**
310     * Handle subscribe requests
311     *
312     * @param symfony_request $symfony_request
313     * @return JsonResponse
314     */
315    public function subscribe(symfony_request $symfony_request): JsonResponse
316    {
317        $this->check_subscribe_requests();
318
319        $data = json_sanitizer::decode($symfony_request->get('data', ''));
320
321        $sql = 'INSERT INTO ' . $this->push_subscriptions_table . ' ' . $this->db->sql_build_array('INSERT', [
322            'user_id'            => $this->user->id(),
323            'endpoint'            => $data['endpoint'],
324            'expiration_time'    => $data['expiration_time'] ?? 0,
325            'p256dh'            => $data['keys']['p256dh'],
326            'auth'                => $data['keys']['auth'],
327        ]);
328        $this->db->sql_query($sql);
329
330        return new JsonResponse([
331            'success'        => true,
332            'form_tokens'    => $this->form_helper->get_form_tokens(self::FORM_TOKEN_UCP),
333        ]);
334    }
335
336    /**
337     * Handle unsubscribe requests
338     *
339     * @param symfony_request $symfony_request
340     * @return JsonResponse
341     */
342    public function unsubscribe(symfony_request $symfony_request): JsonResponse
343    {
344        $this->check_subscribe_requests();
345
346        $data = json_sanitizer::decode($symfony_request->get('data', ''));
347
348        $endpoint = $data['endpoint'];
349
350        $sql = 'DELETE FROM ' . $this->push_subscriptions_table . '
351            WHERE user_id = ' . (int) $this->user->id() . "
352                AND endpoint = '" . $this->db->sql_escape($endpoint) . "'";
353        $this->db->sql_query($sql);
354
355        return new JsonResponse([
356            'success'        => true,
357            'form_tokens'    => $this->form_helper->get_form_tokens(self::FORM_TOKEN_UCP),
358        ]);
359    }
360}