Passed
Pull Request — development (#3834)
by Spuds
08:39
created

Search::_searchParamsFromString()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 20
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 9
nc 2
nop 0
dl 0
loc 20
rs 9.9666
c 1
b 0
f 0
1
<?php
2
3
/**
4
 * This file is meant for controlling the search actions related to personal messages.
5
 *
6
 * @package   ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
9
 *
10
 * @version 2.0 Beta 1
11
 *
12
 */
13
14
namespace ElkArte\PersonalMessage;
15
16
use BBC\ParserWrapper;
17
use ElkArte\AbstractController;
18
use ElkArte\Exceptions\Exception;
19
use ElkArte\Helper\Util;
20
use ElkArte\Helper\ValuesContainer;
21
use ElkArte\Languages\Txt;
22
use ElkArte\MembersList;
23
24
/**
25
 * Class Search
26
 * It allows searching in personal messages
27
 *
28
 * @package ElkArte\PersonalMessage
29
 */
30
class Search extends AbstractController
31
{
32
	/**
33
	 * @var array $_search_params Will carry all settings that differ from the default.
34
	 * That way, the URLs involved in a search page will be kept as short as possible.
35
	 */
36
	private $_search_params = [];
37
38
	/** @var array $_searchq_parameters will carry all the values needed by S_search_params */
39
	private $_searchq_parameters = [];
40
41
	/**
42
	 * Default action for the class
43
	 */
44
	public function action_index()
45
	{
46
		$this->action_search();
47
	}
48
49
	/**
50
	 * Actually do the search of personal messages and show the results
51
	 *
52
	 * What it does:
53
	 *
54
	 * - Accessed with ?action=pm;sa=search2
55
	 * - Checks user input and searches the pm table for messages matching the query.
56
	 * - Uses the search_results sub template of the PersonalMessage template.
57
	 * - Show the results of the search query.
58
	 */
59
	public function action_search2(): ?bool
60
	{
61
		global $scripturl, $modSettings, $context, $txt;
62
63
		$context['display_mode'] = PmHelper::getDisplayMode();
64
65
		// Make sure the server is able to do this right now
66
		if (!empty($modSettings['loadavg_search']) && $modSettings['current_load'] >= $modSettings['loadavg_search'])
67
		{
68
			throw new Exception('loadavg_search_disabled', false);
69
		}
70
71
		// Some useful general permissions.
72
		$context['can_send_pm'] = allowedTo('pm_send');
73
74
		// Extract all the search parameters if coming in from pagination, etc.
75
		$this->_searchParamsFromString();
76
77
		// Set a start for pagination
78
		$context['start'] = $this->_req->getQuery('start', 'intval', 0);
79
80
		// Set/clean search criteria
81
		$this->_prepareSearchParams();
82
83
		$context['folder'] = empty($this->_search_params['sent_only']) ? 'inbox' : 'sent';
84
85
		// Searching for specific members
86
		$userQuery = $this->_setUserQuery();
87
88
		// Set up the sorting variables...
89
		$this->_setSortParams();
90
91
		// Sort out any labels we may be searching for.
92
		$labelQuery = $this->_setLabelQuery();
93
94
		// Unfortunately, searching for words like this is going to be slow, so we're blocking them.
95
		$blocklist_words = ['quote', 'the', 'is', 'it', 'are', 'if', 'in'];
96
97
		// What are we actually searching for?
98
		if (empty($this->_search_params['search']))
99
		{
100
			$this->_search_params['search'] = $this->_req->getPost('search', 'trim|strval', '');
101
		}
102
103
		// If nothing is left to search on - we set an error!
104
		if (!isset($this->_search_params['search']) || $this->_search_params['search'] === '')
105
		{
106
			$context['search_errors']['invalid_search_string'] = true;
107
		}
108
109
		// Change non-word characters into spaces.
110
		$stripped_query = preg_replace('~(?:[\x0B\0\x{A0}\t\r\s\n(){}\\[\\]<>!@$%^*.,:+=`\~\?/\\\\]+|&(?:amp|lt|gt|quot);)+~u', ' ', $this->_search_params['search']);
111
112
		// Make the query lower case since it will case-insensitive anyway.
113
		$stripped_query = un_htmlspecialchars(Util::strtolower($stripped_query));
114
115
		// Extract phrase parts first (e.g., some words "this is a phrase" some more words.)
116
		preg_match_all('/(?:^|\s)([-]?)"([^"]+)"(?:$|\s)/', $stripped_query, $matches, PREG_PATTERN_ORDER);
117
		$phraseArray = $matches[2];
118
119
		// Remove the phrase parts and extract the words.
120
		$wordArray = preg_replace('~(?:^|\s)(?:[-]?)"(?:[^"]+)"(?:$|\s)~u', ' ', $this->_search_params['search']);
121
		$wordArray = explode(' ', Util::htmlspecialchars(un_htmlspecialchars($wordArray), ENT_QUOTES));
122
123
		// A minus sign in front of a word excludes the word.... so...
124
		$excludedWords = [];
125
126
		// Check for things like -"some words", but not "-some words".
127
		foreach ($matches[1] as $index => $word)
128
		{
129
			if ($word === '-')
130
			{
131
				if (($word = trim($phraseArray[$index], "-_' ")) !== '' && !in_array($word, $blocklist_words))
132
				{
133
					$excludedWords[] = $word;
134
				}
135
136
				unset($phraseArray[$index]);
137
			}
138
		}
139
140
		// Now we look for -test, etc.
141
		foreach ($wordArray as $index => $word)
142
		{
143
			if (str_starts_with(trim($word), '-'))
144
			{
145
				if (($word = trim($word, "-_' ")) !== '' && !in_array($word, $blocklist_words))
146
				{
147
					$excludedWords[] = $word;
148
				}
149
150
				unset($wordArray[$index]);
151
			}
152
		}
153
154
		// The remaining words and phrases are all included.
155
		$searchArray = array_merge($phraseArray, $wordArray);
156
157
		// Trim everything and make sure there are no words that are the same.
158
		foreach ($searchArray as $index => $value)
159
		{
160
			// Skip anything that's close to empty.
161
			if (($searchArray[$index] = trim($value, "-_' ")) === '')
162
			{
163
				unset($searchArray[$index]);
164
			}
165
			// Skip blocked words. Make sure to note we skipped them as well
166
			elseif (in_array($searchArray[$index], $blocklist_words))
167
			{
168
				$foundBlockListedWords = true;
169
				unset($searchArray[$index]);
170
171
			}
172
173
			if (isset($searchArray[$index]))
174
			{
175
				$searchArray[$index] = Util::strtolower(trim($value));
176
177
				if ($searchArray[$index] === '')
178
				{
179
					unset($searchArray[$index]);
180
				}
181
				else
182
				{
183
					// Sort out entities first.
184
					$searchArray[$index] = Util::htmlspecialchars($searchArray[$index]);
185
				}
186
			}
187
		}
188
189
		$searchArray = array_slice(array_unique($searchArray), 0, 10);
190
191
		// This contains *everything*
192
		$searchWords = array_merge($searchArray, $excludedWords);
193
194
		// Make sure at least one word is being searched for.
195
		if (empty($searchArray))
196
		{
197
			$context['search_errors']['invalid_search_string' . (empty($foundBlockListedWords) ? '' : '_blocklist')] = true;
198
		}
199
200
		// Sort out the search query so the user can edit it - if they want.
201
		$context['search_params'] = $this->_search_params;
202
		if (isset($context['search_params']['search']))
203
		{
204
			$context['search_params']['search'] = Util::htmlspecialchars($context['search_params']['search']);
205
		}
206
207
		if (isset($context['search_params']['userspec']))
208
		{
209
			$context['search_params']['userspec'] = Util::htmlspecialchars($context['search_params']['userspec']);
210
		}
211
212
		// Now we have all the parameters, combine them together for pagination and the like...
213
		$context['params'] = $this->_compileURLparams();
214
215
		// Compile the subject query part.
216
		$andQueryParts = [];
217
		foreach ($searchWords as $index => $word)
218
		{
219
			if ($word === '')
220
			{
221
				continue;
222
			}
223
224
			if ($this->_search_params['subject_only'])
225
			{
226
				$andQueryParts[] = 'pm.subject' . (in_array($word, $excludedWords) ? ' NOT' : '') . ' LIKE {string:search_' . $index . '}';
227
			}
228
			else
229
			{
230
				$andQueryParts[] = '(pm.subject' . (in_array($word, $excludedWords) ? ' NOT' : '') . ' LIKE {string:search_' . $index . '} ' . (in_array($word, $excludedWords) ? 'AND pm.body NOT' : 'OR pm.body') . ' LIKE {string:search_' . $index . '})';
231
			}
232
233
			$this->_searchq_parameters ['search_' . $index] = '%' . strtr($word, ['_' => '\\_', '%' => '\\%']) . '%';
234
		}
235
236
		$searchQuery = ' 1=1';
237
		if (!empty($andQueryParts))
238
		{
239
			$searchQuery = implode(!empty($this->_search_params['searchtype']) && $this->_search_params['searchtype'] == 2 ? ' OR ' : ' AND ', $andQueryParts);
240
		}
241
242
		// Age limits?
243
		$timeQuery = '';
244
		if (!empty($this->_search_params['minage']))
245
		{
246
			$timeQuery .= ' AND pm.msgtime < ' . (time() - $this->_search_params['minage'] * 86400);
247
		}
248
249
		if (!empty($this->_search_params['maxage']))
250
		{
251
			$timeQuery .= ' AND pm.msgtime > ' . (time() - $this->_search_params['maxage'] * 86400);
252
		}
253
254
		// If we have errors - return back to the first screen...
255
		if (!empty($context['search_errors']))
256
		{
257
			$this->_req->post->params = $context['params'];
258
259
			$this->action_search();
260
261
			return false;
262
		}
263
264
		// Get the number of results.
265
		$numResults = numPMSeachResults($userQuery, $labelQuery, $timeQuery, $searchQuery, $this->_searchq_parameters);
266
267
		// Get all the matching message ids, senders and head pm nodes
268
		[$foundMessages, $posters, $head_pms] = loadPMSearchMessages($userQuery, $labelQuery, $timeQuery, $searchQuery, $this->_searchq_parameters, $this->_search_params);
269
270
		// Find the real head pm when in the conversation view
271
		if ($context['display_mode'] === PmHelper::DISPLAY_AS_CONVERSATION && !empty($head_pms))
272
		{
273
			$real_pm_ids = loadPMSearchHeads($head_pms);
274
		}
275
276
		// Load the found user data
277
		$posters = array_unique($posters);
278
		if (!empty($posters))
279
		{
280
			MembersList::load($posters);
281
		}
282
283
		// Sort out the page index.
284
		$context['page_index'] = constructPageIndex('{scripturl}?action=pm;sa=search2;params=' . $context['params'], $context['start'], $numResults, $modSettings['search_results_per_page']);
285
286
		$context['message_labels'] = [];
287
		$context['message_replied'] = [];
288
		$context['personal_messages'] = [];
289
		$context['first_label'] = [];
290
291
		// If we have results, we have work to do!
292
		if (!empty($foundMessages))
293
		{
294
			$recipients = [];
295
			[$context['message_labels'], $context['message_replied'], $context['message_unread'], $context['first_label']] = loadPMRecipientInfo($foundMessages, $recipients, $context['folder'], true);
296
297
			// Prepare for the callback!
298
			$search_results = loadPMSearchResults($foundMessages, $this->_search_params);
299
			$counter = 0;
300
			$bbc_parser = ParserWrapper::instance();
301
			foreach ($search_results as $row)
302
			{
303
				// If there's no subject, use the default.
304
				$row['subject'] = $row['subject'] === '' ? $txt['no_subject'] : $row['subject'];
305
306
				// Load this poster context info, if not there, then fill in the essentials...
307
				$member = MembersList::get($row['id_member_from']);
308
				$member->loadContext();
309
				if ($member->isEmpty())
310
				{
311
					$member['name'] = $row['from_name'];
312
					$member['id'] = 0;
313
					$member['group'] = $txt['guest_title'];
314
					$member['link'] = $row['from_name'];
315
					$member['email'] = '';
316
					$member['show_email'] = showEmailAddress(0);
317
					$member['is_guest'] = true;
318
				}
319
320
				// Censor anything we don't want to see...
321
				$row['body'] = censor($row['body']);
322
				$row['subject'] = censor($row['subject']);
323
324
				// Parse out any BBC...
325
				$row['body'] = $bbc_parser->parsePM($row['body']);
326
327
				// Highlight the hits
328
				$body_highlighted = '';
329
				$subject_highlighted = '';
330
				foreach ($searchArray as $query)
331
				{
332
					// Fix the international characters in the keyword too.
333
					$query = un_htmlspecialchars($query);
334
					$query = trim($query, '\*+');
335
					$query = strtr(Util::htmlspecialchars($query), ['\\\'' => "'"]);
336
337
					$body_highlighted = preg_replace_callback('/((<[^>]*)|' . preg_quote(strtr($query, ["'" => '&#039;']), '/') . ')/iu',
338
						fn($matches) => $this->_highlighted_callback($matches), $row['body']);
339
					$subject_highlighted = preg_replace('/(' . preg_quote($query, '/') . ')/iu', '<strong class="highlight">$1</strong>', $row['subject']);
340
				}
341
342
				// Set a link using the first label information
343
				$href = $scripturl . '?action=pm;f=' . $context['folder'] . (isset($context['first_label'][$row['id_pm']]) ? ';l=' . $context['first_label'][$row['id_pm']] : '') . ';pmid=' . ($context['display_mode'] === PmHelper::DISPLAY_AS_CONVERSATION && isset($real_pm_ids[$head_pms[$row['id_pm']]]) && $context['folder'] === 'inbox' ? $real_pm_ids[$head_pms[$row['id_pm']]] : $row['id_pm']) . '#msg_' . $row['id_pm'];
344
345
				$context['personal_messages'][] = [
346
					'id' => $row['id_pm'],
347
					'member' => $member,
348
					'subject' => $subject_highlighted,
349
					'body' => $body_highlighted,
350
					'time' => standardTime($row['msgtime']),
351
					'html_time' => htmlTime($row['msgtime']),
352
					'timestamp' => forum_time(true, $row['msgtime']),
353
					'recipients' => &$recipients[$row['id_pm']],
354
					'labels' => &$context['message_labels'][$row['id_pm']],
355
					'fully_labeled' => (empty($context['message_labels'][$row['id_pm']]) ? 0 : count($context['message_labels'][$row['id_pm']])) === count($context['labels']),
356
					'is_replied_to' => &$context['message_replied'][$row['id_pm']],
357
					'href' => $href,
358
					'link' => '<a href="' . $href . '">' . $subject_highlighted . '</a>',
359
					'counter' => ++$counter,
360
					'pmbuttons' => $this->_setSearchPmButtons($row['id_pm'], $member),
361
				];
362
			}
363
		}
364
365
		// Finish off the context.
366
		$context['page_title'] = $txt['pm_search_title'];
367
		$context['sub_template'] = 'search_results';
368
		$context['menu_data_' . $context['pm_menu_id']]['current_area'] = 'search';
369
		$context['breadcrumbs'][] = [
370
			'url' => getUrl('action', ['action' => 'pm', 'sa' => 'search']),
371
			'name' => $txt['pm_search_bar_title'],
372
		];
373
374
		return true;
375
	}
376
377
	/**
378
	 * Return buttons for search results, used when viewing full message as result
379
	 *
380
	 * @param int $id of the PM
381
	 * @param ValuesContainer $member member information
382
	 * @return array[]
383
	 */
384
	private function _setSearchPmButtons($id, $member): array
385
	{
386
		global $context;
387
388
		$pmButtons = [
389
			// Reply, Quote
390
			'reply_button' => [
391
				'text' => 'reply',
392
				'url' => getUrl('action', ['action' => 'pm', 'sa' => 'send', 'f' => $context['folder'], 'pmsg' => $id, 'u' => $member['id']]) . ($context['current_label_id'] !== "-1" ? ';l=' . $context['current_label_id'] : ''),
393
				'class' => 'reply_button',
394
				'icon' => 'modify',
395
				'enabled' => !$member['is_guest'] && $context['can_send_pm'],
396
			],
397
			'quote_button' => [
398
				'text' => 'quote',
399
				'url' => getUrl('action', ['action' => 'pm', 'sa' => 'send', 'f' => $context['folder'], 'pmsg' => $id, 'quote' => '']) . ($context['current_label_id'] !== "-1" ? ';l=' . $context['current_label_id'] : '') . ($context['folder'] === 'sent' ? '' : ';u=' . $member['id']),
400
				'class' => 'quote_button',
401
				'icon' => 'quote',
402
				'enabled' => !$member['is_guest'] && $context['can_send_pm'],
403
			],
404
			// This is for "forwarding" - even if the member is gone.
405
			'reply_quote_button' => [
406
				'text' => 'reply_quote',
407
				'url' => getUrl('action', ['action' => 'pm', 'sa' => 'send', 'f' => $context['folder'], 'pmsg' => $id, 'quote' => '']) . ($context['current_label_id'] !== "-1" ? ';l=' . $context['current_label_id'] : ''),
408
				'class' => 'reply_button',
409
				'icon' => 'modify',
410
				'enabled' => $member['is_guest'] && $context['can_send_pm'],
411
			]
412
		];
413
414
		// Drop any non-enabled ones
415
		return array_filter($pmButtons, static fn($button) => !isset($button['enabled']) || (bool) $button['enabled']);
416
	}
417
418
	/**
419
	 * Extract search params from a string
420
	 *
421
	 * What it does:
422
	 *
423
	 * - When paging search results, reads and decodes the passed parameters
424
	 * - Places what it finds back in search_params
425
	 */
426
	private function _searchParamsFromString(): array
427
	{
428
		$this->_search_params = [];
429
430
		// Read encoded params from either GET or POST using helper
431
		$temp_params = $this->_req->getRequest('params', 'trim|strval');
432
		if ($temp_params !== null && $temp_params !== '')
433
		{
434
			// Decode and replace the uri safe characters we added
435
			$temp_params = base64_decode(str_replace(['-', '_', '.'], ['+', '/', '='], $temp_params));
436
437
			$temp_params = explode('|"|', $temp_params);
438
			foreach ($temp_params as $data)
439
			{
440
				[$k, $v] = array_pad(explode("|'|", $data), 2, '');
441
				$this->_search_params[$k] = $v;
442
			}
443
		}
444
445
		return $this->_search_params;
446
	}
447
448
	/**
449
	 * Sets the search params for the query
450
	 *
451
	 * What it does:
452
	 *
453
	 * - Uses existing ones if coming from pagination or uses those passed from the search pm form
454
	 * - Validates passed params are valid
455
	 */
456
	private function _prepareSearchParams(): void
457
	{
458
		// Store whether simple search was used (needed if the user wants to do another query).
459
		if (!isset($this->_search_params['advanced']))
460
		{
461
			$this->_search_params['advanced'] = $this->_req->hasPost('advanced') ? 1 : 0;
462
		}
463
464
		// 1 => 'allwords' (default, don't set as param), 2 => 'anywords'.
465
		$searchtypePost = $this->_req->getPost('searchtype', 'intval', 1);
466
		if (!empty($this->_search_params['searchtype']) || $searchtypePost == 2)
467
		{
468
			$this->_search_params['searchtype'] = 2;
469
		}
470
471
		// Minimum age of messages. Default to zero (don't set param in that case).
472
		$minagePost = $this->_req->getPost('minage', 'intval', 0);
473
		if (!empty($this->_search_params['minage']) || ($minagePost > 0))
474
		{
475
			$this->_search_params['minage'] = empty($this->_search_params['minage']) ? $minagePost : (int) $this->_search_params['minage'];
476
		}
477
478
		// Maximum age of messages. Default to infinite (9999 days: param not set).
479
		$maxagePost = $this->_req->getPost('maxage', 'intval', 9999);
480
		if (!empty($this->_search_params['maxage']) || ($maxagePost < 9999))
481
		{
482
			$this->_search_params['maxage'] = empty($this->_search_params['maxage']) ? $maxagePost : (int) $this->_search_params['maxage'];
483
		}
484
485
		// Default the username to a wildcard matching every user (*).
486
		$userspecPost = $this->_req->getPost('userspec', 'trim|strval', '*');
487
		if (!empty($this->_search_params['userspec']) || ($userspecPost !== '*'))
488
		{
489
			$this->_search_params['userspec'] = $this->_search_params['userspec'] ?? $userspecPost;
490
		}
491
492
		// Search modifiers
493
		$this->_search_params['subject_only'] = !empty($this->_search_params['subject_only']) || $this->_req->hasPost('subject_only');
494
		$this->_search_params['show_complete'] = !empty($this->_search_params['show_complete']) || $this->_req->hasPost('show_complete');
495
		$this->_search_params['sent_only'] = !empty($this->_search_params['sent_only']) || $this->_req->hasPost('sent_only');
496
	}
497
498
	/**
499
	 * Handles the parameters when searching for specific users
500
	 *
501
	 * What it does:
502
	 *
503
	 * - Returns the user query for use in the main search query
504
	 * - Sets the parameters for use in the query
505
	 *
506
	 * @return string
507
	 */
508
	private function _setUserQuery(): string
509
	{
510
		global $context;
511
512
		// Hardcoded variables that can be tweaked if required.
513
		$maxMembersToSearch = 500;
514
515
		// Init to not be searching based on members
516
		$userQuery = '';
517
518
		// If there's no specific user, then don't mention it in the main query.
519
		if (!empty($this->_search_params['userspec']))
520
		{
521
			// Set up, so we can search by username, wildcards, like, etc.
522
			$userString = strtr(Util::htmlspecialchars($this->_search_params['userspec'], ENT_QUOTES), ['&quot;' => '"']);
523
			$userString = strtr($userString, ['%' => '\%', '_' => '\_', '*' => '%', '?' => '_']);
524
525
			preg_match_all('~"([^"]+)"~', $userString, $matches);
526
			$possible_users = array_merge($matches[1], explode(',', preg_replace('~"[^"]+"~', '', $userString)));
527
528
			// Who matches those criteria?
529
			require_once(SUBSDIR . '/Members.subs.php');
530
			$members = membersBy('member_names', ['member_names' => $possible_users]);
531
532
			foreach ($possible_users as $key => $possible_user)
533
			{
534
				$this->_searchq_parameters['guest_user_name_implode_' . $key] = '{string_case_insensitive:' . $possible_user . '}';
535
			}
536
537
			// Simply do nothing if there are too many members matching the criteria.
538
			if (count($members) > $maxMembersToSearch)
539
			{
540
				$userQuery = '';
541
			}
542
			elseif (count($members) === 0)
543
			{
544
				if ($context['folder'] === 'inbox')
545
				{
546
					$uq = [];
547
					$name = '{column_case_insensitive:pm.from_name}';
548
					foreach (array_keys($possible_users) as $key)
549
					{
550
						$uq[] = 'AND pm.id_member_from = 0 AND (' . $name . ' LIKE {string:guest_user_name_implode_' . $key . '})';
551
					}
552
553
					$userQuery = implode(' ', $uq);
554
					$this->_searchq_parameters['pm_from_name'] = $name;
555
				}
556
				else
557
				{
558
					$userQuery = '';
559
				}
560
			}
561
			else
562
			{
563
				$memberlist = [];
564
				foreach ($members as $id)
565
				{
566
					$memberlist[] = $id;
567
				}
568
569
				// Use the name as sent from or sent to
570
				if ($context['folder'] === 'inbox')
571
				{
572
					$uq = [];
573
					$name = '{column_case_insensitive:pm.from_name}';
574
575
					foreach (array_keys($possible_users) as $key)
576
					{
577
						$uq[] = 'AND (pm.id_member_from IN ({array_int:member_list}) OR (pm.id_member_from = 0 AND (' . $name . ' LIKE {string:guest_user_name_implode_' . $key . '})))';
578
					}
579
580
					$userQuery = implode(' ', $uq);
581
				}
582
				else
583
				{
584
					$userQuery = 'AND (pmr.id_member IN ({array_int:member_list}))';
585
				}
586
587
				$this->_searchq_parameters['pm_from_name'] = '{column_case_insensitive:pm.from_name}';
588
				$this->_searchq_parameters['member_list'] = $memberlist;
589
			}
590
		}
591
592
		return $userQuery;
593
	}
594
595
	/**
596
	 * Read / Set the sort parameters for the results listing
597
	 */
598
	private function _setSortParams(): void
599
	{
600
		$sort_columns = [
601
			'pm.id_pm',
602
		];
603
604
		if (empty($this->_search_params['sort']) && !empty($this->_req->post->sort))
605
		{
606
			[$this->_search_params['sort'], $this->_search_params['sort_dir']] = array_pad(explode('|', $this->_req->post->sort), 2, '');
607
		}
608
609
		$this->_search_params['sort'] = !empty($this->_search_params['sort']) && in_array($this->_search_params['sort'], $sort_columns) ? $this->_search_params['sort'] : 'pm.id_pm';
610
		$this->_search_params['sort_dir'] = !empty($this->_search_params['sort_dir']) && $this->_search_params['sort_dir'] === 'asc' ? 'asc' : 'desc';
611
	}
612
613
	/**
614
	 * Handles the parameters when searching for specific labels
615
	 *
616
	 * What it does:
617
	 *
618
	 * - Returns the label query for use in the main search query
619
	 * - Sets the parameters for use in the query
620
	 *
621
	 * @return string
622
	 * @throws \Exception
623
	 */
624
	private function _setLabelQuery(): string
625
	{
626
		global $context;
627
628
		$db = database();
629
630
		$labelQuery = '';
631
632
		if ($context['folder'] === 'inbox' && !empty($this->_search_params['advanced']) && $context['currently_using_labels'])
633
		{
634
			// Came here from pagination?  Put them back into $_REQUEST for sanitation.
635
			if (isset($this->_search_params['labels']))
636
			{
637
				$this->_req->post->searchlabel = explode(',', $this->_search_params['labels']);
638
			}
639
640
			// Assuming we have some labels - make them all integers.
641
			if (!empty($this->_req->post->searchlabel) && is_array($this->_req->post->searchlabel))
642
			{
643
				$this->_req->post->searchlabel = array_map('intval', $this->_req->post->searchlabel);
644
			}
645
			else
646
			{
647
				$this->_req->post->searchlabel = [];
648
			}
649
650
			// Now that everything is cleaned up a bit, make the labels a param.
651
			$this->_search_params['labels'] = implode(',', $this->_req->post->searchlabel);
652
653
			// No labels selected? That must be an error!
654
			if (empty($this->_req->post->searchlabel))
655
			{
656
				$context['search_errors']['no_labels_selected'] = true;
657
			}
658
			// Otherwise prepare the query!
659
			elseif (count($this->_req->post->searchlabel) !== count($context['labels']))
660
			{
661
				$labelQuery = '
662
				AND {raw:label_implode}';
663
664
				$labelStatements = [];
665
				foreach ($this->_req->post->searchlabel as $label)
666
				{
667
					$labelStatements[] = $db->quote('FIND_IN_SET({string:label}, pmr.labels) != 0', ['label' => $label,]);
668
				}
669
670
				$this->_searchq_parameters ['label_implode'] = '(' . implode(' OR ', $labelStatements) . ')';
671
			}
672
		}
673
674
		return $labelQuery;
675
	}
676
677
	/**
678
	 * Encodes search params in a URL-compatible way
679
	 *
680
	 * @return string - the encoded string to be appended to the URL
681
	 */
682
	private function _compileURLparams(): string
683
	{
684
		$encoded = [];
685
686
		// Now we have all the parameters, combine them together for pagination and the like...
687
		foreach ($this->_search_params as $k => $v)
688
		{
689
			$encoded[] = $k . "|'|" . $v;
690
		}
691
692
		// Base64 encode, then replace +/= with uri safe ones that can be reverted
693
		return str_replace(['+', '/', '='], ['-', '_', '.'], base64_encode(implode('|"|', $encoded)));
694
	}
695
696
	/**
697
	 * Allows searching personal messages.
698
	 *
699
	 * What it does:
700
	 *
701
	 * - Accessed with ?action=pm;sa=search
702
	 * - Shows the screen to search PMs (?action=pm;sa=search)
703
	 * - Uses the search sub template of the PersonalMessage template.
704
	 * - Decodes and loads search parameters given in the URL (if any).
705
	 * - The form redirects to index.php?action=pm;sa=search2.
706
	 *
707
	 * @uses search sub template
708
	 */
709
	public function action_search(): void
710
	{
711
		global $context, $txt;
712
713
		$context['display_mode'] = PmHelper::getDisplayMode();
714
715
		// If they provided some search parameters, we need to extract them
716
		if ($this->_req->hasPost('params'))
717
		{
718
			$context['search_params'] = $this->_searchParamsFromString();
719
		}
720
721
		// Set up the search criteria, type, what, age, etc.
722
		if ($this->_req->hasPost('search'))
723
		{
724
			$context['search_params']['search'] = un_htmlspecialchars($this->_req->getPost('search', 'trim', ''));
725
			$context['search_params']['search'] = htmlspecialchars($context['search_params']['search'], ENT_COMPAT);
726
		}
727
728
		if (isset($context['search_params']['userspec']))
729
		{
730
			$context['search_params']['userspec'] = htmlspecialchars($context['search_params']['userspec'], ENT_COMPAT);
731
		}
732
733
		// 1 => 'allwords' / 2 => 'anywords'.
734
		if (!empty($context['search_params']['searchtype']))
735
		{
736
			$context['search_params']['searchtype'] = 2;
737
		}
738
739
		// Minimum and Maximum age of the message
740
		if (!empty($context['search_params']['minage']))
741
		{
742
			$context['search_params']['minage'] = (int) $context['search_params']['minage'];
743
		}
744
745
		if (!empty($context['search_params']['maxage']))
746
		{
747
			$context['search_params']['maxage'] = (int) $context['search_params']['maxage'];
748
		}
749
750
		$context['search_params']['show_complete'] = !empty($context['search_params']['show_complete']);
751
		$context['search_params']['subject_only'] = !empty($context['search_params']['subject_only']);
752
753
		// Create the array of labels to be searched.
754
		$context['search_labels'] = [];
755
		$searchedLabels = isset($context['search_params']['labels']) && $context['search_params']['labels'] != '' ? explode(',', $context['search_params']['labels']) : [];
756
		foreach ($context['labels'] as $label)
757
		{
758
			$context['search_labels'][] = [
759
				'id' => $label['id'],
760
				'name' => $label['name'],
761
				'checked' => empty($searchedLabels) || in_array($label['id'], $searchedLabels),
762
			];
763
		}
764
765
		// Are all the labels checked?
766
		$context['check_all'] = empty($searchedLabels) || count($context['search_labels']) === count($searchedLabels);
767
768
		// Load the error text strings if there were errors in the search.
769
		if (!empty($context['search_errors']))
770
		{
771
			Txt::load('Errors');
772
			$context['search_errors']['messages'] = [];
773
			foreach ($context['search_errors'] as $search_error => $dummy)
774
			{
775
				if ($search_error === 'messages')
776
				{
777
					continue;
778
				}
779
780
				$context['search_errors']['messages'][] = $txt['error_' . $search_error];
781
			}
782
		}
783
784
		$context['page_title'] = $txt['pm_search_title'];
785
		$context['sub_template'] = 'search';
786
		$context['breadcrumbs'][] = [
787
			'url' => getUrl('action', ['action' => 'pm', 'sa' => 'search']),
788
			'name' => $txt['pm_search_bar_title'],
789
		];
790
	}
791
792
	/**
793
	 * Used to highlight body text with strings that match the search term
794
	 *
795
	 * - Callback function used in $body_highlighted
796
	 *
797
	 * @param string[] $matches
798
	 *
799
	 * @return string
800
	 */
801
	public function _highlighted_callback($matches): string
802
	{
803
		return isset($matches[2]) && $matches[2] === $matches[1] ? stripslashes($matches[1]) : '<strong class="highlight">' . $matches[1] . '</strong>';
804
	}
805
}
806