Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
92 / 92
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
StopWords
100.00% covered (success)
100.00%
92 / 92
100.00% covered (success)
100.00%
12 / 12
32
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
 getLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTableName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLanguage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 update
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 remove
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 match
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getByLang
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 clean
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 checkBannedWord
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 getBannedWords
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3/**
4 * The main StopWords class.
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    Anatoliy Belsky
12 * @author    Matteo Scaramuccia <matteo@phpmyfaq.de>
13 * @copyright 2009-2026 phpMyFAQ Team
14 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
15 * @link      https://www.phpmyfaq.de
16 * @since     2009-04-01
17 */
18
19declare(strict_types=1);
20
21namespace phpMyFAQ;
22
23/**
24 * Class StopWords
25 *
26 * @package phpMyFAQ
27 */
28class StopWords
29{
30    private string $language;
31
32    private readonly string $tableName;
33
34    public function __construct(
35        private readonly Configuration $configuration,
36    ) {
37        $this->tableName = Database::getTablePrefix() . 'faqstopwords';
38    }
39
40    public function getLanguage(): string
41    {
42        return $this->language;
43    }
44
45    public function getTableName(): string
46    {
47        return $this->tableName;
48    }
49
50    public function setLanguage(string $language): StopWords
51    {
52        $this->language = $language;
53        return $this;
54    }
55
56    /**
57     * Add a word to the stop words dictionary.
58     * If the given word already exists, false is returned.
59     */
60    public function add(string $word): bool
61    {
62        if (!$this->match($word)) {
63            $sql = sprintf(
64                "INSERT INTO %s VALUES(%d, '%s', '%s')",
65                $this->getTableName(),
66                $this->configuration->getDb()->nextId($this->tableName, 'id'),
67                $this->configuration->getDb()->escape($this->language),
68                $this->configuration->getDb()->escape($word),
69            );
70
71            return (bool) $this->configuration->getDb()->query($sql);
72        }
73
74        return false;
75    }
76
77    /**
78     * Update a word in the stop words dictionary.
79     */
80    public function update(int $id, string $word): bool
81    {
82        $sql = "UPDATE %s SET stopword = '%s' WHERE id = %d AND lang = '%s'";
83        $sql = sprintf(
84            $sql,
85            $this->getTableName(),
86            $this->configuration->getDb()->escape($word),
87            $id,
88            $this->configuration->getDb()->escape($this->language),
89        );
90
91        return (bool) $this->configuration->getDb()->query($sql);
92    }
93
94    /**
95     * Remove a word from the stop word dictionary.
96     */
97    public function remove(int $id): bool
98    {
99        $sql = sprintf(
100            "DELETE FROM %s WHERE id = %d AND lang = '%s'",
101            $this->getTableName(),
102            $id,
103            $this->configuration->getDb()->escape($this->language),
104        );
105
106        return (bool) $this->configuration->getDb()->query($sql);
107    }
108
109    /**
110     * Match a word against the stop words dictionary.
111     */
112    public function match(string $word): bool
113    {
114        $sql = sprintf(
115            "SELECT id FROM %s WHERE LOWER(stopword) = LOWER('%s') AND lang = '%s'",
116            $this->getTableName(),
117            $this->configuration->getDb()->escape($word),
118            $this->configuration->getDb()->escape($this->language),
119        );
120
121        $result = $this->configuration->getDb()->query($sql);
122
123        return $this->configuration->getDb()->numRows($result) > 0;
124    }
125
126    /**
127     * Retrieve all the stop words by a certain language.
128     *
129     * @param  string|null $lang      Language to retrieve stop words by
130     * @return array<array-key, string|\stdClass> Strings when $wordsOnly is true, otherwise row objects
131     */
132    public function getByLang(?string $lang = null, bool $wordsOnly = false): array
133    {
134        $lang = is_null($lang) ? $this->configuration->getLanguage()->getLanguage() : $lang;
135        $sql = sprintf(
136            "SELECT id, lang, LOWER(stopword) AS stopword FROM %s WHERE lang = '%s'",
137            $this->getTableName(),
138            $this->configuration->getDb()->escape($lang),
139        );
140
141        $result = $this->configuration->getDb()->query($sql);
142
143        $stopWords = [];
144
145        if (!$wordsOnly) {
146            return $this->configuration->getDb()->fetchAll($result) ?? [];
147        }
148
149        while (true) {
150            $row = $this->configuration->getDb()->fetchObject($result);
151            if (!is_object($row)) {
152                break;
153            }
154
155            $stopWords[] = Strings::htmlentities((string) $row->stopword);
156        }
157
158        return $stopWords;
159    }
160
161    /**
162     * Filter some text cutting out all non-words and stop words.
163     *
164     * @param string $input text to filter
165     * @return string[]
166     */
167    public function clean(string $input): array
168    {
169        $words = explode(' ', $input);
170        $stopWords = $this->getByLang(null, true);
171        $result = [];
172
173        foreach ($words as $word) {
174            $word = Strings::strtolower($word);
175            if (is_numeric($word)) {
176                continue;
177            }
178
179            if (1 >= Strings::strlen($word)) {
180                continue;
181            }
182
183            if (in_array($word, $stopWords, strict: true)) {
184                continue;
185            }
186
187            if (in_array($word, $result, strict: true)) {
188                continue;
189            }
190
191            $result[] = $word;
192        }
193
194        return $result;
195    }
196
197    /**
198     * This function checks the content against a bad word list if the banned
199     * word spam protection has been activated from the general phpMyFAQ
200     * configuration.
201     */
202    public function checkBannedWord(string $content): bool
203    {
204        // Sanity checks
205        $content = Strings::strtolower(trim($content));
206        if ('' === $content || !$this->configuration->get(item: 'spam.checkBannedWords')) {
207            return true;
208        }
209
210        // Check if we check more than one word
211        $checkWords = explode(' ', $content);
212        if (1 === count($checkWords)) {
213            $checkWords = [$content];
214        }
215
216        $bannedWords = $this->getBannedWords();
217
218        // We just search a match of, at least, one banned word into $content
219        foreach ($bannedWords as $bannedWord) {
220            foreach ($checkWords as $checkWord) {
221                if (Strings::strtolower($checkWord) !== Strings::strtolower($bannedWord)) {
222                    continue;
223                }
224
225                return false;
226            }
227        }
228
229        return true;
230    }
231
232    /**
233     * This function returns the banned words dictionary as an array.
234     *
235     * @return string[]
236     */
237    private function getBannedWords(): array
238    {
239        $bannedTrimmedWords = [];
240        $bannedWordsFile = (string) PMF_SRC_DIR . '/blockedwords.txt';
241
242        // Read the dictionary
243        $bannedWordsContent = '';
244        if (file_exists($bannedWordsFile) && is_readable($bannedWordsFile)) {
245            $fileContent = file_get_contents($bannedWordsFile);
246            $bannedWordsContent = $fileContent === false ? '' : $fileContent;
247        }
248
249        // Trim it
250        foreach (explode("\n", $bannedWordsContent) as $word) {
251            $bannedTrimmedWords[] = trim($word);
252        }
253
254        return $bannedTrimmedWords;
255    }
256}