Issues (1065)

Sources/Mentions.php (1 issue)

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

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 the id property of an instance of the Account 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.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
473
		}
474
	}
475
}
476
477
?>