From 4d87deb1760533dd94edd7c4b7dd7c1f7a380717 Mon Sep 17 00:00:00 2001 From: Egor Bugaev Date: Mon, 6 Jul 2026 17:03:51 +0300 Subject: [PATCH] Require explicit confirmation before running DELETE statements in SQL mode --- js/index.js | 64 ++++++++++++++++++++++++++------- lib/Controller/DbController.php | 44 ++++++++++++++++++++--- 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/js/index.js b/js/index.js index 93814be..808e429 100644 --- a/js/index.js +++ b/js/index.js @@ -14,6 +14,16 @@ var OCC_PROMPT = 'occ $ '; var SQL_PROMPT = '[[;#ff5555;]sql]# '; + // Быстрая клиентская проверка на DELETE — только для UX (чтобы не + // делать лишний запрос к серверу). Итоговое решение всё равно + // принимает бэкенд (requiresConfirmation), это лишь подсказка. + function scriptHasDelete(sql) { + return sql.split(';').some(function (part) { + var normalized = part.replace(/^(\s*--[^\n]*\n)*\s*/, ''); + return /^DELETE\b/i.test(normalized); + }); + } + function renderSqlResponse(term, response) { if (!response) { term.echo('[[;#ff5555;]Empty response from server]'); @@ -39,6 +49,8 @@ } } else if (r.type === 'set') { term.echo('[[;green;] OK (session variable set)]'); + } else if (r.type === 'delete') { + term.echo('[[;#ff9900;] DELETED ' + r.affected_rows + ' row(s)]'); } else { term.echo('[[;green;] OK, ' + r.affected_rows + ' row(s) affected]'); } @@ -58,6 +70,40 @@ term.echo('[[;yellow;]Switched back to OCC mode.]'); } + function sendSqlQuery(term, sql, confirmed) { + term.pause(); + $.ajax({ + url: baseUrl + '/db/query', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ sql: sql, confirm: !!confirmed }) + }).done(function (response) { + if (response && response.requiresConfirmation) { + term.resume(); + askDeleteConfirmation(term, sql, response.error); + return; + } + renderSqlResponse(term, response); + term.resume(); + }).fail(function (xhr) { + term.echo('[[;#ff5555;]Request failed: ]' + $.terminal.escape_formatting(xhr.status + ' ' + xhr.statusText)); + term.resume(); + }); + } + + function askDeleteConfirmation(term, sql, message) { + var prompt = '[[;#ff5555;]' + (message || 'This script contains DELETE statement(s).') + ' Type "yes" to run it: ]'; + term.read(prompt).then(function (answer) { + if ((answer || '').trim().toLowerCase() === 'yes') { + sendSqlQuery(term, sql, true); + } else { + term.echo('[[;yellow;]Cancelled — nothing was executed.]'); + } + }, function () { + term.echo('[[;yellow;]Cancelled — nothing was executed.]'); + }); + } + $.get(baseUrl + '/cmd', function(response){ $('#app-content').terminal(function(command, term) { if (mode === 'sql') { @@ -78,19 +124,11 @@ if (!trimmed) { return; } - term.pause(); - $.ajax({ - url: baseUrl + '/db/query', - type: 'POST', - contentType: 'application/json', - data: JSON.stringify({ sql: command }) - }).done(function (response) { - renderSqlResponse(term, response); - term.resume(); - }).fail(function (xhr) { - term.echo('[[;#ff5555;]Request failed: ]' + $.terminal.escape_formatting(xhr.status + ' ' + xhr.statusText)); - term.resume(); - }); + if (scriptHasDelete(command)) { + askDeleteConfirmation(term, command); + } else { + sendSqlQuery(term, command, false); + } return; } diff --git a/lib/Controller/DbController.php b/lib/Controller/DbController.php index d9b7f1e..edad487 100644 --- a/lib/Controller/DbController.php +++ b/lib/Controller/DbController.php @@ -28,6 +28,20 @@ class DbController extends Controller $this->userSession = $userSession; } + /** + * Убирает ведущие однострочные комментарии ("-- ...") перед запросом. + * После разбиения пачки по ";" такой комментарий может "приклеиться" + * к следующему запросу и помешать определить его тип (SELECT/SET/DELETE). + */ + private function stripLeadingComments($query) + { + $query = ltrim($query); + while (preg_match('/^--[^\n]*\n/', $query)) { + $query = ltrim(preg_replace('/^--[^\n]*\n/', '', $query, 1)); + } + return $query; + } + /** * @NoCSRFRequired */ @@ -38,12 +52,13 @@ class DbController extends Controller if (!$user) { return new JSONResponse(['error' => 'Not authenticated'], 401); } - + if (!$this->groupManager->isAdmin($user->getUID())) { return new JSONResponse(['error' => 'Admin privileges required'], 403); } $sql = $this->request->getParam('sql', ''); + $confirmed = filter_var($this->request->getParam('confirm', false), FILTER_VALIDATE_BOOLEAN); if (empty(trim($sql))) { return new JSONResponse(['success' => false, 'error' => 'Empty query']); @@ -57,11 +72,31 @@ class DbController extends Controller return $q !== ''; })); + // DELETE необратим, поэтому требуем явное подтверждение с клиента + // (confirm=true), прежде чем выполнять хоть один запрос из пачки. + $deleteCount = 0; + foreach ($queries as $query) { + if (stripos($this->stripLeadingComments($query), 'DELETE') === 0) { + $deleteCount++; + } + } + + if ($deleteCount > 0 && !$confirmed) { + return new JSONResponse([ + 'success' => false, + 'requiresConfirmation' => true, + 'deleteCount' => $deleteCount, + 'error' => "Batch contains {$deleteCount} DELETE statement(s) and was not executed. Resend with confirm=true to proceed." + ]); + } + $results = []; foreach ($queries as $query) { - $isSelect = stripos($query, 'SELECT') === 0; - $isSet = stripos($query, 'SET ') === 0; + $normalized = $this->stripLeadingComments($query); + $isSelect = stripos($normalized, 'SELECT') === 0; + $isSet = stripos($normalized, 'SET ') === 0; + $isDelete = stripos($normalized, 'DELETE') === 0; try { $stmt = $this->db->prepare($query); @@ -77,9 +112,10 @@ class DbController extends Controller ]; } else { $affected = $stmt->rowCount(); + $type = $isSet ? 'set' : ($isDelete ? 'delete' : 'write'); $results[] = [ 'query' => $query, - 'type' => $isSet ? 'set' : 'write', + 'type' => $type, 'affected_rows' => $affected ]; }