mirror of
https://github.com/webmin/webmin.git
synced 2026-10-25 04:30:40 +00:00
322 lines
12 KiB
Perl
Executable File
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'});
|