diff --git a/mysql/exec.cgi b/mysql/exec.cgi index 7092c7137..82b7b3c33 100755 --- a/mysql/exec.cgi +++ b/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>\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', "".&html_escape($cmd).""),"
\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 "
\n"; } - print &ui_columns_end(); } else { - print "$text{'exec_none'}
\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', + "".&html_escape($cmd).""),"
\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 "
\n";
+ }
+ }
}
&open_readfile(OLD, "$commands_file.$in{'db'}");
while(
\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"),"
\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,"
\n";
print &ui_form_end([ [ undef, $text{'exec_exec'} ] ]);
diff --git a/mysql/lang/en b/mysql/lang/en
index e6ebf0e94..48c4a8d4d 100644
--- a/mysql/lang/en
+++ b/mysql/lang/en
@@ -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