Require explicit confirmation before running DELETE statements in SQL mode
This commit is contained in:
+51
-13
@@ -14,6 +14,16 @@
|
|||||||
var OCC_PROMPT = 'occ $ ';
|
var OCC_PROMPT = 'occ $ ';
|
||||||
var SQL_PROMPT = '[[;#ff5555;]sql]# ';
|
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) {
|
function renderSqlResponse(term, response) {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
term.echo('[[;#ff5555;]Empty response from server]');
|
term.echo('[[;#ff5555;]Empty response from server]');
|
||||||
@@ -39,6 +49,8 @@
|
|||||||
}
|
}
|
||||||
} else if (r.type === 'set') {
|
} else if (r.type === 'set') {
|
||||||
term.echo('[[;green;] OK (session variable set)]');
|
term.echo('[[;green;] OK (session variable set)]');
|
||||||
|
} else if (r.type === 'delete') {
|
||||||
|
term.echo('[[;#ff9900;] DELETED ' + r.affected_rows + ' row(s)]');
|
||||||
} else {
|
} else {
|
||||||
term.echo('[[;green;] OK, ' + r.affected_rows + ' row(s) affected]');
|
term.echo('[[;green;] OK, ' + r.affected_rows + ' row(s) affected]');
|
||||||
}
|
}
|
||||||
@@ -58,6 +70,40 @@
|
|||||||
term.echo('[[;yellow;]Switched back to OCC mode.]');
|
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){
|
$.get(baseUrl + '/cmd', function(response){
|
||||||
$('#app-content').terminal(function(command, term) {
|
$('#app-content').terminal(function(command, term) {
|
||||||
if (mode === 'sql') {
|
if (mode === 'sql') {
|
||||||
@@ -78,19 +124,11 @@
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
term.pause();
|
if (scriptHasDelete(command)) {
|
||||||
$.ajax({
|
askDeleteConfirmation(term, command);
|
||||||
url: baseUrl + '/db/query',
|
} else {
|
||||||
type: 'POST',
|
sendSqlQuery(term, command, false);
|
||||||
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();
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ class DbController extends Controller
|
|||||||
$this->userSession = $userSession;
|
$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
|
* @NoCSRFRequired
|
||||||
*/
|
*/
|
||||||
@@ -44,6 +58,7 @@ class DbController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$sql = $this->request->getParam('sql', '');
|
$sql = $this->request->getParam('sql', '');
|
||||||
|
$confirmed = filter_var($this->request->getParam('confirm', false), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
|
||||||
if (empty(trim($sql))) {
|
if (empty(trim($sql))) {
|
||||||
return new JSONResponse(['success' => false, 'error' => 'Empty query']);
|
return new JSONResponse(['success' => false, 'error' => 'Empty query']);
|
||||||
@@ -57,11 +72,31 @@ class DbController extends Controller
|
|||||||
return $q !== '';
|
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 = [];
|
$results = [];
|
||||||
|
|
||||||
foreach ($queries as $query) {
|
foreach ($queries as $query) {
|
||||||
$isSelect = stripos($query, 'SELECT') === 0;
|
$normalized = $this->stripLeadingComments($query);
|
||||||
$isSet = stripos($query, 'SET ') === 0;
|
$isSelect = stripos($normalized, 'SELECT') === 0;
|
||||||
|
$isSet = stripos($normalized, 'SET ') === 0;
|
||||||
|
$isDelete = stripos($normalized, 'DELETE') === 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stmt = $this->db->prepare($query);
|
$stmt = $this->db->prepare($query);
|
||||||
@@ -77,9 +112,10 @@ class DbController extends Controller
|
|||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
$affected = $stmt->rowCount();
|
$affected = $stmt->rowCount();
|
||||||
|
$type = $isSet ? 'set' : ($isDelete ? 'delete' : 'write');
|
||||||
$results[] = [
|
$results[] = [
|
||||||
'query' => $query,
|
'query' => $query,
|
||||||
'type' => $isSet ? 'set' : 'write',
|
'type' => $type,
|
||||||
'affected_rows' => $affected
|
'affected_rows' => $affected
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user