AdvancedSearchListModule::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 20
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 16
nc 1
nop 2
dl 0
loc 20
rs 9.7333
c 1
b 0
f 0
1
<?php
2
3
require_once BASE_PATH . 'server/includes/core/class.indexsqlite.php';
4
5
class AdvancedSearchListModule extends ListModule {
6
	/**
7
	 * Constructor.
8
	 *
9
	 * @param int   $id   unique id
10
	 * @param array $data list of all actions
11
	 */
12
	public function __construct($id, $data) {
13
		parent::__construct($id, $data);
14
		// TODO: create a new method in Properties class that will return only the properties we
15
		// need for search list (and perhaps for preview???)
16
		$this->properties = $GLOBALS["properties"]->getMailListProperties();
17
		$this->properties = array_merge($this->properties, $GLOBALS["properties"]->getAppointmentListProperties());
18
		$this->properties = array_merge($this->properties, $GLOBALS["properties"]->getContactListProperties());
19
		$this->properties = array_merge($this->properties, $GLOBALS["properties"]->getStickyNoteListProperties());
20
		$this->properties = array_merge($this->properties, $GLOBALS["properties"]->getTaskListProperties());
21
		$this->properties = array_merge($this->properties, [
22
			'body' => PR_BODY,
23
			'html_body' => PR_HTML,
24
			'startdate' => "PT_SYSTIME:PSETID_Appointment:" . PidLidAppointmentStartWhole,
25
			'duedate' => "PT_SYSTIME:PSETID_Appointment:" . PidLidAppointmentEndWhole,
26
			'creation_time' => PR_CREATION_TIME,
27
			"task_duedate" => "PT_SYSTIME:PSETID_Task:" . PidLidTaskDueDate,
28
		]);
29
		$this->properties = getPropIdsFromStrings($GLOBALS["mapisession"]->getDefaultMessageStore(), $this->properties);
30
		$this->sort = [
31
			PR_MESSAGE_DELIVERY_TIME => TABLE_SORT_DESCEND,
32
		];
33
	}
34
35
	/**
36
	 * Executes all the actions in the $data variable.
37
	 */
38
	#[Override]
39
	public function execute() {
40
		foreach ($this->data as $actionType => $action) {
41
			if (isset($actionType)) {
42
				try {
43
					$store = $this->getActionStore($action);
44
					$parententryid = $this->getActionParentEntryID($action);
0 ignored issues
show
Unused Code introduced by
The assignment to $parententryid is dead and can be removed.
Loading history...
45
					$entryid = $this->getActionEntryID($action);
46
47
					switch ($actionType) {
48
						case "list":
49
						case "updatelist":
50
							$this->getDelegateFolderInfo($store);
0 ignored issues
show
Bug introduced by
$store of type object is incompatible with the type resource expected by parameter $store of ListModule::getDelegateFolderInfo(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

50
							$this->getDelegateFolderInfo(/** @scrutinizer ignore-type */ $store);
Loading history...
51
							$this->messageList($store, $entryid, $action, $actionType);
52
							break;
53
54
						case "search":
55
							$this->search($store, $entryid, $action, $actionType);
56
							break;
57
58
						case "updatesearch":
59
							$this->updatesearch($store, $entryid, $action);
60
							break;
61
62
						case "stopsearch":
63
							$this->stopSearch($store, $entryid, $action);
64
							break;
65
66
						case "delete_searchfolder":
67
							$this->deleteSearchFolder($store, $entryid, $action);
68
							break;
69
					}
70
				}
71
				catch (MAPIException $e) {
0 ignored issues
show
Bug introduced by
The type MAPIException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
72
					// This is a very nasty hack that makes sure that grommunio Web doesn't show an error message when
73
					// search wants to throw an error. This is only done because a proper fix for this bug has not
74
					// been found yet. When WA-9161 is really solved, this should be removed again.
75
					if ($actionType !== 'search' && $actionType !== 'updatesearch' && $actionType !== 'stopsearch') {
76
						$this->processException($e, $actionType);
77
					}
78
					else {
79
						if (DEBUG_LOADER === 0) {
80
							// Log all info we can get about this error to the error log of the web server
81
							error_log("Error in search: \n" . var_export($e, true) . "\n\n" . var_export(debug_backtrace(), true));
82
						}
83
						// Send success feedback without data, as if nothing strange happened...
84
						$this->sendFeedback(true);
85
					}
86
				}
87
			}
88
		}
89
	}
90
91
	/**
92
	 * Function which retrieves a list of messages in a folder.
93
	 *
94
	 * @param object $store      MAPI Message Store Object
95
	 * @param string $entryid    entryid of the folder
96
	 * @param array  $action     the action data, sent by the client
97
	 * @param string $actionType the action type, sent by the client
98
	 */
99
	#[Override]
100
	public function messageList($store, $entryid, $action, $actionType) {
101
		$this->searchFolderList = false; // Set to indicate this is not the search result, but a normal folder content
102
		$data = [];
103
104
		if ($store && $entryid) {
105
			// Restriction
106
			$this->parseRestriction($action);
0 ignored issues
show
Bug introduced by
$action of type array is incompatible with the type object expected by parameter $action of ListModule::parseRestriction(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

106
			$this->parseRestriction(/** @scrutinizer ignore-type */ $action);
Loading history...
107
108
			// Sort
109
			$this->parseSortOrder($action, null, true);
110
111
			$limit = $action['restriction']['limit'] ?? 1000;
112
113
			$isSearchFolder = isset($action['search_folder_entryid']);
114
			$entryid = $isSearchFolder ? hex2bin((string) $action['search_folder_entryid']) : $entryid;
115
116
			if ($actionType == 'search') {
117
				$rows = [[PR_ENTRYID => $entryid]];
118
				if (isset($action['subfolders']) && $action['subfolders']) {
119
					$folder = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $folder is dead and can be removed.
Loading history...
120
					$inboxEntryId = null;
121
					try {
122
						$folder = mapi_msgstore_openentry($store, $entryid);
123
					}
124
					catch(Exception) {
125
						// Probably trying to open a store without having appropriate permissions.
126
						// Try to open the inbox instead.
127
						$folder = mapi_msgstore_getreceivefolder($store);
128
						$props = mapi_getprops($folder, [PR_ENTRYID]);
129
						$inboxEntryId = $props[PR_ENTRYID];
130
					}
131
					if (!is_null($folder)) {
132
						$htable = mapi_folder_gethierarchytable($folder, CONVENIENT_DEPTH | MAPI_DEFERRED_ERRORS);
133
						$rows = mapi_table_queryallrows($htable, [PR_ENTRYID]);
134
					}
135
					// The inbox itself is not the hierarchy list, add it to the begin.
136
					if (!is_null($inboxEntryId)) {
137
						array_unshift($rows, [PR_ENTRYID => $inboxEntryId]);
138
					}
139
				}
140
				$data['item'] = [];
141
				foreach ($rows as $row) {
142
					$items = $GLOBALS["operations"]->getTable($store, $row[PR_ENTRYID], $this->properties, $this->sort, $this->start, $limit, $this->restriction);
143
					$data['item'] = array_merge($data['item'], $items['item']);
144
					if (count($data['item']) >= $limit) {
145
						break;
146
					}
147
				}
148
				$data['page'] = [];
149
				$data['page']['start'] = 0;
150
				$data['page']['rowcount'] = 0;
151
				$data['page']['totalrowcount'] = count($data['item']);
152
				$data['search_meta'] = [];
153
				$data['search_meta']['searchfolder_entryid'] = null;
154
				$data['search_meta']['search_store_entryid'] = $action['store_entryid'];
155
				$data['search_meta']['searchstate'] = null;
156
				$data['search_meta']['results'] = count($data['item']);
157
				$data['folder'] = [];
158
				$data['folder']['content_count'] = count($data['item']);
159
				$data['folder']['content_unread'] = 0;
160
			}
161
			else {
162
				// Get the table and merge the arrays
163
				$data = $GLOBALS["operations"]->getTable($store, $entryid, $this->properties, $this->sort, $this->start, $limit, $this->restriction);
164
			}
165
166
			// If the request come from search folder then no need to send folder information
167
			if (!$isSearchFolder && !isset($data['folder'])) {
168
				// Open the folder.
169
				$folder = mapi_msgstore_openentry($store, $entryid);
170
				$data["folder"] = [];
171
172
				// Obtain some statistics from the folder contents
173
				$contentcount = mapi_getprops($folder, [PR_CONTENT_COUNT, PR_CONTENT_UNREAD]);
174
				if (isset($contentcount[PR_CONTENT_COUNT])) {
175
					$data["folder"]["content_count"] = $contentcount[PR_CONTENT_COUNT];
176
				}
177
178
				if (isset($contentcount[PR_CONTENT_UNREAD])) {
179
					$data["folder"]["content_unread"] = $contentcount[PR_CONTENT_UNREAD];
180
				}
181
			}
182
183
			$data = $this->filterPrivateItems($data);
184
185
			// Allowing to hook in just before the data sent away to be sent to the client
186
			$GLOBALS['PluginManager']->triggerHook('server.module.listmodule.list.after', [
187
				'moduleObject' => &$this,
188
				'store' => $store,
189
				'entryid' => $entryid,
190
				'action' => $action,
191
				'data' => &$data,
192
			]);
193
194
			// unset will remove the value but will not regenerate array keys, so we need to
195
			// do it here
196
			$data["item"] = array_values($data["item"]);
197
			$this->addActionData($actionType, $data);
198
			$GLOBALS["bus"]->addData($this->getResponseData());
199
		}
200
	}
201
202
	private function initFtsFilterState() {
203
		return [
204
			'message_classes' => [],
205
			'date_start' => null,
206
			'date_end' => null,
207
			'unread' => false,
208
			'has_attachments' => false,
209
		];
210
	}
211
212
	private function mergeFtsFilterState(array $base, array $delta) {
213
		$base['message_classes'] = array_merge($base['message_classes'], $delta['message_classes']);
214
		if ($delta['date_start'] !== null) {
215
			$base['date_start'] = $base['date_start'] === null ? $delta['date_start'] : max($base['date_start'], $delta['date_start']);
216
		}
217
		if ($delta['date_end'] !== null) {
218
			$base['date_end'] = $base['date_end'] === null ? $delta['date_end'] : min($base['date_end'], $delta['date_end']);
219
		}
220
		$base['unread'] = $base['unread'] || $delta['unread'];
221
		$base['has_attachments'] = $base['has_attachments'] || $delta['has_attachments'];
222
223
		return $base;
224
	}
225
226
	private function buildFtsDescriptor($restriction) {
227
		[$ast, $filters] = $this->convertRestrictionToAst($restriction);
228
229
		$filters['message_classes'] = array_values(array_unique($filters['message_classes']));
230
231
		return [
232
			'ast' => $ast,
233
			'message_classes' => $filters['message_classes'],
234
			'date_start' => $filters['date_start'],
235
			'date_end' => $filters['date_end'],
236
			'unread' => $filters['unread'],
237
			'has_attachments' => $filters['has_attachments'],
238
		];
239
	}
240
241
	private function convertRestrictionToAst($restriction, $context = null) {
242
		$filters = $this->initFtsFilterState();
243
244
		if (!is_array($restriction) || empty($restriction)) {
245
			return [null, $filters];
246
		}
247
248
		$type = $restriction[0];
249
		switch ($type) {
250
			case RES_AND:
251
			case RES_OR:
252
				$children = [];
253
				$subRestrictions = $restriction[1] ?? [];
254
				if (is_array($subRestrictions)) {
255
					foreach ($subRestrictions as $subRestriction) {
256
						[$childAst, $childFilters] = $this->convertRestrictionToAst($subRestriction, $context);
257
						$filters = $this->mergeFtsFilterState($filters, $childFilters);
258
						if ($childAst !== null) {
259
							$children[] = $childAst;
260
						}
261
					}
262
				}
263
				if (empty($children)) {
264
					return [null, $filters];
265
				}
266
				if (count($children) === 1) {
267
					return [$children[0], $filters];
268
				}
269
				return [[
270
					'op' => $type == RES_AND ? 'AND' : 'OR',
271
					'children' => $children,
272
				], $filters];
273
274
			case RES_NOT:
275
				$sub = $restriction[1][0] ?? null;
276
				[$childAst, $childFilters] = $this->convertRestrictionToAst($sub, $context);
277
				$filters = $this->mergeFtsFilterState($filters, $childFilters);
278
				if ($childAst === null) {
279
					return [null, $filters];
280
				}
281
				return [[
282
					'op' => 'NOT',
283
					'children' => [$childAst],
284
				], $filters];
285
286
			case RES_CONTENT:
287
				$subres = $restriction[1];
288
				$propTag = $subres[ULPROPTAG] ?? null;
289
				if ($propTag === null) {
290
					return [null, $filters];
291
				}
292
293
				if ($propTag == PR_MESSAGE_CLASS) {
294
					$value = $subres[VALUE][$propTag] ?? null;
295
					if ($value !== null) {
296
						$filters['message_classes'][] = $value;
297
					}
298
					return [null, $filters];
299
				}
300
301
				$fields = $this->mapPropTagToFtsFields($propTag, $context);
302
				$value = $subres[VALUE][$propTag] ?? null;
303
				if (empty($fields) || $value === null) {
304
					return [null, $filters];
305
				}
306
307
				$terms = [];
308
				if (is_array($value)) {
309
					foreach ($value as $entry) {
310
						if ($entry !== '' && $entry !== null) {
311
							$terms[] = [
312
								'type' => 'term',
313
								'fields' => $fields,
314
								'value' => (string) $entry,
315
							];
316
						}
317
					}
318
				} else {
319
					$terms[] = [
320
						'type' => 'term',
321
						'fields' => $fields,
322
						'value' => (string) $value,
323
					];
324
				}
325
326
				if (empty($terms)) {
327
					return [null, $filters];
328
				}
329
				if (count($terms) === 1) {
330
					return [$terms[0], $filters];
331
				}
332
				return [[
333
					'op' => 'OR',
334
					'children' => $terms,
335
				], $filters];
336
337
			case RES_PROPERTY:
338
				$subres = $restriction[1];
339
				$propTag = $subres[ULPROPTAG] ?? null;
340
				if ($propTag === null) {
341
					return [null, $filters];
342
				}
343
344
				if ($propTag == PR_MESSAGE_DELIVERY_TIME || $propTag == PR_LAST_MODIFICATION_TIME) {
345
					$value = $subres[VALUE][$propTag] ?? null;
346
					if ($value !== null) {
347
						if ($subres[RELOP] == RELOP_LT || $subres[RELOP] == RELOP_LE) {
348
							$filters['date_end'] = $value;
349
						} elseif ($subres[RELOP] == RELOP_GT || $subres[RELOP] == RELOP_GE) {
350
							$filters['date_start'] = $value;
351
						}
352
					}
353
					return [null, $filters];
354
				}
355
356
				if (isset($this->properties['hide_attachments']) && $propTag == $this->properties['hide_attachments']) {
357
					$filters['has_attachments'] = true;
358
				}
359
360
				return [null, $filters];
361
362
			case RES_BITMASK:
363
				$subres = $restriction[1];
364
				if (($subres[ULPROPTAG] ?? null) == PR_MESSAGE_FLAGS && ($subres[ULTYPE] ?? null) == BMR_EQZ) {
365
					$filters['unread'] = true;
366
				}
367
				return [null, $filters];
368
369
			case RES_SUBRESTRICTION:
370
				$subres = $restriction[1];
371
				$propTag = $subres[ULPROPTAG] ?? null;
372
				if ($propTag == PR_MESSAGE_ATTACHMENTS) {
373
					$filters['has_attachments'] = true;
374
					$inner = $subres[RESTRICTION] ?? null;
375
					[$childAst, $childFilters] = $this->convertRestrictionToAst($inner, 'attachments');
376
					$filters = $this->mergeFtsFilterState($filters, $childFilters);
377
					return [$childAst, $filters];
378
				}
379
				if ($propTag == PR_MESSAGE_RECIPIENTS) {
380
					$inner = $subres[RESTRICTION] ?? null;
381
					[$childAst, $childFilters] = $this->convertRestrictionToAst($inner, 'recipients');
382
					$filters = $this->mergeFtsFilterState($filters, $childFilters);
383
					return [$childAst, $filters];
384
				}
385
				$inner = $subres[RESTRICTION] ?? null;
386
				[$childAst, $childFilters] = $this->convertRestrictionToAst($inner, $context);
387
				$filters = $this->mergeFtsFilterState($filters, $childFilters);
388
				return [$childAst, $filters];
389
390
			case RES_COMMENT:
391
				$inner = $restriction[1][RESTRICTION] ?? null;
392
				[$childAst, $childFilters] = $this->convertRestrictionToAst($inner, $context);
393
				$filters = $this->mergeFtsFilterState($filters, $childFilters);
394
				return [$childAst, $filters];
395
396
			default:
397
				return [null, $filters];
398
		}
399
	}
400
401
	private function mapPropTagToFtsFields($propTag, $context = null) {
402
		if ($context === 'attachments') {
403
			return ['attachments'];
404
		}
405
		if ($context === 'recipients') {
406
			return ['recipients'];
407
		}
408
409
		static $map = null;
410
		if ($map === null) {
411
			$map = [
412
				PR_SUBJECT => ['subject'],
413
				PR_BODY => ['content', 'attachments'],
414
				PR_SENDER_NAME => ['sender'],
415
				PR_SENDER_EMAIL_ADDRESS => ['sender'],
416
				PR_SENT_REPRESENTING_NAME => ['sending'],
417
				PR_SENT_REPRESENTING_EMAIL_ADDRESS => ['sending'],
418
				PR_DISPLAY_TO => ['recipients'],
419
				PR_DISPLAY_CC => ['recipients'],
420
				PR_DISPLAY_BCC => ['recipients'],
421
				PR_EMAIL_ADDRESS => ['recipients'],
422
				PR_SMTP_ADDRESS => ['recipients'],
423
				PR_DISPLAY_NAME => ['others'],
424
				PR_ATTACH_LONG_FILENAME => ['attachments'],
425
			];
426
			if (defined('PR_NORMALIZED_SUBJECT')) {
427
				$map[PR_NORMALIZED_SUBJECT] = ['subject'];
428
			}
429
			if (isset($this->properties['categories'])) {
430
				$map[$this->properties['categories']] = ['others'];
431
			}
432
		}
433
434
		return $map[$propTag] ?? [];
435
	}
436
437
	/**
438
	 *	Function will set search restrictions on search folder and start search process
439
	 *	and it will also parse visible columns and sorting data when sending results to client.
440
	 *
441
	 * @param object $store      MAPI Message Store Object
442
	 * @param string $entryid    entryid of the folder
443
	 * @param object $action     the action data, sent by the client
444
	 * @param string $actionType the action type, sent by the client
445
	 */
446
	#[Override]
447
	public function search($store, $entryid, $action, $actionType) {
448
		$useSearchFolder = $action["use_searchfolder"] ?? false;
449
		$this->logFtsDebug('Search requested', [
450
			'store_entryid' => $action['store_entryid'] ?? null,
451
			'entryid' => $this->formatEntryIdForLog($entryid),
452
			'subfolders' => $action['subfolders'] ?? null,
453
			'use_searchfolder' => (bool) $useSearchFolder,
454
			'restriction_present' => array_key_exists('restriction', $action),
455
		]);
456
		if (!$useSearchFolder) {
457
			$this->logFtsDebug('Search fallback: store does not support search folders', []);
458
			/*
459
			 * store doesn't support search folders so we can't use this
460
			 * method instead we will pass restriction to messageList and
461
			 * it will give us the restricted results
462
			 */
463
			return parent::messageList($store, $entryid, $action, "list");
0 ignored issues
show
Bug introduced by
$action of type object is incompatible with the type array expected by parameter $action of ListModule::messageList(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

463
			return parent::messageList($store, $entryid, /** @scrutinizer ignore-type */ $action, "list");
Loading history...
Bug introduced by
Are you sure the usage of parent::messageList($sto...tryid, $action, 'list') targeting ListModule::messageList() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
464
		}
465
		$store_props = mapi_getprops($store, [PR_MDB_PROVIDER, PR_DEFAULT_STORE, PR_IPM_SUBTREE_ENTRYID]);
466
		$this->logFtsDebug('Resolved store properties for search', [
467
			'provider' => isset($store_props[PR_MDB_PROVIDER]) ? bin2hex((string) $store_props[PR_MDB_PROVIDER]) : null,
468
			'default_store' => $store_props[PR_DEFAULT_STORE] ?? null,
469
		]);
470
		if ($store_props[PR_MDB_PROVIDER] == ZARAFA_STORE_PUBLIC_GUID) {
471
			$this->logFtsDebug('Search fallback: public store does not support search folders', []);
472
			// public store does not support search folders
473
			return parent::messageList($store, $entryid, $action, "search");
0 ignored issues
show
Bug introduced by
Are you sure the usage of parent::messageList($sto...yid, $action, 'search') targeting ListModule::messageList() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
474
		}
475
		if ($GLOBALS['entryid']->compareEntryIds(bin2hex($entryid), bin2hex(TodoList::getEntryId()))) {
476
			$this->logFtsDebug('Search fallback: todo list uses legacy restriction path', []);
477
			// todo list do not need to perform full text index search
478
			return parent::messageList($store, $entryid, $action, "list");
0 ignored issues
show
Bug introduced by
Are you sure the usage of parent::messageList($sto...tryid, $action, 'list') targeting ListModule::messageList() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
479
		}
480
481
		$this->searchFolderList = true; // Set to indicate this is not the normal folder, but a search folder
482
		$this->restriction = false;
0 ignored issues
show
Documentation Bug introduced by
It seems like false of type false is incompatible with the declared type array of property $restriction.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
483
484
		// Parse Restriction
485
		$this->parseRestriction($action);
486
		if ($this->restriction == false) {
487
			$this->logFtsDebug('Restriction parsing failed', [
488
				'action_type' => $actionType,
489
			]);
490
			// if error in creating restriction then send error to client
491
			$errorInfo = [];
492
			$errorInfo["error_message"] = _("Error in search, please try again") . ".";
493
			$errorInfo["original_error_message"] = "Error in parsing restrictions.";
494
495
			return $this->sendSearchErrorToClient($store, $entryid, $action, $errorInfo);
0 ignored issues
show
Bug introduced by
$errorInfo of type array is incompatible with the type object expected by parameter $errorInfo of ListModule::sendSearchErrorToClient(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

495
			return $this->sendSearchErrorToClient($store, $entryid, $action, /** @scrutinizer ignore-type */ $errorInfo);
Loading history...
Bug introduced by
$entryid of type string is incompatible with the type hexString expected by parameter $entryid of ListModule::sendSearchErrorToClient(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

495
			return $this->sendSearchErrorToClient($store, /** @scrutinizer ignore-type */ $entryid, $action, $errorInfo);
Loading history...
496
		}
497
		$ftsDescriptor = $this->buildFtsDescriptor($this->restriction);
498
		if (empty($ftsDescriptor['ast'])) {
499
			$this->logFtsDebug('Failed to translate restriction to FTS descriptor', [
500
				'restriction_sample' => array_keys($this->restriction),
501
			]);
502
			$errorInfo = [];
503
			$errorInfo["error_message"] = _("Error in search, please try again") . ".";
504
			$errorInfo["original_error_message"] = "Unable to translate search query to full-text expression.";
505
506
			return $this->sendSearchErrorToClient($store, $entryid, $action, $errorInfo);
507
		}
508
		$serializedRestriction = serialize($this->restriction);
509
		$restrictionSignature = md5($serializedRestriction);
510
		$this->logFtsDebug('Restriction parsed for full-text search', [
511
			'restriction_signature' => $restrictionSignature,
512
			'fts_descriptor' => $ftsDescriptor,
513
		]);
514
515
		$isSetSearchFolderEntryId = isset($action['search_folder_entryid']);
516
		if ($isSetSearchFolderEntryId) {
517
			$this->sessionData['searchFolderEntryId'] = $action['search_folder_entryid'];
518
		}
519
520
		if (isset($action['forceCreateSearchFolder']) && $action['forceCreateSearchFolder']) {
521
			$isSetSearchFolderEntryId = false;
522
		}
523
524
		// create or open search folder
525
		$searchFolder = $this->createSearchFolder($store, $isSetSearchFolderEntryId);
526
		if ($searchFolder === false) {
527
			if ($store_props[PR_MDB_PROVIDER] == ZARAFA_STORE_DELEGATE_GUID) {
528
				$this->messageList($store, $entryid, $action, "search");
529
530
				return true;
531
			}
532
			// if error in creating search folder then send error to client
533
			$errorInfo = [];
534
535
			$errorInfo["error_message"] = match (mapi_last_hresult()) {
536
				MAPI_E_NO_ACCESS => _("Unable to perform search query, no permissions to create search folder."),
537
				MAPI_E_NOT_FOUND => _("Unable to perform search query, search folder not found."),
538
				default => _("Unable to perform search query, store might not support searching."),
539
			};
540
541
			$errorInfo["original_error_message"] = _("Error in creating search folder.");
542
543
			return $this->sendSearchErrorToClient($store, $entryid, $action, $errorInfo);
0 ignored issues
show
Bug introduced by
$errorInfo of type array is incompatible with the type object expected by parameter $errorInfo of ListModule::sendSearchErrorToClient(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

543
			return $this->sendSearchErrorToClient($store, $entryid, $action, /** @scrutinizer ignore-type */ $errorInfo);
Loading history...
544
		}
545
546
		$subfolder_flag = 0;
547
		$recursive = false;
548
		if (isset($action["subfolders"]) && $action["subfolders"] == "true") {
549
			$recursive = true;
550
			$subfolder_flag = RECURSIVE_SEARCH;
551
		}
552
553
		if (!is_array($entryid)) {
0 ignored issues
show
introduced by
The condition is_array($entryid) is always false.
Loading history...
554
			$entryids = [$entryid];
555
		}
556
		else {
557
			$entryids = $entryid;
558
		}
559
560
		$searchFolderEntryId = $this->sessionData['searchFolderEntryId'];
561
562
		// check if searchcriteria has changed
563
		$restrictionCheck = md5($serializedRestriction . $searchFolderEntryId . $subfolder_flag);
564
565
		// check if there is need to set searchcriteria again
566
		if (!isset($this->sessionData['searchCriteriaCheck']) || $restrictionCheck != $this->sessionData['searchCriteriaCheck']) {
567
			if (!empty($this->sessionData['searchOriginalEntryids']) &&
568
				isset($action['entryid']) &&
569
				in_array($action['entryid'], $this->sessionData['searchOriginalEntryids'])
570
			) {
571
				// get entryids of original folders, and use it to set new search criteria
572
				$entryids = [];
573
				$entryIdsCount = count($this->sessionData['searchOriginalEntryids']);
574
				for ($index = 0; $index < $entryIdsCount; ++$index) {
575
					$entryids[] = hex2bin((string) $this->sessionData['searchOriginalEntryids'][$index]);
576
				}
577
			}
578
			else {
579
				// store entryids of original folders, so that can be used for re-setting the search criteria if needed
580
				$this->sessionData['searchOriginalEntryids'] = [];
581
				for ($index = 0, $len = count($entryids); $index < $len; ++$index) {
582
					$this->sessionData['searchOriginalEntryids'][] = bin2hex($entryids[$index]);
583
				}
584
			}
585
			// we never start the search folder because we will populate the search folder by ourselves
586
			mapi_folder_setsearchcriteria($searchFolder, $this->restriction, $entryids, $subfolder_flag | STOP_SEARCH);
587
			$this->logFtsDebug('Search criteria updated', [
588
				'search_folder_entryid' => $searchFolderEntryId,
589
				'restriction_signature' => $restrictionSignature,
590
				'recursive' => $recursive,
591
				'scope_entryids' => $this->formatEntryIdForLog($entryids),
592
			]);
593
			$this->sessionData['searchCriteriaCheck'] = $restrictionCheck;
594
		}
595
596
		if (isset($this->sessionData['searchCriteriaCheck']) || $restrictionCheck == $this->sessionData['searchCriteriaCheck']) {
597
			$folderEntryid = bin2hex($entryid);
598
			if ($this->sessionData['searchOriginalEntryids'][0] !== $folderEntryid) {
599
				$this->sessionData['searchOriginalEntryids'][0] = $folderEntryid;
600
				// we never start the search folder because we will populate the search folder by ourselves
601
				mapi_folder_setsearchcriteria($searchFolder, $this->restriction, [$entryid], $subfolder_flag | STOP_SEARCH);
602
				$this->logFtsDebug('Search criteria refreshed for active folder', [
603
					'search_folder_entryid' => $searchFolderEntryId,
604
					'restriction_signature' => $restrictionSignature,
605
					'target_entryid' => $this->formatEntryIdForLog($entryid),
606
					'recursive' => $recursive,
607
				]);
608
			}
609
		}
610
611
		// Sort
612
		$this->parseSortOrder($action);
613
		// Initialize search patterns with default values
614
		if (is_array($ftsDescriptor['message_classes']) &&
615
			count($ftsDescriptor['message_classes']) >= 7) {
616
			$ftsDescriptor['message_classes'] = null;
617
		}
618
619
		$username = null;
620
		if ($store_props[PR_MDB_PROVIDER] == ZARAFA_STORE_DELEGATE_GUID) {
621
			$eidObj = $GLOBALS["entryid"]->createMsgStoreEntryIdObj(hex2bin((string) $action['store_entryid']));
622
			$username = $eidObj['ServerShortname'];
623
			$session = $GLOBALS["mapisession"]->getSession();
624
625
			if ($username) {
626
				$indexDB = new IndexSqlite($username, $session, $store);
627
			}
628
		}
629
		else {
630
			$indexDB = new IndexSqlite();
631
		}
632
		$this->logFtsDebug('Dispatching search to index backend', [
633
			'search_folder_entryid' => $searchFolderEntryId,
634
			'restriction_signature' => $restrictionSignature,
635
			'recursive' => $recursive,
636
			'delegate_username' => $username,
637
			'message_classes' => $ftsDescriptor['message_classes'] ?? null,
638
			'filters' => [
639
				'date_start' => $ftsDescriptor['date_start'] ?? null,
640
				'date_end' => $ftsDescriptor['date_end'] ?? null,
641
				'unread' => !empty($ftsDescriptor['unread']),
642
				'has_attachments' => !empty($ftsDescriptor['has_attachments']),
643
			],
644
			'ast' => $ftsDescriptor['ast'] ?? null,
645
		]);
646
647
		$search_result = $indexDB->search(hex2bin((string) $searchFolderEntryId), $ftsDescriptor, $entryid, $recursive);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $indexDB does not seem to be defined for all execution paths leading up to this point.
Loading history...
648
		if ($search_result == false) {
649
			$this->logFtsDebug('Index search returned no data', [
650
				'search_folder_entryid' => $searchFolderEntryId,
651
				'restriction_signature' => $restrictionSignature,
652
				'recursive' => $recursive,
653
			]);
654
			$errorInfo = [];
655
			$errorInfo["error_message"] = _("Unable to perform search query, store might not support searching.");
656
			$errorInfo["original_error_message"] = "Full-text search backend returned no result set.";
657
658
			return $this->sendSearchErrorToClient($store, $entryid, $action, $errorInfo);
659
		}
660
661
		unset($action["restriction"]);
662
663
		// Get the table and merge the arrays
664
		$table = $GLOBALS["operations"]->getTable($store, hex2bin((string) $searchFolderEntryId), $this->properties, $this->sort, $this->start);
665
		$this->logFtsDebug('Search folder table retrieved', [
666
			'search_folder_entryid' => $searchFolderEntryId,
667
			'table_item_count' => isset($table['item']) ? count($table['item']) : null,
668
			'start' => $this->start,
669
			'sort' => $this->sort,
670
		]);
671
		// Create the data array, which will be send back to the client
672
		$data = [];
673
		$data = array_merge($data, $table);
674
675
		$this->getDelegateFolderInfo($store);
0 ignored issues
show
Bug introduced by
$store of type object is incompatible with the type resource expected by parameter $store of ListModule::getDelegateFolderInfo(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

675
		$this->getDelegateFolderInfo(/** @scrutinizer ignore-type */ $store);
Loading history...
676
		$data = $this->filterPrivateItems($data);
677
		$this->logFtsDebug('Search results filtered for privacy', [
678
			'search_folder_entryid' => $searchFolderEntryId,
679
			'item_count_after_filter' => isset($data['item']) ? count($data['item']) : null,
680
		]);
681
682
		// remember which entryid's are send to the client
683
		$searchResults = [];
684
		foreach ($table["item"] as $item) {
685
			// store entryid => last_modification_time mapping
686
			$searchResults[$item["entryid"]] = $item["props"]["last_modification_time"];
687
		}
688
689
		// store search results into session data
690
		if (!isset($this->sessionData['searchResults'])) {
691
			$this->sessionData['searchResults'] = [];
692
		}
693
		$this->sessionData['searchResults'][$searchFolderEntryId] = $searchResults;
694
		$this->logFtsDebug('Search results stored in session', [
695
			'search_folder_entryid' => $searchFolderEntryId,
696
			'result_count' => count($searchResults),
697
		]);
698
699
		$result = mapi_folder_getsearchcriteria($searchFolder);
700
		$this->logFtsDebug('Search folder state retrieved', [
701
			'search_folder_entryid' => $searchFolderEntryId,
702
			'searchstate' => $result['searchstate'] ?? null,
703
		]);
704
705
		$data["search_meta"] = [];
706
		$data["search_meta"]["searchfolder_entryid"] = $searchFolderEntryId;
707
		$data["search_meta"]["search_store_entryid"] = $action["store_entryid"];
708
		$data["search_meta"]["searchstate"] = $result["searchstate"];
709
		$data["search_meta"]["results"] = count($searchResults);
710
711
		// Reopen the search folder, because otherwise the suggestion property will
712
		// not have been updated
713
		$searchFolder = $this->createSearchFolder($store, true);
714
		$storeProps = mapi_getprops($searchFolder, [PR_EC_SUGGESTION]);
715
		if (isset($storeProps[PR_EC_SUGGESTION])) {
716
			$data["search_meta"]["suggestion"] = $storeProps[PR_EC_SUGGESTION];
717
			$this->logFtsDebug('Search suggestion ready', [
718
				'search_folder_entryid' => $searchFolderEntryId,
719
				'suggestion' => $storeProps[PR_EC_SUGGESTION],
720
			]);
721
		}
722
723
		$this->addActionData("search", $data);
724
		$GLOBALS["bus"]->addData($this->getResponseData());
725
726
		$this->logFtsDebug('Search response dispatched to client', [
727
			'search_folder_entryid' => $searchFolderEntryId,
728
			'items_returned' => isset($data['item']) ? count($data['item']) : null,
729
			'search_meta' => $data['search_meta'] ?? null,
730
		]);
731
		return true;
732
	}
733
734
	private function logFtsDebug(string $message, array $context = []): void {
735
		if (!DEBUG_FULLTEXT_SEARCH) {
736
			return;
737
		}
738
		$prefix = '[fts-debug][module] ';
739
		if (!empty($context)) {
740
			$encoded = json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
741
			if ($encoded === false) {
742
				$encoded = 'context_encoding_failed';
743
			}
744
			error_log($prefix . $message . ' ' . $encoded);
745
		}
746
		else {
747
			error_log($prefix . $message);
748
		}
749
	}
750
751
	private function formatEntryIdForLog($entryid) {
752
		if ($entryid === null) {
753
			return null;
754
		}
755
		if (is_array($entryid)) {
756
			return array_map([$this, 'formatEntryIdForLog'], $entryid);
757
		}
758
		if (!is_string($entryid)) {
759
			return $entryid;
760
		}
761
		if ($entryid === '') {
762
			return '';
763
		}
764
		if (preg_match('/[^\x20-\x7E]/', $entryid)) {
765
			return bin2hex($entryid);
766
		}
767
768
		return $entryid;
769
	}
770
}
771