Files
webmin/sqlite/table.cgi
2026-10-17 17:12:34 +02:00

322 lines
12 KiB
Perl
Executable File

#!/usr/local/bin/perl
# Displays a table or view schema and a page of data.
use strict;
use warnings;
no warnings 'redefine';
no warnings 'uninitialized';
our (%text, %config, %in, %access);
require './sqlite-lib.pl';
&ReadParse();
# Load the object and find a stable row identity.
my $path = &sqlite_database_path($in{'db'});
my $dbh = &sqlite_connect($path, 0);
my $object = &sqlite_object($dbh, $in{'table'});
$object || &error($text{'table_missing'});
my $columns = &sqlite_table_columns($dbh, $object->{'name'});
@$columns || &error($text{'table_nocolumns'});
my $identity = &sqlite_identity($dbh, $object, $columns);
my $editable = $identity && &sqlite_can_write($path);
# Clamp pagination inputs before using them in the query.
my $perpage = int($config{'perpage'} || 50);
# Use one row as the minimum page size.
$perpage = 1 if ($perpage < 1);
# Cap each page at 1,000 rows.
$perpage = 1000 if ($perpage > 1000);
my $start = int($in{'start'} || 0);
# Negative offsets return the first page.
$start = 0 if ($start < 0);
my $quoted_table = &sqlite_quote_identifier($object->{'name'});
my ($total) = $dbh->selectrow_array('SELECT count(*) FROM '.$quoted_table);
# Move an out-of-range offset to the last populated page.
$start = int(($total - 1) / $perpage) * $perpage
if ($total && $start >= $total);
# Accept sorting only by a column returned from the database schema.
my %column_names = map { $_->{'name'} => 1 } @$columns;
my $sort = $column_names{$in{'sort'}} ? $in{'sort'} : undef;
my $order = $sort ? ' ORDER BY '.&sqlite_quote_identifier($sort).
($in{'desc'} ? ' DESC' : ' ASC') : '';
# Bound display values separately from full composite-key values.
my ($value_selects, $type_selects, $length_selects) =
&sqlite_column_selects($columns, undef, 1);
my @select = (@$value_selects, @$type_selects, @$length_selects);
my $from = 'FROM '.$quoted_table.$order.' LIMIT ? OFFSET ?';
my $sql;
# Select rowid separately when it is the stable editing identity.
if ($identity && $identity->{'rowid'}) {
unshift(@select, $identity->{'rowid'});
}
# Fetch a composite key only when its full byte length fits within half
# the result budget. That half is reserved for identity data. Oversized keys
# remain viewable, but the row must not expose edit or delete controls.
elsif ($identity) {
my @quoted_keys = map { &sqlite_quote_identifier($_) }
@{$identity->{'columns'}};
$sql = &sqlite_guarded_select(&sqlite_bytes_sql(\@quoted_keys, 1),
int(&sqlite_result_bytes_limit() / 2), \@select, \@quoted_keys,
$from);
}
# Fetch one page and its rendering metadata within the per-value ceiling and
# request byte budget.
$sql ||= 'SELECT '.join(', ', @select).' '.$from;
my $rows = eval { &sqlite_select_rows_bounded($dbh, $sql, $perpage, $start) };
my $fetch_error = $@;
$dbh->disconnect();
&error(&html_escape($fetch_error)) if ($fetch_error);
my @rows = @$rows;
my $desc = &ui_tag('tt', &html_escape($object->{'name'})).' '.
$text{'table_in'}.' '.&ui_tag('tt', &html_escape($path));
&ui_print_header($desc, $text{'table_title'}, '', 'table');
print &ui_page_start();
# Split schema and row data into independent tabs.
my $view = $in{'view'} eq 'structure' ? 'structure' : 'data';
print &ui_tabs_start([
[ 'structure', $text{'table_structure'} ],
[ 'data', $text{'table_data'} ],
], 'view', $view, 1);
print &ui_tabs_start_tab('view', 'structure');
# Structure changes need a writable table, since views cannot be altered,
# and column renames and drops also need a recent enough SQLite.
my $writable = &sqlite_can_write($path);
my $can_alter = $writable && $object->{'type'} eq 'table';
my $rename_columns = $can_alter && &sqlite_supports('rename_column');
my $drop_columns = $can_alter && &sqlite_supports('drop_column');
my $table_url = 'db='.&urlize($path).'&table='.&urlize($object->{'name'});
# Show each column's type, default and key information.
my @structure_heads = (
$text{'table_column'}, $text{'table_type'}, $text{'table_null'},
$text{'table_default'}, $text{'table_key'} );
push(@structure_heads, $text{'table_action'})
if ($rename_columns || $drop_columns);
print &ui_columns_start(\@structure_heads, 100, 0, undef, undef, 1);
foreach my $column (@$columns) {
my $key = $column->{'pk'} ? &text('table_pk', $column->{'pk'}) : '';
# Generated columns are read-only even when they also belong to a key.
$key = $text{'table_generated'} if ($column->{'hidden'} == 2 ||
$column->{'hidden'} == 3);
my @cells = (
&html_escape($column->{'name'}),
&html_escape($column->{'type'} || ''),
$column->{'notnull'} ? $text{'no'} : $text{'yes'},
defined($column->{'dflt_value'}) ?
&ui_tag('tt', &html_escape($column->{'dflt_value'})) : '',
$key,
);
# Offer the column actions the library supports.
if ($rename_columns || $drop_columns) {
my $column_url = $table_url.'&column='.&urlize($column->{'name'});
my @actions;
push(@actions, &ui_tag('a', $text{'table_rename_column'}, {
'href' => 'column_form.cgi?'.$column_url,
})) if ($rename_columns);
# Key columns cannot be dropped, so no link is offered for them.
push(@actions, &ui_tag('a', $text{'table_drop_column'}, {
'href' => 'drop_column.cgi?'.$column_url,
}))
if ($drop_columns && !$column->{'pk'});
push(@cells, join(' | ', @actions));
}
print &ui_columns_row(\@cells);
}
print &ui_columns_end();
# Columns can be added to any writable table.
if ($can_alter) {
print &ui_form_start('column_form.cgi');
print &ui_hidden('db', $path);
print &ui_hidden('table', $object->{'name'});
print &ui_form_end([ [ undef, $text{'table_add_column'} ] ]);
}
print &ui_tabs_end_tab('view', 'structure');
print &ui_tabs_start_tab('view', 'data');
# Explain why a table has no row actions.
if (!$identity && $object->{'type'} eq 'table') {
print &ui_alert_box($text{'table_noidentity'}, 'warn', undef, undef, '');
}
# The add-row action serves the empty state; the table footer has its own.
my $add_url = 'edit_row.cgi?db='.&urlize($path).'&table='.
&urlize($object->{'name'}).'&new=1&start='.$start;
my $add_button = $editable ?
&ui_action_button($text{'table_add'}, 'success', {
'href' => $add_url, 'icon' => 'plus' }) : undef;
# Render the current page only when at least one row exists.
if (@rows) {
# Column headings link to the view sorted by that column, flipping to
# descending order when it is already the sort column.
my @headers = map {
my $name = $_->{'name'};
my $next_desc = $sort && $sort eq $name && !$in{'desc'} ? 1 : 0;
my $link = 'table.cgi?db='.&urlize($path).'&table='.
&urlize($object->{'name'}).'&view=data&sort='.&urlize($name).
($next_desc ? '&desc=1' : '');
&ui_tag('a', &html_escape($name), { 'href' => $link });
} @$columns;
# Writable rows need a leading selection column.
unshift(@headers, '') if ($editable);
# Writable rows also need a trailing action column.
push(@headers, $text{'table_action'}) if ($editable);
my @table_rows;
foreach my $row (@rows) {
my @parts = @$row;
my @key_values;
my $key_complete = 1;
# A rowid identity is the first returned column.
if ($identity && $identity->{'rowid'}) {
@key_values = (shift(@parts));
}
# A composite identity starts with its fit flag and ends with key values.
elsif ($identity) {
$key_complete = shift(@parts) ? 1 : 0;
}
my @values = splice(@parts, 0, scalar(@$columns));
my @storage_types = splice(@parts, 0, scalar(@$columns));
my @storage_lengths = splice(@parts, 0, scalar(@$columns));
@key_values = @parts if ($identity && !$identity->{'rowid'});
my $row_editable = $editable && $key_complete;
my $token = $row_editable ? &sqlite_encode_key(@key_values) : undef;
my @shown = map {
&sqlite_display_value($values[$_], $storage_types[$_],
$storage_lengths[$_])
} 0..$#values;
# Keep the selection column aligned; only editable rows receive a box.
if ($editable) {
unshift(@shown, $row_editable ? {
'type' => 'checkbox',
'name' => 'row',
'value' => $token,
} : '');
}
# Add an edit link or explain why this row cannot be edited.
if ($editable) {
# Link rows whose full identity was returned.
if ($row_editable) {
my $edit = 'edit_row.cgi?db='.&urlize($path).'&table='.
&urlize($object->{'name'}).'&key='.&urlize($token).
'&start='.$start;
push(@shown, &ui_tag('a', $text{'table_edit'}, {
'href' => $edit,
}));
}
else {
# Explain why an oversized composite key cannot be edited.
push(@shown, &html_escape($text{'table_key_large'}));
}
}
push(@table_rows, \@shown);
}
# Build the row range and optional numbered navigation once for either
# the writable or read-only table footer.
my @controls = (
&ui_tag('span',
&html_escape(&text('table_position',
$start + 1, $start + @rows, $total)),
{ 'class' => 'sqlite_table_position' }),
);
# Add numbered navigation only when the result spans multiple pages.
if ($total > $perpage) {
my $pages = int(($total + $perpage - 1) / $perpage);
my $page = int($start / $perpage) + 1;
my $base = 'table.cgi?db='.&urlize($path).'&table='.
&urlize($object->{'name'}).'&view=data'.
($sort ? '&sort='.&urlize($sort) : '').
($in{'desc'} ? '&desc=1' : '');
push(@controls, &ui_pager({
'page' => $page,
'pages' => $pages,
# sub page_url(page)
# Returns the table URL for one pager page number.
'url' => sub {
return $base.'&start='.(($_[0] - 1) * $perpage);
},
'label' => $text{'table_pages'},
}));
}
my $table_controls = &ui_cluster(\@controls, {
'align' => 'between',
'class' => 'sqlite_table_controls',
});
# Build the checked table and keep both bottom actions in one group.
if ($editable) {
# Target the delete form after any earlier forms on this page.
my $delete_form = $can_alter ? 1 : 0;
print &ui_form_start('delete_rows.cgi', 'post');
print &ui_hidden('db', $path);
print &ui_hidden('table', $object->{'name'});
print &ui_hidden('start', $start);
my @select_links = (
&select_all_link('row', $delete_form),
&select_invert_link('row', $delete_form),
);
print &ui_links_row(\@select_links);
print &ui_columns_table(\@headers, 100, \@table_rows, undef, 1);
print &ui_links_row(\@select_links);
my @add_actions = ([ undef, $text{'table_add'}, undef, 0,
"form='sqlite_add_row_form'" ]);
my @delete_actions = ([ 'delete', $text{'table_delete'} ]);
my $actions = &ui_form_grouped_buttons([
[ \@add_actions, \@delete_actions ],
]);
print &ui_stack([ $actions, $table_controls ], {
'class' => 'sqlite_table_footer',
});
print &ui_form_end();
# The Add Row submit targets its own form instead of the delete
# form.
print &ui_form_start('edit_row.cgi', 'get', undef,
"id='sqlite_add_row_form' style='display:none;'");
print &ui_hidden('db', $path);
print &ui_hidden('table', $object->{'name'});
print &ui_hidden('new', 1);
print &ui_hidden('start', $start);
print &ui_form_end();
}
else {
# Read-only objects need only the table and its spaced footer.
print &ui_columns_table(\@headers, 100, \@table_rows, undef, 1);
print &ui_stack([ $table_controls ], {
'class' => 'sqlite_table_footer',
});
}
}
else {
# Use a compact empty state instead of rendering a header-only table.
print &ui_empty_state({
'title' => $text{'table_empty'},
'desc' => $editable ? $text{'table_empty_desc'} : undef,
'icon' => 'database',
'actions' => $add_button,
});
}
print &ui_tabs_end_tab('view', 'data');
print &ui_tabs_end(1);
# Structured write access permits renaming a table or dropping a table or
# view. The drop page asks for confirmation first.
if ($writable) {
my $is_view = $object->{'type'} eq 'view';
print &ui_hr();
print &ui_buttons_start();
print &ui_buttons_row('rename_table.cgi', $text{'table_rename'},
$text{'table_rename_desc'},
&ui_hidden('db', $path).&ui_hidden('table', $object->{'name'}),
' '.&ui_textbox('name', $object->{'name'}, 30)) if (!$is_view);
print &ui_buttons_row('drop_table.cgi',
$text{$is_view ? 'table_drop_view' : 'table_drop'},
$text{$is_view ? 'table_drop_view_desc' : 'table_drop_desc'},
&ui_hidden('db', $path).&ui_hidden('table', $object->{'name'}));
print &ui_buttons_end();
}
print &ui_page_end();
&ui_print_footer('database.cgi?db='.&urlize($path),
$text{'database_return'}, '', $text{'index_return'});