1 | <?php |
||
2 | /** |
||
3 | * This file contains core of the code for Mentions |
||
4 | * |
||
5 | * Simple Machines Forum (SMF) |
||
6 | * |
||
7 | * @package SMF |
||
8 | * @author Simple Machines https://www.simplemachines.org |
||
9 | * @copyright 2022 Simple Machines and individual contributors |
||
10 | * @license https://www.simplemachines.org/about/smf/license.php BSD |
||
11 | * |
||
12 | * @version 2.1.0 |
||
13 | */ |
||
14 | |||
15 | /** |
||
16 | * This really is a pseudo class, I couldn't justify having instance of it |
||
17 | * while mentioning so I just made every method static |
||
18 | */ |
||
19 | class Mentions |
||
20 | { |
||
21 | /** |
||
22 | * @var string The character used for mentioning users |
||
23 | */ |
||
24 | protected static $char = '@'; |
||
25 | |||
26 | /** |
||
27 | * @var string Regular expression matching BBC that can't contain mentions |
||
28 | */ |
||
29 | protected static $excluded_bbc_regex = ''; |
||
30 | |||
31 | /** |
||
32 | * Returns mentions for a specific content |
||
33 | * |
||
34 | * @static |
||
35 | * @access public |
||
36 | * @param string $content_type The content type |
||
37 | * @param int $content_id The ID of the desired content |
||
38 | * @param array $members Whether to limit to a specific set of members |
||
39 | * @return array An array of arrays containing info about each member mentioned |
||
40 | */ |
||
41 | public static function getMentionsByContent($content_type, $content_id, array $members = array()) |
||
42 | { |
||
43 | global $smcFunc; |
||
44 | |||
45 | $request = $smcFunc['db_query']('', ' |
||
46 | SELECT mem.id_member, mem.real_name, mem.email_address, mem.id_group, mem.id_post_group, mem.additional_groups, |
||
47 | mem.lngfile, ment.id_member AS id_mentioned_by, ment.real_name AS mentioned_by_name |
||
48 | FROM {db_prefix}mentions AS m |
||
49 | INNER JOIN {db_prefix}members AS mem ON (mem.id_member = m.id_mentioned) |
||
50 | INNER JOIN {db_prefix}members AS ment ON (ment.id_member = m.id_member) |
||
51 | WHERE content_type = {string:type} |
||
52 | AND content_id = {int:id}' . (!empty($members) ? ' |
||
53 | AND mem.id_member IN ({array_int:members})' : ''), |
||
54 | array( |
||
55 | 'type' => $content_type, |
||
56 | 'id' => $content_id, |
||
57 | 'members' => (array) $members, |
||
58 | ) |
||
59 | ); |
||
60 | $members = array(); |
||
61 | while ($row = $smcFunc['db_fetch_assoc']($request)) |
||
62 | $members[$row['id_member']] = array( |
||
63 | 'id' => $row['id_member'], |
||
64 | 'real_name' => $row['real_name'], |
||
65 | 'email_address' => $row['email_address'], |
||
66 | 'groups' => array_unique(array_merge(array($row['id_group'], $row['id_post_group']), explode(',', $row['additional_groups']))), |
||
67 | 'mentioned_by' => array( |
||
68 | 'id' => $row['id_mentioned_by'], |
||
69 | 'name' => $row['mentioned_by_name'], |
||
70 | ), |
||
71 | 'lngfile' => $row['lngfile'], |
||
72 | ); |
||
73 | $smcFunc['db_free_result']($request); |
||
74 | |||
75 | return $members; |
||
76 | } |
||
77 | |||
78 | /** |
||
79 | * Inserts mentioned members |
||
80 | * |
||
81 | * @static |
||
82 | * @access public |
||
83 | * @param string $content_type The content type |
||
84 | * @param int $content_id The ID of the specified content |
||
85 | * @param array $members An array of members who have been mentioned |
||
86 | * @param int $id_member The ID of the member who mentioned them |
||
87 | */ |
||
88 | public static function insertMentions($content_type, $content_id, array $members, $id_member) |
||
89 | { |
||
90 | global $smcFunc; |
||
91 | |||
92 | call_integration_hook('mention_insert_' . $content_type, array($content_id, &$members)); |
||
93 | |||
94 | foreach ($members as $member) |
||
95 | $smcFunc['db_insert']('ignore', |
||
96 | '{db_prefix}mentions', |
||
97 | array('content_id' => 'int', 'content_type' => 'string', 'id_member' => 'int', 'id_mentioned' => 'int', 'time' => 'int'), |
||
98 | array((int) $content_id, $content_type, $id_member, $member['id'], time()), |
||
99 | array('content_id', 'content_type', 'id_mentioned') |
||
100 | ); |
||
101 | } |
||
102 | |||
103 | /** |
||
104 | * Updates list of mentioned members. |
||
105 | * |
||
106 | * Intended for use when a post is modified. |
||
107 | * |
||
108 | * @static |
||
109 | * @access public |
||
110 | * @param string $content_type The content type |
||
111 | * @param int $content_id The ID of the specified content |
||
112 | * @param array $members An array of members who have been mentioned |
||
113 | * @param int $id_member The ID of the member who mentioned them |
||
114 | * @return array An array of unchanged, removed, and added member IDs. |
||
115 | */ |
||
116 | public static function modifyMentions($content_type, $content_id, array $members, $id_member) |
||
117 | { |
||
118 | global $smcFunc; |
||
119 | |||
120 | $existing_members = self::getMentionsByContent($content_type, $content_id); |
||
121 | |||
122 | $members_to_remove = array_diff_key($existing_members, $members); |
||
123 | $members_to_insert = array_diff_key($members, $existing_members); |
||
124 | $members_unchanged = array_diff_key($existing_members, $members_to_remove, $members_to_insert); |
||
125 | |||
126 | // Delete mentions from the table that have been deleted in the content. |
||
127 | if (!empty($members_to_remove)) |
||
128 | $smcFunc['db_query']('', ' |
||
129 | DELETE FROM {db_prefix}mentions |
||
130 | WHERE content_type = {string:type} |
||
131 | AND content_id = {int:id} |
||
132 | AND id_mentioned IN ({array_int:members})', |
||
133 | array( |
||
134 | 'type' => $content_type, |
||
135 | 'id' => $content_id, |
||
136 | 'members' => array_keys($members_to_remove), |
||
137 | ) |
||
138 | ); |
||
139 | |||
140 | // Insert any new mentions. |
||
141 | if (!empty($members_to_insert)) |
||
142 | self::insertMentions($content_type, $content_id, $members_to_insert, $id_member); |
||
143 | |||
144 | return array( |
||
145 | 'unchanged' => $members_unchanged, |
||
146 | 'removed' => $members_to_remove, |
||
147 | 'added' => $members_to_insert, |
||
148 | ); |
||
149 | } |
||
150 | |||
151 | /** |
||
152 | * Gets appropriate mentions replaced in the body |
||
153 | * |
||
154 | * @static |
||
155 | * @access public |
||
156 | * @param string $body The text to look for mentions in |
||
157 | * @param array $members An array of arrays containing info about members (each should have 'id' and 'member') |
||
158 | * @return string The body with mentions replaced |
||
159 | */ |
||
160 | public static function getBody($body, array $members) |
||
161 | { |
||
162 | if (empty($body)) |
||
163 | return $body; |
||
164 | |||
165 | foreach ($members as $member) |
||
166 | $body = str_ireplace(static::$char . $member['real_name'], '[member=' . $member['id'] . ']' . $member['real_name'] . '[/member]', $body); |
||
167 | |||
168 | return $body; |
||
169 | } |
||
170 | |||
171 | /** |
||
172 | * Takes a piece of text and finds all the mentioned members in it |
||
173 | * |
||
174 | * @static |
||
175 | * @access public |
||
176 | * @param string $body The body to get mentions from |
||
177 | * @return array An array of arrays containing members who were mentioned (each has 'id_member' and 'real_name') |
||
178 | */ |
||
179 | public static function getMentionedMembers($body) |
||
180 | { |
||
181 | global $smcFunc; |
||
182 | |||
183 | if (empty($body)) |
||
184 | return array(); |
||
185 | |||
186 | $possible_names = self::getPossibleMentions($body); |
||
187 | $existing_mentions = self::getExistingMentions($body); |
||
188 | |||
189 | if ((empty($possible_names) && empty($existing_mentions)) || !allowedTo('mention')) |
||
190 | return array(); |
||
191 | |||
192 | // Make sure we don't pass empty arrays to the query. |
||
193 | if (empty($existing_mentions)) |
||
194 | $existing_mentions = array(0 => ''); |
||
195 | if (empty($possible_names)) |
||
196 | $possible_names = $existing_mentions; |
||
197 | |||
198 | $request = $smcFunc['db_query']('', ' |
||
199 | SELECT id_member, real_name |
||
200 | FROM {db_prefix}members |
||
201 | WHERE id_member IN ({array_int:ids}) |
||
202 | OR real_name IN ({array_string:names}) |
||
203 | ORDER BY LENGTH(real_name) DESC |
||
204 | LIMIT {int:count}', |
||
205 | array( |
||
206 | 'ids' => array_keys($existing_mentions), |
||
207 | 'names' => $possible_names, |
||
208 | 'count' => count($possible_names), |
||
209 | ) |
||
210 | ); |
||
211 | $members = array(); |
||
212 | while ($row = $smcFunc['db_fetch_assoc']($request)) |
||
213 | { |
||
214 | if (!isset($existing_mentions[$row['id_member']]) && stripos($body, static::$char . $row['real_name']) === false) |
||
215 | continue; |
||
216 | |||
217 | $members[$row['id_member']] = array( |
||
218 | 'id' => $row['id_member'], |
||
219 | 'real_name' => $row['real_name'], |
||
220 | ); |
||
221 | } |
||
222 | $smcFunc['db_free_result']($request); |
||
223 | |||
224 | return $members; |
||
225 | } |
||
226 | |||
227 | /** |
||
228 | * Parses a body in order to see if there are any mentions, returns possible mention names |
||
229 | * |
||
230 | * Names are tagged by "@<username>" format in post, but they can contain |
||
231 | * any type of character up to 60 characters length. So we extract, starting from @ |
||
232 | * up to 60 characters in length (or if we encounter a line break) and make |
||
233 | * several combination of strings after splitting it by anything that's not a word and join |
||
234 | * by having the first word, first and second word, first, second and third word and so on and |
||
235 | * search every name. |
||
236 | * |
||
237 | * One potential problem with this is something like "@Admin Space" can match |
||
238 | * "Admin Space" as well as "Admin", so we sort by length in descending order. |
||
239 | * One disadvantage of this is that we can only match by one column, hence I've chosen |
||
240 | * real_name since it's the most obvious. |
||
241 | * |
||
242 | * If there's an @ symbol within the name, it is counted in the ongoing string and a new |
||
243 | * combination string is started from it as well in order to account for all the possibilities. |
||
244 | * This makes the @ symbol to not be required to be escaped |
||
245 | * |
||
246 | * @static |
||
247 | * @access protected |
||
248 | * @param string $body The text to look for mentions in |
||
249 | * @return array An array of names of members who have been mentioned |
||
250 | */ |
||
251 | protected static function getPossibleMentions($body) |
||
252 | { |
||
253 | global $smcFunc; |
||
254 | |||
255 | if (empty($body)) |
||
256 | return array(); |
||
257 | |||
258 | // preparse code does a few things which might mess with our parsing |
||
259 | $body = htmlspecialchars_decode(preg_replace('~<br\s*/?'.'>~', "\n", str_replace(' ', ' ', $body)), ENT_QUOTES); |
||
260 | |||
261 | if (empty(self::$excluded_bbc_regex)) |
||
262 | self::setExcludedBbcRegex(); |
||
263 | |||
264 | // Exclude the content of various BBCodes. |
||
265 | $body = preg_replace('~\[(' . self::$excluded_bbc_regex . ')[^\]]*\](?' . '>(?' . '>[^\[]|\[(?!/?\1[^\]]*\]))|(?0))*\[/\1\]~', '', $body); |
||
266 | |||
267 | $matches = array(); |
||
268 | // Split before every Unicode character. |
||
269 | $string = preg_split('/(?=\X)/u', $body, -1, PREG_SPLIT_NO_EMPTY); |
||
270 | $depth = 0; |
||
271 | foreach ($string as $k => $char) |
||
272 | { |
||
273 | if ($char == static::$char && ($k == 0 || trim($string[$k - 1]) == '')) |
||
274 | { |
||
275 | $depth++; |
||
276 | $matches[] = array(); |
||
277 | } |
||
278 | elseif ($char == "\n") |
||
279 | $depth = 0; |
||
280 | |||
281 | for ($i = $depth; $i > 0; $i--) |
||
282 | { |
||
283 | if (count($matches[count($matches) - $i]) > 60) |
||
284 | { |
||
285 | $depth--; |
||
286 | continue; |
||
287 | } |
||
288 | $matches[count($matches) - $i][] = $char; |
||
289 | } |
||
290 | } |
||
291 | |||
292 | foreach ($matches as $k => $match) |
||
293 | $matches[$k] = substr(implode('', $match), 1); |
||
294 | |||
295 | // Names can have spaces, other breaks, or they can't...we try to match every possible |
||
296 | // combination. |
||
297 | $names = array(); |
||
298 | foreach ($matches as $match) |
||
299 | { |
||
300 | // '[^\p{L}\p{M}\p{N}_]' is the Unicode equivalent of '[^\w]' |
||
301 | $match = preg_split('/([^\p{L}\p{M}\p{N}_])/u', $match, -1, PREG_SPLIT_DELIM_CAPTURE); |
||
302 | $count = count($match); |
||
303 | |||
304 | for ($i = 1; $i <= $count; $i++) |
||
305 | $names[] = $smcFunc['htmlspecialchars']($smcFunc['htmltrim'](implode('', array_slice($match, 0, $i)))); |
||
306 | } |
||
307 | |||
308 | $names = array_unique($names); |
||
309 | |||
310 | return $names; |
||
311 | } |
||
312 | |||
313 | /** |
||
314 | * Like getPossibleMentions(), but for `[member=1]name[/member]` format. |
||
315 | * |
||
316 | * @static |
||
317 | * @access public |
||
318 | * @param string $body The text to look for mentions in. |
||
319 | * @param array $members An array of arrays containing info about members (each should have 'id' and 'member'). |
||
320 | * @return array An array of arrays containing info about members that are in fact mentioned in the body. |
||
321 | */ |
||
322 | public static function getExistingMentions($body) |
||
323 | { |
||
324 | if (empty(self::$excluded_bbc_regex)) |
||
325 | self::setExcludedBbcRegex(); |
||
326 | |||
327 | // Don't include mentions inside quotations, etc. |
||
328 | $body = preg_replace('~\[(' . self::$excluded_bbc_regex . ')[^\]]*\](?' . '>(?' . '>[^\[]|\[(?!/?\1[^\]]*\]))|(?0))*\[/\1\]~', '', $body); |
||
329 | |||
330 | $existing_mentions = array(); |
||
331 | |||
332 | preg_match_all('~\[member=([0-9]+)\]([^\[]*)\[/member\]~', $body, $matches, PREG_SET_ORDER); |
||
333 | |||
334 | foreach ($matches as $match_set) |
||
335 | $existing_mentions[$match_set[1]] = trim($match_set[2]); |
||
336 | |||
337 | return $existing_mentions; |
||
338 | } |
||
339 | |||
340 | /** |
||
341 | * Verifies that members really are mentioned in the text. |
||
342 | * |
||
343 | * This function assumes the incoming text has already been processed by |
||
344 | * the Mentions::getBody() function. |
||
345 | * |
||
346 | * @static |
||
347 | * @access public |
||
348 | * @param string $body The text to look for mentions in. |
||
349 | * @param array $members An array of arrays containing info about members (each should have 'id' and 'member'). |
||
350 | * @return array An array of arrays containing info about members that are in fact mentioned in the body. |
||
351 | */ |
||
352 | public static function verifyMentionedMembers($body, array $members) |
||
353 | { |
||
354 | if (empty($body)) |
||
355 | return array(); |
||
356 | |||
357 | if (empty(self::$excluded_bbc_regex)) |
||
358 | self::setExcludedBbcRegex(); |
||
359 | |||
360 | // Don't include mentions inside quotations, etc. |
||
361 | $body = preg_replace('~\[(' . self::$excluded_bbc_regex . ')[^\]]*\](?' . '>(?' . '>[^\[]|\[(?!/?\1[^\]]*\]))|(?0))*\[/\1\]~', '', $body); |
||
362 | |||
363 | foreach ($members as $member) |
||
364 | { |
||
365 | if (strpos($body, '[member=' . $member['id'] . ']' . $member['real_name'] . '[/member]') === false) |
||
366 | unset($members[$member['id']]); |
||
367 | } |
||
368 | |||
369 | return $members; |
||
370 | } |
||
371 | |||
372 | /** |
||
373 | * Retrieves info about the authors of posts quoted in a block of text. |
||
374 | * |
||
375 | * @static |
||
376 | * @access public |
||
377 | * @param string $body A block of text, such as the body of a post. |
||
378 | * @param int $poster_id The member ID of the author of the text. |
||
379 | * @return array Info about any members who were quoted. |
||
380 | */ |
||
381 | public static function getQuotedMembers($body, $poster_id) |
||
382 | { |
||
383 | global $smcFunc; |
||
384 | |||
385 | if (empty($body)) |
||
386 | return array(); |
||
387 | |||
388 | $blocks = preg_split('/(\[quote.*?\]|\[\/quote\])/i', $body, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); |
||
389 | |||
390 | $quote_level = 0; |
||
391 | $message = ''; |
||
392 | |||
393 | foreach ($blocks as $block) |
||
394 | { |
||
395 | if (preg_match('/\[quote(.*)?\]/i', $block, $matches)) |
||
396 | { |
||
397 | if ($quote_level == 0) |
||
398 | $message .= '[quote' . $matches[1] . ']'; |
||
399 | $quote_level++; |
||
400 | } |
||
401 | elseif (preg_match('/\[\/quote\]/i', $block)) |
||
402 | { |
||
403 | if ($quote_level <= 1) |
||
404 | $message .= '[/quote]'; |
||
405 | if ($quote_level >= 1) |
||
406 | { |
||
407 | $quote_level--; |
||
408 | $message .= "\n"; |
||
409 | } |
||
410 | } |
||
411 | elseif ($quote_level <= 1) |
||
412 | $message .= $block; |
||
413 | } |
||
414 | |||
415 | preg_match_all('/\[quote.*?link=msg=([0-9]+).*?\]/i', $message, $matches); |
||
416 | |||
417 | $id_msgs = $matches[1]; |
||
418 | foreach ($id_msgs as $k => $id_msg) |
||
419 | $id_msgs[$k] = (int) $id_msg; |
||
420 | |||
421 | if (empty($id_msgs)) |
||
422 | return array(); |
||
423 | |||
424 | // Get the messages |
||
425 | $request = $smcFunc['db_query']('', ' |
||
426 | SELECT m.id_member AS id, mem.email_address, mem.lngfile, mem.real_name |
||
427 | FROM {db_prefix}messages AS m |
||
428 | INNER JOIN {db_prefix}members AS mem ON (mem.id_member = m.id_member) |
||
429 | WHERE id_msg IN ({array_int:msgs}) |
||
430 | LIMIT {int:count}', |
||
431 | array( |
||
432 | 'msgs' => array_unique($id_msgs), |
||
433 | 'count' => count(array_unique($id_msgs)), |
||
434 | ) |
||
435 | ); |
||
436 | |||
437 | $members = array(); |
||
438 | while ($row = $smcFunc['db_fetch_assoc']($request)) |
||
439 | { |
||
440 | if ($poster_id == $row['id']) |
||
441 | continue; |
||
442 | |||
443 | $members[$row['id']] = $row; |
||
444 | } |
||
445 | |||
446 | return $members; |
||
447 | } |
||
448 | |||
449 | /** |
||
450 | * Builds a regular expression matching BBC that can't contain mentions. |
||
451 | * |
||
452 | * @static |
||
453 | * @access protected |
||
454 | */ |
||
455 | protected static function setExcludedBbcRegex() |
||
456 | { |
||
457 | if (empty(self::$excluded_bbc_regex)) |
||
458 | { |
||
459 | // Exclude quotes. We don't want to get double mentions. |
||
460 | $excluded_bbc = array('quote'); |
||
461 | |||
462 | // Exclude everything with unparsed content. |
||
463 | foreach (parse_bbc(false) as $code) |
||
464 | { |
||
465 | if (!empty($code['type']) && in_array($code['type'], array('unparsed_content', 'unparsed_commas_content', 'unparsed_equals_content'))) |
||
466 | $excluded_bbc[] = $code['tag']; |
||
467 | } |
||
468 | |||
469 | self::$excluded_bbc_regex = build_regex($excluded_bbc, '~'); |
||
0 ignored issues
–
show
|
|||
470 | } |
||
471 | } |
||
472 | } |
||
473 | |||
474 | ?> |
Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.
For example, imagine you have a variable
$accountId
that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to theid
property of an instance of theAccount
class. This class holds a proper account, so the id value must no longer be false.Either this assignment is in error or a type check should be added for that assignment.