Passed
Pull Request — master (#1593)
by Michael
16:13 queued 07:14
created

Criteria::renderLdap()   B

Complexity

Conditions 7
Paths 12

Size

Total Lines 27
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 7
eloc 18
c 2
b 0
f 0
nc 12
nop 0
dl 0
loc 27
rs 8.8333
1
<?php
2
/**
3
 * XOOPS Criteria parser for database query
4
 *
5
 * You may not change or alter any portion of this comment or credits
6
 * of supporting developers from this source code or any supporting source code
7
 * which is considered copyrighted (c) material of the original comment or credit authors.
8
 * This program is distributed in the hope that it will be useful,
9
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
 *
12
 * @copyright       (c) 2000-2025 XOOPS Project (https://xoops.org)
13
 * @license             GNU GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html)
14
 * @package             kernel
15
 * @subpackage          database
16
 * @since               2.0.0
17
 * @author              Kazumi Ono <[email protected]>
18
 * @author              Nathan Dial
19
 * @author              Taiwen Jiang <[email protected]>
20
 */
21
defined('XOOPS_ROOT_PATH') || exit('Restricted access');
22
23
/**
24
 * A criteria (grammar?) for a database query.
25
 *
26
 * Abstract base class should never be instantiated directly.
27
 *
28
 * @abstract
29
 */
30
class CriteriaElement
31
{
32
    /**
33
     * Sort order
34
     *
35
     * @var string
36
     */
37
    public $order = 'ASC';
38
39
    /**
40
     *
41
     * @var string
42
     */
43
    public $sort = '';
44
45
    /**
46
     * Number of records to retrieve
47
     *
48
     * @var int
49
     */
50
    public $limit = 0;
51
52
    /**
53
     * Offset of first record
54
     *
55
     * @var int
56
     */
57
    public $start = 0;
58
59
    /**
60
     *
61
     * @var string
62
     */
63
    public $groupby = '';
64
65
    /**
66
     * Constructor
67
     */
68
    public function __construct() {}
69
70
    /**
71
     * Render the criteria element
72
     * @return string
73
     */
74
    public function render() {}
75
76
    /**
77
     *
78
     * @param string $sort
79
     */
80
    public function setSort($sort)
81
    {
82
        $this->sort = $sort;
83
    }
84
85
    /**
86
     *
87
     * @return string
88
     */
89
    public function getSort()
90
    {
91
        return $this->sort;
92
    }
93
94
    /**
95
     *
96
     * @param string $order
97
     */
98
    public function setOrder($order)
99
    {
100
        if ('DESC' === strtoupper($order)) {
101
            $this->order = 'DESC';
102
        }
103
    }
104
105
    /**
106
     *
107
     * @return string
108
     */
109
    public function getOrder()
110
    {
111
        return $this->order;
112
    }
113
114
    /**
115
     *
116
     * @param int $limit
117
     */
118
    public function setLimit($limit = 0)
119
    {
120
        $this->limit = (int) $limit;
121
    }
122
123
    /**
124
     *
125
     * @return int
126
     */
127
    public function getLimit()
128
    {
129
        return $this->limit;
130
    }
131
132
    /**
133
     *
134
     * @param int $start
135
     */
136
    public function setStart($start = 0)
137
    {
138
        $this->start = (int) $start;
139
    }
140
141
    /**
142
     *
143
     * @return int
144
     */
145
    public function getStart()
146
    {
147
        return $this->start;
148
    }
149
150
    /**
151
     *
152
     * @param string $group
153
     */
154
    public function setGroupBy($group)
155
    {
156
        $this->groupby = $group;
157
    }
158
159
    /**
160
     *
161
     * @return string
162
     */
163
    public function getGroupby()
164
    {
165
        return $this->groupby ? " GROUP BY {$this->groupby}" : '';
166
    }
167
    /**
168
     * *#@-
169
     */
170
}
171
172
/**
173
 * Collection of multiple {@link CriteriaElement}s
174
 *
175
 */
176
class CriteriaCompo extends CriteriaElement
177
{
178
    /**
179
     * The elements of the collection
180
     *
181
     * @var array Array of {@link CriteriaElement} objects
182
     */
183
    public $criteriaElements = [];
184
185
    /**
186
     * Conditions
187
     *
188
     * @var array
189
     */
190
    public $conditions = [];
191
192
    /**
193
     * Constructor
194
     *
195
     * @param CriteriaElement|null $ele
196
     * @param string $condition
197
     */
198
    public function __construct(?CriteriaElement $ele = null, $condition = 'AND')
199
    {
200
        if (isset($ele)) {
201
            $this->add($ele, $condition);
202
        }
203
    }
204
205
    /**
206
     * Add an element
207
     *
208
     * @param CriteriaElement|object $criteriaElement
209
     * @param string                 $condition
210
     * @return object reference to this collection
211
     */
212
    public function &add(CriteriaElement $criteriaElement, $condition = 'AND')
213
    {
214
        if (is_object($criteriaElement)) {
215
            $this->criteriaElements[] = & $criteriaElement;
216
            $this->conditions[]       = $condition;
217
        }
218
219
        return $this;
220
    }
221
222
    /**
223
     * Make the criteria into a query string
224
     *
225
     * @return string
226
     */
227
    public function render(?\XoopsDatabase $db = null): string
228
    {
229
        $ret   = '';
230
        $count = count($this->criteriaElements);
231
        if ($count > 0) {
232
            $renderString = $this->criteriaElements[0]->render($db);
233
            for ($i = 1; $i < $count; ++$i) {
234
                if (!$render = $this->criteriaElements[$i]->render($db)) {
235
                    continue;
236
                }
237
                $renderString .= (empty($renderString) ? '' : ' ' . $this->conditions[$i] . ' ') . $render;
238
            }
239
            $ret = empty($renderString) ? '' : "({$renderString})";
240
        }
241
242
        return $ret;
243
    }
244
245
    /**
246
     * Make the criteria into a SQL "WHERE" clause
247
     *
248
     * @return string
249
     */
250
    public function renderWhere(?\XoopsDatabase $db = null): string
251
    {
252
        $ret = $this->render($db);
253
        $ret = ($ret !== '') ? 'WHERE ' . $ret : $ret;
254
255
        return $ret;
256
    }
257
258
    /**
259
     * Generate an LDAP filter from criteria
260
     *
261
     * @return string
262
     * @author Nathan Dial [email protected]
263
     */
264
    public function renderLdap()
265
    {
266
        $retval = '';
267
        $count  = count($this->criteriaElements);
268
        if ($count > 0) {
269
            $retval = $this->criteriaElements[0]->renderLdap();
270
            for ($i = 1; $i < $count; ++$i) {
271
                $cond   = strtoupper($this->conditions[$i]);
272
                $op     = ($cond === 'OR') ? '|' : '&';
273
                $retval = "({$op}{$retval}" . $this->criteriaElements[$i]->renderLdap() . ')';
274
            }
275
        }
276
277
        return $retval;
278
    }
279
}
280
281
/**
282
 * A single criteria
283
 *
284
 */
285
class Criteria extends CriteriaElement
286
{
287
    /** @var string|null Optional table prefix (alias) like "u" for "u.`uname`" */
288
    public $prefix;
289
    /** @var string|null Optional column wrapper function with sprintf format, e.g. 'LOWER(%s)' */
290
    public $function;
291
    /** @var string Column name or expression (backticks handled for simple columns) */
292
    public $column;
293
    /** @var string SQL operator (=, <, >, LIKE, IN, IS NULL, etc.) */
294
    public $operator;
295
    /** @var mixed Value for the operator: scalar for most ops, array or "(a,b)" for IN/NOT IN */
296
    public $value;
297
    /** @var bool Allow empty string values to render (default false = skip empty) */
298
    protected $allowEmptyValue = false;
299
    /** @var bool Allow inner wildcards in LIKE (default false = escape inner % and _) */
300
    protected $allowInnerWildcards = false;
301
    /** @var bool Global default for allowing inner wildcards in LIKE across all instances */
302
    protected static $defaultAllowInnerWildcards = false;
303
    /** @var bool|null Cached legacy log flag */
304
    private static $legacyLogEnabled = null;
305
306
    /**
307
     * Initialize logging flag once
308
     */
309
    private static function isLegacyLogEnabled(): bool
310
    {
311
        if (self::$legacyLogEnabled === null) {
312
            self::$legacyLogEnabled = defined('XOOPS_DB_LEGACY_LOG') && XOOPS_DB_LEGACY_LOG;
313
        }
314
        return self::$legacyLogEnabled;
315
    }
316
317
    /**
318
     * Check if a legacy IN value is a safe, parenthesized list of quoted literals.
319
     * Supports both single and double quotes: ("foo","bar") or ('foo','bar')
320
     */
321
    private static function isSafeLegacyInList(string $raw): bool
322
    {
323
        $raw = trim($raw);
324
325
        // 0) Empty list is valid
326
        if ($raw === '()') {
327
            return true;
328
        }
329
330
        // 1) Safe numeric list: (1,2,3) or with spaces
331
        if (preg_match('/^\(\s*\d+(?:\s*,\s*\d+)*\s*\)$/', $raw)) {
332
            return true;
333
        }
334
335
        // 2) Safe quoted list: ("foo","bar") or ('foo','bar')
336
        //    Keep your existing, more complex quoted-string pattern here.
337
        //    Example shape (adjust to match what you already use):
338
        $pattern = '/^\(\s*'
339
                   . '(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\')'
340
                   . '(?:\s*,\s*(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'))*'
341
                   . '\s*\)$/';
342
343
        return (bool)preg_match($pattern, $raw);
344
    }
345
346
    /**
347
     * Set the global default for allowing inner wildcards in LIKE patterns.
348
     * Useful during migrations of legacy modules that intentionally use inner wildcards.
349
     *
350
     * @param bool $on
351
     * @return void
352
     */
353
    public static function setDefaultAllowInnerWildcards(bool $on = true): void
354
    {
355
        self::$defaultAllowInnerWildcards = $on;
356
    }
357
358
    /**
359
     * Opt-in per instance for intentional inner wildcards in LIKE patterns.
360
     * Default remains secure (inner %/_ escaped).
361
     *
362
     * @param bool $on
363
     * @return $this
364
     */
365
    public function allowInnerWildcards(bool $on = true): self
366
    {
367
        $this->allowInnerWildcards = $on;
368
        return $this;
369
    }
370
371
    /**
372
     * Constructor
373
     *
374
     * @param string      $column
375
     * @param mixed       $value
376
     * @param string      $operator
377
     * @param string|null $prefix
378
     * @param string|null $function  sprintf format string, e.g. 'LOWER(%s)'
379
     * @param bool        $allowEmptyValue
380
     */
381
    public function __construct($column, $value = '', $operator = '=', $prefix = '', $function = '', $allowEmptyValue = false)
382
    {
383
        $this->prefix           = $prefix;
384
        $this->function         = $function;
385
        $this->column           = $column;
386
        $this->value            = $value;
387
        $this->operator         = $operator;
388
        $this->allowEmptyValue  = $allowEmptyValue;
389
        $this->allowInnerWildcards = self::$defaultAllowInnerWildcards;
390
391
        // Legacy always-true workaround: new Criteria(1, '1', '=') → no WHERE
392
        if ((int)$column === 1 && (int)$value === 1 && $operator === '=') {
393
            $this->column = '';
394
            $this->value  = '';
395
        }
396
    }
397
398
    /**
399
     * Render the SQL fragment (no leading WHERE)
400
     *
401
     * @param \XoopsDatabase|null $db Database connection
402
     * @return string SQL fragment
403
     * @throws \RuntimeException if database connection is not available
404
     */
405
    public function render(?\XoopsDatabase $db = null)
406
    {
407
        // 1) Explicit injection
408
        // 2) Legacy global
409
        // 3) Factory (if available)
410
        if ($db === null && isset($GLOBALS['xoopsDB']) && $GLOBALS['xoopsDB'] instanceof \XoopsDatabase) {
411
            $db = $GLOBALS['xoopsDB'];
412
        }
413
414
        if ($db === null && class_exists('\XoopsDatabaseFactory')) {
415
            try {
416
                $db = \XoopsDatabaseFactory::getDatabaseConnection();
417
            } catch (\Throwable $e) {
418
                throw new \RuntimeException('Database connection required to render Criteria: ' . $e->getMessage(), 0, $e);
419
            }
420
        }
421
422
        if (!$db) {
423
            throw new \RuntimeException('Database connection required to render Criteria');
424
        }
425
426
        $col = (string)($this->column ?? '');
427
428
        if ($col === '') {
429
            return '';
430
        }
431
432
        $backtick = (strpos($col, '.') === false && strpos($col, '(') === false) ? '`' : '';
433
        $clause = (empty($this->prefix) ? '' : "{$this->prefix}.") . $backtick . $col . $backtick;
434
435
        if (!empty($this->function)) {
436
            $clause = sprintf($this->function, $clause);
437
        }
438
439
        $op = strtoupper((string)$this->operator);
440
        // REMOVE THIS LINE: $valStr = (string)$this->value;
441
442
        // NULL operators
443
        if ($op === 'IS NULL' || $op === 'IS NOT NULL') {
444
            return $clause . ' ' . $op;
445
        }
446
447
        /**
448
         * IN / NOT IN
449
         */
450
        if ($op === 'IN' || $op === 'NOT IN') {
451
            // Modern safe path: array input
452
            if (is_array($this->value)) {
453
                $parts = [];
454
                foreach ($this->value as $v) {
455
                    if (is_int($v) || (is_string($v) && preg_match('/^-?\d+$/', $v))) {
456
                        $parts[] = (string)(int)$v;
457
                    } else {
458
                        $parts[] = $db->quote((string)$v);
459
                    }
460
                }
461
            return $clause . ' ' . $op . ' (' . implode(',', $parts) . ')';
462
        }
463
464
            // Legacy format: preformatted string in parentheses
465
            $legacy = (string)$this->value;
466
467
            // FIRST: strict validation of legacy syntax
468
            if (!self::isSafeLegacyInList($legacy)) {
469
                // Malformed → treat as a single literal safely
470
                return $clause . ' ' . $op . ' (' . $db->quote($legacy) . ')';
471
            }
472
473
            // If legacy logging is not enabled, just pass through
474
            if (!self::isLegacyLogEnabled()) {
475
                return $clause . ' ' . $op . ' ' . $legacy;
476
        }
477
478
            // Build log message
479
            $message = sprintf(
480
                'Legacy Criteria IN format used for column "%s" with value "%s"',
481
                $this->column,
482
                $legacy
483
            );
484
485
            // Only pay backtrace cost in debug mode
486
            if (defined('XOOPS_DEBUG') && XOOPS_DEBUG) {
487
                $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
488
                $caller = $bt[1] ?? [];
489
                $file = $caller['file'] ?? 'unknown';
490
                $line = $caller['line'] ?? 0;
491
                $message .= sprintf(' at %s:%d', $file, $line);
492
            }
493
494
            if (class_exists('XoopsLogger')) {
495
                \XoopsLogger::getInstance()
496
                            ->addExtra('CriteriaLegacyIN', $message);
497
        } else {
498
                error_log($message);
499
        }
500
501
            if (defined('XOOPS_DEBUG') && XOOPS_DEBUG) {
502
                trigger_error($message, E_USER_DEPRECATED);
503
            }
504
505
            return $clause . ' ' . $op . ' ' . $legacy;
506
        }
507
508
        // NOW it's safe to cast to string for other operators
509
        $valStr = (string)$this->value;
510
511
        // Empty value check
512
        if (trim($valStr) === '' && !$this->allowEmptyValue) {
513
            return '';
514
        }
515
516
        /**
517
         * LIKE / NOT LIKE
518
         * - Preserves leading/trailing % as wildcards
519
         * - Escapes inner backslashes
520
         * - Optionally escapes inner % and _ when allowInnerWildcards is false
521
         */
522
        if ($op === 'LIKE' || $op === 'NOT LIKE') {
523
            $pattern = (string)$this->value;
524
525
            // If pattern is only % signs, it's effectively "match everything" → no predicate
526
            if ($op === 'LIKE' && $pattern !== '' && strspn($pattern, '%') === strlen($pattern)) {
527
                return '';
528
            }
529
530
            $len     = strlen($pattern);
531
            $lead    = strspn($pattern, '%');
532
            $trail   = strspn(strrev($pattern), '%');
533
            $coreLen = $len - $lead - $trail;
534
535
            if ($coreLen <= 0) {
536
                $final = $pattern;
537
            } else {
538
                $left  = $lead > 0 ? substr($pattern, 0, $lead) : '';
539
                $core  = substr($pattern, $lead, $coreLen);
540
                $right = $trail > 0 ? substr($pattern, -$trail) : '';
541
542
                // Always escape backslashes in the core
543
                $core = str_replace('\\', '\\\\', $core);
544
545
                // If inner wildcards are NOT allowed, escape % and _ inside core
546
                if (!$this->allowInnerWildcards) {
547
                    $core = strtr($core, [
548
                        '%' => '\\%',
549
                        '_' => '\\_',
550
                    ]);
551
                }
552
553
                $final = $left . $core . $right;
554
            }
555
556
            $quoted = $db->quote($final);
557
            return $clause . ' ' . $op . ' ' . $quoted;
558
        }
559
560
        /**
561
         * All other operators: =, <, >, <=, >=, !=, <>
562
         */
563
564
        // Backtick bypass for column-to-column comparisons
565
        if (strlen($valStr) > 2 && $valStr[0] === '`' && $valStr[strlen($valStr) - 1] === '`') {
566
            $inner = substr($valStr, 1, -1);
567
568
            // Allow alphanumeric, underscore, dot, and dollar sign
569
            // (valid in MySQL identifiers when backticked, incl. db.table)
570
            if (preg_match('/^[a-zA-Z0-9_.$\\-]+$/', $inner)) {
571
                $safeValue = $valStr;
572
        } else {
573
                // Old behavior: empty backticks on invalid identifier content
574
                $safeValue = '``';
575
            }
576
        } else {
577
            // Regular value - keep integers numeric; quote strings
578
            if (is_int($this->value) || (is_string($this->value) && preg_match('/^-?\d+$/', $this->value))) {
579
                $safeValue = (string)(int)$this->value;
580
            } else {
581
                $safeValue = $db->quote((string)$this->value);
582
            }
583
        }
584
585
        return $clause . ' ' . $op . ' ' . $safeValue;
586
    }
587
588
    /**
589
     * Render with leading WHERE clause
590
     *
591
     * @param \XoopsDatabase|null $db Database connection
592
     * @return string SQL WHERE clause or empty string
593
     */
594
    public function renderWhere(?\XoopsDatabase $db = null)
595
    {
596
        $cond = $this->render($db);
597
        return empty($cond) ? '' : "WHERE {$cond}";
598
    }
599
600
    /**
601
     * Generate an LDAP filter from criteria
602
     *
603
     * @return string LDAP filter
604
     */
605
    public function renderLdap()
606
    {
607
        if ($this->operator === '>') {
608
            $this->operator = '>=';
609
        }
610
        if ($this->operator === '<') {
611
            $this->operator = '<=';
612
        }
613
614
        if ($this->operator === '!=' || $this->operator === '<>') {
615
            $operator = '=';
616
            $clause   = '(!(' . $this->column . $operator . $this->value . '))';
617
        } else {
618
            if ($this->operator === 'IN') {
619
                $newvalue = str_replace(['(', ')'], '', $this->value);
620
                $tab      = explode(',', $newvalue);
621
                $clause = '';
622
                foreach ($tab as $uid) {
623
                    $clause .= "({$this->column}={$uid})";
624
                }
625
                $clause = '(|' . $clause . ')';
626
            } else {
627
                $clause = '(' . $this->column . $this->operator . $this->value . ')';
628
            }
629
        }
630
631
        return $clause;
632
    }
633
}
634