Completed
Push — master ( d006f2...210521 )
by Maurício
01:50 queued 29s
created

Triggers::export()   B

Complexity

Conditions 8
Paths 13

Size

Total Lines 50
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 72

Importance

Changes 0
Metric Value
cc 8
eloc 29
nc 13
nop 0
dl 0
loc 50
rs 8.2114
c 0
b 0
f 0
ccs 0
cts 32
cp 0
crap 72
1
<?php
2
3
declare(strict_types=1);
4
5
namespace PhpMyAdmin\Triggers;
6
7
use PhpMyAdmin\DatabaseInterface;
0 ignored issues
show
Bug introduced by
The type PhpMyAdmin\DatabaseInterface 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...
8
use PhpMyAdmin\Html\Generator;
9
use PhpMyAdmin\Message;
10
use PhpMyAdmin\Query\Generator as QueryGenerator;
0 ignored issues
show
Bug introduced by
The type PhpMyAdmin\Query\Generator 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...
11
use PhpMyAdmin\ResponseRenderer;
0 ignored issues
show
Bug introduced by
The type PhpMyAdmin\ResponseRenderer 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...
12
use PhpMyAdmin\Template;
13
use PhpMyAdmin\Util;
14
use Webmozart\Assert\Assert;
15
16
use function __;
17
use function array_column;
18
use function array_multisort;
19
use function count;
20
use function explode;
21
use function htmlspecialchars;
22
use function in_array;
23
use function mb_strtoupper;
24
use function sprintf;
25
use function str_contains;
26
use function trim;
27
28
use const SORT_ASC;
29
30
/**
31
 * Functions for trigger management.
32
 */
33
class Triggers
34
{
35
    /** @var array<int, string> */
36
    private array $time = ['BEFORE', 'AFTER'];
37
38
    /** @var array<int, string> */
39
    private array $event = ['INSERT', 'UPDATE', 'DELETE'];
40
41 112
    public function __construct(
42
        private DatabaseInterface $dbi,
43
        private Template $template,
44
        private ResponseRenderer $response,
45
    ) {
46 112
    }
47
48
    /**
49
     * Handles editor requests for adding or editing an item
50
     */
51
    public function handleEditor(): void
52
    {
53
        $GLOBALS['errors'] ??= null;
54
        $GLOBALS['message'] ??= null;
55
56
        if (! empty($_POST['editor_process_add']) || ! empty($_POST['editor_process_edit'])) {
57
            $sqlQuery = '';
58
59
            $itemQuery = $this->getQueryFromRequest();
60
61
            // set by getQueryFromRequest()
62
            if (! count($GLOBALS['errors'])) {
63
                // Execute the created query
64
                if (! empty($_POST['editor_process_edit'])) {
65
                    // Backup the old trigger, in case something goes wrong
66
                    $trigger = $this->getDataFromName($_POST['item_original_name']);
67
                    $createItem = $trigger['create'];
68
                    $dropItem = $trigger['drop'] . ';';
69
                    $result = $this->dbi->tryQuery($dropItem);
70
                    if (! $result) {
71
                        $GLOBALS['errors'][] = sprintf(
72
                            __('The following query has failed: "%s"'),
73
                            htmlspecialchars($dropItem),
74
                        )
75
                        . '<br>'
76
                        . __('MySQL said: ') . $this->dbi->getError();
77
                    } else {
78
                        $result = $this->dbi->tryQuery($itemQuery);
79
                        if (! $result) {
80
                            $GLOBALS['errors'][] = sprintf(
81
                                __('The following query has failed: "%s"'),
82
                                htmlspecialchars($itemQuery),
83
                            )
84
                            . '<br>'
85
                            . __('MySQL said: ') . $this->dbi->getError();
86
                            // We dropped the old item, but were unable to create the
87
                            // new one. Try to restore the backup query.
88
                            $result = $this->dbi->tryQuery($createItem);
89
90
                            if (! $result) {
91
                                $GLOBALS['errors'] = $this->checkResult($createItem, $GLOBALS['errors']);
92
                            }
93
                        } else {
94
                            $GLOBALS['message'] = Message::success(
95
                                __('Trigger %1$s has been modified.'),
96
                            );
97
                            $GLOBALS['message']->addParam(
98
                                Util::backquote($_POST['item_name']),
99
                            );
100
                            $sqlQuery = $dropItem . $itemQuery;
101
                        }
102
                    }
103
                } else {
104
                    // 'Add a new item' mode
105
                    $result = $this->dbi->tryQuery($itemQuery);
106
                    if (! $result) {
107
                        $GLOBALS['errors'][] = sprintf(
108
                            __('The following query has failed: "%s"'),
109
                            htmlspecialchars($itemQuery),
110
                        )
111
                        . '<br><br>'
112
                        . __('MySQL said: ') . $this->dbi->getError();
113
                    } else {
114
                        $GLOBALS['message'] = Message::success(
115
                            __('Trigger %1$s has been created.'),
116
                        );
117
                        $GLOBALS['message']->addParam(
118
                            Util::backquote($_POST['item_name']),
119
                        );
120
                        $sqlQuery = $itemQuery;
121
                    }
122
                }
123
            }
124
125
            if (count($GLOBALS['errors'])) {
126
                $GLOBALS['message'] = Message::error(
127
                    '<b>'
128
                    . __(
129
                        'One or more errors have occurred while processing your request:',
130
                    )
131
                    . '</b>',
132
                );
133
                $GLOBALS['message']->addHtml('<ul>');
134
                foreach ($GLOBALS['errors'] as $string) {
135
                    $GLOBALS['message']->addHtml('<li>' . $string . '</li>');
136
                }
137
138
                $GLOBALS['message']->addHtml('</ul>');
139
            }
140
141
            $output = Generator::getMessage($GLOBALS['message'], $sqlQuery);
142
143
            if ($this->response->isAjax()) {
144
                if ($GLOBALS['message']->isSuccess()) {
145
                    $items = self::getDetails($this->dbi, $GLOBALS['db'], $GLOBALS['table'], '');
146
                    $trigger = false;
147
                    foreach ($items as $value) {
148
                        if ($value['name'] != $_POST['item_name']) {
149
                            continue;
150
                        }
151
152
                        $trigger = $value;
153
                    }
154
155
                    $insert = false;
156
                    if (empty($GLOBALS['table']) || ($trigger !== false && $GLOBALS['table'] == $trigger['table'])) {
157
                        $insert = true;
158
                        $hasTriggerPrivilege = Util::currentUserHasPrivilege(
159
                            'TRIGGER',
160
                            $GLOBALS['db'],
161
                            $GLOBALS['table'],
162
                        );
163
                        $this->response->addJSON(
164
                            'new_row',
165
                            $this->template->render('triggers/row', [
166
                                'db' => $GLOBALS['db'],
167
                                'table' => $GLOBALS['table'],
168
                                'trigger' => $trigger,
169
                                'has_drop_privilege' => $hasTriggerPrivilege,
170
                                'has_edit_privilege' => $hasTriggerPrivilege,
171
                                'row_class' => '',
172
                            ]),
173
                        );
174
                        $this->response->addJSON(
175
                            'name',
176
                            htmlspecialchars(
177
                                mb_strtoupper(
178
                                    $_POST['item_name'],
179
                                ),
180
                            ),
181
                        );
182
                    }
183
184
                    $this->response->addJSON('insert', $insert);
185
                    $this->response->addJSON('message', $output);
186
                } else {
187
                    $this->response->addJSON('message', $GLOBALS['message']);
188
                    $this->response->setRequestStatus(false);
189
                }
190
191
                $this->response->addJSON('tableType', 'triggers');
192
                exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
193
            }
194
        }
195
196
        /**
197
         * Display a form used to add/edit a trigger, if necessary
198
         */
199
        if (
200
            ! count($GLOBALS['errors'])
201
            && (! empty($_POST['editor_process_add'])
202
            || ! empty($_POST['editor_process_edit'])
203
            || (empty($_REQUEST['add_item'])
204
            && empty($_REQUEST['edit_item']))) // FIXME: this must be simpler than that
205
        ) {
206
            return;
207
        }
208
209
        $mode = '';
210
        $item = null;
211
        $title = '';
212
        // Get the data for the form (if any)
213
        if (! empty($_REQUEST['add_item'])) {
214
            $title = __('Add trigger');
215
            $item = $this->getDataFromRequest();
216
            $mode = 'add';
217
        } elseif (! empty($_REQUEST['edit_item'])) {
218
            $title = __('Edit trigger');
219
            if (! empty($_REQUEST['item_name']) && empty($_POST['editor_process_edit'])) {
220
                $item = $this->getDataFromName($_REQUEST['item_name']);
221
                if ($item !== null) {
222
                    $item['item_original_name'] = $item['item_name'];
223
                }
224
            } else {
225
                $item = $this->getDataFromRequest();
226
            }
227
228
            $mode = 'edit';
229
        }
230
231
        $this->sendEditor($mode, $item, $title, $GLOBALS['db'], $GLOBALS['table']);
232
    }
233
234
    /**
235
     * This function will generate the values that are required to for the editor
236
     *
237
     * @return mixed[]    Data necessary to create the editor.
238
     */
239 8
    public function getDataFromRequest(): array
240
    {
241 8
        $retval = [];
242 8
        $indices = [
243 8
            'item_name',
244 8
            'item_table',
245 8
            'item_original_name',
246 8
            'item_action_timing',
247 8
            'item_event_manipulation',
248 8
            'item_definition',
249 8
            'item_definer',
250 8
        ];
251 8
        foreach ($indices as $index) {
252 8
            $retval[$index] = $_POST[$index] ?? '';
253
        }
254
255 8
        return $retval;
256
    }
257
258
    /**
259
     * This function will generate the values that are required to complete
260
     * the "Edit trigger" form given the name of a trigger.
261
     *
262
     * @param string $name The name of the trigger.
263
     *
264
     * @return mixed[]|null Data necessary to create the editor.
265
     */
266
    public function getDataFromName(string $name): array|null
267
    {
268
        $temp = [];
269
        $items = self::getDetails($this->dbi, $GLOBALS['db'], $GLOBALS['table'], '');
270
        foreach ($items as $value) {
271
            if ($value['name'] != $name) {
272
                continue;
273
            }
274
275
            $temp = $value;
276
        }
277
278
        if (empty($temp)) {
279
            return null;
280
        }
281
282
        $retval = [];
283
        $retval['create'] = $temp['create'];
284
        $retval['drop'] = $temp['drop'];
285
        $retval['item_name'] = $temp['name'];
286
        $retval['item_table'] = $temp['table'];
287
        $retval['item_action_timing'] = $temp['action_timing'];
288
        $retval['item_event_manipulation'] = $temp['event_manipulation'];
289
        $retval['item_definition'] = $temp['definition'];
290
        $retval['item_definer'] = $temp['definer'];
291
292
        return $retval;
293
    }
294
295
    /**
296
     * Displays a form used to add/edit a trigger
297
     *
298
     * @param string  $mode If the editor will be used to edit a trigger or add a new one: 'edit' or 'add'.
299
     * @param mixed[] $item Data for the trigger returned by getDataFromRequest() or getDataFromName()
300
     */
301 72
    public function getEditorForm(string $db, string $table, string $mode, array $item): string
302
    {
303 72
        $tables = $this->getTables($db);
304
305 72
        return $this->template->render('triggers/editor_form', [
306 72
            'db' => $db,
307 72
            'table' => $table,
308 72
            'is_edit' => $mode === 'edit',
309 72
            'item' => $item,
310 72
            'tables' => $tables,
311 72
            'time' => $this->time,
312 72
            'events' => $this->event,
313 72
            'is_ajax' => $this->response->isAjax(),
314 72
        ]);
315
    }
316
317
    /**
318
     * Composes the query necessary to create a trigger from an HTTP request.
319
     *
320
     * @return string  The CREATE TRIGGER query.
321
     */
322 16
    public function getQueryFromRequest(): string
323
    {
324 16
        $GLOBALS['errors'] ??= null;
325
326 16
        $query = 'CREATE ';
327 16
        if (! empty($_POST['item_definer'])) {
328 12
            if (str_contains($_POST['item_definer'], '@')) {
329 8
                $arr = explode('@', $_POST['item_definer']);
330 8
                $query .= 'DEFINER=' . Util::backquote($arr[0]);
331 8
                $query .= '@' . Util::backquote($arr[1]) . ' ';
332
            } else {
333 4
                $GLOBALS['errors'][] = __('The definer must be in the "username@hostname" format!');
334
            }
335
        }
336
337 16
        $query .= 'TRIGGER ';
338 16
        if (! empty($_POST['item_name'])) {
339 12
            $query .= Util::backquote($_POST['item_name']) . ' ';
340
        } else {
341 4
            $GLOBALS['errors'][] = __('You must provide a trigger name!');
342
        }
343
344 16
        if (! empty($_POST['item_timing']) && in_array($_POST['item_timing'], $this->time)) {
345 12
            $query .= $_POST['item_timing'] . ' ';
346
        } else {
347 4
            $GLOBALS['errors'][] = __('You must provide a valid timing for the trigger!');
348
        }
349
350 16
        if (! empty($_POST['item_event']) && in_array($_POST['item_event'], $this->event)) {
351 8
            $query .= $_POST['item_event'] . ' ';
352
        } else {
353 8
            $GLOBALS['errors'][] = __('You must provide a valid event for the trigger!');
354
        }
355
356 16
        $query .= 'ON ';
357 16
        if (! empty($_POST['item_table']) && in_array($_POST['item_table'], $this->dbi->getTables($GLOBALS['db']))) {
358 4
            $query .= Util::backquote($_POST['item_table']);
359
        } else {
360 12
            $GLOBALS['errors'][] = __('You must provide a valid table name!');
361
        }
362
363 16
        $query .= ' FOR EACH ROW ';
364 16
        if (! empty($_POST['item_definition'])) {
365 12
            $query .= $_POST['item_definition'];
366
        } else {
367 4
            $GLOBALS['errors'][] = __('You must provide a trigger definition.');
368
        }
369
370 16
        return $query;
371
    }
372
373
    /**
374
     * @param string  $createStatement Query
375
     * @param mixed[] $errors          Errors
376
     *
377
     * @return mixed[]
378
     */
379
    private function checkResult(string $createStatement, array $errors): array
380
    {
381
        // OMG, this is really bad! We dropped the query,
382
        // failed to create a new one
383
        // and now even the backup query does not execute!
384
        // This should not happen, but we better handle
385
        // this just in case.
386
        $errors[] = __('Sorry, we failed to restore the dropped trigger.') . '<br>'
387
            . __('The backed up query was:')
388
            . '"' . htmlspecialchars($createStatement) . '"<br>'
389
            . __('MySQL said: ') . $this->dbi->getError();
390
391
        return $errors;
392
    }
393
394
    /**
395
     * Send editor via ajax or by echoing.
396
     *
397
     * @param string       $mode  Editor mode 'add' or 'edit'
398
     * @param mixed[]|null $item  Data necessary to create the editor
399
     * @param string       $title Title of the editor
400
     * @param string       $db    Database
401
     * @param string       $table Table
402
     */
403
    private function sendEditor(string $mode, array|null $item, string $title, string $db, string $table): void
404
    {
405
        if ($item !== null) {
0 ignored issues
show
introduced by
The condition $item !== null is always true.
Loading history...
406
            $editor = $this->getEditorForm($db, $table, $mode, $item);
407
            if ($this->response->isAjax()) {
408
                $this->response->addJSON('message', $editor);
409
                $this->response->addJSON('title', $title);
410
            } else {
411
                echo "\n\n<h2>" . $title . "</h2>\n\n" . $editor;
412
                unset($_POST);
413
            }
414
415
            exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
416
        }
417
418
        $message = __('Error in processing request:') . ' ';
419
        $message .= sprintf(
420
            __('No trigger with name %1$s found in database %2$s.'),
421
            htmlspecialchars(Util::backquote($_REQUEST['item_name'])),
422
            htmlspecialchars(Util::backquote($db)),
423
        );
424
        $message = Message::error($message);
425
        if ($this->response->isAjax()) {
426
            $this->response->setRequestStatus(false);
427
            $this->response->addJSON('message', $message);
428
            exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
429
        }
430
431
        echo $message->getDisplay();
432
    }
433
434
    public function export(): void
435
    {
436
        if (empty($_GET['export_item']) || empty($_GET['item_name'])) {
437
            return;
438
        }
439
440
        $itemName = $_GET['item_name'];
441
        $triggers = self::getDetails($this->dbi, $GLOBALS['db'], $GLOBALS['table'], '');
442
        $exportData = false;
443
444
        foreach ($triggers as $trigger) {
445
            if ($trigger['name'] === $itemName) {
446
                $exportData = $trigger['create'];
447
                break;
448
            }
449
        }
450
451
        if ($exportData !== false) {
452
            $title = sprintf(__('Export of trigger %s'), htmlspecialchars(Util::backquote($itemName)));
453
454
            if ($this->response->isAjax()) {
455
                $this->response->addJSON('message', htmlspecialchars(trim($exportData)));
456
                $this->response->addJSON('title', $title);
457
458
                exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
459
            }
460
461
            $this->response->addHTML($this->template->render('triggers/export', [
462
                'data' => $exportData,
463
                'item_name' => $itemName,
464
            ]));
465
466
            return;
467
        }
468
469
        $message = sprintf(
470
            __('Error in processing request: No trigger with name %1$s found in database %2$s.'),
471
            htmlspecialchars(Util::backquote($itemName)),
472
            htmlspecialchars(Util::backquote($GLOBALS['db'])),
473
        );
474
        $message = Message::error($message);
475
476
        if ($this->response->isAjax()) {
477
            $this->response->setRequestStatus(false);
478
            $this->response->addJSON('message', $message);
479
480
            exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
481
        }
482
483
        $this->response->addHTML($message->getDisplay());
484
    }
485
486
    /**
487
     * Returns details about the TRIGGERs for a specific table or database.
488
     *
489
     * @param string $db        db name
490
     * @param string $table     table name
491
     * @param string $delimiter the delimiter to use (may be empty)
492
     *
493
     * @return mixed[] information about triggers (may be empty)
494
     */
495 16
    public static function getDetails(
496
        DatabaseInterface $dbi,
497
        string $db,
498
        string $table = '',
499
        string $delimiter = '//',
500
    ): array {
501 16
        $result = [];
502 16
        if (! $GLOBALS['cfg']['Server']['DisableIS']) {
503 8
            $query = QueryGenerator::getInformationSchemaTriggersRequest(
504 8
                $dbi->quoteString($db),
505 8
                $table === '' ? null : $dbi->quoteString($table),
506 8
            );
507
        } else {
508 8
            $query = 'SHOW TRIGGERS FROM ' . Util::backquote($db);
509 8
            if ($table !== '') {
510 4
                $query .= ' LIKE ' . $dbi->quoteString($table) . ';';
511
            }
512
        }
513
514
        /** @var mixed[][] $triggers */
515 16
        $triggers = $dbi->fetchResult($query);
516
517 16
        foreach ($triggers as $trigger) {
518 16
            $newTrigger = Trigger::tryFromArray($trigger);
519 16
            if ($newTrigger === null) {
520
                continue;
521
            }
522
523 16
            $oneResult = [];
524 16
            $oneResult['name'] = $newTrigger->name;
525 16
            $oneResult['table'] = $newTrigger->table;
526 16
            $oneResult['action_timing'] = $newTrigger->timing;
527 16
            $oneResult['event_manipulation'] = $newTrigger->event;
528 16
            $oneResult['definition'] = $newTrigger->statement;
529 16
            $oneResult['definer'] = $newTrigger->definer;
530
531
            // do not prepend the schema name; this way, importing the
532
            // definition into another schema will work
533 16
            $oneResult['full_trigger_name'] = Util::backquote($newTrigger->name);
534 16
            $oneResult['drop'] = 'DROP TRIGGER IF EXISTS ' . $oneResult['full_trigger_name'];
535 16
            $oneResult['create'] = sprintf(
536 16
                "CREATE TRIGGER %s %s %s ON %s\n FOR EACH ROW %s\n%s\n",
537 16
                $oneResult['full_trigger_name'],
538 16
                $newTrigger->timing,
539 16
                $newTrigger->event,
540 16
                Util::backquote($newTrigger->table),
541 16
                $newTrigger->statement,
542 16
                $delimiter,
543 16
            );
544
545 16
            $result[] = $oneResult;
546
        }
547
548
        // Sort results by name
549 16
        $name = array_column($result, 'name');
550 16
        array_multisort($name, SORT_ASC, $result);
0 ignored issues
show
Bug introduced by
SORT_ASC cannot be passed to array_multisort() as the parameter $rest expects a reference. ( Ignorable by Annotation )

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

550
        array_multisort($name, /** @scrutinizer ignore-type */ SORT_ASC, $result);
Loading history...
551
552 16
        return $result;
553
    }
554
555
    /** @return list<non-empty-string> */
0 ignored issues
show
Bug introduced by
The type PhpMyAdmin\Triggers\list 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...
556 72
    private function getTables(string $db): array
557
    {
558 72
        $query = sprintf(
559 72
            'SELECT `TABLE_NAME` FROM `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_SCHEMA`=%s'
560 72
            . " AND `TABLE_TYPE` IN ('BASE TABLE', 'SYSTEM VERSIONED')",
561 72
            $this->dbi->quoteString($db),
562 72
        );
563 72
        $tables = $this->dbi->fetchResult($query);
564 72
        Assert::allStringNotEmpty($tables);
565 72
        Assert::isList($tables);
566
567 72
        return $tables;
568
    }
569
}
570