Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 375
0.00% covered (danger)
0.00%
0 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
fulltext_sphinx
0.00% covered (danger)
0.00%
0 / 375
0.00% covered (danger)
0.00%
0 / 23
11342
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 get_name
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_available
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
12
 init
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_search_query
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_common_words
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_word_length
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 split_keywords
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 keyword_search
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 1
2256
 author_search
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 supports_phrase_search
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 index
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
42
 index_remove
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 tidy
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create_index
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 delete_index
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 index_created
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 index_stats
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 get_stats
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 sphinx_clean_search_string
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 get_acp_options
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 config_generate
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 1
182
 get_type
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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\search\backend;
15
16use phpbb\auth\auth;
17use phpbb\config\config;
18use phpbb\db\driver\driver_interface;
19use phpbb\db\tools\tools_interface;
20use phpbb\event\dispatcher_interface;
21use phpbb\language\language;
22use phpbb\log\log;
23use phpbb\user;
24
25/**
26* Fulltext search based on the sphinx search daemon
27*/
28class fulltext_sphinx implements search_backend_interface
29{
30    protected const SPHINX_MAX_MATCHES = 20000;
31    protected const SPHINX_CONNECT_RETRIES = 3;
32    protected const SPHINX_CONNECT_WAIT_TIME = 300;
33
34    /**
35     * Associative array holding index stats
36     * @var array
37     */
38    protected $stats = array();
39
40    /**
41     * Holds the words entered by user, obtained by splitting the entered query on whitespace
42     * @var array
43     */
44    protected $split_words = array();
45
46    /**
47     * Holds unique sphinx id
48     * @var string
49     */
50    protected $id;
51
52    /**
53     * Stores the names of both main and delta sphinx indexes
54     * separated by a semicolon
55     * @var string
56     */
57    protected $indexes;
58
59    /**
60     * Sphinx search client object
61     * @var \SphinxClient
62     */
63    protected $sphinx;
64
65    /**
66     * Relative path to board root
67     * @var string
68     */
69    protected $phpbb_root_path;
70
71    /**
72     * PHP Extension
73     * @var string
74     */
75    protected $php_ext;
76
77    /**
78     * Auth object
79     * @var auth
80     */
81    protected $auth;
82
83    /**
84     * Config object
85     * @var config
86     */
87    protected $config;
88
89    /**
90     * Database connection
91     * @var driver_interface
92     */
93    protected $db;
94
95    /**
96     * Database Tools object
97     * @var tools_interface
98     */
99    protected $db_tools;
100
101    /**
102     * Stores the database type if supported by sphinx
103     * @var string
104     */
105    protected $dbtype;
106
107    /**
108     * phpBB event dispatcher object
109     * @var dispatcher_interface
110     */
111    protected $phpbb_dispatcher;
112
113    /**
114     * @var language
115     */
116    protected $language;
117
118    /**
119     * @var log
120     */
121    protected $log;
122
123    /**
124     * User object
125     * @var user
126     */
127    protected $user;
128
129    /**
130     * Stores the generated content of the sphinx config file
131     * @var string
132     */
133    protected $config_file_data = '';
134
135    /**
136     * Contains tidied search query.
137     * Operators are prefixed in search query and common words excluded
138     * @var string
139     */
140    protected $search_query = '';
141
142    /**
143     * Constructor
144     * Creates a new \phpbb\search\backend\fulltext_postgres, which is used as a search backend
145     *
146     * @param auth $auth Auth object
147     * @param config $config Config object
148     * @param driver_interface $db Database object
149     * @param tools_interface $db_tools
150     * @param dispatcher_interface $phpbb_dispatcher Event dispatcher object
151     * @param language $language
152     * @param log $log
153     * @param user $user User object
154     * @param string $phpbb_root_path Relative path to phpBB root
155     * @param string $phpEx PHP file extension
156     */
157    public function __construct(auth $auth, config $config, driver_interface $db, tools_interface $db_tools, dispatcher_interface $phpbb_dispatcher, language $language, log $log, user $user, string $phpbb_root_path, string $phpEx)
158    {
159        $this->auth = $auth;
160        $this->config = $config;
161        $this->db = $db;
162        $this->phpbb_dispatcher = $phpbb_dispatcher;
163        $this->language = $language;
164        $this->log = $log;
165        $this->user = $user;
166
167        $this->phpbb_root_path = $phpbb_root_path;
168        $this->php_ext = $phpEx;
169
170        $this->db_tools = $db_tools;
171
172        if (!$this->config['fulltext_sphinx_id'])
173        {
174            $this->config->set('fulltext_sphinx_id', unique_id());
175        }
176        $this->id = $this->config['fulltext_sphinx_id'];
177        $this->indexes = 'index_phpbb_' . $this->id . '_delta;index_phpbb_' . $this->id . '_main';
178
179        if (!class_exists('SphinxClient'))
180        {
181            require($this->phpbb_root_path . 'includes/sphinxapi.' . $this->php_ext);
182        }
183
184        // Initialize sphinx client
185        $this->sphinx = new \SphinxClient();
186
187        $this->sphinx->SetServer(($this->config['fulltext_sphinx_host'] ? $this->config['fulltext_sphinx_host'] : 'localhost'), ($this->config['fulltext_sphinx_port'] ? (int) $this->config['fulltext_sphinx_port'] : 9312));
188    }
189
190    /**
191     * {@inheritdoc}
192     */
193    public function get_name(): string
194    {
195        return 'Sphinx Fulltext';
196    }
197
198    /**
199     * {@inheritdoc}
200     */
201    public function is_available(): bool
202    {
203        return ($this->db->get_sql_layer() == 'mysqli' || $this->db->get_sql_layer() == 'postgres') && class_exists('SphinxClient');
204    }
205
206    /**
207     * {@inheritdoc}
208     */
209    public function init()
210    {
211        if (!$this->is_available())
212        {
213            return $this->language->lang('FULLTEXT_SPHINX_WRONG_DATABASE');
214        }
215
216        // Move delta to main index each hour
217        $this->config->set('search_gc', 3600);
218
219        return false;
220    }
221
222    /**
223     * {@inheritdoc}
224     */
225    public function get_search_query(): string
226    {
227        return $this->search_query;
228    }
229
230    /**
231     * {@inheritdoc}
232     */
233    public function get_common_words(): array
234    {
235        return array();
236    }
237
238    /**
239     * {@inheritdoc}
240     */
241    public function get_word_length()
242    {
243        return false;
244    }
245
246    /**
247     * {@inheritdoc}
248     */
249    public function split_keywords(string &$keywords, string $terms): bool
250    {
251        // Keep quotes and new lines
252        $keywords = str_replace(['&quot;', "\n"], ['"', ' '], trim($keywords));
253
254        if ($terms == 'all')
255        {
256            // Replaces verbal operators OR and NOT with special characters | and -, unless appearing within quotation marks
257            $match        = ['#\sor\s(?=([^"]*"[^"]*")*[^"]*$)#i', '#\snot\s(?=([^"]*"[^"]*")*[^"]*$)#i'];
258            $replace    = [' | ', ' -'];
259
260            $keywords = preg_replace($match, $replace, $keywords);
261            $this->sphinx->SetMatchMode(SPH_MATCH_EXTENDED);
262        }
263        else
264        {
265            $match = ['\\', '(',')', '|', '!', '@', '~', '/', '^', '$', '=', '&amp;', '&lt;', '&gt;'];
266
267            $keywords = str_replace($match, ' ', $keywords);
268            $this->sphinx->SetMatchMode(SPH_MATCH_ANY);
269        }
270
271        if (strlen($keywords) > 0)
272        {
273            $this->search_query = str_replace('"', '&quot;', $keywords);
274            return true;
275        }
276
277        return false;
278    }
279
280    /**
281     * {@inheritdoc}
282     */
283    public function keyword_search(string $type, string $fields, string $terms, array $sort_by_sql, string $sort_key, string $sort_dir, string $sort_days, array $ex_fid_ary, string $post_visibility, int $topic_id, array $author_ary, string $author_name, array &$id_ary, int &$start, int $per_page)
284    {
285        // No keywords? No posts.
286        if (!strlen($this->search_query) && !count($author_ary))
287        {
288            return false;
289        }
290
291        $id_ary = array();
292
293        // Sorting
294
295        if ($type == 'topics')
296        {
297            switch ($sort_key)
298            {
299                case 'a':
300                    $this->sphinx->SetGroupBy('topic_id', SPH_GROUPBY_ATTR, 'poster_id ' . (($sort_dir == 'a') ? 'ASC' : 'DESC'));
301                break;
302
303                case 'f':
304                    $this->sphinx->SetGroupBy('topic_id', SPH_GROUPBY_ATTR, 'forum_id ' . (($sort_dir == 'a') ? 'ASC' : 'DESC'));
305                break;
306
307                case 'i':
308
309                case 's':
310                    $this->sphinx->SetGroupBy('topic_id', SPH_GROUPBY_ATTR, 'post_subject ' . (($sort_dir == 'a') ? 'ASC' : 'DESC'));
311                break;
312
313                case 't':
314
315                default:
316                    $this->sphinx->SetGroupBy('topic_id', SPH_GROUPBY_ATTR, 'topic_last_post_time ' . (($sort_dir == 'a') ? 'ASC' : 'DESC'));
317                break;
318            }
319        }
320        else
321        {
322            switch ($sort_key)
323            {
324                case 'a':
325                    $this->sphinx->SetSortMode(($sort_dir == 'a') ? SPH_SORT_ATTR_ASC : SPH_SORT_ATTR_DESC, 'poster_id');
326                break;
327
328                case 'f':
329                    $this->sphinx->SetSortMode(($sort_dir == 'a') ? SPH_SORT_ATTR_ASC : SPH_SORT_ATTR_DESC, 'forum_id');
330                break;
331
332                case 'i':
333
334                case 's':
335                    $this->sphinx->SetSortMode(($sort_dir == 'a') ? SPH_SORT_ATTR_ASC : SPH_SORT_ATTR_DESC, 'post_subject');
336                break;
337
338                case 't':
339
340                default:
341                    $this->sphinx->SetSortMode(($sort_dir == 'a') ? SPH_SORT_ATTR_ASC : SPH_SORT_ATTR_DESC, 'post_time');
342                break;
343            }
344        }
345
346        // Most narrow filters first
347        if ($topic_id)
348        {
349            $this->sphinx->SetFilter('topic_id', array($topic_id));
350        }
351
352        /**
353        * Allow modifying the Sphinx search options
354        *
355        * @event core.search_sphinx_keywords_modify_options
356        * @var    string    type                Searching type ('posts', 'topics')
357        * @var    string    fields                Searching fields ('titleonly', 'msgonly', 'firstpost', 'all')
358        * @var    string    terms                Searching terms ('all', 'any')
359        * @var    int        sort_days            Time, in days, of the oldest possible post to list
360        * @var    string    sort_key            The sort type used from the possible sort types
361        * @var    int        topic_id            Limit the search to this topic_id only
362        * @var    array    ex_fid_ary            Which forums not to search on
363        * @var    string    post_visibility        Post visibility data
364        * @var    array    author_ary            Array of user_id containing the users to filter the results to
365        * @var    string    author_name            The username to search on
366        * @var    object    sphinx                The Sphinx searchd client object
367        * @since 3.1.7-RC1
368        */
369        $sphinx = $this->sphinx;
370        $vars = array(
371            'type',
372            'fields',
373            'terms',
374            'sort_days',
375            'sort_key',
376            'topic_id',
377            'ex_fid_ary',
378            'post_visibility',
379            'author_ary',
380            'author_name',
381            'sphinx',
382        );
383        extract($this->phpbb_dispatcher->trigger_event('core.search_sphinx_keywords_modify_options', compact($vars)));
384        $this->sphinx = $sphinx;
385        unset($sphinx);
386
387        $search_query_prefix = '';
388
389        switch ($fields)
390        {
391            case 'titleonly':
392                // Only search the title
393                if ($terms == 'all')
394                {
395                    $search_query_prefix = '@title ';
396                }
397                // Weight for the title
398                $this->sphinx->SetFieldWeights(array("title" => 5, "data" => 1));
399                // 1 is first_post, 0 is not first post
400                $this->sphinx->SetFilter('topic_first_post', array(1));
401            break;
402
403            case 'msgonly':
404                // Only search the body
405                if ($terms == 'all')
406                {
407                    $search_query_prefix = '@data ';
408                }
409                // Weight for the body
410                $this->sphinx->SetFieldWeights(array("title" => 1, "data" => 5));
411            break;
412
413            case 'firstpost':
414                // More relative weight for the title, also search the body
415                $this->sphinx->SetFieldWeights(array("title" => 5, "data" => 1));
416                // 1 is first_post, 0 is not first post
417                $this->sphinx->SetFilter('topic_first_post', array(1));
418            break;
419
420            default:
421                // More relative weight for the title, also search the body
422                $this->sphinx->SetFieldWeights(array("title" => 5, "data" => 1));
423            break;
424        }
425
426        if (count($author_ary))
427        {
428            $this->sphinx->SetFilter('poster_id', $author_ary);
429        }
430
431        // As this is not simply possible at the moment, we limit the result to approved posts.
432        // This will make it impossible for moderators to search unapproved and softdeleted posts,
433        // but at least it will also cause the same for normal users.
434        $this->sphinx->SetFilter('post_visibility', array(ITEM_APPROVED));
435
436        if (count($ex_fid_ary))
437        {
438            // All forums that a user is allowed to access
439            $fid_ary = array_unique(array_intersect(array_keys($this->auth->acl_getf('f_read', true)), array_keys($this->auth->acl_getf('f_search', true))));
440            // All forums that the user wants to and can search in
441            $search_forums = array_diff($fid_ary, $ex_fid_ary);
442
443            if (count($search_forums))
444            {
445                $this->sphinx->SetFilter('forum_id', $search_forums);
446            }
447        }
448
449        $this->sphinx->SetFilter('deleted', array(0));
450
451        $this->sphinx->SetLimits((int) $start, (int) $per_page, max(self::SPHINX_MAX_MATCHES, (int) $start + $per_page));
452        $result = $this->sphinx->Query($search_query_prefix . $this->sphinx_clean_search_string(str_replace('&quot;', '"', $this->search_query)), $this->indexes);
453
454        // Could be connection to localhost:9312 failed (errno=111,
455        // msg=Connection refused) during rotate, retry if so
456        $retries = self::SPHINX_CONNECT_RETRIES;
457        while (!$result && (strpos($this->sphinx->GetLastError(), "errno=111,") !== false) && $retries--)
458        {
459            usleep(self::SPHINX_CONNECT_WAIT_TIME);
460            $result = $this->sphinx->Query($search_query_prefix . $this->sphinx_clean_search_string(str_replace('&quot;', '"', $this->search_query)), $this->indexes);
461        }
462
463        if ($this->sphinx->GetLastError())
464        {
465            $this->log->add('critical', $this->user->data['user_id'], $this->user->ip, 'LOG_SPHINX_ERROR', false, array($this->sphinx->GetLastError()));
466            if ($this->auth->acl_get('a_'))
467            {
468                trigger_error($this->language->lang('SPHINX_SEARCH_FAILED', $this->sphinx->GetLastError()));
469            }
470            else
471            {
472                trigger_error($this->language->lang('SPHINX_SEARCH_FAILED_LOG'));
473            }
474        }
475
476        $result_count = $result['total_found'];
477
478        if ($result_count && $start >= $result_count)
479        {
480            $start = floor(($result_count - 1) / $per_page) * $per_page;
481
482            $this->sphinx->SetLimits((int) $start, (int) $per_page, max(self::SPHINX_MAX_MATCHES, (int) $start + $per_page));
483            $result = $this->sphinx->Query($search_query_prefix . $this->sphinx_clean_search_string(str_replace('&quot;', '"', $this->search_query)), $this->indexes);
484
485            // Could be connection to localhost:9312 failed (errno=111,
486            // msg=Connection refused) during rotate, retry if so
487            $retries = self::SPHINX_CONNECT_RETRIES;
488            while (!$result && (strpos($this->sphinx->GetLastError(), "errno=111,") !== false) && $retries--)
489            {
490                usleep(self::SPHINX_CONNECT_WAIT_TIME);
491                $result = $this->sphinx->Query($search_query_prefix . $this->sphinx_clean_search_string(str_replace('&quot;', '"', $this->search_query)), $this->indexes);
492            }
493        }
494
495        $id_ary = array();
496        if (isset($result['matches']))
497        {
498            if ($type == 'posts')
499            {
500                $id_ary = array_keys($result['matches']);
501            }
502            else
503            {
504                foreach ($result['matches'] as $key => $value)
505                {
506                    $id_ary[] = $value['attrs']['topic_id'];
507                }
508            }
509        }
510        else
511        {
512            return false;
513        }
514
515        $id_ary = array_slice($id_ary, 0, (int) $per_page);
516
517        return $result_count;
518    }
519
520    /**
521     * {@inheritdoc}
522     */
523    public function author_search(string $type, bool $firstpost_only, array $sort_by_sql, string $sort_key, string $sort_dir, string $sort_days, array $ex_fid_ary, string $post_visibility, int $topic_id, array $author_ary, string $author_name, array &$id_ary, int &$start, int $per_page)
524    {
525        $this->search_query = '';
526
527        $this->sphinx->SetMatchMode(SPH_MATCH_FULLSCAN);
528        $fields = ($firstpost_only) ? 'firstpost' : 'all';
529        $terms = 'all';
530        return $this->keyword_search($type, $fields, $terms, $sort_by_sql, $sort_key, $sort_dir, $sort_days, $ex_fid_ary, $post_visibility, $topic_id, $author_ary, $author_name, $id_ary, $start, $per_page);
531    }
532
533    /**
534     * {@inheritdoc}
535     */
536    public function supports_phrase_search(): bool
537    {
538        return false;
539    }
540
541    /**
542     * {@inheritdoc}
543     */
544    public function index(string $mode, int $post_id, string &$message, string &$subject, int $poster_id, int $forum_id)
545    {
546        /**
547        * Event to modify method arguments before the Sphinx search index is updated
548        *
549        * @event core.search_sphinx_index_before
550        * @var string    mode                Contains the post mode: edit, post, reply, quote
551        * @var int        post_id                The id of the post which is modified/created
552        * @var string    message                New or updated post content
553        * @var string    subject                New or updated post subject
554        * @var int        poster_id            Post author's user id
555        * @var int        forum_id            The id of the forum in which the post is located
556        * @since 3.2.3-RC1
557        */
558        $vars = array(
559            'mode',
560            'post_id',
561            'message',
562            'subject',
563            'poster_id',
564            'forum_id',
565        );
566        extract($this->phpbb_dispatcher->trigger_event('core.search_sphinx_index_before', compact($vars)));
567
568        if ($mode == 'edit')
569        {
570            $this->sphinx->UpdateAttributes($this->indexes, array('forum_id', 'poster_id'), array((int) $post_id => array((int) $forum_id, (int) $poster_id)));
571        }
572        else if ($mode != 'post' && $post_id)
573        {
574            // Update topic_last_post_time for full topic
575            $sql_array = array(
576                'SELECT'    => 'p1.post_id',
577                'FROM'        => array(
578                    POSTS_TABLE    => 'p1',
579                ),
580                'LEFT_JOIN'    => array(array(
581                    'FROM'    => array(
582                        POSTS_TABLE    => 'p2'
583                    ),
584                    'ON'    => 'p1.topic_id = p2.topic_id',
585                )),
586                'WHERE' => 'p2.post_id = ' . ((int) $post_id),
587            );
588
589            $sql = $this->db->sql_build_query('SELECT', $sql_array);
590            $result = $this->db->sql_query($sql);
591
592            $post_updates = array();
593            $post_time = time();
594            while ($row = $this->db->sql_fetchrow($result))
595            {
596                $post_updates[(int) $row['post_id']] = array($post_time);
597            }
598            $this->db->sql_freeresult($result);
599
600            if (count($post_updates))
601            {
602                $this->sphinx->UpdateAttributes($this->indexes, array('topic_last_post_time'), $post_updates);
603            }
604        }
605    }
606
607    /**
608     * {@inheritdoc}
609     */
610    public function index_remove(array $post_ids, array $author_ids, array $forum_ids): void
611    {
612        $values = array();
613        foreach ($post_ids as $post_id)
614        {
615            $values[$post_id] = array(1);
616        }
617
618        $this->sphinx->UpdateAttributes($this->indexes, array('deleted'), $values);
619    }
620
621    /**
622     * Nothing needs to be destroyed
623     */
624    public function tidy(): void
625    {
626        $this->config->set('search_last_gc', time(), false);
627    }
628
629    /**
630     * {@inheritdoc}
631     */
632    public function create_index(int &$post_counter = 0): ?array
633    {
634        if (!$this->index_created())
635        {
636            $table_data = array(
637                'COLUMNS'    => array(
638                    'counter_id'    => array('UINT', 0),
639                    'max_doc_id'    => array('UINT', 0),
640                ),
641                'PRIMARY_KEY'    => 'counter_id',
642            );
643            $this->db_tools->sql_create_table(SPHINX_TABLE, $table_data);
644
645            $data = array(
646                'counter_id'    => '1',
647                'max_doc_id'    => '0',
648            );
649            $sql = 'INSERT INTO ' . SPHINX_TABLE . ' ' . $this->db->sql_build_array('INSERT', $data);
650            $this->db->sql_query($sql);
651        }
652
653        return null;
654    }
655
656    /**
657     * {@inheritdoc}
658    */
659    public function delete_index(int &$post_counter = null): ?array
660    {
661        if ($this->index_created())
662        {
663            $this->db_tools->sql_table_drop(SPHINX_TABLE);
664        }
665
666        return null;
667    }
668
669    /**
670     * {@inheritdoc}
671    */
672    public function index_created($allow_new_files = true): bool
673    {
674        $created = false;
675
676        if ($this->db_tools->sql_table_exists(SPHINX_TABLE))
677        {
678            $created = true;
679        }
680
681        return $created;
682    }
683
684    /**
685     * {@inheritdoc}
686    */
687    public function index_stats()
688    {
689        if (empty($this->stats))
690        {
691            $this->get_stats();
692        }
693
694        return array(
695            $this->language->lang('FULLTEXT_SPHINX_MAIN_POSTS')            => ($this->index_created()) ? $this->stats['main_posts'] : 0,
696            $this->language->lang('FULLTEXT_SPHINX_DELTA_POSTS')            => ($this->index_created()) ? $this->stats['total_posts'] - $this->stats['main_posts'] : 0,
697            $this->language->lang('FULLTEXT_MYSQL_TOTAL_POSTS')            => ($this->index_created()) ? $this->stats['total_posts'] : 0,
698        );
699    }
700
701    /**
702     * Computes the stats and store them in the $this->stats associative array
703     */
704    protected function get_stats()
705    {
706        if ($this->index_created())
707        {
708            $sql = 'SELECT COUNT(post_id) as total_posts
709                FROM ' . POSTS_TABLE;
710            $result = $this->db->sql_query($sql);
711            $this->stats['total_posts'] = (int) $this->db->sql_fetchfield('total_posts');
712            $this->db->sql_freeresult($result);
713
714            $sql = 'SELECT COUNT(p.post_id) as main_posts
715                FROM ' . POSTS_TABLE . ' p, ' . SPHINX_TABLE . ' m
716                WHERE p.post_id <= m.max_doc_id
717                    AND m.counter_id = 1';
718            $result = $this->db->sql_query($sql);
719            $this->stats['main_posts'] = (int) $this->db->sql_fetchfield('main_posts');
720            $this->db->sql_freeresult($result);
721        }
722    }
723
724    /**
725     * Cleans search query passed into Sphinx search engine, as follows:
726     * 1. Hyphenated words are replaced with keyword search for either the exact phrase with spaces
727     *    or as a single word without spaces eg search for "know-it-all" becomes ("know it all"|"knowitall*")
728     * 2. Words with apostrophes are contracted eg "it's" becomes "its"
729     * 3. <, >, " and & are decoded from HTML entities.
730     * 4. Following special characters used as search operators in Sphinx are preserved when used with correct syntax:
731     *    (a) quorum matching: "the world is a wonderful place"/3
732     *        Finds 3 of the words within the phrase. Number must be between 1 and 9.
733     *    (b) proximity search: "hello world"~10
734     *        Finds hello and world within 10 words of each other. Number can be between 1 and 99.
735     *    (c) strict word order: aaa << bbb << ccc
736     *        Finds "aaa" only where it appears before "bbb" and only where "bbb" appears before "ccc".
737     *    (d) exact match operator: if lemmatizer or stemming enabled,
738     *        search will find exact match only and ignore other grammatical forms of the same word stem.
739     *        eg. raining =cats and =dogs
740     *            will not return "raining cat and dog"
741     *        eg. ="search this exact phrase"
742     *            will not return "searched this exact phrase", "searching these exact phrases".
743     * 5. Special characters /, ~, << and = not complying with the correct syntax
744     *    and other reserved operators are escaped and searched literally.
745     *    Special characters not explicitly listed in charset_table or blend_chars in sphinx.conf
746     *    will not be indexed and keywords containing them will be ignored by Sphinx.
747     *    By default, only $, %, & and @ characters are indexed and searchable.
748     *    String transformation is in backend only and not visible to the end user
749     *    nor reflected in the results page URL or keyword highlighting.
750     *
751     * @param string    $search_string
752     * @return string
753     */
754    protected function sphinx_clean_search_string($search_string)
755    {
756        $from = ['@', '^', '$', '!', '&lt;', '&gt;', '&quot;', '&amp;', '\''];
757        $to = ['\@', '\^', '\$', '\!', '<', '>', '"', '&', ''];
758
759        $search_string = str_replace($from, $to, $search_string);
760
761        $search_string = strrev($search_string);
762        $search_string = preg_replace(['#\/(?!"[^"]+")#', '#~(?!"[^"]+")#'], ['/\\', '~\\'], $search_string);
763        $search_string = strrev($search_string);
764
765        $match = ['#(/|\\\\/)(?![1-9](\s|$))#', '#(~|\\\\~)(?!\d{1,2}(\s|$))#', '#((?:\p{L}|\p{N})+)-((?:\p{L}|\p{N})+)(?:-((?:\p{L}|\p{N})+))?(?:-((?:\p{L}|\p{N})+))?#i', '#<<\s*$#', '#(\S\K=|=(?=\s)|=$)#'];
766        $replace = ['\/', '\~', '("$1 $2 $3 $4"|$1$2$3$4*)', '\<\<', '\='];
767
768        $search_string = preg_replace($match, $replace, $search_string);
769        $search_string = preg_replace('#\s+"\|#', '"|', $search_string);
770
771        /**
772         * OPTIONAL: Thousands separator stripped from numbers, eg search for '90,000' is queried as '90000'.
773         * By default commas are stripped from search index so that '90,000' is indexed as '90000'
774         */
775        // $search_string = preg_replace('#[0-9]{1,3}\K,(?=[0-9]{3})#', '', $search_string);
776
777        return $search_string;
778    }
779
780    /**
781     * {@inheritdoc}
782     */
783    public function get_acp_options(): array
784    {
785        $config_vars = array(
786            'fulltext_sphinx_data_path' => 'string',
787            'fulltext_sphinx_host' => 'string',
788            'fulltext_sphinx_port' => 'string',
789            'fulltext_sphinx_indexer_mem_limit' => 'int',
790        );
791
792        $tpl = '
793        <span class="error">' . $this->language->lang('FULLTEXT_SPHINX_CONFIGURE'). '</span>
794        <dl>
795            <dt><label for="fulltext_sphinx_data_path">' . $this->language->lang('FULLTEXT_SPHINX_DATA_PATH') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('FULLTEXT_SPHINX_DATA_PATH_EXPLAIN') . '</span></dt>
796            <dd><input id="fulltext_sphinx_data_path" type="text" size="40" maxlength="255" name="config[fulltext_sphinx_data_path]" value="' . $this->config['fulltext_sphinx_data_path'] . '" /></dd>
797        </dl>
798        <dl>
799            <dt><label for="fulltext_sphinx_host">' . $this->language->lang('FULLTEXT_SPHINX_HOST') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('FULLTEXT_SPHINX_HOST_EXPLAIN') . '</span></dt>
800            <dd><input id="fulltext_sphinx_host" type="text" size="40" maxlength="255" name="config[fulltext_sphinx_host]" value="' . $this->config['fulltext_sphinx_host'] . '" /></dd>
801        </dl>
802        <dl>
803            <dt><label for="fulltext_sphinx_port">' . $this->language->lang('FULLTEXT_SPHINX_PORT') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('FULLTEXT_SPHINX_PORT_EXPLAIN') . '</span></dt>
804            <dd><input id="fulltext_sphinx_port" type="number" min="0" max="9999999999" name="config[fulltext_sphinx_port]" value="' . $this->config['fulltext_sphinx_port'] . '" /></dd>
805        </dl>
806        <dl>
807            <dt><label for="fulltext_sphinx_indexer_mem_limit">' . $this->language->lang('FULLTEXT_SPHINX_INDEXER_MEM_LIMIT') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('FULLTEXT_SPHINX_INDEXER_MEM_LIMIT_EXPLAIN') . '</span></dt>
808            <dd><input id="fulltext_sphinx_indexer_mem_limit" type="number" min="0" max="9999999999" name="config[fulltext_sphinx_indexer_mem_limit]" value="' . $this->config['fulltext_sphinx_indexer_mem_limit'] . '" /> ' . $this->language->lang('MIB') . '</dd>
809        </dl>
810        <dl>
811            <dt><label for="fulltext_sphinx_config_file">' . $this->language->lang('FULLTEXT_SPHINX_CONFIG_FILE') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('FULLTEXT_SPHINX_CONFIG_FILE_EXPLAIN') . '</span></dt>
812            <dd>' . (($this->config_generate()) ? '<textarea readonly="readonly" rows="6" id="sphinx_config_data">' . htmlspecialchars($this->config_file_data, ENT_COMPAT) . '</textarea>' : $this->config_file_data) . '</dd>
813        <dl>
814        ';
815
816        // These are fields required in the config table
817        return array(
818            'tpl'        => $tpl,
819            'config'    => $config_vars
820        );
821    }
822
823    /**
824     * Generates content of sphinx.conf
825     *
826     * @return bool True if sphinx.conf content is correctly generated, false otherwise
827     */
828    protected function config_generate()
829    {
830        // Check if Database is supported by Sphinx
831        if ($this->db->get_sql_layer() == 'mysqli')
832        {
833            $this->dbtype = 'mysql';
834        }
835        else if ($this->db->get_sql_layer() == 'postgres')
836        {
837            $this->dbtype = 'pgsql';
838        }
839        else
840        {
841            $this->config_file_data = $this->language->lang('FULLTEXT_SPHINX_WRONG_DATABASE');
842            return false;
843        }
844
845        // Check if directory paths have been filled
846        if (!$this->config['fulltext_sphinx_data_path'])
847        {
848            $this->config_file_data = $this->language->lang('FULLTEXT_SPHINX_NO_CONFIG_DATA');
849            return false;
850        }
851
852        include($this->phpbb_root_path . 'config.' . $this->php_ext);
853
854        /* Now that we're sure everything was entered correctly,
855        generate a config for the index. We use a config value
856        fulltext_sphinx_id for this, as it should be unique. */
857        $config_object = new \phpbb\search\backend\sphinx\config();
858        /** @psalm-suppress UndefinedVariable */
859        $config_data = array(
860            'source source_phpbb_' . $this->id . '_main' => array(
861                array('type',                        $this->dbtype . ' # mysql or pgsql'),
862                // This config value sql_host needs to be changed incase sphinx and sql are on different servers
863                array('sql_host',                    $dbhost . ' # SQL server host sphinx connects to'),
864                array('sql_user',                    '[dbuser]'),
865                array('sql_pass',                    '[dbpassword]'),
866                array('sql_db',                        $dbname),
867                array('sql_port',                    $dbport . ' # optional, default is 3306 for mysql and 5432 for pgsql'),
868                array('sql_query_pre',                'SET NAMES \'utf8\''),
869                array('sql_query_pre',                'UPDATE ' . SPHINX_TABLE . ' SET max_doc_id = (SELECT MAX(post_id) FROM ' . POSTS_TABLE . ') WHERE counter_id = 1'),
870                array('sql_query_range',            'SELECT MIN(post_id), MAX(post_id) FROM ' . POSTS_TABLE . ''),
871                array('sql_range_step',                '5000'),
872                array('sql_query',                    'SELECT
873                        p.post_id AS id,
874                        p.forum_id,
875                        p.topic_id,
876                        p.poster_id,
877                        p.post_visibility,
878                        CASE WHEN p.post_id = t.topic_first_post_id THEN 1 ELSE 0 END as topic_first_post,
879                        p.post_time,
880                        p.post_subject,
881                        p.post_subject as title,
882                        p.post_text as data,
883                        t.topic_last_post_time,
884                        0 as deleted
885                    FROM ' . POSTS_TABLE . ' p, ' . TOPICS_TABLE . ' t
886                    WHERE
887                        p.topic_id = t.topic_id
888                        AND p.post_id >= $start AND p.post_id <= $end'),
889                array('sql_query_post',                ''),
890                array('sql_query_post_index',        'UPDATE ' . SPHINX_TABLE . ' SET max_doc_id = $maxid WHERE counter_id = 1'),
891                array('sql_attr_uint',                'forum_id'),
892                array('sql_attr_uint',                'topic_id'),
893                array('sql_attr_uint',                'poster_id'),
894                array('sql_attr_uint',                'post_visibility'),
895                array('sql_attr_bool',                'topic_first_post'),
896                array('sql_attr_bool',                'deleted'),
897                array('sql_attr_timestamp',            'post_time'),
898                array('sql_attr_timestamp',            'topic_last_post_time'),
899                array('sql_attr_string',            'post_subject'),
900            ),
901            'source source_phpbb_' . $this->id . '_delta : source_phpbb_' . $this->id . '_main' => array(
902                array('sql_query_pre',                'SET NAMES \'utf8\''),
903                array('sql_query_range',            ''),
904                array('sql_range_step',                ''),
905                array('sql_query',                    'SELECT
906                        p.post_id AS id,
907                        p.forum_id,
908                        p.topic_id,
909                        p.poster_id,
910                        p.post_visibility,
911                        CASE WHEN p.post_id = t.topic_first_post_id THEN 1 ELSE 0 END as topic_first_post,
912                        p.post_time,
913                        p.post_subject,
914                        p.post_subject as title,
915                        p.post_text as data,
916                        t.topic_last_post_time,
917                        0 as deleted
918                    FROM ' . POSTS_TABLE . ' p, ' . TOPICS_TABLE . ' t
919                    WHERE
920                        p.topic_id = t.topic_id
921                        AND p.post_id >=  ( SELECT max_doc_id FROM ' . SPHINX_TABLE . ' WHERE counter_id=1 )'),
922                array('sql_query_post_index',        ''),
923            ),
924            'index index_phpbb_' . $this->id . '_main' => array(
925                array('path',                        $this->config['fulltext_sphinx_data_path'] . 'index_phpbb_' . $this->id . '_main'),
926                array('source',                        'source_phpbb_' . $this->id . '_main'),
927                array('docinfo',                    'extern'),
928                array('morphology',                    'none'),
929                array('stopwords',                    ''),
930                array('wordforms',                    '  # optional, specify path to wordforms file. See ./docs/sphinx_wordforms.txt for example'),
931                array('exceptions',                    '  # optional, specify path to exceptions file. See ./docs/sphinx_exceptions.txt for example'),
932                array('min_word_len',                '2'),
933                array('charset_table',                'U+FF10..U+FF19->0..9, 0..9, U+FF41..U+FF5A->a..z, U+FF21..U+FF3A->a..z, A..Z->a..z, a..z, U+0149, U+017F, U+0138, U+00DF, U+00FF, U+00C0..U+00D6->U+00E0..U+00F6, U+00E0..U+00F6, U+00D8..U+00DE->U+00F8..U+00FE, U+00F8..U+00FE, U+0100->U+0101, U+0101, U+0102->U+0103, U+0103, U+0104->U+0105, U+0105, U+0106->U+0107, U+0107, U+0108->U+0109, U+0109, U+010A->U+010B, U+010B, U+010C->U+010D, U+010D, U+010E->U+010F, U+010F, U+0110->U+0111, U+0111, U+0112->U+0113, U+0113, U+0114->U+0115, U+0115, U+0116->U+0117, U+0117, U+0118->U+0119, U+0119, U+011A->U+011B, U+011B, U+011C->U+011D, U+011D, U+011E->U+011F, U+011F, U+0130->U+0131, U+0131, U+0132->U+0133, U+0133, U+0134->U+0135, U+0135, U+0136->U+0137, U+0137, U+0139->U+013A, U+013A, U+013B->U+013C, U+013C, U+013D->U+013E, U+013E, U+013F->U+0140, U+0140, U+0141->U+0142, U+0142, U+0143->U+0144, U+0144, U+0145->U+0146, U+0146, U+0147->U+0148, U+0148, U+014A->U+014B, U+014B, U+014C->U+014D, U+014D, U+014E->U+014F, U+014F, U+0150->U+0151, U+0151, U+0152->U+0153, U+0153, U+0154->U+0155, U+0155, U+0156->U+0157, U+0157, U+0158->U+0159, U+0159, U+015A->U+015B, U+015B, U+015C->U+015D, U+015D, U+015E->U+015F, U+015F, U+0160->U+0161, U+0161, U+0162->U+0163, U+0163, U+0164->U+0165, U+0165, U+0166->U+0167, U+0167, U+0168->U+0169, U+0169, U+016A->U+016B, U+016B, U+016C->U+016D, U+016D, U+016E->U+016F, U+016F, U+0170->U+0171, U+0171, U+0172->U+0173, U+0173, U+0174->U+0175, U+0175, U+0176->U+0177, U+0177, U+0178->U+00FF, U+00FF, U+0179->U+017A, U+017A, U+017B->U+017C, U+017C, U+017D->U+017E, U+017E, U+0410..U+042F->U+0430..U+044F, U+0430..U+044F, U+4E00..U+9FFF'),
934                array('ignore_chars',                 'U+0027, U+002C'),
935                array('min_prefix_len',                '3 # Minimum number of characters for wildcard searches by prefix (min 1). Default is 3. If specified, set min_infix_len to 0'),
936                array('min_infix_len',                '0 # Minimum number of characters for wildcard searches by infix (min 2). If specified, set min_prefix_len to 0'),
937                array('html_strip',                    '1'),
938                array('index_exact_words',            '0 # Set to 1 to enable exact search operator. Requires wordforms or morphology'),
939                array('blend_chars',                 'U+23, U+24, U+25, U+26, U+40'),
940            ),
941            'index index_phpbb_' . $this->id . '_delta : index_phpbb_' . $this->id . '_main' => array(
942                array('path',                        $this->config['fulltext_sphinx_data_path'] . 'index_phpbb_' . $this->id . '_delta'),
943                array('source',                        'source_phpbb_' . $this->id . '_delta'),
944            ),
945            'indexer' => array(
946                array('mem_limit',                    $this->config['fulltext_sphinx_indexer_mem_limit'] . 'M'),
947            ),
948            'searchd' => array(
949                array('listen'    ,                    ($this->config['fulltext_sphinx_host'] ? $this->config['fulltext_sphinx_host'] : 'localhost') . ':' . ($this->config['fulltext_sphinx_port'] ? $this->config['fulltext_sphinx_port'] : '9312')),
950                array('log',                        $this->config['fulltext_sphinx_data_path'] . 'log/searchd.log'),
951                array('query_log',                    $this->config['fulltext_sphinx_data_path'] . 'log/sphinx-query.log'),
952                array('read_timeout',                '5'),
953                array('max_children',                '30'),
954                array('pid_file',                    $this->config['fulltext_sphinx_data_path'] . 'searchd.pid'),
955                array('binlog_path',                $this->config['fulltext_sphinx_data_path']),
956            ),
957        );
958
959        $non_unique = array('sql_query_pre' => true, 'sql_attr_uint' => true, 'sql_attr_timestamp' => true, 'sql_attr_str2ordinal' => true, 'sql_attr_bool' => true);
960        $delete = array('sql_group_column' => true, 'sql_date_column' => true, 'sql_str2ordinal_column' => true);
961
962        /**
963         * Allow adding/changing the Sphinx configuration data
964         *
965         * @event core.search_sphinx_modify_config_data
966         * @var    array    config_data    Array with the Sphinx configuration data
967         * @var    array    non_unique    Array with the Sphinx non-unique variables to delete
968         * @var    array    delete        Array with the Sphinx variables to delete
969         * @since 3.1.7-RC1
970         */
971        $vars = array(
972            'config_data',
973            'non_unique',
974            'delete',
975        );
976        extract($this->phpbb_dispatcher->trigger_event('core.search_sphinx_modify_config_data', compact($vars)));
977
978        foreach ($config_data as $section_name => $section_data)
979        {
980            $section = $config_object->get_section_by_name($section_name);
981            if (!$section)
982            {
983                $section = $config_object->add_section($section_name);
984            }
985
986            foreach ($delete as $key => $void)
987            {
988                $section->delete_variables_by_name($key);
989            }
990
991            foreach ($non_unique as $key => $void)
992            {
993                $section->delete_variables_by_name($key);
994            }
995
996            foreach ($section_data as $entry)
997            {
998                $key = $entry[0];
999                $value = $entry[1];
1000
1001                if (!isset($non_unique[$key]))
1002                {
1003                    $variable = $section->get_variable_by_name($key);
1004                    if (!$variable)
1005                    {
1006                        $section->create_variable($key, $value);
1007                    }
1008                    else
1009                    {
1010                        $variable->set_value($value);
1011                    }
1012                }
1013                else
1014                {
1015                    $section->create_variable($key, $value);
1016                }
1017            }
1018        }
1019        $this->config_file_data = $config_object->get_data();
1020
1021        return true;
1022    }
1023
1024    /**
1025     * {@inheritdoc}
1026     */
1027    public function get_type(): string
1028    {
1029        return static::class;
1030    }
1031}