convertRestrictionToAst()   F
last analyzed

Complexity

Conditions 43
Paths 43

Size

Total Lines 157
Code Lines 114

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 43
eloc 114
nc 43
nop 2
dl 0
loc 157
rs 3.3333
c 1
b 0
f 0

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

475
			return parent::messageList($store, $entryid, /** @scrutinizer ignore-type */ $action, "list");
Loading history...
476
		}
477
		$store_props = mapi_getprops($store, [PR_MDB_PROVIDER, PR_DEFAULT_STORE, PR_IPM_SUBTREE_ENTRYID]);
478
		$this->logFtsDebug('Resolved store properties for search', [
479
			'provider' => isset($store_props[PR_MDB_PROVIDER]) ? bin2hex((string) $store_props[PR_MDB_PROVIDER]) : null,
480
			'default_store' => $store_props[PR_DEFAULT_STORE] ?? null,
481
		]);
482
		if ($store_props[PR_MDB_PROVIDER] == ZARAFA_STORE_PUBLIC_GUID) {
483
			$this->logFtsDebug('Search fallback: public store does not support search folders', []);
484
			// public store does not support search folders
485
			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...
486
		}
487
		if ($GLOBALS['entryid']->compareEntryIds(bin2hex($entryid), bin2hex(TodoList::getEntryId()))) {
488
			$this->logFtsDebug('Search fallback: todo list uses legacy restriction path', []);
489
			// todo list do not need to perform full text index search
490
			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...
491
		}
492
493
		$this->searchFolderList = true; // Set to indicate this is not the normal folder, but a search folder
494
		$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...
495
496
		// Parse Restriction
497
		$this->parseRestriction($action);
498
		if ($this->restriction == false) {
499
			$this->logFtsDebug('Restriction parsing failed', [
500
				'action_type' => $actionType,
501
			]);
502
			// if error in creating restriction then send error to client
503
			$errorInfo = [];
504
			$errorInfo["error_message"] = _("Error in search, please try again") . ".";
505
			$errorInfo["original_error_message"] = "Error in parsing restrictions.";
506
507
			return $this->sendSearchErrorToClient($store, $entryid, $action, $errorInfo);
0 ignored issues
show
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

507
			return $this->sendSearchErrorToClient($store, /** @scrutinizer ignore-type */ $entryid, $action, $errorInfo);
Loading history...
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

507
			return $this->sendSearchErrorToClient($store, $entryid, $action, /** @scrutinizer ignore-type */ $errorInfo);
Loading history...
508
		}
509
		$ftsDescriptor = $this->buildFtsDescriptor($this->restriction);
510
		if (empty($ftsDescriptor['ast'])) {
511
			$this->logFtsDebug('Failed to translate restriction to FTS descriptor', [
512
				'restriction_sample' => array_keys($this->restriction),
513
			]);
514
			$errorInfo = [];
515
			$errorInfo["error_message"] = _("Error in search, please try again") . ".";
516
			$errorInfo["original_error_message"] = "Unable to translate search query to full-text expression.";
517
518
			return $this->sendSearchErrorToClient($store, $entryid, $action, $errorInfo);
519
		}
520
		$serializedRestriction = serialize($this->restriction);
521
		$restrictionSignature = md5($serializedRestriction);
522
		$this->logFtsDebug('Restriction parsed for full-text search', [
523
			'restriction_signature' => $restrictionSignature,
524
			'fts_descriptor' => $ftsDescriptor,
525
		]);
526
527
		$isSetSearchFolderEntryId = isset($action['search_folder_entryid']);
528
		if ($isSetSearchFolderEntryId) {
529
			$this->sessionData['searchFolderEntryId'] = $action['search_folder_entryid'];
530
		}
531
532
		if (isset($action['forceCreateSearchFolder']) && $action['forceCreateSearchFolder']) {
533
			$isSetSearchFolderEntryId = false;
534
		}
535
536
		// create or open search folder
537
		$searchFolder = $this->createSearchFolder($store, $isSetSearchFolderEntryId);
538
		if ($searchFolder === false) {
539
			if ($store_props[PR_MDB_PROVIDER] == ZARAFA_STORE_DELEGATE_GUID) {
540
				$this->messageList($store, $entryid, $action, "search");
541
542
				return true;
543
			}
544
			// if error in creating search folder then send error to client
545
			$errorInfo = [];
546
547
			$errorInfo["error_message"] = match (mapi_last_hresult()) {
548
				MAPI_E_NO_ACCESS => _("Unable to perform search query, no permissions to create search folder."),
549
				MAPI_E_NOT_FOUND => _("Unable to perform search query, search folder not found."),
550
				default => _("Unable to perform search query, store might not support searching."),
551
			};
552
553
			$errorInfo["original_error_message"] = _("Error in creating search folder.");
554
555
			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

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

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