Passed
Push — master ( e0bf61...25f737 )
by
unknown
17:31
created

IndexSqlite::formatBinaryFieldForLog()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 7
nc 4
nop 1
dl 0
loc 12
rs 10
c 1
b 0
f 0
1
<?php
2
3
define('PRIVATE_FID_ROOT', 0x1);
4
5
define('PR_FOLDER_ID', 0x67480014);
6
define('PR_MID', 0x674A0014);
7
define('PR_CHANGE_NUMBER', 0x67A40014);
8
9
class IndexSqlite extends SQLite3 {
10
	private $username;
11
	private $count;
12
	private $store;
13
	private $session;
14
	private $openResult;
15
16
	private const DEBUG_SAMPLE_LIMIT = 5;
17
18
	private function logDebug(string $message, array $context = []): void {
19
		if (!DEBUG_FULLTEXT_SEARCH) {
20
			return;
21
		}
22
		$prefix = '[fts-debug][index] ';
23
		if (!empty($context)) {
24
			$encoded = json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
25
			if ($encoded === false) {
26
				$encoded = 'context_encoding_failed';
27
			}
28
			error_log($prefix . $message . ' ' . $encoded);
29
		}
30
		else {
31
			error_log($prefix . $message);
32
		}
33
	}
34
35
	private static function formatEntryIdForLog($entryid) {
36
		if ($entryid === null) {
37
			return null;
38
		}
39
		if (is_array($entryid)) {
40
			return array_map([self::class, 'formatEntryIdForLog'], $entryid);
41
		}
42
		if (!is_string($entryid)) {
43
			return $entryid;
44
		}
45
46
		return bin2hex($entryid);
47
	}
48
49
	private static function formatBinaryFieldForLog($value) {
50
		if ($value === null) {
51
			return null;
52
		}
53
		if (!is_string($value)) {
54
			return $value;
55
		}
56
		if (preg_match('/[^\x20-\x7E]/', $value)) {
57
			return bin2hex($value);
58
		}
59
60
		return $value;
61
	}
62
63
	private static function get_gc_value($eid) {
64
		$r0 = ($eid >> 56) & 0xFF;
65
		$r1 = ($eid >> 48) & 0xFF;
66
		$r2 = ($eid >> 40) & 0xFF;
67
		$r3 = ($eid >> 32) & 0xFF;
68
		$r4 = ($eid >> 24) & 0xFF;
69
		$r5 = ($eid >> 16) & 0xFF;
70
		$value = $r0 | ($r1 << 8) | ($r2 << 16) | ($r3 << 24) | ($r4 << 32) | ($r5 << 40);
71
72
		return $value;
73
	}
74
75
	public function __construct($username = null, $session = null, $store = null) {
76
		$this->username = $username ?? $GLOBALS["mapisession"]->getSMTPAddress();
77
		$this->session = $session ?? $GLOBALS["mapisession"]->getSession();
78
		$this->store = $store ?? $GLOBALS["mapisession"]->getDefaultMessageStore();
79
		$indexPath = SQLITE_INDEX_PATH . '/' . $this->username . '/index.sqlite3';
80
81
		try {
82
			$this->open($indexPath, SQLITE3_OPEN_READONLY);
83
			$this->openResult = 0;
84
			$this->logDebug('Opened index database', ['path' => $indexPath]);
85
		}
86
		catch (Exception $e) {
87
			error_log(sprintf("Error opening the index database: %s", $e));
88
			$this->openResult = 1;
89
			$this->logDebug('Failed to open index database', [
90
				'path' => $indexPath,
91
				'error' => $e->getMessage(),
92
			]);
93
		}
94
	}
95
96
	private function try_insert_content(
97
		$search_entryid,
98
		$row,
99
		$message_classes,
100
		$date_start,
101
		$date_end,
102
		$unread,
103
		$has_attachments
104
	) {
105
		// if match condition contains '@', $row['entryid'] will disappear. it seems a bug for php-sqlite
106
		if (empty($row['entryid'])) {
107
			$results = $this->query("SELECT entryid FROM msg_content WHERE message_id=" . $row['message_id']);
108
			$row1 = $results->fetchArray(SQLITE3_NUM);
109
			if ($row1 && !empty($row1[0])) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $row1 of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
110
				$row['entryid'] = $row1[0];
111
				$this->logDebug('Recovered missing entryid from msg_content', [
112
					'message_id' => $row['message_id'],
113
				]);
114
			}
115
			// abort if the entryid is not available
116
			else {
117
				error_log(sprintf("No entryid available, not possible to link the message %d.", $row['message_id']));
118
				$this->logDebug('Missing entryid prevents linking message', [
119
					'message_id' => $row['message_id'],
120
				]);
121
122
				return;
123
			}
124
		}
125
		if (is_array($message_classes) && $message_classes !== []) {
126
			$found = false;
127
			foreach ($message_classes as $message_class) {
128
				if (strncasecmp((string) $row['message_class'], (string) $message_class, strlen((string) $message_class)) == 0) {
129
					$found = true;
130
					break;
131
				}
132
			}
133
			if (!$found) {
134
				$this->logDebug('Skipping message because message class is filtered out', [
135
					'message_id' => $row['message_id'] ?? null,
136
					'message_class' => $row['message_class'] ?? null,
137
				]);
138
				return;
139
			}
140
		}
141
		if ($date_start !== null && $row['date'] < $date_start) {
142
			$this->logDebug('Skipping message before start date filter', [
143
				'message_id' => $row['message_id'] ?? null,
144
				'message_date' => $row['date'] ?? null,
145
				'date_start' => $date_start,
146
			]);
147
			return;
148
		}
149
		if ($date_end !== null && $row['date'] > $date_end) {
150
			$this->logDebug('Skipping message after end date filter', [
151
				'message_id' => $row['message_id'] ?? null,
152
				'message_date' => $row['date'] ?? null,
153
				'date_end' => $date_end,
154
			]);
155
			return;
156
		}
157
		if ($unread && $row['readflag']) {
158
			$this->logDebug('Skipping message because unread flag filter is active', [
159
				'message_id' => $row['message_id'] ?? null,
160
				'readflag' => $row['readflag'] ?? null,
161
			]);
162
			return;
163
		}
164
		if ($has_attachments && !$row['attach_indexed']) {
165
			$this->logDebug('Skipping message because attachment filter is active', [
166
				'message_id' => $row['message_id'] ?? null,
167
				'attach_indexed' => $row['attach_indexed'] ?? null,
168
			]);
169
			return;
170
		}
171
172
		try {
173
			mapi_linkmessage($this->session, $search_entryid, $row['entryid']);
174
		}
175
		catch (Exception $e) {
176
			$details = [
177
				'message_id' => $row['message_id'],
178
				'entryid' => self::formatBinaryFieldForLog($row['entryid']),
179
				'error' => $e->getMessage(),
180
			];
181
			if (function_exists('mapi_last_hresult')) {
182
				$details['hresult'] = mapi_last_hresult();
183
			}
184
			$this->logDebug('MAPI linkmessage failed', $details);
185
			return;
186
		}
187
		++$this->count;
188
	}
189
190
	private function result_full() {
191
		return $this->count >= MAX_FTS_RESULT_ITEMS;
192
	}
193
194
	public function search($search_entryid, $descriptor, $folder_entryid, $recursive) {
195
		$startTime = microtime(true);
196
		$this->logDebug('Search invoked', [
197
			'user' => $this->username,
198
			'search_entryid' => self::formatEntryIdForLog($search_entryid),
199
			'folder_entryid' => self::formatEntryIdForLog($folder_entryid),
200
			'recursive' => (bool) $recursive,
201
			'descriptor' => $descriptor,
202
		]);
203
		if ($this->openResult) {
204
			$this->logDebug('Search aborted: index database unavailable', ['open_result' => $this->openResult]);
205
			return false;
206
		}
207
		$whereFolderids = '';
208
		if (isset($folder_entryid)) {
209
			try {
210
				$folder = mapi_msgstore_openentry($this->store, $folder_entryid);
211
				if (!$folder) {
212
					return false;
213
				}
214
				$tmp_props = mapi_getprops($folder, [PR_FOLDER_ID]);
215
				if (empty($tmp_props[PR_FOLDER_ID])) {
216
					return false;
217
				}
218
				$folder_id = IndexSqlite::get_gc_value((int) $tmp_props[PR_FOLDER_ID]);
219
				$whereFolderids .= "c.folder_id in (" . $folder_id . ", ";
220
				if ($recursive) {
221
					$this->getWhereFolderids($folder, $whereFolderids);
222
				}
223
				$whereFolderids = substr($whereFolderids, 0, -2) . ") AND ";
224
				$this->logDebug('Folder scope resolved', [
225
					'root_folder_gc_id' => $folder_id,
226
					'folder_clause' => rtrim($whereFolderids),
227
				]);
228
			}
229
			catch (Exception $e) {
230
				error_log(sprintf("Index: error getting folder information %s - %s", $this->username, $e));
231
				$this->logDebug('Failed to resolve folder scope', ['error' => $e->getMessage()]);
232
233
				return false;
234
			}
235
		}
236
		$sql_string = "SELECT c.message_id, c.entryid, c.folder_id, " .
237
			"c.message_class, c.date, c.readflag, c.attach_indexed " .
238
			"FROM msg_content c " .
239
			"JOIN messages m ON c.message_id = m.rowid " .
240
			"WHERE ";
241
		if (!empty($whereFolderids)) {
242
			$sql_string .= $whereFolderids;
243
		}
244
		$ftsAst = $descriptor['ast'] ?? null;
245
		$message_classes = $descriptor['message_classes'] ?? null;
246
		$date_start = $descriptor['date_start'] ?? null;
247
		$date_end = $descriptor['date_end'] ?? null;
248
		$unread = !empty($descriptor['unread']);
249
		$has_attachments = !empty($descriptor['has_attachments']);
250
		$this->logDebug('Search filters resolved', [
251
			'unread' => $unread,
252
			'has_attachments' => $has_attachments,
253
			'date_start' => $date_start,
254
			'date_end' => $date_end,
255
			'message_classes_count' => is_array($message_classes) ? count($message_classes) : null,
256
		]);
257
258
		$ftsQuery = $this->compileFtsExpression($ftsAst);
259
		if ($ftsQuery === null || $ftsQuery === '') {
260
			$this->logDebug('FTS query compilation returned empty expression', [
261
				'ast' => $ftsAst,
262
			]);
263
			return false;
264
		}
265
266
		$sql_string .= "messages MATCH '" . $ftsQuery . "'";
267
		$this->count = 0;
268
		$sql_string .= " ORDER BY c.date DESC LIMIT " . MAX_FTS_RESULT_ITEMS;
269
		$this->logDebug('Executing SQLite FTS query', ['sql' => $sql_string]);
270
		$results = $this->query($sql_string);
271
		if ($results === false) {
272
			$this->logDebug('SQLite query execution failed', [
273
				'error_code' => $this->lastErrorCode(),
274
				'error_message' => $this->lastErrorMsg(),
275
			]);
276
			return false;
277
		}
278
		$matchedRows = 0;
279
		$sampleRows = [];
280
		while (($row = $results->fetchArray(SQLITE3_ASSOC)) && !$this->result_full()) {
281
			++$matchedRows;
282
			$previousCount = $this->count;
283
			$this->try_insert_content(
284
				$search_entryid,
285
				$row,
286
				$message_classes,
287
				$date_start,
288
				$date_end,
289
				$unread,
290
				$has_attachments
291
			);
292
			if ($this->count > $previousCount && count($sampleRows) < self::DEBUG_SAMPLE_LIMIT) {
293
				$sampleRows[] = [
294
					'message_id' => $row['message_id'] ?? null,
295
					'entryid' => self::formatBinaryFieldForLog($row['entryid'] ?? null),
296
					'message_class' => $row['message_class'] ?? null,
297
					'date' => $row['date'] ?? null,
298
					'readflag' => $row['readflag'] ?? null,
299
					'attach_indexed' => $row['attach_indexed'] ?? null,
300
				];
301
			}
302
		}
303
		$durationMs = (int) round((microtime(true) - $startTime) * 1000);
304
		$this->logDebug('Search completed', [
305
			'fts_query' => $ftsQuery,
306
			'matched_rows' => $matchedRows,
307
			'linked_messages' => $this->count,
308
			'limit_reached' => $this->result_full(),
309
			'folder_clause' => $whereFolderids !== '' ? rtrim($whereFolderids) : null,
310
			'sample_messages' => $sampleRows,
311
			'duration_ms' => $durationMs,
312
		]);
313
314
		return true;
315
	}
316
317
	private function compileFtsExpression($ast) {
318
		if ($ast === null) {
319
			return null;
320
		}
321
322
		if (isset($ast['type']) && $ast['type'] === 'term') {
323
			$fields = $ast['fields'] ?? [];
324
			if (empty($fields)) {
325
				return null;
326
			}
327
			$escaped = SQLite3::escapeString($this->quote_words($ast['value'] ?? ''));
328
			$segments = [];
329
			foreach ($fields as $field) {
330
				$segments[] = $field . ':' . $escaped;
331
			}
332
			if (count($segments) === 1) {
333
				return $segments[0];
334
			}
335
			return '(' . implode(' OR ', $segments) . ')';
336
		}
337
338
		$operator = $ast['op'] ?? null;
339
		$children = $ast['children'] ?? [];
340
		if ($operator === 'NOT') {
341
			$child = $this->compileFtsExpression($children[0] ?? null);
342
			if ($child === null) {
343
				return null;
344
			}
345
			return 'NOT (' . $child . ')';
346
		}
347
		if ($operator === 'AND' || $operator === 'OR') {
348
			$parts = [];
349
			foreach ($children as $child) {
350
				$compiled = $this->compileFtsExpression($child);
351
				if ($compiled !== null) {
352
					$parts[] = $compiled;
353
				}
354
			}
355
			if (empty($parts)) {
356
				return null;
357
			}
358
			if (count($parts) === 1) {
359
				return $parts[0];
360
			}
361
			$wrapped = array_map(function ($segment) {
362
				return '(' . $segment . ')';
363
			}, $parts);
364
			return implode(' ' . $operator . ' ', $wrapped);
365
		}
366
367
		return null;
368
	}
369
370
	private function quote_words($search_string) {
371
		return '"' . preg_replace("/(\\s+)/", '*" "', trim((string) $search_string)) . '"*';
372
	}
373
374
	/**
375
	 * Returns the restriction to filter hidden folders.
376
	 *
377
	 * @return array
378
	 */
379
	private function getHiddenRestriction() {
380
		return
381
			[RES_OR, [
382
				[RES_PROPERTY,
383
					[
384
						RELOP => RELOP_EQ,
385
						ULPROPTAG => PR_ATTR_HIDDEN,
386
						VALUE => [PR_ATTR_HIDDEN => false],
387
					],
388
				],
389
				[RES_NOT,
390
					[
391
						[RES_EXIST,
392
							[
393
								ULPROPTAG => PR_ATTR_HIDDEN,
394
							],
395
						],
396
					],
397
				],
398
			]];
399
	}
400
401
	/**
402
	 * Returns the comma joined folderids for the WHERE clause in the SQL
403
	 * statement.
404
	 *
405
	 * @param mixed  $folder
406
	 * @param string $whereFolderids
407
	 */
408
	private function getWhereFolderids($folder, &$whereFolderids) {
409
		/**
410
		 * remove hidden folders, folders with PR_ATTR_HIDDEN property set
411
		 * should not be shown to the client.
412
		 */
413
		$restriction = $this->getHiddenRestriction();
414
		$hierarchy = mapi_folder_gethierarchytable($folder, CONVENIENT_DEPTH | MAPI_DEFERRED_ERRORS);
415
		mapi_table_restrict($hierarchy, $restriction, TBL_BATCH);
416
		$rows = mapi_table_queryallrows($hierarchy, [PR_FOLDER_ID]);
417
		foreach ($rows as $row) {
418
			if (isset($row[PR_FOLDER_ID])) {
419
				$whereFolderids .= IndexSqlite::get_gc_value((int) $row[PR_FOLDER_ID]) . ", ";
420
			}
421
		}
422
	}
423
}
424