mirror of
https://github.com/webmin/webmin.git
synced 2026-05-06 15:20:29 +01:00
Add multi-statement SQL script support when executing inline script
https://forum.virtualmin.com/t/edit-databases-sql-query-box-strange-behavior/136988 [no-build]
This commit is contained in:
275
mysql/exec.cgi
275
mysql/exec.cgi
@@ -9,6 +9,197 @@ $access{'edonly'} && &error($text{'dbase_ecannot'});
|
||||
&error_setup($text{'exec_err'});
|
||||
$sql_charset = $in{'charset'};
|
||||
|
||||
# normalize_sql_for_history(command)
|
||||
# Collapse textarea SQL into a single history line.
|
||||
sub normalize_sql_for_history
|
||||
{
|
||||
my ($cmd) = @_;
|
||||
$cmd =~ s/\r//g;
|
||||
return join(" ", split(/\n+/, $cmd));
|
||||
}
|
||||
|
||||
# count_sql_terminators(sql)
|
||||
# Count semicolons in SQL text, skipping those inside single/double/backtick
|
||||
# quotes, line comments (# and --), and block comments (/* ... */). Used to
|
||||
# detect multi-statement input. Miscounts should not happen in practice but are
|
||||
# safe either way: on undercount, multi-statement SQL goes through the
|
||||
# single-statement path and fails visibly; on overcount, a single statement goes
|
||||
# through the file executor which handles it fine.
|
||||
sub count_sql_terminators
|
||||
{
|
||||
my ($sql) = @_;
|
||||
my $count = 0;
|
||||
my ($sq, $dq, $bq, $line_comment, $block_comment, $escaped);
|
||||
for (my $i = 0; $i < length($sql); $i++) {
|
||||
my $c = substr($sql, $i, 1);
|
||||
my $n = $i+1 < length($sql) ? substr($sql, $i+1, 1) : '';
|
||||
if ($line_comment) {
|
||||
$line_comment = 0 if ($c eq "\n");
|
||||
next;
|
||||
}
|
||||
if ($block_comment) {
|
||||
if ($c eq '*' && $n eq '/') {
|
||||
$block_comment = 0;
|
||||
$i++;
|
||||
}
|
||||
next;
|
||||
}
|
||||
if ($sq) {
|
||||
if ($c eq "\\" && !$escaped) {
|
||||
$escaped = 1;
|
||||
next;
|
||||
}
|
||||
$sq = 0 if ($c eq "'" && !$escaped);
|
||||
$escaped = 0;
|
||||
next;
|
||||
}
|
||||
if ($dq) {
|
||||
if ($c eq "\\" && !$escaped) {
|
||||
$escaped = 1;
|
||||
next;
|
||||
}
|
||||
$dq = 0 if ($c eq '"' && !$escaped);
|
||||
$escaped = 0;
|
||||
next;
|
||||
}
|
||||
if ($bq) {
|
||||
$bq = 0 if ($c eq '`');
|
||||
next;
|
||||
}
|
||||
if ($c eq '#' ) {
|
||||
$line_comment = 1;
|
||||
next;
|
||||
}
|
||||
if ($c eq '-' && $n eq '-') {
|
||||
my $p = $i ? substr($sql, $i-1, 1) : '';
|
||||
my $a = $i+2 < length($sql) ? substr($sql, $i+2, 1) : '';
|
||||
if (($i == 0 || $p =~ /\s/) && ($a eq '' || $a =~ /\s/)) {
|
||||
$line_comment = 1;
|
||||
$i++;
|
||||
next;
|
||||
}
|
||||
}
|
||||
if ($c eq '/' && $n eq '*') {
|
||||
$block_comment = 1;
|
||||
$i++;
|
||||
next;
|
||||
}
|
||||
if ($c eq "'") {
|
||||
$sq = 1;
|
||||
$escaped = 0;
|
||||
next;
|
||||
}
|
||||
if ($c eq '"') {
|
||||
$dq = 1;
|
||||
$escaped = 0;
|
||||
next;
|
||||
}
|
||||
if ($c eq '`') {
|
||||
$bq = 1;
|
||||
next;
|
||||
}
|
||||
if ($c eq ';') {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
# looks_like_sql_script(command)
|
||||
# Detect pasted SQL that should use the file-style script executor.
|
||||
sub looks_like_sql_script
|
||||
{
|
||||
my ($cmd) = @_;
|
||||
return 1 if ($cmd =~ /^\s*(?:delimiter|source|\\\.)\b/im);
|
||||
return 1 if ($cmd =~ /^\s*(?:--(?:\s|$)|#)/m);
|
||||
return &count_sql_terminators($cmd) > 1;
|
||||
}
|
||||
|
||||
# execute_textarea_script(database, command)
|
||||
# Run pasted SQL through the same path used for uploaded script files.
|
||||
sub execute_textarea_script
|
||||
{
|
||||
my ($db, $cmd) = @_;
|
||||
my $file = &transname();
|
||||
&open_tempfile(TEMP, ">$file");
|
||||
&print_tempfile(TEMP, $cmd);
|
||||
&print_tempfile(TEMP, "\n") if ($cmd !~ /\n\z/);
|
||||
&close_tempfile(TEMP);
|
||||
my @rv = &execute_sql_file($db, $file, undef, undef, $access{'buser'});
|
||||
&unlink_file($file);
|
||||
return @rv;
|
||||
}
|
||||
|
||||
# summarize_sql_script(sql)
|
||||
# Count common DDL and DML actions for friendly success messages.
|
||||
sub summarize_sql_script
|
||||
{
|
||||
my ($sql) = @_;
|
||||
my %rv = ( 'create_count' => 0,
|
||||
'insert_count' => 0 );
|
||||
foreach my $line (split(/\n/, $sql)) {
|
||||
if ($line =~ /^\s*insert\s+into\s+`(\S+)`/i ||
|
||||
$line =~ /^\s*insert\s+into\s+(\S+)/i) {
|
||||
$rv{'insert_count'}++;
|
||||
}
|
||||
if ($line =~ /^\s*create\s+table\s+`(\S+)`/i ||
|
||||
$line =~ /^\s*create\s+table\s+(\S+)/i) {
|
||||
$rv{'create_count'}++;
|
||||
}
|
||||
}
|
||||
return \%rv;
|
||||
}
|
||||
|
||||
# extract_execute_error_text(error)
|
||||
# Strip Webmin's wrapper text down to the database error itself.
|
||||
sub extract_execute_error_text
|
||||
{
|
||||
my ($err) = @_;
|
||||
$err =~ s/\s+at\s+\S+\s+line\s+\d+.*$//s;
|
||||
if ($err =~ /failed\s*:\s*<tt>(.*?)<\/tt>\s*$/s) {
|
||||
$err = $1;
|
||||
}
|
||||
elsif ($err =~ /failed\s*:\s*"([^"]+)"\s*$/s) {
|
||||
$err = $1;
|
||||
}
|
||||
elsif ($err =~ /failed\s*:\s*(.*?)\s*$/s) {
|
||||
$err = $1;
|
||||
}
|
||||
$err =~ s/<[^>]+>//g;
|
||||
# Normalize to plain text: undo any HTML escaping from shared helpers
|
||||
# so the caller can re-escape once at final display time.
|
||||
$err = &html_unescape($err);
|
||||
return $err;
|
||||
}
|
||||
|
||||
# print_exec_status_lines(first, rest...)
|
||||
# Print one or more status lines with predictable wrapper spans.
|
||||
sub print_exec_status_lines
|
||||
{
|
||||
my ($first, @rest) = @_;
|
||||
print &ui_tag('span', $first, { 'data-first-print' => undef });
|
||||
if (@rest) {
|
||||
print &ui_tag('br');
|
||||
for (my $i = 0; $i < @rest; $i++) {
|
||||
print &ui_tag('span', $rest[$i],
|
||||
{ 'data-second-print' => undef });
|
||||
print &ui_tag('br') if ($i+1 < @rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# print_exec_error_block(first, second, detail)
|
||||
# Print a wrapped error block with a preformatted message body.
|
||||
sub print_exec_error_block
|
||||
{
|
||||
my ($first, $second, $detail) = @_;
|
||||
$detail = defined($detail) && $detail =~ /\S/ ? $detail : $text{'exec_noout'};
|
||||
&print_exec_status_lines($first, $second);
|
||||
print &ui_tag('br');
|
||||
print &ui_tag('pre', &html_escape($detail),
|
||||
{ 'style' => 'white-space: pre-wrap; margin-left: 10px;' });
|
||||
}
|
||||
|
||||
if ($in{'clear'}) {
|
||||
# Delete the history file
|
||||
&unlink_file($commands_file.".".$in{'db'});
|
||||
@@ -16,33 +207,87 @@ if ($in{'clear'}) {
|
||||
}
|
||||
else {
|
||||
# Run some SQL
|
||||
$in{'cmd'} = join(" ", split(/[\r\n]+/, $in{'cmd'}));
|
||||
$cmd = $in{'cmd'} ? $in{'cmd'} : $in{'old'};
|
||||
$d = &execute_sql_logged($in{'db'}, $cmd);
|
||||
$rawcmd = defined($in{'cmd'}) ? $in{'cmd'} : undef;
|
||||
defined($rawcmd) && $rawcmd =~ /\S/ || &error($text{'exec_ecmd'});
|
||||
$rawcmd =~ s/\r//g;
|
||||
$cmd = &normalize_sql_for_history($rawcmd);
|
||||
# Multi-statement input uses the same executor as uploaded SQL files.
|
||||
$script = &looks_like_sql_script($rawcmd);
|
||||
|
||||
&ui_print_header(undef, $text{'exec_title'}, "");
|
||||
print &text('exec_out', "<tt>".&html_escape($cmd)."</tt>"),"<p>\n";
|
||||
@data = @{$d->{'data'}};
|
||||
if (@data) {
|
||||
print &ui_columns_start($d->{'titles'});
|
||||
foreach $r (@data) {
|
||||
print &ui_columns_row([ map { &html_escape($_) } @$r ]);
|
||||
if ($script) {
|
||||
($ex, $out) = &execute_textarea_script($in{'db'}, $rawcmd);
|
||||
$summary = &summarize_sql_script($rawcmd);
|
||||
|
||||
&ui_print_header(undef, $text{'exec_title'}, "");
|
||||
if ($ex) {
|
||||
&print_exec_error_block($text{'exec_scriptout'},
|
||||
$text{'exec_scriptfailed'},
|
||||
$out);
|
||||
}
|
||||
else {
|
||||
@lines = ( );
|
||||
if ($summary->{'create_count'}) {
|
||||
push(@lines, &text('exec_scriptcreated',
|
||||
$summary->{'create_count'}));
|
||||
}
|
||||
if ($summary->{'insert_count'}) {
|
||||
push(@lines, &text('exec_scriptinserted',
|
||||
$summary->{'insert_count'}));
|
||||
}
|
||||
if (!@lines) {
|
||||
push(@lines, $text{'exec_scriptok'});
|
||||
}
|
||||
&print_exec_status_lines($text{'exec_scriptout'},
|
||||
@lines);
|
||||
print "<p>\n";
|
||||
}
|
||||
print &ui_columns_end();
|
||||
}
|
||||
else {
|
||||
print "<b>$text{'exec_none'}</b> <p>\n";
|
||||
eval {
|
||||
local $main::error_must_die = 1;
|
||||
# Capture DBI/mysql errors so we can render them consistently.
|
||||
$d = &execute_sql_logged($in{'db'}, $cmd);
|
||||
};
|
||||
if ($@) {
|
||||
$failed = 1;
|
||||
$err = &extract_execute_error_text($@);
|
||||
&ui_print_header(undef, $text{'exec_title'}, "");
|
||||
&print_exec_error_block($text{'exec_cmdout'},
|
||||
$text{'exec_cmdfailed'}, $err);
|
||||
}
|
||||
else {
|
||||
@data = @{$d->{'data'}};
|
||||
|
||||
&ui_print_header(undef, $text{'exec_title'}, "");
|
||||
if (@data) {
|
||||
print &text('exec_out',
|
||||
"<tt>".&html_escape($cmd)."</tt>"),"<p>\n";
|
||||
print &ui_columns_start($d->{'titles'});
|
||||
foreach $r (@data) {
|
||||
print &ui_columns_row([
|
||||
map { &html_escape($_) } @$r ]);
|
||||
}
|
||||
print &ui_columns_end();
|
||||
}
|
||||
else {
|
||||
&print_exec_status_lines($text{'exec_cmdout'},
|
||||
$text{'exec_cmdok'});
|
||||
print "<p>\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&open_readfile(OLD, "$commands_file.$in{'db'}");
|
||||
while(<OLD>) {
|
||||
s/\r|\n//g;
|
||||
$already++ if ($_ eq $in{'cmd'});
|
||||
$already++ if ($_ eq $cmd);
|
||||
}
|
||||
close(OLD);
|
||||
if (!$already && $in{'cmd'} =~ /\S/) {
|
||||
# Only store successful single commands in history. Script runs are
|
||||
# intentionally left out to avoid flattening multi-line SQL for re-use.
|
||||
if (!$script && !$failed && !$already && $cmd =~ /\S/) {
|
||||
&open_lock_tempfile(OLD, ">>$commands_file.$in{'db'}");
|
||||
&print_tempfile(OLD, "$in{'cmd'}\n");
|
||||
&print_tempfile(OLD, "$cmd\n");
|
||||
&close_tempfile(OLD);
|
||||
chmod(0700, "$commands_file.$in{'db'}");
|
||||
}
|
||||
|
||||
@@ -36,13 +36,21 @@ print &ui_form_start("exec.cgi", "form-data");
|
||||
print &ui_hidden("db", $in{'db'});
|
||||
print &ui_textarea("cmd", undef, 10, 70),"<br>\n";
|
||||
if (@old) {
|
||||
print $text{'exec_old'}," ",
|
||||
&ui_select("old", undef,
|
||||
[ map { [ $_, &html_escape(length($_) > 80 ?
|
||||
substr($_, 0, 80).".." : $_) ] } @old ]),"\n",
|
||||
&ui_button($text{'exec_edit'}, "movecmd", undef,
|
||||
"onClick='cmd.value = old.options[old.selectedIndex].value'"),
|
||||
" ",&ui_submit($text{'exec_clear'}, "clear"),"<br>\n";
|
||||
$oldrow = $text{'exec_old_cmd'}." ".
|
||||
&ui_select("old", undef,
|
||||
[ map { [ $_, &html_escape(length($_) > 80
|
||||
? substr($_, 0, 80).".."
|
||||
: $_) ] } @old ],
|
||||
undef, undef, undef, undef,
|
||||
"style='max-width: 40%;'")."\n".
|
||||
&ui_button($text{'exec_edit'}, "movecmd", undef,
|
||||
"onClick='cmd.value = ".
|
||||
"old.options[old.selectedIndex].value'").
|
||||
" ".&ui_submit($text{'exec_clear'}, "clear");
|
||||
print &ui_tag('div', $oldrow,
|
||||
{ 'style' => 'display: flex; align-items: center; '.
|
||||
'flex-wrap: wrap; gap: 5px; '.
|
||||
'margin-bottom: 5px;' });
|
||||
}
|
||||
print "$text{'exec_cs'} ",$csel,"<br>\n";
|
||||
print &ui_form_end([ [ undef, $text{'exec_exec'} ] ]);
|
||||
|
||||
@@ -408,7 +408,7 @@ newdb_ecannot2=You are not allowed to create any more databases
|
||||
|
||||
exec_title=Execute SQL
|
||||
exec_header=Enter an SQL command to execute on database $1 ..
|
||||
exec_old=Or select a previous SQL command :
|
||||
exec_old_cmd=Or select a previous SQL command
|
||||
exec_exec=Execute
|
||||
exec_clear=Clear History
|
||||
exec_header2=Select an SQL commands file to execute on database $1.
|
||||
@@ -416,7 +416,16 @@ exec_header2a=This can also be used to restore a MySQL backup, which is just a f
|
||||
exec_file=From local file
|
||||
exec_upload=From uploaded file
|
||||
exec_err=Failed to execute SQL
|
||||
exec_ecmd=No SQL command was entered
|
||||
exec_cmdout=Output from submitted SQL command ..
|
||||
exec_cmdok=.. SQL command executed successfully
|
||||
exec_cmdfailed=.. SQL command failed :
|
||||
exec_out=Output from SQL command $1 ..
|
||||
exec_scriptout=Output from submitted SQL script ..
|
||||
exec_scriptok=.. SQL script executed successfully
|
||||
exec_scriptfailed=.. SQL script failed :
|
||||
exec_scriptcreated=.. $1 tables were successfully created
|
||||
exec_scriptinserted=.. $1 records were successfully inserted
|
||||
exec_none=No data returned
|
||||
exec_eupload=No file selected to upload
|
||||
exec_efile=Local file does not exist
|
||||
|
||||
Reference in New Issue
Block a user