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() { 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'}"); } diff --git a/mysql/exec_form.cgi b/mysql/exec_form.cgi index f317d818d..f3c1ee322 100755 --- a/mysql/exec_form.cgi +++ b/mysql/exec_form.cgi @@ -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),"
\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