Issues (1014)

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
			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('&nbsp;', ' ', $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)
0 ignored issues
show
The expression parse_bbc(false) of type string is not traversable.
Loading history...
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, '~');
470
		}
471
	}
472
}
473
474
?>