Mentions::modifyMentions()   A
last analyzed

Complexity

Conditions 3
Paths 4

Size

Total Lines 32
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 17
nc 4
nop 4
dl 0
loc 32
rs 9.7
c 0
b 0
f 0
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
Bug introduced by
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, '~');
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...
470
		}
471
	}
472
}
473
474
?>