Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
223 / 223
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
manager
100.00% covered (success)
100.00%
223 / 223
100.00% covered (success)
100.00%
11 / 11
63
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 ban
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
10
 unban
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
6
 check
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
13
 get_bans
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_banned_users
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
14
 get_ban_end
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 tidy
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 find_type
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_info_cache
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 get_ban_message
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
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\ban;
15
16use phpbb\ban\exception\invalid_length_exception;
17use phpbb\ban\exception\type_not_found_exception;
18use phpbb\ban\type\type_interface;
19use phpbb\cache\driver\driver_interface as cache_driver;
20use phpbb\db\driver\driver_interface;
21use phpbb\di\service_collection;
22use phpbb\language\language;
23use phpbb\log\log_interface;
24use phpbb\user;
25
26class manager
27{
28    const CACHE_KEY_INFO = '_ban_info';
29    const CACHE_KEY_USERS = '_banned_users';
30    const CACHE_TTL = 3600;
31
32    /** @var string */
33    protected $bans_table;
34
35    /** @var cache_driver */
36    protected $cache;
37
38    /** @var driver_interface */
39    protected $db;
40
41    /** @var service_collection */
42    protected $types;
43
44    /** @var language */
45    protected $language;
46
47    /** @var log_interface */
48    protected $log;
49
50    /** @var user */
51    protected $user;
52
53    /** @var string */
54    protected $users_table;
55
56    /**
57     * Creates a service which manages all bans. Developers can
58     * create their own ban types which will be handled in this.
59     *
60     * @param service_collection    $types                    A service collection containing all ban types
61     * @param cache_driver            $cache                    A cache object
62     * @param driver_interface        $db                        A phpBB DBAL object
63     * @param language                $language                Language object
64     * @param log_interface            $log                    Log object
65     * @param user                    $user                    User object
66     * @param string                $bans_table                The bans table
67     * @param string                $users_table            The users table
68     */
69    public function __construct(service_collection $types, cache_driver $cache, driver_interface $db, language $language,
70                                log_interface $log, user $user, string $bans_table, string $users_table = '')
71    {
72        $this->bans_table = $bans_table;
73        $this->cache = $cache;
74        $this->db = $db;
75        $this->types = $types;
76        $this->language = $language;
77        $this->log = $log;
78        $this->user = $user;
79        $this->users_table = $users_table;
80    }
81
82    /**
83     * Creates ban entries for the given $items. Returns true if successful
84     * and false if no entries were added to the database
85     *
86     * @param string $mode            A string which identifies a ban type
87     * @param array                    $items            An array of items which should be banned
88     * @param \DateTimeInterface    $start            A DateTimeInterface object which is the start of the ban
89     * @param \DateTimeInterface    $end            A DateTimeInterface object which is the end of the ban (or 0 if permanent)
90     * @param string $reason            An (internal) reason for the ban
91     * @param string $display_reason    An optional reason which should be displayed to the banned
92     *
93     * @return bool
94     */
95    public function ban(string $mode, array $items, \DateTimeInterface $start, \DateTimeInterface $end, string $reason, string $display_reason = ''): bool
96    {
97        if ($start > $end && $end->getTimestamp() !== 0)
98        {
99            throw new invalid_length_exception('LENGTH_BAN_INVALID');
100        }
101
102        /** @var type_interface $ban_mode */
103        $ban_mode = $this->find_type($mode);
104        if ($ban_mode === false)
105        {
106            throw new type_not_found_exception();
107        }
108
109        if (!empty($this->user))
110        {
111            $ban_mode->set_user($this->user);
112        }
113        $this->tidy();
114
115        $ban_items = $ban_mode->prepare_for_storage($items);
116
117        // Prevent duplicate bans
118        $sql = 'DELETE FROM ' . $this->bans_table . "
119            WHERE ban_mode = '" . $this->db->sql_escape($mode) . "'
120                AND " . $this->db->sql_in_set('ban_item', $ban_items, false, true);
121        $this->db->sql_query($sql);
122
123        $insert_array = [];
124        foreach ($ban_items as $ban_item)
125        {
126            $insert_array[] = [
127                'ban_mode'                => $mode,
128                'ban_item'                => $ban_item,
129                'ban_userid'            => $mode === 'user' ? $ban_item : 0,
130                'ban_start'                => $start->getTimestamp(),
131                'ban_end'                => $end->getTimestamp(),
132                'ban_reason'            => $reason,
133                'ban_reason_display'    => $display_reason,
134            ];
135        }
136
137        if (empty($insert_array))
138        {
139            return false;
140        }
141
142        $this->db->sql_multi_insert($this->bans_table, $insert_array);
143
144        $ban_data = [
145            'items'                => $ban_items,
146            'start'                => $start,
147            'end'                => $end,
148            'reason'            => $reason,
149            'display_reason'    => $display_reason,
150        ];
151
152        // Add to admin log, moderator log and user notes
153        $ban_list_log = implode(', ', $items);
154
155        $log_operation = 'LOG_BAN_' . strtoupper($mode);
156        $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, $log_operation, false, [$reason, $ban_list_log]);
157        $this->log->add('mod', $this->user->data['user_id'], $this->user->ip, $log_operation, false, [
158            'forum_id' => 0,
159            'topic_id' => 0,
160            $reason,
161            $ban_list_log
162        ]);
163
164        if ($banlist_ary = $ban_mode->after_ban($ban_data))
165        {
166            foreach ($banlist_ary as $user_id)
167            {
168                $this->log->add('user', $this->user->data['user_id'], $this->user->ip, $log_operation, false, [
169                    'reportee_id' => $user_id,
170                    $reason,
171                    $ban_list_log
172                ]);
173            }
174        }
175
176        $this->cache->destroy(self::CACHE_KEY_INFO);
177        $this->cache->destroy(self::CACHE_KEY_USERS);
178
179        return true;
180    }
181
182    /**
183     * Removes ban entries from the database with the given IDs
184     *
185     * @param string    $mode        The ban type in which the ban IDs were created
186     * @param array        $items        An array of ban IDs which should be removed
187     */
188    public function unban(string $mode, array $items)
189    {
190        /** @var type_interface $ban_mode */
191        $ban_mode = $this->find_type($mode);
192        if ($ban_mode === false)
193        {
194            throw new type_not_found_exception();
195        }
196        $this->tidy();
197
198        $sql_ids = array_map('intval', $items);
199
200        if (count($sql_ids))
201        {
202            $sql = 'SELECT ban_item
203                FROM ' . $this->bans_table . '
204                WHERE ' . $this->db->sql_in_set('ban_id', $sql_ids);
205            $result = $this->db->sql_query($sql);
206
207            $unbanned_items = [];
208            while ($row = $this->db->sql_fetchrow($result))
209            {
210                $unbanned_items[] = $row['ban_item'];
211            }
212            $this->db->sql_freeresult($result);
213
214            $sql = 'DELETE FROM ' . $this->bans_table . '
215                WHERE ' . $this->db->sql_in_set('ban_id', $sql_ids);
216            $this->db->sql_query($sql);
217
218            $unban_data = [
219                'items' => $unbanned_items,
220            ];
221            $unbanned_users = $ban_mode->after_unban($unban_data);
222
223            // Add to moderator log, admin log and user notes
224            $log_operation = 'LOG_UNBAN_' . strtoupper($mode);
225            $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, $log_operation, false, [$unbanned_users]);
226            $this->log->add('mod', $this->user->data['user_id'], $this->user->ip, $log_operation, false, [
227                'forum_id' => 0,
228                'topic_id' => 0,
229                $unbanned_users
230            ]);
231            if (count($unbanned_users))
232            {
233                foreach ($unbanned_users as $user_id)
234                {
235                    $this->log->add('user', $this->user->data['user_id'], $this->user->ip, $log_operation, false, array(
236                        'reportee_id' => $user_id,
237                        $unbanned_users
238                    ));
239                }
240            }
241        }
242
243        $this->cache->destroy(self::CACHE_KEY_INFO);
244        $this->cache->destroy(self::CACHE_KEY_USERS);
245    }
246
247    /**
248     * Checks for the given user data whether the user is banned.
249     * Returns false if nothing was found and an array containing
250     * 'mode', 'end', 'reason' and 'item' otherwise.
251     *
252     * @param array    $user_data    The array containing the user data
253     *
254     * @return array|bool
255     */
256    public function check(array $user_data = [])
257    {
258        if (empty($user_data))
259        {
260            $user_data = $this->user->data;
261        }
262
263        $ban_info = $this->get_info_cache();
264
265        foreach ($ban_info as $mode => $ban_rows)
266        {
267            /** @var type_interface $ban_mode */
268            $ban_mode = $this->find_type($mode);
269            if ($ban_mode === false)
270            {
271                continue;
272            }
273
274            if ($ban_mode->get_user_column() === null)
275            {
276                $ban_result = $ban_mode->check($ban_rows, $user_data);
277                if ($ban_result !== false)
278                {
279                    return $ban_result + ['mode' => $mode];
280                }
281            }
282            else
283            {
284                $user_column = $ban_mode->get_user_column();
285                if (!isset($user_data[$user_column]))
286                {
287                    continue;
288                }
289
290                foreach ($ban_rows as $ban_row)
291                {
292                    if (!$ban_row['end'] || $ban_row['end'] > time())
293                    {
294                        if (stripos($ban_row['item'], '*') === false)
295                        {
296                            if ($ban_row['item'] == $user_data[$user_column])
297                            {
298                                return $ban_row + ['mode' => $mode];
299                            }
300                        }
301                        else
302                        {
303                            $regex = '#^' . str_replace('\*', '.*?', preg_quote($ban_row['item'], '#')) . '$#i';
304                            if (preg_match($regex, $user_data[$user_column]))
305                            {
306                                return $ban_row + ['mode' => $mode];
307                            }
308                        }
309                    }
310                }
311            }
312        }
313
314        return false;
315    }
316
317    /**
318     * Returns all bans for a given ban type. False, if none were found
319     *
320     * @param string    $mode    The ban type for which the entries should be retrieved
321     *
322     * @return array|bool
323     */
324    public function get_bans(string $mode)
325    {
326        /** @var type_interface $ban_type */
327        $ban_type = $this->find_type($mode);
328        if ($ban_type === false)
329        {
330            throw new type_not_found_exception();
331        }
332        $this->tidy();
333
334        return $ban_type->get_ban_options();
335    }
336
337    /**
338     * Returns an array of banned users with 'id' => 'end' values.
339     * The result is cached for performance reasons and is not as
340     * accurate as the check() method. (Wildcards aren't considered e.g.)
341     *
342     * @return array
343     */
344    public function get_banned_users(): array
345    {
346        $banned_users = $this->cache->get(self::CACHE_KEY_USERS);
347        if ($banned_users === false)
348        {
349            $manual_modes = [];
350            $where_array = [];
351
352            /** @var type_interface $ban_mode */
353            foreach ($this->types as $ban_mode)
354            {
355                $user_column = $ban_mode->get_user_column();
356                if (empty($user_column))
357                {
358                    $manual_modes[] = $ban_mode;
359                    continue;
360                }
361
362                $where_column = $user_column == 'user_id' ? 'b.ban_userid' : 'b.ban_item';
363
364                $where_array[] = ['AND',
365                    [
366                        [$where_column, '=', 'u.' . $user_column],
367                        ['b.ban_mode', '=', "'{$ban_mode->get_type()}'"],
368                    ],
369                ];
370            }
371
372            $sql_array = [
373                'SELECT'    => 'u.user_id, b.ban_end',
374                'FROM'        => [
375                    $this->bans_table    => 'b',
376                    $this->users_table    => 'u',
377                ],
378                'WHERE'        => ['AND',
379                    [
380                        ['OR',
381                            $where_array,
382                        ],
383                        ['u.user_type', '<>', USER_FOUNDER],
384                    ],
385                ],
386            ];
387            $sql = $this->db->sql_build_query('SELECT', $sql_array);
388            $result = $this->db->sql_query($sql);
389
390            $banned_users = [];
391            while ($row = $this->db->sql_fetchrow($result))
392            {
393                $user_id = (int) $row['user_id'];
394                $end = (int) $row['ban_end'];
395                if (!isset($banned_users[$user_id]) || ($banned_users[$user_id] > 0 && $banned_users[$user_id] < $end))
396                {
397                    $banned_users[$user_id] = $end;
398                }
399            }
400            $this->db->sql_freeresult($result);
401
402            /** @var type_interface $manual_mode */
403            foreach ($manual_modes as $manual_mode)
404            {
405                $mode_banned_users = $manual_mode->get_banned_users();
406                foreach ($mode_banned_users as $user_id => $end)
407                {
408                    $user_id = (int) $user_id;
409                    $end = (int) $end;
410                    if (!isset($banned_users[$user_id]) || ($banned_users[$user_id] > 0 && $banned_users[$user_id] < $end))
411                    {
412                        $banned_users[$user_id] = $end;
413                    }
414                }
415            }
416
417            $this->cache->put(self::CACHE_KEY_USERS, $banned_users, self::CACHE_TTL);
418        }
419
420        return $banned_users;
421    }
422
423    /**
424     * Get ban end
425     *
426     * @param \DateTimeInterface $ban_start Ban start time
427     * @param int $length Ban length in minutes
428     * @param string $end_date Ban end date as YYYY-MM-DD string
429     * @return \DateTimeInterface Ban end as DateTimeInterface instance
430     */
431    public function get_ban_end(\DateTimeInterface $ban_start, int $length, string $end_date): \DateTimeInterface
432    {
433        $current_time = $ban_start->getTimestamp();
434        $end_time = 0;
435
436        if ($length)
437        {
438            if ($length != -1 || !$end_date)
439            {
440                $end_time = max($current_time, $current_time + ($length) * 60);
441            }
442            else
443            {
444                if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $end_date))
445                {
446                    $end_time = max(
447                        $current_time,
448                        \DateTime::createFromFormat('Y-m-d', $end_date, $this->user->timezone)->getTimestamp()
449                    );
450                }
451                else
452                {
453                    throw new invalid_length_exception('LENGTH_BAN_INVALID');
454                }
455            }
456        }
457
458        $ban_end = new \DateTime();
459        $ban_end->setTimestamp($end_time);
460
461        return $ban_end;
462    }
463
464    /**
465     * Cleans up the database of e.g. stale bans
466     */
467    public function tidy()
468    {
469        // Delete stale bans
470        $sql = 'DELETE FROM ' . $this->bans_table . '
471            WHERE ban_end > 0
472                AND ban_end < ' . (int) time();
473        $this->db->sql_query($sql);
474
475        /** @var type_interface $type */
476        foreach ($this->types as $type)
477        {
478            $type->tidy();
479        }
480    }
481
482    /**
483     * Finds the ban type for the given mode string.
484     * Returns false if none was found
485     *
486     * @param string $mode    The mode string
487     *
488     * @return bool|type\type_interface
489     */
490    protected function find_type(string $mode)
491    {
492        /** @var type_interface $type */
493        foreach ($this->types as $type)
494        {
495            if ($type->get_type() === $mode)
496            {
497                return $type;
498            }
499        }
500
501        return false;
502    }
503
504    /**
505     * Returns the ban_info from the cache.
506     * If they're not in the cache, bans are retrieved from the database
507     * and then put into the cache.
508     * The array contains an array for each mode with respectively
509     * three values for 'item', 'end' and 'reason' only.
510     *
511     * @return array
512     */
513    protected function get_info_cache(): array
514    {
515        $ban_info = $this->cache->get(self::CACHE_KEY_INFO);
516        if ($ban_info === false)
517        {
518            $sql = 'SELECT ban_mode, ban_item, ban_end, ban_reason_display
519                FROM ' . $this->bans_table;
520            $result = $this->db->sql_query($sql);
521
522            $ban_info = [];
523
524            while ($row = $this->db->sql_fetchrow($result))
525            {
526                if (!isset($ban_info[$row['ban_mode']]))
527                {
528                    $ban_info[$row['ban_mode']] = [];
529                }
530
531                $ban_info[$row['ban_mode']][] = [
532                    'item'        => $row['ban_item'],
533                    'end'        => $row['ban_end'],
534                    'reason'    => $row['ban_reason_display'],
535                ];
536            }
537            $this->db->sql_freeresult($result);
538
539            $this->cache->put(self::CACHE_KEY_INFO, $ban_info, self::CACHE_TTL);
540        }
541
542        return $ban_info;
543    }
544
545    /**
546     * Get ban info message
547     *
548     * @param array $ban_row Ban data row from database
549     * @param string $ban_triggered_by Ban triggered by; allowed 'user', 'ip', 'email
550     * @param string $contact_link Contact link URL
551     *
552     * @return string Ban message
553     */
554    public function get_ban_message(array $ban_row, string $ban_triggered_by, string $contact_link): string
555    {
556        if ($ban_row['end'] > 0)
557        {
558            $till_date = $this->user->format_date($ban_row['end']);
559            $ban_type = 'BOARD_BAN_TIME';
560        }
561        else
562        {
563            $till_date = '';
564            $ban_type = 'BOARD_BAN_PERM';
565        }
566
567        $message = $this->language->lang($ban_type, $till_date, '<a href="' . $contact_link . '">', '</a>');
568        $message .= !empty($ban_row['reason']) ? '<br><br>' . $this->language->lang('BOARD_BAN_REASON', $ban_row['reason']) : '';
569        $message .= '<br><br><em>' . $this->language->lang('BAN_TRIGGERED_BY_' . strtoupper($ban_triggered_by)) . '</em>';
570
571        return $message;
572    }
573}