AdvancedSearchListModule::execute()   C
last analyzed

Complexity

Conditions 14
Paths 42

Size

Total Lines 47
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 14
eloc 32
nc 42
nop 0
dl 0
loc 47
rs 6.2666
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

446
			return parent::messageList($store, $entryid, /** @scrutinizer ignore-type */ $action, "list");
Loading history...
447
		}
448
		$store_props = mapi_getprops($store, [PR_MDB_PROVIDER, PR_DEFAULT_STORE, PR_IPM_SUBTREE_ENTRYID]);
449
		$this->logFtsDebug('Resolved store properties for search', [
450
			'provider' => isset($store_props[PR_MDB_PROVIDER]) ? bin2hex((string) $store_props[PR_MDB_PROVIDER]) : null,
451
			'default_store' => $store_props[PR_DEFAULT_STORE] ?? null,
452
		]);
453
		if ($store_props[PR_MDB_PROVIDER] == ZARAFA_STORE_PUBLIC_GUID) {
454
			$this->logFtsDebug('Search fallback: public store does not support search folders', []);
455
			// public store does not support search folders
456
			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...
457
		}
458
		if ($GLOBALS['entryid']->compareEntryIds(bin2hex($entryid), bin2hex(TodoList::getEntryId()))) {
459
			$this->logFtsDebug('Search fallback: todo list uses legacy restriction path', []);
460
			// todo list do not need to perform full text index search
461
			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...
462
		}
463
464
		$this->searchFolderList = true; // Set to indicate this is not the normal folder, but a search folder
465
		$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...
466
467
		// Parse Restriction
468
		$this->parseRestriction($action);
469
		if ($this->restriction == false) {
470
			$this->logFtsDebug('Restriction parsing failed', [
471
				'action_type' => $actionType,
472
			]);
473
			// if error in creating restriction then send error to client
474
			$errorInfo = [];
475
			$errorInfo["error_message"] = _("Error in search, please try again") . ".";
476
			$errorInfo["original_error_message"] = "Error in parsing restrictions.";
477
478
			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

478
			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

478
			return $this->sendSearchErrorToClient($store, /** @scrutinizer ignore-type */ $entryid, $action, $errorInfo);
Loading history...
479
		}
480
		$ftsDescriptor = $this->buildFtsDescriptor($this->restriction);
481
		if (empty($ftsDescriptor['ast'])) {
482
			$this->logFtsDebug('Failed to translate restriction to FTS descriptor', [
483
				'restriction_sample' => array_keys($this->restriction),
484
			]);
485
			$errorInfo = [];
486
			$errorInfo["error_message"] = _("Error in search, please try again") . ".";
487
			$errorInfo["original_error_message"] = "Unable to translate search query to full-text expression.";
488
489
			return $this->sendSearchErrorToClient($store, $entryid, $action, $errorInfo);
490
		}
491
		$serializedRestriction = serialize($this->restriction);
492
		$restrictionSignature = md5($serializedRestriction);
493
		$this->logFtsDebug('Restriction parsed for full-text search', [
494
			'restriction_signature' => $restrictionSignature,
495
			'fts_descriptor' => $ftsDescriptor,
496
		]);
497
498
		$isSetSearchFolderEntryId = isset($action['search_folder_entryid']);
499
		if ($isSetSearchFolderEntryId) {
500
			$this->sessionData['searchFolderEntryId'] = $action['search_folder_entryid'];
501
		}
502
503
		if (isset($action['forceCreateSearchFolder']) && $action['forceCreateSearchFolder']) {
504
			$isSetSearchFolderEntryId = false;
505
		}
506
507
		// create or open search folder
508
		$searchFolder = $this->createSearchFolder($store, $isSetSearchFolderEntryId);
509
		if ($searchFolder === false) {
510
			if ($store_props[PR_MDB_PROVIDER] == ZARAFA_STORE_DELEGATE_GUID) {
511
				$this->messageList($store, $entryid, $action, "search");
512
513
				return true;
514
			}
515
			// if error in creating search folder then send error to client
516
			$errorInfo = [];
517
518
			$errorInfo["error_message"] = match (mapi_last_hresult()) {
519
				MAPI_E_NO_ACCESS => _("Unable to perform search query, no permissions to create search folder."),
520
				MAPI_E_NOT_FOUND => _("Unable to perform search query, search folder not found."),
521
				default => _("Unable to perform search query, store might not support searching."),
522
			};
523
524
			$errorInfo["original_error_message"] = _("Error in creating search folder.");
525
526
			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

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

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