mirror of
https://github.com/webmin/webmin.git
synced 2026-10-20 11:10:40 +01:00
Compare commits
3 Commits
master
...
dev/fix-rp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91d3dd843b | ||
|
|
5231d6de0f | ||
|
|
ffa551a24b |
@@ -1,26 +1,18 @@
|
||||
## Changelog
|
||||
#### 2.680 (October, 2026)
|
||||
* Add an SQLite database browser and editor with schema inspection, table management, row editing and direct SQL execution [forum.virtualmin.com/t/137508](https://forum.virtualmin.com/t/sqlite-database-browser-and-editor/137508/)
|
||||
* Add color schemes to the Terminal module
|
||||
* Add per-user Terminal preferences for color scheme, font size, screen reader mode and terminal dimensions
|
||||
* Add protection against infinite loops resolving recursive symlinks (thanks to Carl Zuzart, Zedden I.T.)
|
||||
* Add a Cloudmin virtualization host profile to the nftables module
|
||||
* Add drop-down menu, dialog, breadcrumb and page link widgets to the UI library, built on native `details` and `dialog` elements
|
||||
* Add confirmation dialogs for buttons and links carrying `data-ui-confirm`, replacing the browser's own prompt
|
||||
* Update the Framed Theme with many visual and usability improvements
|
||||
* Update File Manager to use the new UI widgets in the standard theme, dropping legacy templates
|
||||
* Fix standard theme sortable tables dropping controls from table headings, such as the select-all checkbox in File Manager
|
||||
* Fix package installation with DNF running `update` instead of `install` when one of several requested packages is already installed, leaving the others uninstalled
|
||||
* Fix two-factor authentication validation to use the Webmin configuration and data directories passed by the caller
|
||||
* Fix HTML escaping of module, script and session fields on the Webmin Actions Log details page
|
||||
* Fix mailbox attachment option injection
|
||||
* Fix archive extraction bypassing Upload and Download directory restrictions
|
||||
* Fix path traversal and command injection in parameterized Custom Commands file editors
|
||||
* Fix MySQL/MariaDB manual editor ACL bypass
|
||||
* Fix MySQL database backups allowing restricted users to write files or save root-run settings outside their access limits
|
||||
* Fix the logged in users list in Users and Groups module showing nobody when `who` prints ISO dates under a non-C locale or `sshd pts/N` lines from the logind backend [forum.virtualmin.com/t/138162](https://forum.virtualmin.com/t/138162)
|
||||
* Fix missing SSH logins in the Users and Groups module on Ubuntu 26.04 by using systemd-logind when `utmp` is absent
|
||||
* Fix mail date parsing of ISO dates like `2026-10-15 02:33` returning the wrong minutes
|
||||
* Add optional address-family selection to HTTP downloads and socket connections, preserved across redirects [#1300](https://github.com/virtualmin/virtualmin-gpl/issues/1300)
|
||||
* Update the Authentic theme to the latest version with various improvements:
|
||||
- Add support for Terminal module color preferences
|
||||
|
||||
File diff suppressed because one or more lines are too long
21
fastrpc.cgi
21
fastrpc.cgi
@@ -142,6 +142,8 @@ while(1) {
|
||||
$rawarg .= $got;
|
||||
}
|
||||
print STDERR "fastrpc[$$]: raw $rawarg\n" if ($gconfig{'rpcdebug'});
|
||||
|
||||
# Mirror the request's wire format in the response
|
||||
my $dumper = substr($rawarg, 0, 5) eq '$VAR1' ? 1 : 0;
|
||||
my $arg = &unserialise_variable($rawarg);
|
||||
|
||||
@@ -211,12 +213,6 @@ while(1) {
|
||||
elsif ($use_ipv6 && vec($rmask, fileno($tsock6), 1)) {
|
||||
accept(TRANS, $tsock6) || exit;
|
||||
}
|
||||
if ($arg->{'xferauth'}) {
|
||||
my $auth = <TRANS>;
|
||||
exit if (!defined($auth));
|
||||
chomp($auth);
|
||||
exit if ($auth ne $sid);
|
||||
}
|
||||
print STDERR "fastrpc[$$]: tcpwrite $file accepted\n" if ($gconfig{'rpcdebug'});
|
||||
my $buf;
|
||||
my $err;
|
||||
@@ -246,9 +242,7 @@ while(1) {
|
||||
$xfer_kids{$cpid} = 1;
|
||||
print STDERR "fastrpc[$$]: tcpwrite $file started\n"
|
||||
if ($gconfig{'rpcdebug'});
|
||||
$rv = { 'status' => 1,
|
||||
'rv' => [ $file, $tport,
|
||||
$arg->{'xferauth'} ? $sid : undef ] };
|
||||
$rv = { 'status' => 1, 'rv' => [ $file, $tport ] };
|
||||
}
|
||||
close($tsock);
|
||||
close($tsock6) if ($use_ipv6);
|
||||
@@ -303,12 +297,6 @@ while(1) {
|
||||
elsif (vec($rmask, fileno($tsock6), 1)) {
|
||||
accept(TRANS, $tsock6) || exit;
|
||||
}
|
||||
if ($arg->{'xferauth'}) {
|
||||
my $auth = <TRANS>;
|
||||
exit if (!defined($auth));
|
||||
chomp($auth);
|
||||
exit if ($auth ne $sid);
|
||||
}
|
||||
my $buf;
|
||||
while(read(FILE, $buf, 1024) > 0) {
|
||||
print TRANS $buf;
|
||||
@@ -323,8 +311,7 @@ while(1) {
|
||||
"started\n"
|
||||
if ($gconfig{'rpcdebug'});
|
||||
$rv = { 'status' => 1,
|
||||
'rv' => [ $arg->{'file'}, $tport,
|
||||
$arg->{'xferauth'} ? $sid : undef ] };
|
||||
'rv' => [ $arg->{'file'}, $tport ] };
|
||||
}
|
||||
close(FILE);
|
||||
close($tsock);
|
||||
|
||||
@@ -40,17 +40,11 @@ else {
|
||||
}
|
||||
$| = 1;
|
||||
|
||||
# list_disks_partitions([include-cds], [disk-only], [error-ref])
|
||||
# Returns a list of disk hash refs; disk-only mode omits partition metadata.
|
||||
# Failure returns an empty list, never an error string. The optional error-ref
|
||||
# is a scalar reference that receives the reason without changing the return type.
|
||||
# Example: my @disks = list_disks_partitions(undef, 1, \$error);
|
||||
# list_disks_partitions([include-cds])
|
||||
# Returns a structure containing the details of all disks and partitions
|
||||
sub list_disks_partitions
|
||||
{
|
||||
local ($include_cds, $disk_only) = @_;
|
||||
my $error_ref = $_[2];
|
||||
$$error_ref = undef if ($disk_only && $error_ref);
|
||||
if (!$disk_only && scalar(@list_disks_partitions_cache)) {
|
||||
if (scalar(@list_disks_partitions_cache)) {
|
||||
return @list_disks_partitions_cache;
|
||||
}
|
||||
|
||||
@@ -128,13 +122,10 @@ else {
|
||||
}
|
||||
|
||||
local (@disks, @devs, $d);
|
||||
my $discovery_partitions = '';
|
||||
|
||||
if (open(PARTS, "</proc/partitions")) {
|
||||
# The list of all disks can come from the kernel
|
||||
local $sc = 0;
|
||||
while(<PARTS>) {
|
||||
$discovery_partitions .= $_ if ($disk_only);
|
||||
if (/\d+\s+\d+\s+\d+\s+sd([a-z]+)\s/ ||
|
||||
/\d+\s+\d+\s+\d+\s+(scsi\/host(\d+)\/bus(\d+)\/target(\d+)\/lun(\d+)\/disc)\s+/) {
|
||||
# New or old style SCSI device
|
||||
@@ -166,7 +157,7 @@ if (open(PARTS, "</proc/partitions")) {
|
||||
if (open(MEDIA, "</proc/ide/hd$n/media")) {
|
||||
local $media = <MEDIA>;
|
||||
close(MEDIA);
|
||||
if ($media =~ /^disk/ && !$include_cds) {
|
||||
if ($media =~ /^disk/ && !$_[0]) {
|
||||
push(@devs, "/dev/hd$n");
|
||||
}
|
||||
}
|
||||
@@ -222,36 +213,17 @@ if (open(PARTS, "</proc/partitions")) {
|
||||
@devs = sort { ($b =~ /\/hd[a-z]+$/ ? 1 : 0) <=>
|
||||
($a =~ /\/hd[a-z]+$/ ? 1 : 0) } @devs;
|
||||
}
|
||||
elsif ($disk_only) {
|
||||
return &disk_discovery_error("Cannot open disk inventory for disk-only discovery", $error_ref);
|
||||
}
|
||||
if ($disk_only) {
|
||||
return &disk_discovery_error("Invalid disk inventory for disk-only discovery", $error_ref)
|
||||
if ($discovery_partitions !~ /major\s+minor\s+#blocks\s+name/);
|
||||
return &disk_discovery_error("Disk inventory changed during disk-only discovery", $error_ref)
|
||||
if ($discovery_partitions ne &read_file_contents('/proc/partitions'));
|
||||
}
|
||||
return ( ) if (!@devs); # No disks, ie on Xen
|
||||
|
||||
# Skip cd-rom drive, identified from symlink. Don't do this if we can identify
|
||||
# cds by their media type though
|
||||
if (!-d "/proc/ide") {
|
||||
local @cdstat = stat("/dev/cdrom");
|
||||
if (@cdstat && !$include_cds) {
|
||||
if (@cdstat && !$_[0]) {
|
||||
@devs = grep { (stat($_))[1] != $cdstat[1] } @devs;
|
||||
}
|
||||
}
|
||||
|
||||
my %discovery_identity;
|
||||
if ($disk_only) {
|
||||
foreach my $device (@devs) {
|
||||
my $identity = &disk_discovery_identity($device);
|
||||
return &disk_discovery_error("Missing device during disk-only discovery: $device", $error_ref)
|
||||
if (!defined($identity));
|
||||
$discovery_identity{$device} = $identity;
|
||||
}
|
||||
}
|
||||
|
||||
# Get Linux disk ID mapping
|
||||
local %id_map;
|
||||
local %all_id_map;
|
||||
@@ -268,246 +240,6 @@ foreach my $id (readdir(IDS)) {
|
||||
}
|
||||
closedir(IDS);
|
||||
|
||||
# Both paths use the same classification, ID and model logic.
|
||||
my $set_metadata = sub {
|
||||
my ($disk) = @_;
|
||||
if ($disk->{'device'} =~ /\/sd([a-z]+)$/) {
|
||||
# Old-style SCSI disk
|
||||
$disk->{'desc'} = &text('select_device', 'SCSI',
|
||||
uc($1));
|
||||
local ($dscsi) = grep { $_->{'dev'} eq "sd$1" } @dscsi;
|
||||
$disk->{'scsi'} = $dscsi ? &indexof($dscsi, @dscsi)
|
||||
: ord(uc($1))-65;
|
||||
$disk->{'type'} = 'scsi';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/hd([a-z]+)$/) {
|
||||
# IDE disk
|
||||
$disk->{'desc'} = &text('select_device', 'IDE', uc($1));
|
||||
$disk->{'type'} = 'ide';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/xvd([a-z]+)$/) {
|
||||
# Xen virtual disk
|
||||
$disk->{'desc'} = &text('select_device', 'Xen', uc($1));
|
||||
$disk->{'type'} = 'ide';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/mmcblk([0-9]+)$/) {
|
||||
# SD-card / MMC
|
||||
$disk->{'desc'} = &text('select_device', 'SD-Card', $1);
|
||||
$disk->{'type'} = 'ide';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/vd([a-z]+)$/) {
|
||||
# KVM virtual disk
|
||||
$disk->{'desc'} = &text('select_device',
|
||||
'VirtIO', uc($1));
|
||||
$disk->{'type'} = 'virtio';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(scsi\/host(\d+)\/bus(\d+)\/target(\d+)\/lun(\d+)\/disc)/) {
|
||||
# New complete SCSI disk specification
|
||||
$disk->{'host'} = $2;
|
||||
$disk->{'bus'} = $3;
|
||||
$disk->{'target'} = $4;
|
||||
$disk->{'lun'} = $5;
|
||||
$disk->{'desc'} = &text('select_scsi',
|
||||
"$2", "$3", "$4", "$5");
|
||||
|
||||
# Work out the SCSI index for this disk
|
||||
local $j;
|
||||
if ($dscsi_mode) {
|
||||
for($j=0; $j<@dscsi; $j++) {
|
||||
if ($dscsi[$j]->{'host'} == $disk->{'host'} && $dscsi[$j]->{'bus'} == $disk->{'bus'} && $dscsi[$j]->{'target'} == $disk->{'target'} && $dscsi[$j]->{'lnun'} == $disk->{'lun'}) {
|
||||
$disk->{'scsi'} = $j;
|
||||
last;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
for($j=0; $j<@pscsi; $j++) {
|
||||
if ($pscsi[$j] =~ /Host:\s+scsi(\d+).*Id:\s+(\d+)/i && $disk->{'host'} == $1 && $disk->{'target'} == $2) {
|
||||
$disk->{'scsi'} = $j;
|
||||
last;
|
||||
}
|
||||
}
|
||||
}
|
||||
$disk->{'type'} = 'scsi';
|
||||
$disk->{'prefix'} =~ s/disc$/part/g;
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(ide\/host(\d+)\/bus(\d+)\/target(\d+)\/lun(\d+)\/disc)/) {
|
||||
# New-style IDE specification
|
||||
$disk->{'host'} = $2;
|
||||
$disk->{'bus'} = $3;
|
||||
$disk->{'target'} = $4;
|
||||
$disk->{'lun'} = $5;
|
||||
$disk->{'desc'} = &text('select_newide',
|
||||
"$2", "$3", "$4", "$5");
|
||||
$disk->{'type'} = 'ide';
|
||||
$disk->{'prefix'} =~ s/disc$/part/g;
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(rd\/c(\d+)d(\d+))/) {
|
||||
# Mylex raid device
|
||||
local ($mc, $md) = ($2, $3);
|
||||
$disk->{'desc'} = &text('select_mylex', $mc, $md);
|
||||
open(RD, "</proc/rd/c$mc/current_status");
|
||||
while(<RD>) {
|
||||
if (/^Configuring\s+(.*)/i) {
|
||||
$disk->{'model'} = $1;
|
||||
}
|
||||
elsif (/\s+(\S+):\s+([^, ]+)/ &&
|
||||
$1 eq $disk->{'device'}) {
|
||||
$disk->{'raid'} = $2;
|
||||
}
|
||||
}
|
||||
close(RD);
|
||||
$disk->{'type'} = 'raid';
|
||||
$disk->{'prefix'} = $disk->{'device'}.'p';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(ida\/c(\d+)d(\d+))/) {
|
||||
# Compaq RAID device
|
||||
local ($ic, $id) = ($2, $3);
|
||||
$disk->{'desc'} = &text('select_cpq', $ic, $id);
|
||||
open(IDA, -d "/proc/driver/array" ? "</proc/driver/array/ida$ic" : "</proc/driver/cpqarray/ida$ic");
|
||||
while(<IDA>) {
|
||||
if (/^(\S+):\s+(.*)/ && $1 eq "ida$ic") {
|
||||
$disk->{'model'} = $2;
|
||||
}
|
||||
}
|
||||
close(IDA);
|
||||
$disk->{'type'} = 'raid';
|
||||
$disk->{'prefix'} = $disk->{'device'}.'p';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(cciss\/c(\d+)d(\d+))/) {
|
||||
# Compaq Smart Array RAID
|
||||
local ($ic, $id) = ($2, $3);
|
||||
$disk->{'desc'} = &text('select_smart', $ic, $id);
|
||||
open(CCI, "</proc/driver/cciss/cciss$ic");
|
||||
while(<CCI>) {
|
||||
if (/^\s*(\S+):\s*(.*)/ && $1 eq "cciss$ic") {
|
||||
$disk->{'model'} = $2;
|
||||
}
|
||||
}
|
||||
close(CCI);
|
||||
$disk->{'type'} = 'raid';
|
||||
$disk->{'prefix'} = $disk->{'device'}.'p';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(ataraid\/disc(\d+)\/disc)/) {
|
||||
# Promise RAID controller
|
||||
local $dd = $2;
|
||||
$disk->{'desc'} = &text('select_promise', $dd);
|
||||
$disk->{'type'} = 'raid';
|
||||
$disk->{'prefix'} =~ s/disc$/part/g;
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/nvme(\d+)n(\d+)$/) {
|
||||
# NVME SSD controller
|
||||
$disk->{'desc'} = &text('select_nvme', "$1", "$2");
|
||||
$disk->{'type'} = 'scsi';
|
||||
$disk->{'prefix'} = $disk->{'device'}.'p';
|
||||
}
|
||||
|
||||
# Work out short name, like sda
|
||||
local $short;
|
||||
if (defined($disk->{'host'})) {
|
||||
$short = &hbt_to_device($disk->{'host'},
|
||||
$disk->{'bus'},
|
||||
$disk->{'target'});
|
||||
}
|
||||
else {
|
||||
$short = $disk->{'device'};
|
||||
$short =~ s/^.*\///g;
|
||||
}
|
||||
$disk->{'short'} = $short;
|
||||
|
||||
$disk->{'id'} = $id_map{$disk->{'device'}} ||
|
||||
$id_map{"/dev/$short"};
|
||||
$disk->{'ids'} = $all_id_map{$disk->{'device'}} ||
|
||||
$all_id_map{"/dev/$short"};
|
||||
};
|
||||
my $enrich_metadata = sub {
|
||||
local @disks = @_;
|
||||
local $d;
|
||||
# Check /proc/ide for IDE disk models
|
||||
foreach $d (@disks) {
|
||||
if ($d->{'type'} eq 'ide') {
|
||||
local $short = $d->{'short'};
|
||||
$d->{'model'} = &read_file_contents("/proc/ide/$short/model");
|
||||
$d->{'model'} =~ s/\r|\n//g;
|
||||
$d->{'media'} = &read_file_contents("/proc/ide/$short/media");
|
||||
$d->{'media'} =~ s/\r|\n//g;
|
||||
if ($d->{'short'} =~ /^vd/ && !$d->{'model'}) {
|
||||
# Fake up model for KVM VirtIO disks
|
||||
$d->{'model'} = "KVM VirtIO";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Fill in SCSI information
|
||||
foreach $d (@disks) {
|
||||
if ($d->{'type'} eq 'scsi') {
|
||||
local $s = $d->{'scsi'};
|
||||
local $sysdir = "/sys/block/$d->{'short'}/device";
|
||||
if (-d $sysdir) {
|
||||
# From kernel 2.6.30+ sys directory
|
||||
$d->{'model'} = &read_file_contents("$sysdir/vendor").
|
||||
" ".
|
||||
&read_file_contents("$sysdir/model");
|
||||
$d->{'model'} =~ s/\r|\n//g;
|
||||
$d->{'media'} = &read_file_contents("$sysdir/media");
|
||||
$d->{'media'} =~ s/\r|\n//g;
|
||||
}
|
||||
elsif ($dscsi_mode) {
|
||||
# From other scsi files
|
||||
$d->{'model'} = "$dscsi[$s]->{'make'} $dscsi[$s]->{'model'}";
|
||||
$d->{'controller'} = $dscsi[$s]->{'host'};
|
||||
$d->{'scsiid'} = $dscsi[$s]->{'target'};
|
||||
}
|
||||
else {
|
||||
# From /proc/scsi/scsi lines
|
||||
if ($pscsi[$s] =~ /Vendor:\s+(\S+).*Model:\s+(.*)\s+Rev:/i) {
|
||||
$d->{'model'} = "$1 $2";
|
||||
}
|
||||
if ($pscsi[$s] =~ /Host:\s+scsi(\d+).*Id:\s+(\d+)/i) {
|
||||
$d->{'controller'} = int($1);
|
||||
$d->{'scsiid'} = int($2);
|
||||
}
|
||||
}
|
||||
if ($d->{'model'} =~ /ATA/) {
|
||||
# Fake SCSI disk, actually IDE
|
||||
$d->{'scsi'} = 0;
|
||||
$d->{'desc'} =~ s/SCSI/SATA/g;
|
||||
foreach my $p (@{$d->{'parts'} || []}) {
|
||||
$p->{'desc'} =~ s/SCSI/SATA/g;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
if ($disk_only) {
|
||||
my @inventory = @devs;
|
||||
foreach my $device (@inventory) {
|
||||
my $disk = { 'device' => $device, 'prefix' => $device };
|
||||
$set_metadata->($disk);
|
||||
push(@disks, $disk);
|
||||
}
|
||||
$enrich_metadata->(@disks);
|
||||
foreach my $disk (@disks) {
|
||||
my $device = $disk->{'device'};
|
||||
my $identity = &disk_discovery_identity($device);
|
||||
return &disk_discovery_error("Missing device during disk-only discovery: $device", $error_ref)
|
||||
if (!defined($identity));
|
||||
return &disk_discovery_error("Disk identity changed during disk-only discovery: $device", $error_ref)
|
||||
if ($discovery_identity{$device} ne $identity);
|
||||
# An absent controller model must not silently bypass RAID expansion.
|
||||
return &disk_discovery_error("Missing disk model during disk-only discovery: $device", $error_ref)
|
||||
if (($disk->{'type'} eq 'scsi' || $disk->{'type'} eq 'raid') &&
|
||||
$disk->{'model'} !~ /\S/);
|
||||
}
|
||||
return &disk_discovery_error("Disk inventory changed during disk-only discovery", $error_ref)
|
||||
if ($discovery_partitions ne &read_file_contents('/proc/partitions'));
|
||||
# These are metadata records, not claims of empty partition tables.
|
||||
# Do not read or populate the full partition cache.
|
||||
return @disks;
|
||||
}
|
||||
|
||||
# Call fdisk to get partition and geometry information
|
||||
local $qdevs = join(" ", map { quotemeta($_) } @devs);
|
||||
local ($disk, $m2);
|
||||
@@ -565,7 +297,154 @@ while(<FDISK>) {
|
||||
|
||||
local @st = stat($disk->{'device'});
|
||||
next if (@cdstat && $st[1] == $cdstat[1]);
|
||||
$set_metadata->($disk);
|
||||
if ($disk->{'device'} =~ /\/sd([a-z]+)$/) {
|
||||
# Old-style SCSI disk
|
||||
$disk->{'desc'} = &text('select_device', 'SCSI',
|
||||
uc($1));
|
||||
local ($dscsi) = grep { $_->{'dev'} eq "sd$1" } @dscsi;
|
||||
$disk->{'scsi'} = $dscsi ? &indexof($dscsi, @dscsi)
|
||||
: ord(uc($1))-65;
|
||||
$disk->{'type'} = 'scsi';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/hd([a-z]+)$/) {
|
||||
# IDE disk
|
||||
$disk->{'desc'} = &text('select_device', 'IDE', uc($1));
|
||||
$disk->{'type'} = 'ide';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/xvd([a-z]+)$/) {
|
||||
# Xen virtual disk
|
||||
$disk->{'desc'} = &text('select_device', 'Xen', uc($1));
|
||||
$disk->{'type'} = 'ide';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/mmcblk([0-9]+)$/) {
|
||||
# SD-card / MMC
|
||||
$disk->{'desc'} = &text('select_device', 'SD-Card', $1);
|
||||
$disk->{'type'} = 'ide';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/vd([a-z]+)$/) {
|
||||
# KVM virtual disk
|
||||
$disk->{'desc'} = &text('select_device',
|
||||
'VirtIO', uc($1));
|
||||
$disk->{'type'} = 'virtio';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(scsi\/host(\d+)\/bus(\d+)\/target(\d+)\/lun(\d+)\/disc)/) {
|
||||
# New complete SCSI disk specification
|
||||
$disk->{'host'} = $2;
|
||||
$disk->{'bus'} = $3;
|
||||
$disk->{'target'} = $4;
|
||||
$disk->{'lun'} = $5;
|
||||
$disk->{'desc'} = &text('select_scsi',
|
||||
"$2", "$3", "$4", "$5");
|
||||
|
||||
# Work out the SCSI index for this disk
|
||||
local $j;
|
||||
if ($dscsi_mode) {
|
||||
for($j=0; $j<@dscsi; $j++) {
|
||||
if ($dscsi[$j]->{'host'} == $disk->{'host'} && $dscsi[$j]->{'bus'} == $disk->{'bus'} && $dscsi[$j]->{'target'} == $disk->{'target'} && $dscsi[$j]->{'lnun'} == $disk->{'lun'}) {
|
||||
$disk->{'scsi'} = $j;
|
||||
last;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
for($j=0; $j<@pscsi; $j++) {
|
||||
if ($pscsi[$j] =~ /Host:\s+scsi(\d+).*Id:\s+(\d+)/i && $disk->{'host'} == $1 && $disk->{'target'} == $2) {
|
||||
$disk->{'scsi'} = $j;
|
||||
last;
|
||||
}
|
||||
}
|
||||
}
|
||||
$disk->{'type'} = 'scsi';
|
||||
$disk->{'prefix'} =~ s/disc$/part/g;
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(ide\/host(\d+)\/bus(\d+)\/target(\d+)\/lun(\d+)\/disc)/) {
|
||||
# New-style IDE specification
|
||||
$disk->{'host'} = $2;
|
||||
$disk->{'bus'} = $3;
|
||||
$disk->{'target'} = $4;
|
||||
$disk->{'lun'} = $5;
|
||||
$disk->{'desc'} = &text('select_newide',
|
||||
"$2", "$3", "$4", "$5");
|
||||
$disk->{'type'} = 'ide';
|
||||
$disk->{'prefix'} =~ s/disc$/part/g;
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(rd\/c(\d+)d(\d+))/) {
|
||||
# Mylex raid device
|
||||
local ($mc, $md) = ($2, $3);
|
||||
$disk->{'desc'} = &text('select_mylex', $mc, $md);
|
||||
open(RD, "</proc/rd/c$mc/current_status");
|
||||
while(<RD>) {
|
||||
if (/^Configuring\s+(.*)/i) {
|
||||
$disk->{'model'} = $1;
|
||||
}
|
||||
elsif (/\s+(\S+):\s+([^, ]+)/ &&
|
||||
$1 eq $disk->{'device'}) {
|
||||
$disk->{'raid'} = $2;
|
||||
}
|
||||
}
|
||||
close(RD);
|
||||
$disk->{'type'} = 'raid';
|
||||
$disk->{'prefix'} = $disk->{'device'}.'p';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(ida\/c(\d+)d(\d+))/) {
|
||||
# Compaq RAID device
|
||||
local ($ic, $id) = ($2, $3);
|
||||
$disk->{'desc'} = &text('select_cpq', $ic, $id);
|
||||
open(IDA, -d "/proc/driver/array" ? "</proc/driver/array/ida$ic" : "</proc/driver/cpqarray/ida$ic");
|
||||
while(<IDA>) {
|
||||
if (/^(\S+):\s+(.*)/ && $1 eq "ida$ic") {
|
||||
$disk->{'model'} = $2;
|
||||
}
|
||||
}
|
||||
close(IDA);
|
||||
$disk->{'type'} = 'raid';
|
||||
$disk->{'prefix'} = $disk->{'device'}.'p';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(cciss\/c(\d+)d(\d+))/) {
|
||||
# Compaq Smart Array RAID
|
||||
local ($ic, $id) = ($2, $3);
|
||||
$disk->{'desc'} = &text('select_smart', $ic, $id);
|
||||
open(CCI, "</proc/driver/cciss/cciss$ic");
|
||||
while(<CCI>) {
|
||||
if (/^\s*(\S+):\s*(.*)/ && $1 eq "cciss$ic") {
|
||||
$disk->{'model'} = $2;
|
||||
}
|
||||
}
|
||||
close(CCI);
|
||||
$disk->{'type'} = 'raid';
|
||||
$disk->{'prefix'} = $disk->{'device'}.'p';
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/(ataraid\/disc(\d+)\/disc)/) {
|
||||
# Promise RAID controller
|
||||
local $dd = $2;
|
||||
$disk->{'desc'} = &text('select_promise', $dd);
|
||||
$disk->{'type'} = 'raid';
|
||||
$disk->{'prefix'} =~ s/disc$/part/g;
|
||||
}
|
||||
elsif ($disk->{'device'} =~ /\/nvme(\d+)n(\d+)$/) {
|
||||
# NVME SSD controller
|
||||
$disk->{'desc'} = &text('select_nvme', "$1", "$2");
|
||||
$disk->{'type'} = 'scsi';
|
||||
$disk->{'prefix'} = $disk->{'device'}.'p';
|
||||
}
|
||||
|
||||
# Work out short name, like sda
|
||||
local $short;
|
||||
if (defined($disk->{'host'})) {
|
||||
$short = &hbt_to_device($disk->{'host'},
|
||||
$disk->{'bus'},
|
||||
$disk->{'target'});
|
||||
}
|
||||
else {
|
||||
$short = $disk->{'device'};
|
||||
$short =~ s/^.*\///g;
|
||||
}
|
||||
$disk->{'short'} = $short;
|
||||
|
||||
$disk->{'id'} = $id_map{$disk->{'device'}} ||
|
||||
$id_map{"/dev/$short"};
|
||||
$disk->{'ids'} = $all_id_map{$disk->{'device'}} ||
|
||||
$all_id_map{"/dev/$short"};
|
||||
|
||||
push(@disks, $disk);
|
||||
}
|
||||
@@ -721,40 +600,66 @@ while(<FDISK>) {
|
||||
}
|
||||
close(FDISK);
|
||||
|
||||
$enrich_metadata->(@disks);
|
||||
# Check /proc/ide for IDE disk models
|
||||
foreach $d (@disks) {
|
||||
if ($d->{'type'} eq 'ide') {
|
||||
local $short = $d->{'short'};
|
||||
$d->{'model'} = &read_file_contents("/proc/ide/$short/model");
|
||||
$d->{'model'} =~ s/\r|\n//g;
|
||||
$d->{'media'} = &read_file_contents("/proc/ide/$short/media");
|
||||
$d->{'media'} =~ s/\r|\n//g;
|
||||
if ($d->{'short'} =~ /^vd/ && !$d->{'model'}) {
|
||||
# Fake up model for KVM VirtIO disks
|
||||
$d->{'model'} = "KVM VirtIO";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Fill in SCSI information
|
||||
foreach $d (@disks) {
|
||||
if ($d->{'type'} eq 'scsi') {
|
||||
local $s = $d->{'scsi'};
|
||||
local $sysdir = "/sys/block/$d->{'short'}/device";
|
||||
if (-d $sysdir) {
|
||||
# From kernel 2.6.30+ sys directory
|
||||
$d->{'model'} = &read_file_contents("$sysdir/vendor").
|
||||
" ".
|
||||
&read_file_contents("$sysdir/model");
|
||||
$d->{'model'} =~ s/\r|\n//g;
|
||||
$d->{'media'} = &read_file_contents("$sysdir/media");
|
||||
$d->{'media'} =~ s/\r|\n//g;
|
||||
}
|
||||
elsif ($dscsi_mode) {
|
||||
# From other scsi files
|
||||
$d->{'model'} = "$dscsi[$s]->{'make'} $dscsi[$s]->{'model'}";
|
||||
$d->{'controller'} = $dscsi[$s]->{'host'};
|
||||
$d->{'scsiid'} = $dscsi[$s]->{'target'};
|
||||
}
|
||||
else {
|
||||
# From /proc/scsi/scsi lines
|
||||
if ($pscsi[$s] =~ /Vendor:\s+(\S+).*Model:\s+(.*)\s+Rev:/i) {
|
||||
$d->{'model'} = "$1 $2";
|
||||
}
|
||||
if ($pscsi[$s] =~ /Host:\s+scsi(\d+).*Id:\s+(\d+)/i) {
|
||||
$d->{'controller'} = int($1);
|
||||
$d->{'scsiid'} = int($2);
|
||||
}
|
||||
}
|
||||
if ($d->{'model'} =~ /ATA/) {
|
||||
# Fake SCSI disk, actually IDE
|
||||
$d->{'scsi'} = 0;
|
||||
$d->{'desc'} =~ s/SCSI/SATA/g;
|
||||
foreach my $p (@{$d->{'parts'}}) {
|
||||
$p->{'desc'} =~ s/SCSI/SATA/g;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@list_disks_partitions_cache = @disks;
|
||||
return @disks;
|
||||
}
|
||||
|
||||
# Report disk-only failure without changing the list-returning caller contract.
|
||||
sub disk_discovery_error
|
||||
{
|
||||
my ($message, $error_ref) = @_;
|
||||
if ($error_ref) {
|
||||
$$error_ref = $message;
|
||||
}
|
||||
else {
|
||||
warn "$message\n";
|
||||
}
|
||||
return ();
|
||||
}
|
||||
|
||||
# Compare device-node and sysfs object identity without opening the device.
|
||||
# This detects disappearance/replacement during enumeration, not all possible
|
||||
# changes after this function returns. The next collection always scans again.
|
||||
sub disk_discovery_identity
|
||||
{
|
||||
my ($device) = @_;
|
||||
my @node = stat($device);
|
||||
return if (!@node);
|
||||
my $short = $device;
|
||||
$short =~ s{^/dev/}{};
|
||||
$short =~ s{/}{!}g;
|
||||
my @sys = stat("/sys/class/block/$short");
|
||||
return join(':', @node[0,1,6], @sys ? @sys[0,1] : ('', ''));
|
||||
}
|
||||
|
||||
# partition_description(device)
|
||||
# Converts a device path like /dev/hda1 into a human-readable name
|
||||
sub partition_description
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Disk-only discovery tests
|
||||
|
||||
Run `prove -lr fdisk/t`. Perl::Critic enables lint checks for the added tests.
|
||||
Fixtures use temporary kernel/device trees and fake partition commands; they
|
||||
never query real disks. Coverage includes GPT/MBR, fdisk fallback, cache isolation,
|
||||
fresh inventory and identifiers, disappearance, invalid/empty inventory, and
|
||||
non-throwing error reporting, controller-qualified sysfs replacement with a retained
|
||||
device node, and canonical identities through symlinked temporary parents. Physical
|
||||
RAID and real hotplug need hardware tests.
|
||||
@@ -1,16 +0,0 @@
|
||||
use strict;
|
||||
use warnings;
|
||||
use Test::More;
|
||||
use FindBin qw($Bin);
|
||||
BEGIN {
|
||||
eval { require Perl::Critic; 1 }
|
||||
or plan skip_all => 'Perl::Critic not installed';
|
||||
}
|
||||
# Apply the existing policy to new tests, not untouched legacy module files.
|
||||
my $critic = Perl::Critic->new(-profile => "$Bin/../../.perlcriticrc");
|
||||
for my $file (qw(disk-discovery.t fixtures/disk-discovery.pl disk-discovery-perlcritic.t)) {
|
||||
my @violations = $critic->critique("$Bin/$file");
|
||||
is(scalar @violations, 0, "$file perlcritic");
|
||||
diag join('', @violations) if @violations;
|
||||
}
|
||||
done_testing();
|
||||
@@ -1,223 +0,0 @@
|
||||
use strict;
|
||||
use warnings;
|
||||
use Test::More;
|
||||
use JSON::PP;
|
||||
use File::Temp qw(tempdir);
|
||||
use File::Path qw(make_path remove_tree);
|
||||
use File::Basename qw(dirname);
|
||||
use Cwd qw(abs_path);
|
||||
use IPC::Open3;
|
||||
use Symbol qw(gensym);
|
||||
|
||||
my $here = abs_path(dirname(__FILE__));
|
||||
my $library = "$here/../fdisk-lib.pl";
|
||||
my $fixture;
|
||||
|
||||
sub write_file {
|
||||
my ($name, $value) = @_;
|
||||
make_path(dirname("$fixture/$name"));
|
||||
open(my $fh, '>', "$fixture/$name") or die $!;
|
||||
print {$fh} $value;
|
||||
close($fh) or die $!;
|
||||
}
|
||||
sub disk {
|
||||
my ($name, $vendor, $model) = @_;
|
||||
write_file("dev/$name", '');
|
||||
write_file("sys/block/$name/device/vendor", "$vendor\n");
|
||||
write_file("sys/block/$name/device/model", "$model\n");
|
||||
make_path("$fixture/sys/class/block/$name");
|
||||
}
|
||||
sub setup_fixture {
|
||||
my ($parent) = @_;
|
||||
$fixture = abs_path(tempdir(CLEANUP => 1, defined($parent) ? (DIR => $parent) : ()));
|
||||
write_file('proc/partitions', "major minor #blocks name\n 8 0 100 sda\n 8 1 90 sda1\n");
|
||||
write_file('proc/scsi/scsi', "Attached devices:\n");
|
||||
disk('sda', 'Example', 'USB NVMe');
|
||||
disk('sdb', 'ATA', 'second');
|
||||
make_path("$fixture/dev/disk/by-id");
|
||||
symlink('../../sda', "$fixture/dev/disk/by-id/old") or die $!;
|
||||
write_file('tool-output', '');
|
||||
for my $tool (qw(parted fdisk)) {
|
||||
write_file("bin/$tool", "#!$^X\n" . <<'TOOL');
|
||||
use strict;
|
||||
my $root = $ENV{'FIXTURE_ROOT'};
|
||||
open(my $calls, '>>', "$root/calls") or die $!;
|
||||
print {$calls} "query\n";
|
||||
close($calls);
|
||||
open(my $data, '<', "$root/tool-output") or die $!;
|
||||
print while <$data>;
|
||||
TOOL
|
||||
chmod(0755, "$fixture/bin/$tool") or die $!;
|
||||
}
|
||||
}
|
||||
sub run_library {
|
||||
my ($scenario) = @_;
|
||||
local $ENV{'PATH'} = "$fixture/bin:$ENV{'PATH'}";
|
||||
local $ENV{'FIXTURE_ROOT'} = $fixture;
|
||||
local $SIG{'ALRM'} = sub { die "fixture timed out\n"; };
|
||||
alarm(15);
|
||||
my $err = gensym();
|
||||
my $pid = open3(my $in, my $out, $err, $^X, "$here/fixtures/disk-discovery.pl",
|
||||
$library, $fixture, $scenario || 'disk');
|
||||
close($in);
|
||||
my ($stdout, $stderr);
|
||||
{ local $/; $stdout = <$out>; $stderr = <$err>; }
|
||||
waitpid($pid, 0);
|
||||
my $status = $?;
|
||||
alarm(0);
|
||||
is($status, 0, 'fixture process succeeds') or diag($stderr);
|
||||
is($stderr, '', 'no unexpected warnings');
|
||||
my $result = decode_json($stdout);
|
||||
is($result->{'exception'}, undef, 'library does not throw an exception');
|
||||
return $result;
|
||||
}
|
||||
sub parted_output {
|
||||
my ($table) = @_;
|
||||
my $part = $table eq 'gpt' ? ' 1 1cyl 9cyl 8cyl ext4 name' : ' 1 1cyl 9cyl 8cyl primary ext4';
|
||||
my $extra = $table eq 'msdos' ? " 2 9cyl 10cyl 1cyl extended\n" : '';
|
||||
write_file('tool-output', "Disk $fixture/dev/sda: 10cyl\nBIOS cylinder,head,sector geometry: 10,4,8. Each cylinder is 16384b.\nPartition Table: $table\n$part\n$extra");
|
||||
}
|
||||
|
||||
subtest 'disk-only inventory avoids partition commands and cache' => sub {
|
||||
setup_fixture();
|
||||
my $r = run_library();
|
||||
is($r->{'error'}, undef, 'discovery succeeds');
|
||||
my $d = $r->{'results'}->[0]->[0];
|
||||
is($d->{'model'}, 'Example USB NVMe', 'model retained');
|
||||
is_deeply($d->{'ids'}, ['old'], 'stable ID retained');
|
||||
ok(!exists($d->{'parts'}) && !exists($d->{'table'}) && !exists($d->{'size'}),
|
||||
'metadata does not claim partition or geometry data');
|
||||
is_deeply($r->{'cache'}, [], 'full partition cache not populated');
|
||||
ok(!-e "$fixture/calls", 'no partition tool called');
|
||||
};
|
||||
subtest 'full discovery still parses GPT, MBR and extended partitions' => sub {
|
||||
for my $table (qw(gpt msdos)) {
|
||||
setup_fixture(); parted_output($table);
|
||||
my $r = run_library('full');
|
||||
is($r->{'error'}, undef, 'full discovery succeeds');
|
||||
my $d = $r->{'results'}->[0]->[0];
|
||||
is($d->{'table'}, $table, 'partition table retained');
|
||||
is(scalar(@{$d->{'parts'}}), $table eq 'gpt' ? 1 : 2, 'partitions retained');
|
||||
is($d->{'model'}, 'Example USB NVMe', 'shared model metadata retained');
|
||||
ok(-e "$fixture/calls", 'full path queries partition tool');
|
||||
}
|
||||
};
|
||||
subtest 'full fdisk fallback remains available' => sub {
|
||||
setup_fixture();
|
||||
write_file('tool-output', "Disk $fixture/dev/sda: 1 GiB, 1073741824 bytes, 2097152 sectors\nGeometry: 4 heads, 8 sectors/track, 10 cylinders\nUnits = cylinders of 32 * 512\nDisklabel type: gpt\n$fixture/dev/sda1 1 9 9 4K Linux filesystem\n");
|
||||
my $r = run_library('full_fdisk');
|
||||
is($r->{'error'}, undef, 'fdisk fallback succeeds');
|
||||
is(scalar(@{$r->{'results'}->[0]->[0]->{'parts'}}), 1, 'fdisk partition retained');
|
||||
ok(-e "$fixture/calls", 'fdisk command used');
|
||||
};
|
||||
subtest 'cache isolation in both call orders' => sub {
|
||||
for my $scenario (qw(disk_full full_disk)) {
|
||||
setup_fixture(); parted_output('gpt');
|
||||
my $r = run_library($scenario);
|
||||
my ($disk, $full) = @{$r->{'results'}};
|
||||
($disk, $full) = ($full, $disk) if $scenario eq 'full_disk';
|
||||
ok(!exists($disk->[0]->{'parts'}), 'disk-only result has no partition claim');
|
||||
ok(@{$full->[0]->{'parts'}}, 'full result has partitions');
|
||||
is_deeply($r->{'cache'}, $full, 'full cache contains only full result');
|
||||
}
|
||||
};
|
||||
subtest 'replacement refreshes model and identifiers' => sub {
|
||||
setup_fixture(); my $r = run_library('refresh');
|
||||
my ($a, $b) = map { $_->[0] } @{$r->{'results'}};
|
||||
is($a->{'model'}, 'Example USB NVMe', 'first model');
|
||||
is($b->{'model'}, 'Example Replacement', 'new model');
|
||||
is_deeply($b->{'ids'}, ['new'], 'new stable ID');
|
||||
};
|
||||
subtest 'addition and removal refresh inventory' => sub {
|
||||
setup_fixture(); my $r = run_library('remove_add');
|
||||
is($r->{'results'}->[0]->[0]->{'device'}, "$fixture/dev/sda", 'initial disk');
|
||||
is($r->{'results'}->[1]->[0]->{'device'}, "$fixture/dev/sdb", 'replacement inventory');
|
||||
};
|
||||
subtest 'disappearance during scan fails explicitly' => sub {
|
||||
setup_fixture(); like(run_library('race')->{'error'}, qr/Missing device/, 'no stale result');
|
||||
};
|
||||
subtest 'missing model and missing inventory fail explicitly' => sub {
|
||||
setup_fixture();
|
||||
unlink("$fixture/sys/block/sda/device/vendor", "$fixture/sys/block/sda/device/model");
|
||||
like(run_library()->{'error'}, qr/Missing disk model/, 'missing model fails');
|
||||
unlink("$fixture/proc/partitions");
|
||||
like(run_library()->{'error'}, qr/Cannot open disk inventory/, 'missing inventory fails');
|
||||
};
|
||||
subtest 'multiple drives and namespaces retain classifications' => sub {
|
||||
setup_fixture();
|
||||
my @names = qw(sda sdaa nvme0n1 nvme0n2 mmcblk0 xvda vda hda);
|
||||
write_file('proc/partitions', "major minor #blocks name\n" . join('', map { "8 0 100 $_\n" } @names));
|
||||
write_file('proc/ide/hda/media', "disk\n"); write_file('proc/ide/hda/model', "IDE fixture\n");
|
||||
disk($_, 'Example', 'disk') for @names;
|
||||
my $r = run_library();
|
||||
is($r->{'error'}, undef, 'multi-drive discovery succeeds');
|
||||
is_deeply([sort map { $_->{'short'} } @{$r->{'results'}->[0]}], [sort @names], 'all devices retained');
|
||||
my ($virtio) = grep { $_->{'short'} eq 'vda' } @{$r->{'results'}->[0]};
|
||||
is($virtio->{'type'}, 'virtio', 'VirtIO classification preserved');
|
||||
ok(!-e "$fixture/calls", 'multiple drives need no partition query');
|
||||
};
|
||||
subtest 'unpartitioned disk without stable link remains discoverable' => sub {
|
||||
setup_fixture(); unlink("$fixture/dev/disk/by-id/old");
|
||||
write_file('proc/partitions', "major minor #blocks name\n 8 0 100 sda\n");
|
||||
my $r = run_library();
|
||||
is(scalar(@{$r->{'results'}->[0]}), 1, 'disk retained');
|
||||
is($r->{'results'}->[0]->[0]->{'ids'}, undef, 'no fabricated identifiers');
|
||||
};
|
||||
subtest 'legacy controller metadata and SCSI fallback remain available' => sub {
|
||||
setup_fixture();
|
||||
my @names = qw(cciss/c0d0 rd/c0d0 ida/c0d0);
|
||||
write_file('proc/partitions', "major minor #blocks name\n" . join('', map { "8 0 100 $_\n" } @names));
|
||||
write_file("dev/$_", '') for @names;
|
||||
write_file('proc/driver/cciss/cciss0', "cciss0: Smart Array\n");
|
||||
write_file('proc/rd/c0/current_status', "Configuring Mylex\n");
|
||||
write_file('proc/driver/cpqarray/ida0', "ida0: Compaq\n");
|
||||
my $r = run_library();
|
||||
is_deeply([map { $_->{'model'} } @{$r->{'results'}->[0]}], ['Smart Array','Mylex','Compaq'], 'controller models retained');
|
||||
ok(!grep({ $_->{'type'} ne 'raid' } @{$r->{'results'}->[0]}), 'legacy RAID classification retained');
|
||||
write_file('proc/partitions', "major minor #blocks name\n 8 0 100 sda\n");
|
||||
remove_tree("$fixture/sys/block/sda/device");
|
||||
write_file('proc/scsi/scsi', "Attached devices:\nHost: scsi0 Channel: 00 Id: 00 Lun: 00\n Vendor: LSI Model: RAID fixture Rev: 1\n Type: Direct-Access\n");
|
||||
like(run_library()->{'results'}->[0]->[0]->{'model'}, qr/LSI/, 'proc SCSI fallback retained');
|
||||
};
|
||||
subtest 'failure reporting preserves the list-returning contract' => sub {
|
||||
setup_fixture();
|
||||
unlink("$fixture/dev/sda");
|
||||
my $r = run_library('warn');
|
||||
is_deeply($r->{'results'}, [[]], 'unreported-reference failure returns no partial disks');
|
||||
is(scalar @{$r->{'warnings'}}, 1, 'caller without error reference receives one warning');
|
||||
like($r->{'warnings'}->[0], qr/Missing device/, 'warning explains the failure');
|
||||
setup_fixture();
|
||||
is(run_library('reset_error')->{'error'}, undef, 'successful disk-only call clears stale error');
|
||||
write_file('proc/partitions', "invalid inventory\n");
|
||||
like(run_library()->{'error'}, qr/Invalid disk inventory/, 'invalid header is reported');
|
||||
write_file('proc/partitions', "major minor #blocks name\n");
|
||||
$r = run_library();
|
||||
is_deeply($r->{'results'}, [[]], 'empty valid inventory succeeds');
|
||||
is($r->{'error'}, undef, 'empty valid inventory is not an error');
|
||||
};
|
||||
subtest 'controller-qualified sysfs replacement is detected with a retained device node' => sub {
|
||||
setup_fixture();
|
||||
write_file('proc/partitions', "major minor #blocks name\n 104 0 100 cciss/c0d0\n");
|
||||
write_file('dev/cciss/c0d0', '');
|
||||
write_file('proc/driver/cciss/cciss0', "cciss0: Smart Array\n");
|
||||
make_path("$fixture/sys/class/block/cciss!c0d0");
|
||||
my $stable = run_library('legacy_stable');
|
||||
is($stable->{'error'}, undef, 'controller sysfs path is accepted');
|
||||
my $before = [stat("$fixture/dev/cciss/c0d0")];
|
||||
my $changed = run_library('legacy_replace');
|
||||
like($changed->{'error'}, qr/Disk identity changed/, 'sysfs replacement is detected');
|
||||
is_deeply($changed->{'results'}, [[]], 'replacement returns no stale inventory');
|
||||
my $after = [stat("$fixture/dev/cciss/c0d0")];
|
||||
is_deeply([@{$after}[0,1,6]], [@{$before}[0,1,6]], 'device-node identity was retained');
|
||||
};
|
||||
subtest 'symlinked temporary parents produce canonical fixture identities' => sub {
|
||||
my $parent = abs_path(tempdir(CLEANUP => 1));
|
||||
make_path("$parent/real");
|
||||
symlink("$parent/real", "$parent/alias") or die $!;
|
||||
setup_fixture("$parent/alias");
|
||||
is($fixture, abs_path($fixture), 'fixture root is canonical');
|
||||
my $result = run_library();
|
||||
is($result->{'error'}, undef, 'discovery succeeds through symlinked parent');
|
||||
is_deeply($result->{'results'}->[0]->[0]->{'ids'}, ['old'], 'resolved stable IDs match canonical devices');
|
||||
};
|
||||
done_testing();
|
||||
@@ -1,90 +0,0 @@
|
||||
use strict;
|
||||
use warnings;
|
||||
use JSON::PP;
|
||||
use Cwd qw(abs_path);
|
||||
use File::Basename qw(dirname);
|
||||
my ($source, $root, $scenario) = @ARGV;
|
||||
open(my $fh, '<', $source) or die $!;
|
||||
local $/; my $code = <$fh>; close($fh); $/ = "\n";
|
||||
# Keep the actual library functions; replace literal filesystem roots only.
|
||||
$code = substr($code, index($code, 'sub list_disks_partitions'));
|
||||
for my $prefix ('/proc/', '/sys/', '/dev/') {
|
||||
$code =~ s/\Q$prefix\E/$root$prefix/g;
|
||||
}
|
||||
our (@reads, $has_parted, @list_disks_partitions_cache, $change);
|
||||
$has_parted = $scenario !~ /fdisk/;
|
||||
# Mock the cross-module filesystem helper used by the real library.
|
||||
{ package mount; ## no critic (Modules::RequireFilenameMatchesPackage)
|
||||
sub list_fstypes { return ('ext2', 'ext3', 'ext4', 'xfs'); }
|
||||
}
|
||||
sub text { return join(' ', @_); }
|
||||
sub indexof { my ($x, @v) = @_; for (0..$#v) { return $_ if $v[$_] eq $x; } return -1; }
|
||||
sub resolve_links { return abs_path($_[0]); }
|
||||
sub simplify_path { my $p = $_[0]; $p =~ s{/dev/disk/by-id/../../}{/dev/}; return $p; }
|
||||
sub read_file_contents {
|
||||
my ($p) = @_;
|
||||
die "Attempt to read device data" if $p =~ m{\Q$root\E/dev/};
|
||||
push(@reads, $p);
|
||||
open(my $f, '<', $p) or return '';
|
||||
local $/; my $s = <$f>; close($f);
|
||||
if ($change && $p =~ m{/device/model$}) {
|
||||
unlink("$root/dev/sda");
|
||||
$change = 0;
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
{
|
||||
# Load the real legacy functions, which use Webmin package globals.
|
||||
no strict 'vars'; ## no critic (TestingAndDebugging::ProhibitNoStrict)
|
||||
# Webmin does not enable these warnings for optional legacy metadata.
|
||||
no warnings 'uninitialized';
|
||||
eval $code; ## no critic (BuiltinFunctions::ProhibitStringyEval)
|
||||
die $@ if $@;
|
||||
}
|
||||
if ($scenario eq 'legacy_replace') {
|
||||
my $identity = \&disk_discovery_identity;
|
||||
my $calls = 0;
|
||||
no warnings 'redefine';
|
||||
*disk_discovery_identity = sub {
|
||||
if (++$calls == 2) {
|
||||
my $sys = "$root/sys/class/block/cciss!c0d0";
|
||||
rename($sys, "$sys.old") or die $!;
|
||||
mkdir($sys) or die $!;
|
||||
}
|
||||
return $identity->(@_);
|
||||
};
|
||||
}
|
||||
my @results;
|
||||
my $error;
|
||||
my $exception;
|
||||
my @warnings;
|
||||
eval {
|
||||
if ($scenario eq 'race') { $change = 1; }
|
||||
if ($scenario eq 'reset_error') { $error = 'previous failure'; }
|
||||
if ($scenario eq 'disk_full' || $scenario eq 'full_disk') {
|
||||
my $first = $scenario eq 'disk_full' ? 1 : 0;
|
||||
push(@results, [list_disks_partitions(undef, $first, \$error)]);
|
||||
push(@results, [list_disks_partitions(undef, !$first, \$error)]);
|
||||
}
|
||||
elsif ($scenario eq 'refresh') {
|
||||
push(@results, [list_disks_partitions(undef, 1, \$error)]);
|
||||
open(my $f, '>', "$root/sys/block/sda/device/model") or die $!;
|
||||
print $f "Replacement\n"; close($f);
|
||||
unlink("$root/dev/disk/by-id/old");
|
||||
symlink('../../sda', "$root/dev/disk/by-id/new") or die $!;
|
||||
push(@results, [list_disks_partitions(undef, 1, \$error)]);
|
||||
}
|
||||
elsif ($scenario eq 'remove_add') {
|
||||
push(@results, [list_disks_partitions(undef, 1, \$error)]);
|
||||
open(my $f, '>', "$root/proc/partitions") or die $!;
|
||||
print $f "major minor #blocks name\n 8 16 100 sdb\n"; close($f);
|
||||
push(@results, [list_disks_partitions(undef, 1, \$error)]);
|
||||
}
|
||||
elsif ($scenario eq 'warn') {
|
||||
local $SIG{__WARN__} = sub { push(@warnings, @_); };
|
||||
push(@results, [list_disks_partitions(undef, 1)]);
|
||||
}
|
||||
else { push(@results, [list_disks_partitions(undef, $scenario =~ /^full/ ? 0 : 1, \$error)]); }
|
||||
1;
|
||||
} or $exception = $@;
|
||||
print JSON::PP->new->canonical->encode({results=>\@results, error=>$error, exception=>$exception, warnings=>\@warnings, reads=>\@reads, cache=>\@list_disks_partitions_cache});
|
||||
@@ -9,7 +9,7 @@ require './fetchmail-lib.pl';
|
||||
$file = "$uinfo[7]/.fetchmailrc";
|
||||
@conf = &parse_config_file("$uinfo[7]/.fetchmailrc");
|
||||
@conf = grep { $_->{'poll'} } @conf;
|
||||
&ui_print_header(&text('user_header', "<tt>".&html_escape($in{'user'})."</tt>"), $text{'user_title'}, "");
|
||||
&ui_print_header(&text('user_header', "<tt>$in{'user'}</tt>"), $text{'user_title'}, "");
|
||||
|
||||
print &show_polls(\@conf, $file, $in{'user'});
|
||||
|
||||
|
||||
24
gray-theme/edit_overlay.cgi
Executable file
24
gray-theme/edit_overlay.cgi
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Show a form for changing the theme overlay
|
||||
|
||||
require "gray-theme/gray-theme-lib.pl";
|
||||
|
||||
# Get the user and themes
|
||||
&foreign_require("acl", "acl-lib.pl");
|
||||
@overlays = &list_virtualmin_theme_overlays();
|
||||
($user) = grep { $_->{'name'} eq $base_remote_user } &acl::list_users();
|
||||
$user || &error($text{'overlay_euser'});
|
||||
$overlay = $current_themes[1];
|
||||
|
||||
&ui_print_header(undef, $text{'overlay_title'}, "");
|
||||
|
||||
print &ui_form_start("save_overlay.cgi");
|
||||
print $text{'overlay_desc'},"<p>\n";
|
||||
print "<b>$text{'overlay_msg'}</b>\n";
|
||||
print &ui_select("overlay", $overlay,
|
||||
[ [ "", $text{'overlay_none'} ],
|
||||
map { [ $_->{'dir'}, $_->{'desc'} ] } @overlays ]),"<br>\n";
|
||||
print &ui_form_end([ [ undef, $text{'overlay_ok'} ] ]);
|
||||
|
||||
&ui_print_footer("right.cgi", $text{'right_return'});
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<g transform="matrix(.3804 0 0 -.3804 -45.383 147.81)">
|
||||
<g clip-path="url(#clipPath4378)">
|
||||
<g transform="translate(188.69 382.73)">
|
||||
<path d="m0 0c40.231-1.537 22.668-21.769 73.743-8.284 43.506 11.486 52.259-59.513 6.705-55.023-31.707 3.126-18.03-51.842-80.448-55.274-34.592 0-62.645 26.551-62.645 59.291 0 32.739 28.053 59.29 62.645 59.29" fill="#fff"/>
|
||||
<path d="m0 0c40.231-1.537 22.668-21.769 73.743-8.284 43.506 11.486 52.259-59.513 6.705-55.023-31.707 3.126-18.03-51.842-80.448-55.274-34.592 0-62.645 26.551-62.645 59.291 0 32.739 28.053 59.29 62.645 59.29" fill="#e67e38"/>
|
||||
</g>
|
||||
</g>
|
||||
<g clip-path="url(#clipPath4390)">
|
||||
@@ -80,14 +80,14 @@
|
||||
<g clip-path="url(#clipPath4436)">
|
||||
<g clip-path="url(#clipPath4444)">
|
||||
<g clip-path="url(#clipPath4448)">
|
||||
<g transform="matrix(1 0 0 -1 142.87 284.15)" fill="#df7935">
|
||||
<g transform="matrix(1 0 0 -1 142.87 284.15)" fill="#fff">
|
||||
<path d="m75.456-71.654c-6.912-8.8704-18.432-11.981-27.533-11.981-25.459 0-44.006 16.704-44.006 42.854s18.547 42.854 44.006 42.854c11.174 0 22.694-4.4928 29.491-14.17l-14.976-11.174c-3.6864 5.5296-9.5616 8.7552-16.819 8.7552-13.018 0-23.04-11.059-23.04-26.266s10.022-26.266 23.616-26.266c6.6816 0 11.866 2.4192 15.437 6.6816z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g clip-path="url(#clipPath4472)">
|
||||
<g clip-path="url(#clipPath4476)">
|
||||
<g transform="matrix(1 0 0 -1 241.04 335.48)" fill="#df7935">
|
||||
<g transform="matrix(1 0 0 -1 241.04 335.48)" fill="#fff">
|
||||
<path d="m2.7136 0h7.68v-12.39c0-3.2256 0.6144-6.3488 4.608-6.3488 3.5328 0 3.4304 3.7376 3.4304 5.5808v13.158h7.68v-13.158c0-3.072 1.1264-5.5808 4.6592-5.5808 2.5088 0 3.3792 1.9456 3.3792 4.2496v14.49h7.68v-14.694c0-6.0416-2.048-10.803-8.8064-10.803-3.6864 0-6.0928 1.28-7.8848 4.4544-1.3312-3.0208-4.1984-4.4544-7.68-4.4544-3.7376 0-6.2464 2.048-7.2704 3.9936h-0.1024v-3.3792h-7.3728z"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -63,6 +63,25 @@ else {
|
||||
}
|
||||
$title = &get_html_title($title);
|
||||
|
||||
# Work out if we have a top frame
|
||||
if ($hasvirt) {
|
||||
%vconfig = &foreign_config("virtual-server");
|
||||
}
|
||||
$upperframe = $vconfig{'theme_topframe'} ||
|
||||
$gconfig{'theme_topframe'};
|
||||
$upperrows = $vconfig{'theme_toprows'} ||
|
||||
$gconfig{'theme_toprows'} || 200;
|
||||
if ($upperframe =~ /\$LEVEL|\$\{LEVEL/) {
|
||||
# Sub in user level
|
||||
$levelnum = &get_virtualmin_user_level();
|
||||
$level = $levelnum == 0 ? "master" :
|
||||
$levelnum == 1 ? "reseller" :
|
||||
$levelnum == 2 ? "domain" :
|
||||
$levelnum == 3 ? "usermin" :
|
||||
$levelnum == 4 ? "owner" : "unknown";
|
||||
$upperframe = &substitute_template($upperframe, { 'level' => $level });
|
||||
}
|
||||
|
||||
# Show frameset
|
||||
&PrintHeader();
|
||||
$cols = &get_left_frame_width();
|
||||
@@ -74,15 +93,15 @@ if ($current_lang_info->{'rtl'} || $current_lang eq "ar") {
|
||||
$fscols = "*,$cols";
|
||||
}
|
||||
|
||||
# The frameset declares the scheme inherited by its frames. The script keeps
|
||||
# an automatic scheme stable across reloads and provides the per-tab Alt+T
|
||||
# toggle and Alt+R content-frame reload. The stylesheet paints the background
|
||||
# behind frames while they load.
|
||||
# Page head. The frameset declares the same color scheme as its frames,
|
||||
# since browsers give a frame the scheme of the page holding it. When no
|
||||
# scheme is forced, the script keeps it steady across reloads. The
|
||||
# stylesheet paints the page ground behind a frame that is still loading.
|
||||
$scheme = &theme_color_scheme();
|
||||
print "<html".($scheme ? " data-scheme='$scheme'" : "").">\n";
|
||||
print "<head>\n";
|
||||
print "<meta name='color-scheme' content='".($scheme || "light dark")."'>\n";
|
||||
print &theme_scheme_script($scheme);
|
||||
print &theme_scheme_script() if (!$scheme);
|
||||
print "<link rel='stylesheet' type='text/css' href='".&get_webprefix().
|
||||
"/unauthenticated/gray-theme.css?".
|
||||
&theme_asset_key("gray-theme.css")."'>\n";
|
||||
@@ -104,6 +123,19 @@ print "<link rel='icon' type='image/png' sizes='32x32' href='$imgdir/favicons/
|
||||
print "<link rel='icon' type='image/png' sizes='192x192' href='$imgdir/favicons/$prod/favicon-192x192.png'>\n";
|
||||
print "</head>\n";
|
||||
|
||||
# Upper custom frame
|
||||
if ($upperframe) {
|
||||
print "<frameset rows='$upperrows,*' border=0>\n";
|
||||
if ($upperframe =~ /^\//) {
|
||||
# Local file to serve
|
||||
print "<frame name=top src='top.cgi' scrolling=auto>\n";
|
||||
}
|
||||
else {
|
||||
# Absolute URL
|
||||
print "<frame name=top src='$upperframe' scrolling=auto>\n";
|
||||
}
|
||||
}
|
||||
|
||||
# Left and right frames
|
||||
print "<frameset cols='$fscols' border=0>\n";
|
||||
print $frame1,"\n";
|
||||
@@ -117,6 +149,9 @@ print "</body>\n";
|
||||
print "</noframes>\n";
|
||||
|
||||
# End of the frames and page
|
||||
if ($upperframe) {
|
||||
print "</frameset>\n";
|
||||
}
|
||||
print "</frameset>\n";
|
||||
print "</html>\n";
|
||||
|
||||
|
||||
@@ -75,4 +75,13 @@ has_vm2=Cloudmin
|
||||
has_webmin=Webmin
|
||||
has_usermin=Usermin
|
||||
|
||||
overlay_title=Change Theme
|
||||
overlay_desc=This page allows you to select a different theme overlay, which can change the colors, icons and layout of the Virtualmin user interface.
|
||||
overlay_msg=Change theme overlay to:
|
||||
overlay_ok=Change Now
|
||||
overlay_none=None - use default colors and icons
|
||||
overlay_refresh=Refreshing Virtualmin user interface ..
|
||||
overlay_done=.. done
|
||||
overlay_euser=Failed to get details of current user!
|
||||
overlay_webmin=Reloading Webmin configuration ..
|
||||
__norefs=1
|
||||
|
||||
@@ -82,7 +82,7 @@ my %prodnames = ( 'cloudmin' => $text{'has_vm2'},
|
||||
popup_header($prodnames{$prod});
|
||||
|
||||
# The whole menu sits in one panel that floats on the page ground
|
||||
print "<div class='menu-panel menu-panel-$prod'>\n";
|
||||
print "<div class='menu-panel'>\n";
|
||||
|
||||
if ($mode eq "modules") {
|
||||
# Only showing Webmin modules
|
||||
|
||||
33
gray-theme/save_overlay.cgi
Executable file
33
gray-theme/save_overlay.cgi
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Update the theme overlay
|
||||
|
||||
require "gray-theme/gray-theme-lib.pl";
|
||||
&ReadParse();
|
||||
($gtheme) = split(/\s+/, $gconfig{'theme'});
|
||||
|
||||
# Get and modify the user
|
||||
&foreign_require("acl", "acl-lib.pl");
|
||||
($user) = grep { $_->{'name'} eq $base_remote_user } &acl::list_users();
|
||||
$user || &error($text{'overlay_euser'});
|
||||
if ($in{'overlay'}) {
|
||||
$user->{'theme'} = $current_theme;
|
||||
}
|
||||
elsif ($gtheme eq $current_theme) {
|
||||
# No need for user-specific theme
|
||||
delete($user->{'theme'});
|
||||
}
|
||||
$user->{'overlay'} = $in{'overlay'};
|
||||
&acl::modify_user($user->{'name'}, $user);
|
||||
|
||||
# Refresh the page
|
||||
&ui_print_header(undef, $text{'overlay_title'}, "");
|
||||
|
||||
print $text{'overlay_webmin'},"<br>\n";
|
||||
&reload_miniserv();
|
||||
print $text{'overlay_done'},"<p>\n";
|
||||
|
||||
print $text{'overlay_refresh'},"<br>\n";
|
||||
print &js_redirect("/", "top");
|
||||
print $text{'overlay_done'},"<p>\n";
|
||||
|
||||
&ui_print_footer("right.cgi", $text{'right_return'});
|
||||
@@ -279,7 +279,7 @@ sub theme_prehead
|
||||
my $pfx = &get_webprefix();
|
||||
my $scheme = &theme_color_scheme();
|
||||
print "<meta name='color-scheme' content='".($scheme || "light dark")."'>\n";
|
||||
print &theme_scheme_script($scheme);
|
||||
print &theme_scheme_script() if (!$scheme);
|
||||
print &theme_frame_script();
|
||||
print "<link rel='stylesheet' type='text/css' href='$pfx/unauthenticated/".
|
||||
"gray-theme.css?".&theme_asset_key("gray-theme.css")."'>\n";
|
||||
@@ -290,157 +290,42 @@ $tconfig{'inbody'} = (@cls ? "class='".join(" ", @cls)."'" : "").
|
||||
($scheme ? " data-scheme='$scheme'" : "");
|
||||
}
|
||||
|
||||
# theme_scheme_script([forced-scheme])
|
||||
# Returns the scheme bootstrap and frame-wide keyboard shortcuts. The optional
|
||||
# forced scheme is the configured default. Alt+T stores a per-tab override
|
||||
# without changing that setting, and Alt+R reloads only the content frame.
|
||||
# Scheme changes reach the frameset and both frames. An automatic scheme uses
|
||||
# its last value before first paint, follows system changes, and is checked
|
||||
# again after load.
|
||||
# theme_scheme_script()
|
||||
# Returns a script that keeps the color scheme steady when none is forced.
|
||||
# A browser can report the system scheme for a moment before settling on
|
||||
# its own, and gives a frame the scheme of the page holding it only once
|
||||
# that page is styled, so a frame could paint dark and then turn light on
|
||||
# every reload. The script applies the last scheme seen to the root
|
||||
# element before the first paint and follows later changes. In a top
|
||||
# window it also writes the scheme into the root style, so the frames
|
||||
# inherit it, and checks again a second after load if no change came, in
|
||||
# case the stored scheme was stale.
|
||||
sub theme_scheme_script
|
||||
{
|
||||
my ($forced) = @_;
|
||||
$forced = $forced && $forced =~ /^(light|dark)$/ ? $1 : "";
|
||||
my $rv = <<'EOF';
|
||||
return <<'EOF';
|
||||
<script type='text/javascript'>
|
||||
/* initializeGrayTheme(): Apply the scheme and bind frame shortcuts. */
|
||||
(function() {
|
||||
var key = 'gray-theme-scheme', overrideKey = key + '-override',
|
||||
forced = '__GRAY_THEME_FORCED_SCHEME__',
|
||||
root = document.documentElement,
|
||||
var key = 'gray-theme-scheme', root = document.documentElement,
|
||||
mq = window.matchMedia('(prefers-color-scheme: dark)'),
|
||||
stored = null, changed = false;
|
||||
try { stored = localStorage.getItem(key); } catch(e) { }
|
||||
/* valid(value): Return whether value is a supported scheme. */
|
||||
function valid(v) { return v == 'dark' || v == 'light'; }
|
||||
/* browserScheme(): Return the scheme selected by the browser. */
|
||||
function browserScheme() { return mq.matches ? 'dark' : 'light'; }
|
||||
/* baseScheme(): Return the configured scheme or system preference. */
|
||||
function baseScheme() { return valid(forced) ? forced : browserScheme(); }
|
||||
/* getOverride(): Read this tab's override when storage is available. */
|
||||
function getOverride() {
|
||||
try { return sessionStorage.getItem(overrideKey); }
|
||||
catch(e) { return null; }
|
||||
}
|
||||
/* rememberOverride(value): Store only a scheme that differs from the base. */
|
||||
function rememberOverride(v) {
|
||||
try {
|
||||
/* Remove an override that now matches the base. */
|
||||
if (v == baseScheme()) sessionStorage.removeItem(overrideKey);
|
||||
/* Keep a different choice for later pages in this tab. */
|
||||
else sessionStorage.setItem(overrideKey, v);
|
||||
}
|
||||
catch(e) { }
|
||||
}
|
||||
/* apply(value, [remember]): Apply a scheme and optionally cache it. */
|
||||
function apply(v, remember) {
|
||||
/* Ignore malformed values from storage or another frame. */
|
||||
if (!valid(v)) return;
|
||||
function current() { return mq.matches ? 'dark' : 'light'; }
|
||||
function apply(v) {
|
||||
root.setAttribute('data-scheme', v);
|
||||
/* Update body selectors once the body exists. */
|
||||
if (document.body) document.body.setAttribute('data-scheme', v);
|
||||
/* Let newly loaded frames inherit the frameset scheme. */
|
||||
if (self == top) root.style.colorScheme = v;
|
||||
/* Do not cache a scheme relayed from another frame. */
|
||||
if (remember !== false) {
|
||||
try { localStorage.setItem(key, v); } catch(e) { }
|
||||
}
|
||||
try { localStorage.setItem(key, v); } catch(e) { }
|
||||
}
|
||||
/* grayThemeApplyScheme(value, [remember]): Apply a relayed scheme. */
|
||||
window.grayThemeApplyScheme = function(v, remember) {
|
||||
changed = true;
|
||||
/* Save a relayed user choice as this tab's override. */
|
||||
if (remember) rememberOverride(v);
|
||||
apply(v, false);
|
||||
};
|
||||
/* applyEverywhere(value): Apply a scheme across the frameset. */
|
||||
function applyEverywhere(v) {
|
||||
changed = true;
|
||||
rememberOverride(v);
|
||||
apply(v);
|
||||
try {
|
||||
/* Relay a child-frame change to the frameset. */
|
||||
if (top != window && top.grayThemeApplyScheme)
|
||||
top.grayThemeApplyScheme(v, true);
|
||||
}
|
||||
catch(e) { }
|
||||
/* syncFrame(name): Apply the scheme to one named frame. */
|
||||
['left', 'right'].forEach(function(name) {
|
||||
try {
|
||||
var frame = top.frames[name];
|
||||
/* Skip this document and frames that are not ready. */
|
||||
if (frame && frame != window && frame.grayThemeApplyScheme)
|
||||
frame.grayThemeApplyScheme(v, true);
|
||||
}
|
||||
catch(e) { }
|
||||
});
|
||||
}
|
||||
/* Prefer the tab override, configured scheme, saved scheme, then system. */
|
||||
var override = getOverride();
|
||||
apply(valid(override) ? override :
|
||||
valid(forced) ? forced :
|
||||
valid(stored) ? stored : browserScheme());
|
||||
/* syncBodyScheme(): Copy the root scheme to the new body. */
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
apply(root.getAttribute('data-scheme'), false);
|
||||
});
|
||||
/* onchange(): Follow system changes unless a tab override is active. */
|
||||
function onchange() {
|
||||
var override = getOverride(), current = browserScheme();
|
||||
/* Preserve an override that differs from the system scheme. */
|
||||
if (valid(override) && override != current) return;
|
||||
/* Remove an override once the system reaches the same scheme. */
|
||||
if (valid(override)) rememberOverride(current);
|
||||
changed = true;
|
||||
apply(current);
|
||||
}
|
||||
/* A configured scheme does not follow system preference changes. */
|
||||
if (!valid(forced)) {
|
||||
/* Prefer the current media-query listener API. */
|
||||
if (mq.addEventListener) mq.addEventListener('change', onchange);
|
||||
/* Fall back to the older listener API. */
|
||||
else mq.addListener(onchange);
|
||||
}
|
||||
/* handleThemeShortcut(event): Handle Alt+T and Alt+R in any frame. */
|
||||
document.addEventListener('keydown', function(event) {
|
||||
/* Ignore other modifiers, repeats, composition and handled events. */
|
||||
if (event.defaultPrevented || event.repeat || event.isComposing ||
|
||||
!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
||||
return;
|
||||
/* Alt+T toggles the palette throughout the frameset. */
|
||||
if (event.code == 'KeyT' || event.keyCode == 84) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
applyEverywhere(root.getAttribute('data-scheme') == 'dark' ?
|
||||
'light' : 'dark');
|
||||
}
|
||||
/* Alt+R reloads content without refreshing navigation. */
|
||||
else if (event.code == 'KeyR' || event.keyCode == 82) {
|
||||
var frame = null;
|
||||
try { frame = top.frames['right']; } catch(e) { }
|
||||
/* Do not claim the browser shortcut outside the main frameset. */
|
||||
if (!frame) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try { frame.location.reload(); } catch(e) { }
|
||||
}
|
||||
}, true);
|
||||
/* Recheck an automatic scheme after the top-level page loads. */
|
||||
if (self == top && !valid(forced))
|
||||
/* refreshBrowserScheme(): Recheck after all frames load. */
|
||||
apply(stored == 'dark' || stored == 'light' ? stored : current());
|
||||
function onchange() { changed = true; apply(current()); }
|
||||
if (mq.addEventListener) mq.addEventListener('change', onchange);
|
||||
else mq.addListener(onchange);
|
||||
if (self == top)
|
||||
window.addEventListener('load', function() {
|
||||
/* refreshUnchangedScheme(): Correct untouched automatic state. */
|
||||
setTimeout(function() {
|
||||
/* Preserve user changes and tab overrides. */
|
||||
if (!changed && !valid(getOverride()))
|
||||
applyEverywhere(browserScheme());
|
||||
}, 1000);
|
||||
setTimeout(function() { if (!changed) apply(current()); }, 1000);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
EOF
|
||||
$rv =~ s/__GRAY_THEME_FORCED_SCHEME__/$forced/g;
|
||||
return $rv;
|
||||
}
|
||||
|
||||
# theme_frame_script()
|
||||
@@ -519,8 +404,6 @@ return &theme_prehead();
|
||||
sub theme_ui_table_start
|
||||
{
|
||||
my ($heading, $tabletags, $cols, $tds, $rightheading) = @_;
|
||||
# Expose the column count so CSS can size the form layout.
|
||||
my $formcols = $cols || 4;
|
||||
if (! $tabletags =~ /width/) { $tabletages .= " width=100%"; }
|
||||
if (defined($main::ui_table_cols)) {
|
||||
# Push on stack, for nested call
|
||||
@@ -542,11 +425,9 @@ $rv .= "<table class='ui_table' $tabletags>\n";
|
||||
if (defined($heading) || defined($rightheading)) {
|
||||
# Heading row, with an optional part at the right
|
||||
$rv .= "<thead><tr>";
|
||||
# Add the main heading when present.
|
||||
if (defined($heading)) {
|
||||
$rv .= "<td><b>$heading</b></td>"
|
||||
}
|
||||
# Put optional actions or context at the right.
|
||||
if (defined($rightheading)) {
|
||||
$rv .= "<td align=right>$rightheading</td>";
|
||||
$colspan++;
|
||||
@@ -554,8 +435,8 @@ if (defined($heading) || defined($rightheading)) {
|
||||
$rv .= "</tr></thead>\n";
|
||||
}
|
||||
$rv .= "<tbody> <tr class='ui_table_body'> <td colspan=$colspan>".
|
||||
"<table class='ui_form_rows ui_form_rows_$formcols' width=100%>\n";
|
||||
$main::ui_table_cols = $formcols;
|
||||
"<table width=100%>\n";
|
||||
$main::ui_table_cols = $cols || 4;
|
||||
$main::ui_table_pos = 0;
|
||||
$main::ui_table_default_tds = $tds;
|
||||
return $rv;
|
||||
@@ -690,10 +571,8 @@ my ($border) = @_;
|
||||
return $border ? "</div>\n" : "";
|
||||
}
|
||||
|
||||
# theme_ui_columns_start(&headings, [width-percent], [no-border],
|
||||
# [&td-tags], [title], [sortable], [class])
|
||||
# Returns HTML for a multi-column table. The sortable flag forces client-side
|
||||
# sorting, and class adds a table class.
|
||||
# theme_ui_columns_start(&headings, [width-percent], [noborder], [&tdtags], [title])
|
||||
# Returns HTML for a multi-column table, with the given headings
|
||||
sub theme_ui_columns_start
|
||||
{
|
||||
my ($heads, $width, $noborder, $tdtags, $title, $sortable, $class) = @_;
|
||||
@@ -701,12 +580,11 @@ my ($href) = grep { $_ =~ /<a\s+href/i } @$heads;
|
||||
my $rv;
|
||||
$theme_ui_columns_row_toggle = 0;
|
||||
if (!$noborder && !$main::COLUMNS_WRAPPER_OPEN) {
|
||||
# The outermost bordered table opens the card and a container that
|
||||
# scrolls wide columns without widening the page.
|
||||
# The outermost bordered table opens the card
|
||||
$rv .= "<table class='wrapper' width="
|
||||
. ($width ? $width : "100")
|
||||
. "%>\n";
|
||||
$rv .= "<tr><td><div class='ui_columns_scroll'>\n";
|
||||
$rv .= "<tr><td>\n";
|
||||
}
|
||||
if (!$noborder) {
|
||||
# Nested bordered tables share that card
|
||||
@@ -773,15 +651,13 @@ return $rv;
|
||||
}
|
||||
|
||||
# theme_ui_columns_end()
|
||||
# Ends a table from theme_ui_columns_start. Also closes the scroll container
|
||||
# and card opened for the outermost bordered table.
|
||||
# Returns HTML to end a table started by ui_columns_start
|
||||
sub theme_ui_columns_end
|
||||
{
|
||||
my $rv;
|
||||
$rv = "</tbody> </table>\n";
|
||||
# Close the shared scroll container with the outermost table.
|
||||
if ($main::COLUMNS_WRAPPER_OPEN == 1) {
|
||||
$rv .= "</div></td> </tr> </table>\n";
|
||||
if ($main::COLUMNS_WRAPPER_OPEN == 1) { # Last wrapper
|
||||
$rv .= "</td> </tr> </table>\n";
|
||||
}
|
||||
$main::COLUMNS_WRAPPER_OPEN--;
|
||||
return $rv;
|
||||
@@ -982,21 +858,14 @@ else {
|
||||
}
|
||||
|
||||
# theme_ui_links_row(&links)
|
||||
# Returns links in a toolbar wrapper. Gray Theme hides the legacy separators
|
||||
# and styles each link as a button.
|
||||
# Returns a row of links separated by bars, in a block of its own so that
|
||||
# the stylesheet can draw it as a toolbar when it sits above a table
|
||||
sub theme_ui_links_row
|
||||
{
|
||||
my ($links) = @_;
|
||||
# An empty list does not need a toolbar container.
|
||||
return "" if (!$links || !@$links);
|
||||
my @button_links = map {
|
||||
my $link = $_;
|
||||
# Drop the final period from the button without changing its source text.
|
||||
$link =~ s/(?<!\.)\.(?=(?:\s*<\/[^>]+>)*\s*<\/a>\s*$)//i;
|
||||
$link;
|
||||
} @$links;
|
||||
return "<div class='ui_links_row'>".
|
||||
join(" <span class='ui_links_sep'>|</span> ", @button_links).
|
||||
join(" <span class='ui_links_sep'>|</span> ", @$links).
|
||||
"</div>\n";
|
||||
}
|
||||
|
||||
|
||||
15
gray-theme/top.cgi
Executable file
15
gray-theme/top.cgi
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Output contents of top frame file
|
||||
|
||||
require "gray-theme/gray-theme-lib.pl";
|
||||
|
||||
$hasvirt = &foreign_available("virtual-server");
|
||||
if ($hasvirt) {
|
||||
%vconfig = &foreign_config("virtual-server");
|
||||
}
|
||||
$upperframe = $vconfig{'theme_topframe'} ||
|
||||
$gconfig{'theme_topframe'};
|
||||
|
||||
&PrintHeader();
|
||||
print &read_file_contents($upperframe);
|
||||
|
||||
@@ -416,19 +416,12 @@ details[open].menu-cat > summary::after {
|
||||
|
||||
/* ---- Form controls ---- */
|
||||
|
||||
/* Forms and their controls shrink inside the content column. */
|
||||
form.ui_form {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Text fields, menus and text areas share one height, edge and radius */
|
||||
input[type="text"], input[type="password"], input[type="email"],
|
||||
input[type="number"], input[type="search"], input[type="url"],
|
||||
input[type="tel"], input[type="date"], input[type="time"],
|
||||
input[type="datetime-local"], input:not([type]), select, textarea {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
min-height: var(--control-height);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color-input);
|
||||
@@ -449,29 +442,6 @@ select:not([multiple]):not([size]), select[size="1"]:not([multiple]) {
|
||||
background-position: right 9px center;
|
||||
background-size: 10px 10px;
|
||||
}
|
||||
/* Treat byte values and their unit menu as one compact control. */
|
||||
.ui_bytesbox {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ui_bytesbox > input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
.ui_bytesbox > select {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: -1px;
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
.ui_bytesbox > :focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
/* List boxes keep their own height */
|
||||
select[multiple], select[size]:not([size="1"]) {
|
||||
min-height: 0;
|
||||
@@ -520,11 +490,6 @@ textarea + input[type="radio"],
|
||||
button + input[type="radio"] {
|
||||
margin-left: 10px;
|
||||
}
|
||||
/* Keep controls on the line below a text area from touching it. */
|
||||
textarea + br + input[type="checkbox"],
|
||||
textarea + br + input[type="radio"] {
|
||||
margin-top: 8px;
|
||||
}
|
||||
input[type="file"] { font: inherit; }
|
||||
/* Labels of checkboxes inside bold cells stay plain */
|
||||
.ui_form_value td > b > input + label + tt,
|
||||
@@ -534,7 +499,8 @@ input[type="file"] { font: inherit; }
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
|
||||
/* Form buttons use Gray Theme's compact raised surface and small radius. */
|
||||
/* Every button, from the core classes to plain button tags, is a white
|
||||
* pill with a gray edge */
|
||||
input[type="submit"], input[type="button"], input[type="reset"], button,
|
||||
input.ui_submit, input.ui_button, input.ui_reset {
|
||||
display: inline-block;
|
||||
@@ -576,135 +542,6 @@ input[type="reset"]:disabled, button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* UI-lib actions can carry classes used by other themes. Reset the outer
|
||||
* control so they cannot change Gray Theme's sizing; the inner element draws
|
||||
* the visible surface. */
|
||||
:is(button, a, summary).ui_action {
|
||||
--ui-action-radius: var(--radius-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--ui-action-radius);
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
color: var(--ui-action-text);
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Gray Theme softens UI-lib's square default to match its form controls,
|
||||
* while keeping the stronger rounded and pill shape options distinct. */
|
||||
:is(button, a, summary).ui_action.ui_action_rounded {
|
||||
--ui-action-radius: var(--radius);
|
||||
}
|
||||
:is(button, a, summary).ui_action.ui_action_pill {
|
||||
--ui-action-radius: 999px;
|
||||
}
|
||||
/* Neutral actions sit beside fields, so use the same surface and edge as
|
||||
* inputs and selects in both palettes. */
|
||||
:is(button, a, summary).ui_action.ui_action_neutral {
|
||||
--ui-action-text: var(--text-color);
|
||||
--ui-action-bg: var(--bg-color-input);
|
||||
--ui-action-border: var(--border-color-input);
|
||||
--ui-action-hover-text: var(--text-color);
|
||||
--ui-action-hover-bg: var(--bg-color-hover);
|
||||
--ui-action-hover-border: var(--text-color-disabled);
|
||||
--ui-action-active-text: var(--text-color);
|
||||
--ui-action-active-bg: var(--bg-color-switch);
|
||||
--ui-action-active-border: var(--border-color-darker);
|
||||
}
|
||||
/* The inner element draws the action; the outer element handles semantics,
|
||||
* keyboard focus and shape. */
|
||||
:is(button, a, summary).ui_action > .ui_action_content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--ui-action-border);
|
||||
border-radius: inherit;
|
||||
background: var(--ui-action-bg);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* Compact and large actions retain the shared component's size variants. */
|
||||
:is(button, a, summary).ui_action.ui_action_small > .ui_action_content {
|
||||
padding: 0 8px 0 7px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
:is(button, a, summary).ui_action.ui_action_large > .ui_action_content {
|
||||
min-height: var(--control-height);
|
||||
padding: 4px 12px;
|
||||
font-size: 1em;
|
||||
}
|
||||
/* Interaction colors belong to the visible inner surface. */
|
||||
:is(button, a, summary).ui_action:not(:disabled):not([aria-disabled="true"]):hover {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ui-action-hover-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
:is(button, a, summary).ui_action:not(:disabled):not([aria-disabled="true"]):hover > .ui_action_content {
|
||||
background: var(--ui-action-hover-bg);
|
||||
border-color: var(--ui-action-hover-border);
|
||||
}
|
||||
:is(button, a, summary).ui_action:not(:disabled):not([aria-disabled="true"]):active {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ui-action-active-text);
|
||||
}
|
||||
:is(button, a, summary).ui_action:not(:disabled):not([aria-disabled="true"]):active > .ui_action_content {
|
||||
background: var(--ui-action-active-bg);
|
||||
border-color: var(--ui-action-active-border);
|
||||
box-shadow: inset 0 0 0 1px var(--ui-action-active-border);
|
||||
}
|
||||
/* Put the keyboard outline around the painted surface, not its wrapper. */
|
||||
:is(button, a, summary).ui_action:focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
:is(button, a, summary).ui_action:focus-visible > .ui_action_content {
|
||||
outline: 2px solid var(--ui-action-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Disabled actions keep their semantic state and use disabled palette
|
||||
* variables on the visible surface. */
|
||||
:is(button, a, summary).ui_action:is(:disabled, [aria-disabled="true"]) {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ui-action-disabled-text);
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
:is(button, a, summary).ui_action:is(:disabled, [aria-disabled="true"]) > .ui_action_content {
|
||||
background: var(--ui-action-disabled-bg);
|
||||
border-color: var(--ui-action-disabled-border);
|
||||
}
|
||||
|
||||
/* Legacy forms can print action buttons directly instead of using a
|
||||
* button-row wrapper. Keep consecutive controls from touching. */
|
||||
form.ui_form > :is(input[type="submit"], input[type="button"],
|
||||
input[type="reset"], button) +
|
||||
:is(input[type="submit"], input[type="button"], input[type="reset"], button) {
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
|
||||
/* The first button of a form is the primary action, unless it deletes */
|
||||
.ui_form_end_buttons td:first-child > input.ui_submit:not([name*="delete"]):not([name*="remove"]),
|
||||
.ui_form_end_side_left .ui_form_end_buttons td:first-child > input.ui_submit,
|
||||
@@ -906,7 +743,7 @@ table.header > tbody > tr > td {
|
||||
color: var(--text-color);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.333;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -1098,14 +935,6 @@ table.header:has(+ .ui_post_header) {
|
||||
/* Forms with a heading, tables of rows, collapsible sections, tab boxes
|
||||
* and icon grids all sit in the same card */
|
||||
|
||||
/* Give modern widgets the theme's card spacing. Add the same gap after a
|
||||
* widget group followed by legacy page content. */
|
||||
body .ui_page { --ui-gap: var(--card-gap); }
|
||||
.ui_page:has(~ :not(script, style, link, dialog, [hidden], .ui_hidden,
|
||||
input[type="hidden"])) {
|
||||
margin-bottom: var(--card-gap);
|
||||
}
|
||||
|
||||
table.shrinkwrapper, table.wrapper, div.wrapper,
|
||||
details.ui_hidden_table_start, details.section, div.ui_tabs_box {
|
||||
width: 100%;
|
||||
@@ -1119,29 +948,9 @@ div.wrapper, details.ui_hidden_table_start, details.section,
|
||||
div.ui_tabs_box { box-sizing: border-box; }
|
||||
table.shrinkwrapper > tbody > tr > td,
|
||||
table.wrapper > tbody > tr > td { padding: 0; }
|
||||
/* Data-table cards stay within the page. Only their columns scroll when
|
||||
* their natural width is wider than the available content area. */
|
||||
table.wrapper:has(> tbody > tr > td > .ui_columns_scroll) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
.ui_columns_scroll {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
/* Keep form tables inside their cards regardless of control width. Column
|
||||
* count classes give labels useful proportions in multi-field rows. */
|
||||
table.shrinkwrapper > tbody > tr > td > .ui_table,
|
||||
table.ui_form_rows { table-layout: fixed; }
|
||||
table.ui_form_rows_2 > tbody > tr.ui_form_pair > td.ui_form_label:not([width]) {
|
||||
width: 20%;
|
||||
}
|
||||
:is(table.ui_form_rows_3, table.ui_form_rows_4) > tbody > tr.ui_form_pair > td.ui_form_label:not([width]) {
|
||||
width: 15%;
|
||||
}
|
||||
:is(table.ui_form_rows_5, table.ui_form_rows_6) > tbody > tr.ui_form_pair > td.ui_form_label:not([width]) {
|
||||
width: 10%;
|
||||
}
|
||||
/* A fixed layout keeps the label and value columns steady whatever the
|
||||
* values hold */
|
||||
table.shrinkwrapper > tbody > tr > td > .ui_table { table-layout: fixed; }
|
||||
|
||||
/* ---- Forms with a heading (ui_table_start) ---- */
|
||||
/* Markup: table.shrinkwrapper > td > table.ui_table with the heading in
|
||||
@@ -1298,32 +1107,9 @@ details.ui_hidden_table_start > summary span.rightheading {
|
||||
font-weight: 400;
|
||||
}
|
||||
details.ui_hidden_table_start > table {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 8px 14px 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
details.ui_hidden_table_start > table > tbody {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
/* Make a collapsible form the only horizontal scroll area. Nested column
|
||||
* tables keep their natural width without adding another scrollbar. */
|
||||
details.ui_hidden_table_start table.wrapper:has(.ui_columns_scroll) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
details.ui_hidden_table_start .ui_columns_scroll {
|
||||
width: auto;
|
||||
max-width: none;
|
||||
overflow-x: visible;
|
||||
}
|
||||
details.ui_hidden_table_start table.ui_columns {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
.section-body { padding: 10px 16px 14px; }
|
||||
/* Text above a dashboard block's content */
|
||||
@@ -1541,14 +1327,6 @@ form.ui_form > table.wrapper + table.wrapper:has(> tbody > tr > td > table.ui_gr
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* A legacy card form may print its action buttons directly after the
|
||||
* table. Leave the normal card gap before another form begins; the core
|
||||
* may put its form-end script between the two forms. */
|
||||
form.ui_form:has(> :is(table.wrapper, table.shrinkwrapper)):has(> .ui_submit):has(
|
||||
+ form.ui_form, + script + form.ui_form) {
|
||||
margin-bottom: var(--card-gap);
|
||||
}
|
||||
|
||||
/* A small form of controls before another form or a table, such as the
|
||||
* search box over a list of web apps or the file menu over a config
|
||||
* editor, is one row of controls with the card gap under it, so it
|
||||
@@ -1687,11 +1465,6 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
}
|
||||
/* Card actions align help bubbles through ui-lib, but this theme still
|
||||
* needs the bubble to anchor its generated tooltip. */
|
||||
.ui_card_actions .ui_help[data-tooltip][aria-label] {
|
||||
position: relative;
|
||||
}
|
||||
[data-tooltip][aria-label]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -1707,8 +1480,6 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
/* Keep a tooltip near the top above the sticky page header. */
|
||||
z-index: 10;
|
||||
}
|
||||
[data-tooltip][aria-label]::before {
|
||||
content: attr(aria-label);
|
||||
@@ -1725,13 +1496,11 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
text-shadow: none;
|
||||
/* Wrap between words, breaking one only when it is too long. */
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
transform: translateX(50%);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
[data-tooltip][aria-label]:hover::before,
|
||||
[data-tooltip][aria-label]:hover::after {
|
||||
@@ -1739,12 +1508,6 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
|
||||
opacity: 1;
|
||||
transition-duration: 0.5s;
|
||||
}
|
||||
/* Opacity gives the tooltip trigger its own stacking context. Raise that
|
||||
* context while the tooltip is visible, because raising its children alone
|
||||
* cannot place them above the sticky header. */
|
||||
[data-tooltip][aria-label]:hover {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ---- Inline disclosures (ui_details) ---- */
|
||||
|
||||
@@ -1834,12 +1597,10 @@ body.left-frame {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding-bottom: 12px;
|
||||
background: var(--bg-color-frame);
|
||||
border-bottom: 1px solid var(--border-color-frame);
|
||||
}
|
||||
/* Leave room below the product switch only when it is present. Without
|
||||
* one, the top block ends at its single divider. */
|
||||
.menu-top:has(> .mode) { padding-bottom: 12px; }
|
||||
/* A bar bouncing along the top edge of the panel while the right frame
|
||||
* loads a page; the menu script sets the class on the root */
|
||||
.menu-loader {
|
||||
@@ -1863,17 +1624,12 @@ html.loading-right .menu-loader { display: block; }
|
||||
background: var(--accent-color);
|
||||
animation: menu-bounce 0.9s ease-in-out infinite alternate;
|
||||
}
|
||||
/* Match Authentic's crimson progress bar for Cloudmin navigation. */
|
||||
.menu-panel-cloudmin .menu-loader::before { background: #dc143c; }
|
||||
@keyframes menu-bounce { to { left: 65%; } }
|
||||
/* The brand block: the logo has as much room above it as below it, and
|
||||
* the logo files are trimmed to their drawing, so every product sits the
|
||||
* same way. The logo links to the system information page. */
|
||||
.menu-brand {
|
||||
padding: 14px 16px 14px;
|
||||
}
|
||||
/* Separate the brand details from the product switch when it exists. */
|
||||
.menu-top:has(> .mode) .menu-brand {
|
||||
border-bottom: 1px solid var(--border-color-frame);
|
||||
}
|
||||
.menu-brand-name {
|
||||
@@ -2000,29 +1756,6 @@ html.loading-right .menu-loader { display: block; }
|
||||
color: var(--text-color-inverse);
|
||||
text-decoration: none;
|
||||
}
|
||||
/* Cloudmin's creation action uses a quiet orange tint and outline. Normal
|
||||
* theme text stays readable without making the button dominate the menu. */
|
||||
.menu-panel-cloudmin .menu-create {
|
||||
background: rgba(223, 121, 53, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba(223, 121, 53, 0.42);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.menu-panel-cloudmin .menu-create:hover {
|
||||
background: rgba(223, 121, 53, 0.22);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.menu-panel-cloudmin .menu-create svg { color: #df7935; }
|
||||
/* Virtualmin uses the same quiet treatment with the theme's blue palette. */
|
||||
.menu-panel-virtualmin .menu-create {
|
||||
background: var(--element-highlight-bg-color);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-color-soft);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.menu-panel-virtualmin .menu-create:hover {
|
||||
box-shadow: inset 0 0 0 2px var(--border-color-info);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.menu-panel-virtualmin .menu-create svg { color: var(--accent-color-hover); }
|
||||
/* Forms in the menu: a caps label over a field that fills the panel */
|
||||
.menu-form { margin: 0 8px 12px; }
|
||||
.menu-label {
|
||||
@@ -2110,9 +1843,6 @@ details.menu-cat[open] { margin-bottom: 4px; }
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border-color-frame);
|
||||
}
|
||||
/* A leading separator follows hidden format metadata in modern Cloudmin,
|
||||
* so it has no visible section above it to divide. */
|
||||
.leftmenu > .menu-divider:first-child { display: none; }
|
||||
.menu-text, .menu-html {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
|
||||
2
lang/en
2
lang/en
@@ -478,6 +478,4 @@ defcert_error=Default $1 bundled SSL certificate is being used. It is highly adv
|
||||
|
||||
main_error_details=Error details
|
||||
|
||||
exempli_gratia=e.g.
|
||||
|
||||
__norefs=1
|
||||
|
||||
@@ -150,7 +150,7 @@ $cmd_unpacked .= " | tac" if ($journal_tac);
|
||||
my $view_title = $in{'idx'} =~ /^journal/ ?
|
||||
$text{'view_titlejournal'} : $text{'view_title'};
|
||||
&ui_print_header("<tt>".&html_escape($file || $cmd_unpacked)."</tt>",
|
||||
&html_escape($in{'linktitle'}) || $view_title, "", undef,
|
||||
$in{'linktitle'} || $view_title, "", undef,
|
||||
!$no_navlinks && $skip_index,
|
||||
($no_navlinks || $skip_index) ? 1 : undef,
|
||||
0, $help_link);
|
||||
|
||||
@@ -6,8 +6,7 @@ require './lpadmin-lib.pl';
|
||||
&ReadParse();
|
||||
print "Refresh: $config{'queue_refresh'}\r\n"
|
||||
if ($config{'queue_refresh'});
|
||||
&ui_print_header(&text('jobs_on', "<tt>".&html_escape($in{'name'})."</tt>"),
|
||||
$text{'jobs_title'}, "");
|
||||
&ui_print_header(&text('jobs_on', "<tt>$in{'name'}</tt>"), $text{'jobs_title'}, "");
|
||||
|
||||
@jobs = &get_jobs($in{'name'});
|
||||
if (@jobs) {
|
||||
@@ -75,8 +74,7 @@ if (@jobs) {
|
||||
}
|
||||
}
|
||||
else {
|
||||
print "<b>",&text('jobs_none', "<tt>".&html_escape($in{'name'})."</tt>"),
|
||||
"</b><p>\n";
|
||||
print "<b>",&text('jobs_none', "<tt>$in{'name'}</tt>"),"</b><p>\n";
|
||||
}
|
||||
|
||||
# Test print button
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
require './lpadmin-lib.pl';
|
||||
&ReadParse();
|
||||
$access{'test'} || &error($text{'test_ecannot'});
|
||||
&ui_print_header(&text('jobs_on', "<tt>".&html_escape($in{'name'})."</tt>"),
|
||||
&ui_print_header(&text('jobs_on', "<tt>$in{'name'}</tt>"),
|
||||
$text{'test_title'}, "");
|
||||
|
||||
print "<form action=test_print.cgi method=post enctype=multipart/form-data>\n";
|
||||
|
||||
@@ -6,7 +6,7 @@ require './lpadmin-lib.pl';
|
||||
&foreign_require("proc", "proc-lib.pl");
|
||||
&ReadParseMime();
|
||||
$access{'test'} || &error($text{'test_ecannot'});
|
||||
&ui_print_header(&text('jobs_on', "<tt>".&html_escape($in{'name'})."</tt>"),
|
||||
&ui_print_header(&text('jobs_on', "<tt>$in{'name'}</tt>"),
|
||||
$text{'test_title'}, "");
|
||||
|
||||
if ($in{'mode'} == 0) {
|
||||
|
||||
@@ -3001,7 +3001,7 @@ my $rv = eval {
|
||||
}
|
||||
elsif ($str =~ /^(\d{4})\-(\d+)\-(\d+)\s+(\d+):(\d+)/) {
|
||||
# Format like 2004-12-07 12:53
|
||||
local $tm = timelocal(0, $5, $4, $3, $2-1,
|
||||
local $tm = timelocal(0, $4, $4, $3, $2-1,
|
||||
$1 < 50 ? $1+100 : $1 < 1000 ? $1 : $1-1900);
|
||||
return $tm;
|
||||
}
|
||||
|
||||
@@ -351,8 +351,6 @@ sform_and=Find messages matching all criteria below ..
|
||||
sform_or=Find messages matches any criteria below ..
|
||||
sform_neg0=contains
|
||||
sform_neg1=doesn't contain
|
||||
sform_larger=larger than
|
||||
sform_smaller=smaller than
|
||||
sform_ok=Search Now
|
||||
sform_folder2=Search in folder(s)
|
||||
sform_all=<All folders>
|
||||
|
||||
@@ -11,27 +11,6 @@ require './mailboxes-lib.pl';
|
||||
&ui_print_header(undef, $text{'sform_title'}, "", undef, 0, 0, undef,
|
||||
&folder_link($in{'user'}, $folder));
|
||||
|
||||
# Escape localized labels before inserting them into JavaScript strings.
|
||||
my $sform_larger_js = "e_javascript($text{'sform_larger'});
|
||||
my $sform_smaller_js = "e_javascript($text{'sform_smaller'});
|
||||
my $sform_neg0_js = "e_javascript($text{'sform_neg0'});
|
||||
my $sform_neg1_js = "e_javascript($text{'sform_neg1'});
|
||||
print <<EOF;
|
||||
<script>
|
||||
function set_neg(mode, negfield)
|
||||
{
|
||||
if (mode == "size") {
|
||||
negfield.options[0].text = "$sform_larger_js";
|
||||
negfield.options[1].text = "$sform_smaller_js";
|
||||
}
|
||||
else {
|
||||
negfield.options[0].text = "$sform_neg0_js";
|
||||
negfield.options[1].text = "$sform_neg1_js";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
EOF
|
||||
|
||||
# Start of form
|
||||
print &ui_form_start("mail_search.cgi");
|
||||
print &ui_hidden("user", $in{'user'});
|
||||
@@ -54,8 +33,7 @@ for($i=0; $i<=4; $i++) {
|
||||
map { [ $_, $_ eq 'all' ? $text{'sform_allmsg'}
|
||||
: $text{"sform_".$_} ] }
|
||||
( 'from', 'subject', 'to', 'cc', 'date',
|
||||
'body', 'headers', 'all', 'size') ],
|
||||
1, 0, 0, undef, "onChange='set_neg(value, form.neg_$i)'"));
|
||||
'body', 'headers', 'all', 'size') ]));
|
||||
|
||||
push(@cols, &ui_select("neg_$i", 0,
|
||||
[ [ 0, $text{'sform_neg0'} ],
|
||||
|
||||
@@ -2383,31 +2383,18 @@ $miniserv::page_capture_out .= $buf
|
||||
if ($miniserv::page_capture);
|
||||
}
|
||||
|
||||
# READ(handle, buffer, length, [offset])
|
||||
# Read buffered POST data or SOCK, replacing the buffer tail like Perl read.
|
||||
# Send back already read data while we have it, then read from SOCK
|
||||
sub READ
|
||||
{
|
||||
my $r = shift;
|
||||
my $bufref = \$_[0];
|
||||
my $len = $_[1];
|
||||
my $offset = $_[2] || 0;
|
||||
die "Negative length" if ($len < 0);
|
||||
$$bufref = '' if (!defined($$bufref));
|
||||
# Resolve offsets before consuming input, and pad beyond the old buffer end.
|
||||
$offset += length($$bufref) if ($offset < 0);
|
||||
die "Offset outside string" if ($offset < 0);
|
||||
$$bufref .= "\0" x ($offset - length($$bufref))
|
||||
if ($offset > length($$bufref));
|
||||
# A zero-length read must not wait for network input.
|
||||
if (!$len) {
|
||||
substr($$bufref, $offset) = '';
|
||||
return 0;
|
||||
}
|
||||
my $offset = $_[2];
|
||||
if ($postpos < length($postinput)) {
|
||||
# Reading from already fetched array
|
||||
my $left = length($postinput) - $postpos;
|
||||
my $canread = $len > $left ? $left : $len;
|
||||
substr($$bufref, $offset) =
|
||||
substr($$bufref, $offset, $canread) =
|
||||
substr($postinput, $postpos, $canread);
|
||||
$postpos += $canread;
|
||||
return $canread;
|
||||
@@ -2419,7 +2406,7 @@ else {
|
||||
# End of socket
|
||||
shutdown(SOCK, 0);
|
||||
}
|
||||
substr($$bufref, $offset) = $data;
|
||||
substr($$bufref, $offset, length($data)) = $data;
|
||||
return length($data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
acl adsl-client apache at authentic-theme backup-config bacula-backup bandwidth bind8 bsdexports bsdfdisk change-user cluster-copy cluster-cron cluster-passwd cluster-shell cluster-software cluster-useradmin cluster-usermin cluster-webmin cpan cron custom dfsadmin dhcpd dovecot exim exports fail2ban fdisk fetchmail filemin filter firewall firewall6 firewalld format fsdump gray-theme grub2 hardware-info heartbeat hpuxexports htaccess-htpasswd idmapd inetd init systemd inittab ipfilter ipfw ipsec iscsi-client iscsi-server iscsi-target iscsi-tgtd kea-dhcp krb5 ldap-client ldap-server ldap-useradmin logrotate logviewer lpadmin lvm mailboxes mailcap man mount mysql net nftables nginx nis openslp package-updates pam pap passwd phpini postfix postgresql ppp-client pptp-client pptp-server proc procmail proftpd qmailadmin quota raid rbac samba sarg sendmail servers sgiexports shell shorewall shorewall6 smart-status smf software spam sqlite squid sshd status stunnel syslog syslog-ng system-status tcpwrappers time tunnel updown useradmin usermin webalizer webmin webmincron webminlog xinetd xterm zones
|
||||
acl adsl-client apache at authentic-theme backup-config bacula-backup bandwidth bind8 bsdexports bsdfdisk change-user cluster-copy cluster-cron cluster-passwd cluster-shell cluster-software cluster-useradmin cluster-usermin cluster-webmin cpan cron custom dfsadmin dhcpd dovecot exim exports fail2ban fdisk fetchmail filemin filter firewall firewall6 firewalld format fsdump gray-theme grub2 hardware-info heartbeat hpuxexports htaccess-htpasswd idmapd inetd init systemd inittab ipfilter ipfw ipsec iscsi-client iscsi-server iscsi-target iscsi-tgtd kea-dhcp krb5 ldap-client ldap-server ldap-useradmin logrotate logviewer lpadmin lvm mailboxes mailcap man mount mysql net nftables nginx nis openslp package-updates pam pap passwd phpini postfix postgresql ppp-client pptp-client pptp-server proc procmail proftpd qmailadmin quota raid rbac samba sarg sendmail servers sgiexports shell shorewall shorewall6 smart-status smf software spam squid sshd status stunnel syslog syslog-ng system-status tcpwrappers time tunnel updown useradmin usermin webalizer webmin webmincron webminlog xinetd xterm zones
|
||||
|
||||
@@ -23,30 +23,16 @@ if ($in{'dest'} == 1 && $in{'all'}) {
|
||||
if ($in{'dest'} == 1 && ($in{'sched'} || $in{'save'})) {
|
||||
&error($text{'backup_edownloadsave'});
|
||||
}
|
||||
# Apply the path restriction to both immediate and saved backups.
|
||||
if (!$in{'dest'} && !&is_under_directory($access{'bpath'}, $in{'file'})) {
|
||||
&error($text{'backup_epath'}."<br>".
|
||||
&text('backup_eunder', "<tt>$access{'bpath'}</tt>"));
|
||||
}
|
||||
# Dump options can create files directly and bypass output restrictions.
|
||||
if ($in{'parameters'} &&
|
||||
($access{'bpath'} ne '/' || $access{'buser'} ne 'root')) {
|
||||
&error($text{'backup_eparameters'});
|
||||
}
|
||||
|
||||
# Validate fields required to run a backup now or from a schedule.
|
||||
if (!$in{'save'} || $in{'sched'}) {
|
||||
if ($in{'all'}) {
|
||||
# All-database backups write one file per database into a directory.
|
||||
-d $in{'file'} || -d &date_subs($in{'file'}) || $in{'mkdir'} ||
|
||||
&error($text{'backup_efile2'});
|
||||
}
|
||||
else {
|
||||
# A single-database file backup needs an absolute destination.
|
||||
$in{'dest'} || $in{'file'} =~ /^\/\S+$/ ||
|
||||
&error($text{'backup_efile'});
|
||||
}
|
||||
# Check optional filters, character sets, and compression programs.
|
||||
$in{'where_def'} || $in{'where'} || &error($text{'backup_ewhere'});
|
||||
$in{'charset_def'} || $in{'charset'} =~ /^\S+$/ ||
|
||||
&error($text{'backup_echarset'});
|
||||
@@ -54,8 +40,11 @@ if (!$in{'save'} || $in{'sched'}) {
|
||||
$in{'compress'} == 2 ? "bzip2" : undef;
|
||||
!$ccmd || &has_command($ccmd) ||
|
||||
&error(&text('backup_eccmd', "<tt>$ccmd</tt>"));
|
||||
if (!$in{'dest'} && !&is_under_directory($access{'bpath'}, $in{'file'})) {
|
||||
&error($text{'backup_epath'}."<br>".
|
||||
&text('backup_eunder', "<tt>$access{'bpath'}</tt>"));
|
||||
}
|
||||
if (!$in{'all'} && !$in{'tables_def'}) {
|
||||
# A custom table selection must include at least one table.
|
||||
@tables = split(/\0/, $in{'tables'});
|
||||
@tables || &error($text{'backup_etables'});
|
||||
}
|
||||
@@ -64,10 +53,6 @@ if (!$in{'save'} || $in{'sched'}) {
|
||||
push(@compat, split(/\0/, $in{'options'}));
|
||||
$cron = !$module_info{'usermin'} && $access{'buser'} eq 'root' &&
|
||||
!$access{'user'} && &foreign_installed("cron");
|
||||
# Only root-backup schedulers may save shared settings.
|
||||
if (($in{'save'} || $in{'sched'}) && !$cron) {
|
||||
&error($text{'backup_esave'});
|
||||
}
|
||||
$commands = &can_run_backup_commands($cron);
|
||||
$cmode = 0;
|
||||
if ($commands) {
|
||||
@@ -84,22 +69,12 @@ if ($cron) {
|
||||
|
||||
&foreign_require("cron");
|
||||
@jobs = &cron::list_cron_jobs();
|
||||
if ($in{'all'}) {
|
||||
($job) = grep { $_->{'command'} eq "$cron_cmd --all" } @jobs;
|
||||
}
|
||||
else {
|
||||
($job) = grep {
|
||||
$_->{'command'} eq $cron_cmd." ".$in{'db'} ||
|
||||
$_->{'command'} eq $cron_cmd." ".quotemeta($in{'db'}) }
|
||||
@jobs;
|
||||
}
|
||||
$cmd = $in{'all'} ? "$cron_cmd --all" : "$cron_cmd $in{'db'}";
|
||||
($job) = grep { $_->{'command'} eq $cmd } @jobs;
|
||||
$oldjob = $job;
|
||||
$cmd = $in{'all'} ? "$cron_cmd --all"
|
||||
: $cron_cmd." ".quotemeta($in{'db'});
|
||||
$job ||= { 'user' => 'root',
|
||||
$job ||= { 'command' => $cmd,
|
||||
'user' => 'root',
|
||||
'active' => 1 };
|
||||
# Replace legacy unquoted commands when updating existing jobs.
|
||||
$job->{'command'} = $cmd;
|
||||
&cron::parse_times_input($job, \%in);
|
||||
}
|
||||
|
||||
@@ -302,7 +277,7 @@ if (!$in{'dest'}) {
|
||||
}
|
||||
else {
|
||||
&ui_print_footer(
|
||||
"/$module_name/edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
|
||||
"edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
|
||||
&get_databases_return_link($in{'db'}), $text{'index_return'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,12 +152,10 @@ $q = $c{'backup_quick_'.$in{'db'}};
|
||||
print &ui_table_row($text{'backup_quick'},
|
||||
&ui_yesno_radio("quick", $q ? 1 : 0));
|
||||
|
||||
# Show custom dump options only to unrestricted root backup users.
|
||||
if ($access{'bpath'} eq '/' && $access{'buser'} eq 'root') {
|
||||
$b = $c{'backup_parameters_'.$in{'db'}};
|
||||
print &ui_table_row($text{'backup_parameters'},
|
||||
&ui_textbox("parameters", $b, 60));
|
||||
}
|
||||
# Allow user to specify custom backup parameters
|
||||
$b = $c{'backup_parameters_'.$in{'db'}};
|
||||
print &ui_table_row($text{'backup_parameters'},
|
||||
&ui_textbox("parameters", $b, 60));
|
||||
|
||||
if ($commands) {
|
||||
# Show before/after commands
|
||||
@@ -201,15 +199,8 @@ if ($cron) {
|
||||
# Show cron time
|
||||
&foreign_require("cron", "cron-lib.pl");
|
||||
@jobs = &cron::list_cron_jobs();
|
||||
if ($in{'all'}) {
|
||||
($job) = grep { $_->{'command'} eq "$cron_cmd --all" } @jobs;
|
||||
}
|
||||
else {
|
||||
($job) = grep {
|
||||
$_->{'command'} eq $cron_cmd." ".$in{'db'} ||
|
||||
$_->{'command'} eq $cron_cmd." ".quotemeta($in{'db'}) }
|
||||
@jobs;
|
||||
}
|
||||
$cmd = $in{'all'} ? "$cron_cmd --all" : "$cron_cmd $in{'db'}";
|
||||
($job) = grep { $_->{'command'} eq $cmd } @jobs;
|
||||
|
||||
print &ui_table_row($text{'backup_sched'},
|
||||
&ui_radio("sched", $job ? 1 : 0,
|
||||
|
||||
@@ -653,8 +653,6 @@ backup_efile=Missing or invalid backup filename
|
||||
backup_efile2=Missing or invalid backup directory
|
||||
backup_epath=Invalid backup path
|
||||
backup_eunder=Backups can only be created under $1
|
||||
backup_eparameters=Custom backup parameters require unrestricted root backup access
|
||||
backup_esave=You do not have permission to save backup settings or schedules
|
||||
backup_ewhere=Missing <tt>where</tt> clause
|
||||
backup_echarset=Missing character set
|
||||
backup_ebackup=<tt>mysqldump</tt> failed : $1
|
||||
|
||||
@@ -1900,30 +1900,25 @@ return $two eq "\037\213" ? 1 :
|
||||
$two eq "BZ" ? 3 : 0;
|
||||
}
|
||||
|
||||
# backup_database(db, dest-file, compress-mode, drop-flag, [where-clause],
|
||||
# [charset], &compatible, &only-tables, [run-as-user],
|
||||
# single-transaction-flag, quick-flag, [force-flag],
|
||||
# [parameters])
|
||||
# Writes a database backup to a file, with optional compression. Returns undef
|
||||
# on success, or an error message on failure.
|
||||
# backup_database(db, dest-file, compress-mode, drop-flag, where-clause,
|
||||
# charset, &compatible, &only-tables, run-as-user,
|
||||
# single-transaction-flag, quick-flag, force-flag, parameters)
|
||||
# Backs up a database to the given file, optionally with compression. Returns
|
||||
# undef on success, or an error message on failure.
|
||||
sub backup_database
|
||||
{
|
||||
my ($db, $file, $compress, $drop, $where, $charset, $compatible,
|
||||
$tables, $user, $single, $quick, $force, $parameters) = @_;
|
||||
my $writer;
|
||||
if ($compress == 0) {
|
||||
# Write an uncompressed SQL dump.
|
||||
$writer = "cat >".quotemeta($file);
|
||||
}
|
||||
elsif ($compress == 1) {
|
||||
# Compress the dump with gzip while writing it.
|
||||
$writer = "gzip -c >".quotemeta($file);
|
||||
}
|
||||
elsif ($compress == 2) {
|
||||
# Compress the dump with bzip2 while writing it.
|
||||
$writer = "bzip2 -c >".quotemeta($file);
|
||||
}
|
||||
# Build dump-client options for the requested backup behavior.
|
||||
my $dropsql = $drop ? "--add-drop-table" : "";
|
||||
my $singlesql = $single ? "--single-transaction" : "";
|
||||
my $forcesql = $force ? "--force" : "";
|
||||
@@ -1933,15 +1928,13 @@ my $parameterssql = $parameters ?
|
||||
my $wheresql = $where ? "--where=".quotemeta($in{'where'}) : "";
|
||||
my $charsetsql = $charset ?
|
||||
"--default-character-set=".quotemeta($charset) : "";
|
||||
# Quote compatibility values and table names as single shell arguments.
|
||||
my $compatiblesql = @$compatible ?
|
||||
"--compatible=".quotemeta(join(",", @$compatible)) : "";
|
||||
"--compatible=".join(",", @$compatible) : "";
|
||||
my $quotingsql = &supports_quoting() ? "--quote-names" : "";
|
||||
my $routinessql = &supports_routines() ? "--routines" : "";
|
||||
my $tablessql = join(" ", map { quotemeta($_) } @$tables);
|
||||
my $eventssql = &supports_mysqldump_events() ? "--events" : "";
|
||||
my $gtidsql = "";
|
||||
# Check whether the client and server need explicit GTID suppression.
|
||||
if (&supports_mysqldump_setgtid() &&
|
||||
$config{'mysqldump'} !~ /--set-gtid-purged/) {
|
||||
eval {
|
||||
@@ -1952,7 +1945,7 @@ if (&supports_mysqldump_setgtid() &&
|
||||
if (@{$d->{'data'}} && uc($d->{'data'}->[0]->[1]) eq 'ON' &&
|
||||
$variant eq 'mysql' &&
|
||||
&compare_version_numbers($ver, "5.6") >= 0) {
|
||||
# Omit GTID_PURGED statements when backing up a GTID server.
|
||||
# Add flag to support GTIDs
|
||||
$gtidsql = "--set-gtid-purged=OFF";
|
||||
}
|
||||
};
|
||||
@@ -1962,15 +1955,12 @@ if ($user && $user ne "root") {
|
||||
$writer = &command_as_user($user, 0, $writer);
|
||||
}
|
||||
&set_authstr_env();
|
||||
# Prevent database and table names from being parsed as dump options.
|
||||
my $cmd = "$config{'mysqldump'} $authstr $dropsql $singlesql $forcesql $quicksql $parameterssql $wheresql $charsetsql $compatiblesql $quotingsql $routinessql $eventssql $gtidsql -- ".quotemeta($db)." $tablessql | $writer";
|
||||
my $cmd = "$config{'mysqldump'} $authstr $dropsql $singlesql $forcesql $quicksql $parameterssql $wheresql $charsetsql $compatiblesql $quotingsql $routinessql ".quotemeta($db)." $tablessql $eventssql $gtidsql | $writer";
|
||||
if (&shell_is_bash()) {
|
||||
# Propagate dump-client failures through the output pipeline.
|
||||
$cmd = "set -o pipefail ; $cmd";
|
||||
}
|
||||
my $out = &backquote_logged("($cmd) 2>&1");
|
||||
if ($? || !-s $file || $out =~ /Aborted\s+connection|max_allowed_packet/i) {
|
||||
# Return errors for command failures, empty output, or partial dumps.
|
||||
return $out;
|
||||
}
|
||||
return undef;
|
||||
@@ -1983,8 +1973,8 @@ sub delete_database_backup_job
|
||||
my ($db) = @_;
|
||||
&foreign_require("cron");
|
||||
my @jobs = &cron::list_cron_jobs();
|
||||
my ($job) = grep { $_->{'command'} eq $cron_cmd." ".$db ||
|
||||
$_->{'command'} eq $cron_cmd." ".quotemeta($db) } @jobs;
|
||||
my $cmd = "$cron_cmd $db";
|
||||
my ($job) = grep { $_->{'command'} eq $cmd } @jobs;
|
||||
if ($job) {
|
||||
&lock_file(&cron::cron_file($job));
|
||||
&cron::delete_cron_job($job);
|
||||
|
||||
@@ -6,8 +6,7 @@ require './phpini-lib.pl';
|
||||
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
|
||||
$conf = &get_config($in{'file'});
|
||||
|
||||
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
|
||||
$text{'dirs_title'}, "");
|
||||
&ui_print_header("<tt>$in{'file'}</tt>", $text{'dirs_title'}, "");
|
||||
|
||||
print &ui_form_start("save_dirs.cgi", "post");
|
||||
print &ui_hidden("file", $in{'file'}),"\n";
|
||||
|
||||
@@ -6,8 +6,7 @@ require './phpini-lib.pl';
|
||||
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
|
||||
$conf = &get_config($in{'file'});
|
||||
|
||||
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
|
||||
$text{'disable_title'}, "");
|
||||
&ui_print_header("<tt>$in{'file'}</tt>", $text{'disable_title'}, "");
|
||||
|
||||
print &ui_form_start("save_disable.cgi", "post");
|
||||
print &ui_hidden("file", $in{'file'}),"\n";
|
||||
|
||||
@@ -6,8 +6,7 @@ require './phpini-lib.pl';
|
||||
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
|
||||
$conf = &get_config($in{'file'});
|
||||
|
||||
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
|
||||
$text{'errors_title'}, "");
|
||||
&ui_print_header("<tt>$in{'file'}</tt>", $text{'errors_title'}, "");
|
||||
|
||||
print &ui_form_start("save_errors.cgi", "post");
|
||||
print &ui_hidden("file", $in{'file'}),"\n";
|
||||
|
||||
@@ -6,8 +6,7 @@ require './phpini-lib.pl';
|
||||
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
|
||||
$conf = &get_config($in{'file'});
|
||||
|
||||
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
|
||||
$text{'limits_title'}, "");
|
||||
&ui_print_header("<tt>$in{'file'}</tt>", $text{'limits_title'}, "");
|
||||
|
||||
print &ui_form_start("save_limits.cgi", "post");
|
||||
print &ui_hidden("file", $in{'file'}),"\n";
|
||||
|
||||
@@ -7,8 +7,7 @@ require './phpini-lib.pl';
|
||||
&can_php_config($in{'file'}) || &error($text{'manual_ecannot'});
|
||||
$access{'manual'} || &error($text{'manual_ecannot'});
|
||||
|
||||
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
|
||||
$text{'manual_title'}, "");
|
||||
&ui_print_header("<tt>$in{'file'}</tt>", $text{'manual_title'}, "");
|
||||
|
||||
print $text{'manual_desc'},"<p>\n";
|
||||
print &ui_form_start("save_manual.cgi", "form-data");
|
||||
|
||||
@@ -6,8 +6,7 @@ require './phpini-lib.pl';
|
||||
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
|
||||
$conf = &get_config($in{'file'});
|
||||
|
||||
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
|
||||
$text{'misc_title'}, "");
|
||||
&ui_print_header("<tt>$in{'file'}</tt>", $text{'misc_title'}, "");
|
||||
|
||||
# Build list of timezones
|
||||
my %tzlist;
|
||||
|
||||
@@ -6,8 +6,7 @@ require './phpini-lib.pl';
|
||||
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
|
||||
$conf = &get_config($in{'file'});
|
||||
|
||||
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
|
||||
$text{'session_title'}, "");
|
||||
&ui_print_header("<tt>$in{'file'}</tt>", $text{'session_title'}, "");
|
||||
|
||||
print &ui_form_start("save_session.cgi", "post");
|
||||
print &ui_hidden("file", $in{'file'}),"\n";
|
||||
|
||||
@@ -6,8 +6,7 @@ require './phpini-lib.pl';
|
||||
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
|
||||
$conf = &get_config_as_user($in{'file'});
|
||||
|
||||
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
|
||||
$text{'vars_title'}, "");
|
||||
&ui_print_header("<tt>$in{'file'}</tt>", $text{'vars_title'}, "");
|
||||
|
||||
print &ui_form_start("save_vars.cgi", "post");
|
||||
print &ui_hidden("file", $in{'file'}),"\n";
|
||||
|
||||
@@ -16,14 +16,16 @@ $mail || &error($text{'mailq_egone'});
|
||||
$attach = $mail->{'attach'}->[$in{'attach'}];
|
||||
|
||||
print "X-no-links: 1\n";
|
||||
if ($attach->{'type'} eq 'message/delivery-status' ||
|
||||
$attach->{'type'} =~ /^text\/html/i ||
|
||||
$attach->{'type'} =~ /^image\/svg(?:\+xml)?/i ||
|
||||
$attach->{'type'} =~ /^application\/xhtml\+xml/i) {
|
||||
if ($attach->{'type'} eq 'message/delivery-status') {
|
||||
print "Content-type: text/plain\n\n";
|
||||
}
|
||||
else {
|
||||
print "Content-type: $attach->{'type'}\n\n";
|
||||
}
|
||||
print $attach->{'data'};
|
||||
if ($attach->{'type'} =~ /^(text\/html|image\/svg|application\/xhtml)/i) {
|
||||
print &filter_javascript($attach->{'data'});
|
||||
}
|
||||
else {
|
||||
print $attach->{'data'};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ our (%access, %in, %text);
|
||||
|
||||
$access{'smtpd'} || &error($text{'smtpd_ecannot'});
|
||||
&ReadParse();
|
||||
&ui_print_header(undef, &html_escape($in{'title'}), "");
|
||||
&ui_print_header(undef, $in{'title'}, "");
|
||||
|
||||
if (&get_current_value($in{'name'}) eq "")
|
||||
{
|
||||
|
||||
@@ -405,7 +405,7 @@ else {
|
||||
&host_port_flags().
|
||||
(!&supports_pgpass() ? " -u" :
|
||||
" -U ".quotemeta($postgres_login)).
|
||||
" -c ".quotemeta($sql).
|
||||
" -c "."e_path($sql).
|
||||
" ".quotemeta($_[0]);
|
||||
if ($postgres_sameunix && defined(getpwnam($postgres_login))) {
|
||||
$cmd = &command_as_user($postgres_login, 0, $cmd);
|
||||
|
||||
@@ -10,13 +10,11 @@ $mail = &read_mail_file($in{'file'});
|
||||
$attach = $mail->{'attach'}->[$in{'attach'}];
|
||||
|
||||
print "X-no-links: 1\n";
|
||||
if ($attach->{'type'} =~ /^text\/html/i ||
|
||||
$attach->{'type'} =~ /^image\/svg(?:\+xml)?/i ||
|
||||
$attach->{'type'} =~ /^application\/xhtml\+xml/i) {
|
||||
print "Content-type: text/plain\n\n";
|
||||
print "Content-type: $attach->{'type'}\n\n";
|
||||
if ($attach->{'type'} =~ /^(text\/html|image\/svg|application\/xhtml)/i) {
|
||||
print &filter_javascript($attach->{'data'});
|
||||
}
|
||||
else {
|
||||
print "Content-type: $attach->{'type'}\n\n";
|
||||
print $attach->{'data'};
|
||||
}
|
||||
print $attach->{'data'};
|
||||
|
||||
|
||||
@@ -28,14 +28,16 @@ foreach $s (@sub) {
|
||||
$attach = $mail->{'attach'}->[$in{'attach'}];
|
||||
|
||||
print "X-no-links: 1\n";
|
||||
if ($attach->{'type'} eq 'message/delivery-status' ||
|
||||
$attach->{'type'} =~ /^text\/html/i ||
|
||||
$attach->{'type'} =~ /^image\/svg(?:\+xml)?/i ||
|
||||
$attach->{'type'} =~ /^application\/xhtml\+xml/i) {
|
||||
if ($attach->{'type'} eq 'message/delivery-status') {
|
||||
print "Content-type: text/plain\n\n";
|
||||
}
|
||||
else {
|
||||
print "Content-type: $attach->{'type'}\n\n";
|
||||
}
|
||||
print $attach->{'data'};
|
||||
if ($attach->{'type'} =~ /^(text\/html|image\/svg|application\/xhtml)/i) {
|
||||
print &filter_javascript($attach->{'data'});
|
||||
}
|
||||
else {
|
||||
print $attach->{'data'};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ require './servers-lib.pl';
|
||||
our (%in);
|
||||
&ReadParse();
|
||||
my $enc = &encode_base64($in{'user'}.":".$in{'pass'});
|
||||
$in{'id'} =~ s/\r|\n|\0//g;
|
||||
$in{'id'} =~ /\A[0-9]+\z/ || &error("Invalid server ID");
|
||||
$enc =~ s/\r|\n|\0//g;
|
||||
$enc =~ s/\r|\n//g;
|
||||
print "Set-Cookie: $in{'id'}=$enc; path=/";
|
||||
if (uc($ENV{'HTTPS'}) eq 'ON') {
|
||||
print "; secure";
|
||||
|
||||
@@ -9,8 +9,6 @@ no warnings 'uninitialized';
|
||||
require './servers-lib.pl';
|
||||
&ReadParse();
|
||||
our (%in);
|
||||
$in{'id'} =~ s/\r|\n|\0//g;
|
||||
$in{'id'} =~ /\A[0-9]+\z/ || &error("Invalid server ID");
|
||||
print "Set-Cookie: $in{'id'}=; path=/";
|
||||
if (uc($ENV{'HTTPS'}) eq 'ON') {
|
||||
print "; secure";
|
||||
|
||||
@@ -14,7 +14,6 @@ local($i, $list); $i = 0;
|
||||
$list = @_ ? join(' ', map { quotemeta($_) } @_) : "-a";
|
||||
%packages = ( );
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
&open_execute_command(RPM, "rpm -q $list --queryformat \"%{NAME}\\n%{VERSION}-%{RELEASE}\\n%{EPOCH}\\n%{GROUP}\\n%{ARCH}\\n%{SUMMARY}\\n\\n\"", 1, 1);
|
||||
while(my $name = <RPM>) {
|
||||
next if ($name =~ /is\s+not\s+installed$/);
|
||||
@@ -51,7 +50,6 @@ sub package_info
|
||||
local(@rv, @tmp, $d);
|
||||
local $n = $_[1] ? "$_[0]-$_[1]" : $_[0];
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
&open_execute_command(RPM, "rpm -q $n --queryformat \"%{NAME}\\n%{GROUP}\\n%{ARCH}\\n%{VERSION}-%{RELEASE}\\n%{VENDOR}\\n%{INSTALLTIME}\\n%{URL}\\n\" 2>/dev/null", 1, 1);
|
||||
@tmp = <RPM>;
|
||||
chop(@tmp);
|
||||
@@ -59,7 +57,6 @@ local $ex = close(RPM);
|
||||
&reset_environment();
|
||||
if (!@tmp || $tmp[0] =~ /not\s+installed/) { return (); }
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
&open_execute_command(RPM, "rpm -q $n --queryformat \"%{DESCRIPTION}\"", 1, 1);
|
||||
while(<RPM>) { $d .= $_; }
|
||||
close(RPM);
|
||||
@@ -80,7 +77,6 @@ my $query_format =
|
||||
'%{INSTALLTIME}\n%{DESCRIPTION}';
|
||||
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
&open_execute_command(*RPM, "rpm -q --whatprovides $vpkg --queryformat ".
|
||||
"\"$query_format\" 2>/dev/null", 1, 1);
|
||||
my @tmp = <RPM>;
|
||||
@@ -117,14 +113,12 @@ else {
|
||||
# just a normal file .. check if it is an RPM and not an SRPM
|
||||
local $qm = quotemeta($_[0]);
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
$out = &backquote_command("rpm -q -p $qm 2>&1", 1);
|
||||
&reset_environment();
|
||||
if ($out =~ /does not appear|No such file|with major numbers|not an rpm/i || $?) {
|
||||
return 0;
|
||||
}
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
&open_execute_command(OUT, "rpm -q -p -l $qm 2>&1", 1, 1);
|
||||
&reset_environment();
|
||||
while(<OUT>) {
|
||||
@@ -145,7 +139,6 @@ local $qm = quotemeta($_[0]);
|
||||
if (-d $real) {
|
||||
local @rv;
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
&open_execute_command(RPM, "cd $qm ; rpm -q -p *.rpm --queryformat \"%{NAME} %{SUMMARY}\\n\" 2>&1", 1, 1);
|
||||
while(<RPM>) {
|
||||
chop;
|
||||
@@ -158,7 +151,6 @@ if (-d $real) {
|
||||
elsif ($_[0] =~ /\*|\?/) {
|
||||
local @rv;
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
&open_execute_command(RPM, "rpm -q -p $qm --queryformat \"%{NAME} %{SUMMARY}\\n\" 2>&1", 1);
|
||||
while(<RPM>) {
|
||||
chop;
|
||||
@@ -171,7 +163,6 @@ elsif ($_[0] =~ /\*|\?/) {
|
||||
else {
|
||||
local($out);
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
$out = &backquote_command("rpm -q -p $qm --queryformat \"%{NAME} %{SUMMARY}\\n\" 2>&1", 1);
|
||||
&reset_environment();
|
||||
$out =~ s/warning:.*\n//;
|
||||
@@ -232,7 +223,6 @@ if (-d $real) {
|
||||
# Find the package in the directory
|
||||
local ($f, $out);
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
opendir(DIR, $real);
|
||||
while($f = readdir(DIR)) {
|
||||
next if ($f !~ /\.rpm$/);
|
||||
@@ -254,7 +244,6 @@ elsif ($file =~ /\*|\?/) {
|
||||
# XXX won't work when translation is in effect
|
||||
local ($f, $out);
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
foreach $f (glob($real)) {
|
||||
my $qf = quotemeta($f);
|
||||
$out = &backquote_command("rpm -q -p $qf --queryformat \"%{NAME}\\n\" 2>&1", 1);
|
||||
@@ -270,7 +259,6 @@ elsif ($file =~ /\*|\?/) {
|
||||
}
|
||||
local $temp = &transname();
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
local $rv = &system_logged("rpm -i $opts ".quotemeta($file)." >$temp 2>&1");
|
||||
local $out = &backquote_command("cat $temp");
|
||||
&reset_environment();
|
||||
@@ -310,7 +298,6 @@ local $temp = &transname();
|
||||
local $qm = quotemeta($file);
|
||||
$qm =~ s/\\([*?])/$1/g if ($file =~ /[\*\?]/);
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
local $rv = &system_logged("rpm -i $opts $qm >".quotemeta($temp)." 2>&1");
|
||||
local $out = &backquote_command("cat ".quotemeta($temp));
|
||||
&reset_environment();
|
||||
@@ -331,7 +318,6 @@ local($i, $_, @w, %errs, $epath); $i = 0;
|
||||
local $n = $_[1] ? "$_[0]-$_[1]" : $_[0];
|
||||
local $qn = quotemeta($n);
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
&open_execute_command(RPM, "rpm -V $qn", 1, 1);
|
||||
while(<RPM>) {
|
||||
/^(.{8}) (.) (.*)$/;
|
||||
@@ -374,7 +360,6 @@ local ($pkg, $version) = @_;
|
||||
local $qn = quotemeta($version ? "$pkg-$version" : $pkg);
|
||||
local @rv;
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
&open_execute_command(RPM, "rpm -q -l $qn", 1, 1);
|
||||
while(<RPM>) {
|
||||
s/\r|\n//g;
|
||||
@@ -395,13 +380,11 @@ local($pkg, @w, $_, @pkgs, @vers);
|
||||
undef(%file);
|
||||
local $qm = quotemeta($_[0]);
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
$pkg = &backquote_command("rpm -q -f $qm --queryformat \"%{NAME}\\n\" 2>&1", 1);
|
||||
&reset_environment();
|
||||
if ($pkg =~ /not owned/ || $?) { return 0; }
|
||||
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
@pkgs = split(/\n/, $pkg);
|
||||
$pkg = &backquote_command("rpm -q -f $qm --queryformat \"%{VERSION}-%{RELEASE}\\n\" 2>&1", 1);
|
||||
@vers = split(/\n/, $pkg);
|
||||
@@ -450,7 +433,6 @@ $opts .= $_[1]->{'noscripts'} ? "--noscripts " : "";
|
||||
local $n = $_[2] ? "$_[0]-$_[2]" : $_[0];
|
||||
local $qm = quotemeta($n);
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
local $out = &backquote_logged("rpm -e $opts $qm 2>&1");
|
||||
&reset_environment();
|
||||
if ($? || $out =~ /error:/) { return "<pre>$out</pre>"; }
|
||||
@@ -475,7 +457,6 @@ for($i=0; $i<@{$_[0]}; $i++) {
|
||||
}
|
||||
}
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
local $out = &backquote_logged("$cmd 2>&1");
|
||||
&reset_environment();
|
||||
if ($? || $out =~ /error:/) { return "<pre>$out</pre>"; }
|
||||
@@ -488,7 +469,6 @@ sub package_dependencies
|
||||
{
|
||||
my ($pkg, $ver) = @_;
|
||||
&clean_environment();
|
||||
&clean_language();
|
||||
my $out = &backquote_command(
|
||||
"rpm -qR ".quotemeta($pkg.($ver ? "-$ver" : ""))." 2>/dev/null");
|
||||
&reset_environment();
|
||||
|
||||
@@ -61,10 +61,7 @@ return &get_dnf_version() >= 5 ? '--setopt=disable_excludes=*' :
|
||||
}
|
||||
|
||||
# update_system_install([packages], [&in], [no-force], [flags])
|
||||
# Installs or updates space-separated packages with YUM or DNF. The package
|
||||
# list defaults to form input, the input hash can select a repository, and
|
||||
# flags are passed to the transaction. No-force is kept for compatibility but
|
||||
# has no effect.
|
||||
# Installs or updates packages with YUM or DNF.
|
||||
sub update_system_install
|
||||
{
|
||||
local $update = $_[0] || $in{'update'};
|
||||
@@ -79,30 +76,15 @@ $qflags = &trim(join(" ", map { quotemeta($_) } split(/ /, $runflags)))
|
||||
if ($runflags);
|
||||
$update =~ s/\.\*/\*/g;
|
||||
local $enable;
|
||||
# Add the repository selected on the install form.
|
||||
if ($in->{'enablerepo'}) {
|
||||
$enable = "enablerepo=".quotemeta($in->{'enablerepo'});
|
||||
}
|
||||
local (@rv, @newpacks);
|
||||
|
||||
# Split installed packages by architecture and record missing packages.
|
||||
# If there are multiple architectures to update for a package, split them out
|
||||
local @updates = split(/\s+/, $update);
|
||||
local (@names, @installed, @missing);
|
||||
foreach my $u (@updates) {
|
||||
my $installed;
|
||||
my @package_names = &append_architectures($u, \$installed);
|
||||
push(@names, @package_names);
|
||||
if ($installed) {
|
||||
# Queue installed architectures for update.
|
||||
push(@installed, @package_names);
|
||||
}
|
||||
else {
|
||||
# Queue missing package specifications for installation.
|
||||
push(@missing, @package_names);
|
||||
}
|
||||
}
|
||||
local @names = map { &append_architectures($_) } split(/\s+/, $update);
|
||||
if (@names == 1) {
|
||||
# Keep the original specification when no architecture split was needed.
|
||||
@names = ( $update );
|
||||
}
|
||||
$update = join(" ", map { quotemeta($_) } @names);
|
||||
@@ -128,39 +110,30 @@ if ($versionlock_update) {
|
||||
}
|
||||
}
|
||||
|
||||
# DNF 4 installs and upgrades a mixed package set in one transaction. DNF 5
|
||||
# needs separate actions because install does not upgrade packages by name.
|
||||
my @actions;
|
||||
if ($yum_command =~ /dnf$/ && @installed && @missing &&
|
||||
&get_dnf_version() >= 5) {
|
||||
@actions = ( [ 'update', \@installed ], [ 'install', \@missing ] );
|
||||
# Use update for installed DNF packages and install for everything else.
|
||||
local $cmd;
|
||||
if ($yum_command =~ /dnf$/) {
|
||||
local @pinfo = &package_info($updates[0]);
|
||||
if ($pinfo[0]) {
|
||||
$cmd = "update";
|
||||
}
|
||||
else {
|
||||
$cmd = "install";
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Use one action for YUM and non-mixed DNF sets.
|
||||
my $cmd = $yum_command =~ /dnf$/ && @installed && !@missing ?
|
||||
'update' : 'install';
|
||||
@actions = ( [ $cmd, \@names ] );
|
||||
$cmd = "install";
|
||||
}
|
||||
|
||||
# Join actions so DNF 5 installs only after its update succeeds.
|
||||
my (@uicmds, @fullcmds);
|
||||
foreach my $action (@actions) {
|
||||
my ($cmd, $packages) = @$action;
|
||||
my $uicmd = "$yum_command $enable -y $cmd ".join(" ", @$packages);
|
||||
$uicmd .= " $runflags" if ($runflags);
|
||||
push(@uicmds, $uicmd);
|
||||
my $fullcmd = "$yum_command $enable -y $cmd ".
|
||||
join(" ", map { quotemeta($_) } @$packages);
|
||||
$fullcmd .= " $qflags" if ($qflags);
|
||||
push(@fullcmds, $fullcmd);
|
||||
}
|
||||
my $uicmd = join(" && ", @uicmds);
|
||||
my $fullcmd = join(" && ", @fullcmds);
|
||||
# Work out the command to run, which may enable some repos
|
||||
my $uicmd = "$yum_command $enable -y $cmd ".join(" ", @names);
|
||||
$uicmd .= " $runflags" if ($runflags);
|
||||
my $fullcmd = "$yum_command $enable -y $cmd $update";
|
||||
$fullcmd .= " $qflags" if ($qflags);
|
||||
foreach my $u (@updates) {
|
||||
my $repo = &update_system_repo($u);
|
||||
if ($repo) {
|
||||
# Install a required repository package first.
|
||||
$fullcmd = "$yum_command -y install $repo ; $fullcmd";
|
||||
$fullcmd = "$yum_command -y $cmd $repo ; $fullcmd";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +141,7 @@ print &text('yum_install', "<tt>".&html_escape($uicmd)."</tt>"),"\n";
|
||||
print "<pre data-installer>";
|
||||
&additional_log('exec', undef, $fullcmd);
|
||||
$SIG{'TERM'} = 'ignore'; # Installing webmin itself may kill this script
|
||||
&open_execute_command(CMD, "($fullcmd) </dev/null", 2);
|
||||
&open_execute_command(CMD, "$fullcmd </dev/null", 2);
|
||||
while(<CMD>) {
|
||||
s/\r|\n//g;
|
||||
if (/^\[(update|install|deps):\s+(\S+)\s+/) {
|
||||
@@ -247,25 +220,21 @@ else {
|
||||
}
|
||||
}
|
||||
|
||||
# append_architectures(package, [installed])
|
||||
# Returns installed package names with their architectures. Sets installed
|
||||
# when any are found, or returns the original specification when none are.
|
||||
# append_architectures(package)
|
||||
# Given a package name, if it has multiple architectures return the name with
|
||||
# each appended
|
||||
sub append_architectures
|
||||
{
|
||||
my ($name, $installed) = @_;
|
||||
my ($name) = @_;
|
||||
local %packages;
|
||||
my $n = &list_packages($name);
|
||||
$$installed = $n ? 1 : 0 if ($installed);
|
||||
# Keep an unresolved specification for installation.
|
||||
return ( $name ) if (!$n);
|
||||
my @rv;
|
||||
for(my $i=0; $i<$n; $i++) {
|
||||
if ($packages{$i,'arch'}) {
|
||||
# Include the installed architecture.
|
||||
push(@rv, $packages{$i,'name'}.".".$packages{$i,'arch'});
|
||||
}
|
||||
else {
|
||||
# Keep names with no architecture metadata.
|
||||
push(@rv, $packages{$i,'name'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
require 'sqlite-lib.pl';
|
||||
|
||||
# sub acl_security_form(&options)
|
||||
# Outputs the SQLite module access-control fields from the saved options.
|
||||
sub acl_security_form
|
||||
{
|
||||
my ($o) = @_;
|
||||
# Show the stored entries as they are, so a $HOME token stays a token
|
||||
# rather than becoming the editing administrator's own home.
|
||||
my $paths = join("\n", grep { length($_) }
|
||||
split(/\t+|\r?\n/, $o->{'paths'} // ''));
|
||||
|
||||
# Render the path and capability fields.
|
||||
print &ui_table_row($text{'acl_paths'},
|
||||
&ui_textarea("paths", $paths, 8, 70, undef, undef,
|
||||
"style='width: 100%'").&ui_help($text{'acl_paths_desc'}), 3);
|
||||
print &ui_table_row($text{'acl_write'},
|
||||
&ui_yesno_radio("write", $o->{'write'}), 3);
|
||||
print &ui_table_row($text{'acl_create'},
|
||||
&ui_yesno_radio("create", $o->{'create'}), 3);
|
||||
print &ui_table_row($text{'acl_sql'},
|
||||
&ui_yesno_radio("sql", $o->{'sql'}), 3);
|
||||
|
||||
# Choose which Unix user does the file work: root, the Webmin user's account,
|
||||
# or a named one. An older ACL shows what the module derives for it.
|
||||
my $as = &sqlite_work_as_user($o);
|
||||
my $mode = !defined($as) ? 0 : $o->{'work_as_user'} ? 2 : 1;
|
||||
print &ui_table_row($text{'acl_work_as'},
|
||||
&ui_radio_table("user_mode", $mode, [
|
||||
[ 0, $text{'acl_root'} ],
|
||||
[ 1, $text{'acl_same'} ],
|
||||
[ 2, $text{'acl_user'},
|
||||
&ui_user_textbox("acl_user", $o->{'work_as_user'}) ] ]), 3);
|
||||
}
|
||||
|
||||
# sub acl_security_save(&options, &input)
|
||||
# Validates the submitted fields and stores them in the options hash.
|
||||
sub acl_security_save
|
||||
{
|
||||
my ($o, $in) = @_;
|
||||
my @paths = grep { /\S/ } split(/\r?\n/, $in->{'paths'});
|
||||
@paths || &error($text{'acl_epaths'});
|
||||
my @resolved;
|
||||
# Resolve each root so later boundary checks use canonical paths.
|
||||
foreach my $path (@paths) {
|
||||
$path =~ s/^\s+|\s+$//g;
|
||||
# $HOME is resolved per user at request time, so it is kept as is.
|
||||
if ($path eq '$HOME') {
|
||||
push(@resolved, $path);
|
||||
next;
|
||||
}
|
||||
$path =~ /^\// && $path !~ /[\0\t\r\n]/ ||
|
||||
&error(&text('acl_epath', &html_escape($path)));
|
||||
my $real = &simplify_path(&resolve_links($path));
|
||||
defined($real) && -d $real ||
|
||||
&error(&text('acl_epath', &html_escape($path)));
|
||||
push(@resolved, $real);
|
||||
}
|
||||
# Save unique roots and Boolean capabilities.
|
||||
$o->{'paths'} = join("\t", &unique(@resolved));
|
||||
$o->{'write'} = $in->{'write'} ? 1 : 0;
|
||||
$o->{'create'} = $in->{'create'} ? 1 : 0;
|
||||
$o->{'sql'} = $in->{'sql'} ? 1 : 0;
|
||||
# A named Unix user must exist; the other two modes name no user.
|
||||
if ($in->{'user_mode'} == 2) {
|
||||
defined(getpwnam($in->{'acl_user'})) || &error($text{'acl_euser'});
|
||||
$o->{'work_as_root'} = 0;
|
||||
$o->{'work_as_user'} = $in->{'acl_user'};
|
||||
}
|
||||
else {
|
||||
# The root and matching-user modes do not store a named account.
|
||||
$o->{'work_as_root'} = $in->{'user_mode'} == 0 ? 1 : 0;
|
||||
$o->{'work_as_user'} = '';
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Displays the form for adding a column to a table, or for renaming one.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
|
||||
# Structure changes need a writable table; views cannot be altered.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&sqlite_can_write($path) || &error($text{'error_readonly'});
|
||||
my $dbh = &sqlite_connect($path, 0);
|
||||
my $object = &sqlite_object($dbh, $in{'table'});
|
||||
$object && $object->{'type'} eq 'table' || &error($text{'table_missing'});
|
||||
my $columns = &sqlite_table_columns($dbh, $object->{'name'});
|
||||
$dbh->disconnect();
|
||||
|
||||
my $existing;
|
||||
# Renaming needs the column to still exist and the library to support it.
|
||||
if ($in{'column'} ne '') {
|
||||
($existing) = grep { $_->{'name'} eq $in{'column'} } @$columns;
|
||||
$existing || &error($text{'column_emissing'});
|
||||
&sqlite_supports('rename_column') ||
|
||||
&error($text{'column_eunsupported'});
|
||||
}
|
||||
|
||||
my $desc = &ui_tag('tt', &html_escape($object->{'name'})).
|
||||
$text{'table_in'}.' '.&ui_tag('tt', &html_escape($path));
|
||||
&ui_print_header($desc,
|
||||
$text{$existing ? 'column_title_rename' : 'column_title_add'}, '',
|
||||
'column');
|
||||
print &ui_form_start('save_column.cgi', 'post');
|
||||
print &ui_hidden('db', $path);
|
||||
print &ui_hidden('table', $object->{'name'});
|
||||
print &ui_hidden('mode', $existing ? 'rename' : 'add');
|
||||
print &ui_table_start($text{$existing ? 'column_header_rename' :
|
||||
'column_header_add'}, 'width=100%', 2);
|
||||
# A rename shows the current name and asks for the new one only.
|
||||
if ($existing) {
|
||||
print &ui_hidden('column', $existing->{'name'});
|
||||
print &ui_table_row($text{'column_current'},
|
||||
&ui_tag('tt', &html_escape($existing->{'name'})).' '.
|
||||
&ui_tag('small', &html_escape($existing->{'type'} ||
|
||||
$text{'edit_untyped'})));
|
||||
print &ui_table_row($text{'column_newname'},
|
||||
&ui_textbox('name', $existing->{'name'}, 40));
|
||||
}
|
||||
else {
|
||||
# A new column takes a name, a type, a NULL rule and a default.
|
||||
print &ui_table_row($text{'column_name'},
|
||||
&ui_textbox('name', $in{'name'}, 40));
|
||||
print &ui_table_row($text{'column_type'},
|
||||
&ui_select('type', $in{'type'} || 'TEXT',
|
||||
[ map { [ $_ ] } &sqlite_column_types() ]));
|
||||
print &ui_table_row($text{'column_null'},
|
||||
&ui_yesno_radio('null', defined($in{'null'}) ? $in{'null'} : 1));
|
||||
print &ui_table_row($text{'column_default'},
|
||||
&ui_textbox('default', $in{'default'}, 30).' '.
|
||||
&ui_help($text{'column_default_help'}));
|
||||
}
|
||||
print &ui_table_end();
|
||||
print &ui_form_end([ [ undef,
|
||||
$text{$existing ? 'column_rename' : 'column_add'} ] ]);
|
||||
&ui_print_footer('table.cgi?db='.&urlize($path).'&table='.
|
||||
&urlize($object->{'name'}).'&view=structure', $text{'column_return'});
|
||||
@@ -1,7 +0,0 @@
|
||||
recent=3
|
||||
perpage=50
|
||||
max_text=1000
|
||||
busy_timeout=5000
|
||||
max_query_rows=1000
|
||||
max_result_bytes=16777216
|
||||
confirm_delete=1
|
||||
@@ -1,7 +0,0 @@
|
||||
recent=Number of recent databases to list for each user,0,5
|
||||
perpage=Rows per page,0,5
|
||||
max_text=Maximum characters to display from each text value,3,Unlimited
|
||||
busy_timeout=Locked database wait time in milliseconds,0,5
|
||||
max_query_rows=Maximum SQL query rows to display,0,5
|
||||
max_result_bytes=Maximum result data kept per request,0,12,,bytes
|
||||
confirm_delete=Confirm before deleting rows?,1,1-Yes,0-No
|
||||
@@ -1,8 +0,0 @@
|
||||
require 'sqlite-lib.pl';
|
||||
|
||||
# sub cpan_recommended()
|
||||
# Returns the Perl modules that provide SQLite database access.
|
||||
sub cpan_recommended
|
||||
{
|
||||
return ( "DBI", "DBD::SQLite" );
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Creates a table from the table form, or shows the form again with more
|
||||
# column rows.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
&error_setup($text{'create_err'});
|
||||
|
||||
# Recheck write access before anything else.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&sqlite_can_write($path) || &error($text{'error_readonly'});
|
||||
# Keep the row count the form carried between one and fifty.
|
||||
my $count = int($in{'fields'} || 4);
|
||||
$count = 1 if ($count < 1);
|
||||
$count = 50 if ($count > 50);
|
||||
|
||||
# Show the form again with more rows, keeping what was typed so far.
|
||||
if ($in{'more'}) {
|
||||
$count += 4;
|
||||
$count = 50 if ($count > 50);
|
||||
&ui_print_header(&ui_tag('tt', &html_escape($path)),
|
||||
$text{'create_title'}, '', 'create');
|
||||
print &ui_page_start();
|
||||
print &sqlite_table_form($path, \%in, $count);
|
||||
print &ui_page_end();
|
||||
&ui_print_footer('database.cgi?db='.&urlize($path),
|
||||
$text{'database_return'}, '', $text{'index_return'});
|
||||
exit;
|
||||
}
|
||||
|
||||
# Collect the rows that were filled in, skipping blank ones.
|
||||
my @columns;
|
||||
for(my $i = 0; $i < $count; $i++) {
|
||||
my $name = $in{'name_'.$i};
|
||||
$name =~ s/^\s+|\s+$//g;
|
||||
next if ($name eq '');
|
||||
push(@columns, {
|
||||
'name' => $name,
|
||||
'type' => $in{'type_'.$i},
|
||||
'null' => $in{'null_'.$i} ? 1 : 0,
|
||||
'key' => $in{'key_'.$i},
|
||||
'default' => $in{'default_'.$i},
|
||||
});
|
||||
}
|
||||
my $name = $in{'name'};
|
||||
$name =~ s/^\s+|\s+$//g;
|
||||
my $sql = eval { &sqlite_create_table_sql($name, \@columns) };
|
||||
# Report a definition problem as a form error.
|
||||
if ($@) {
|
||||
my $message = $@;
|
||||
chomp($message);
|
||||
&error($message);
|
||||
}
|
||||
|
||||
my $dbh = &sqlite_connect($path, 1);
|
||||
# Refuse to replace an existing table or view.
|
||||
if (&sqlite_object($dbh, $name)) {
|
||||
$dbh->disconnect();
|
||||
&error(&text('create_eexists', &html_escape($name)));
|
||||
}
|
||||
eval { $dbh->do($sql); };
|
||||
# Pass SQLite's own objection through the normal error page.
|
||||
if ($@) {
|
||||
my $message = $@;
|
||||
$dbh->disconnect();
|
||||
&error(&html_escape($message));
|
||||
}
|
||||
$dbh->disconnect();
|
||||
&sqlite_adopt_ownership($path);
|
||||
&webmin_log('create', 'table', $name, { 'db' => $path });
|
||||
&redirect('table.cgi?db='.&urlize($path).'&table='.&urlize($name).
|
||||
'&view=structure');
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Lists the tables and views in an SQLite database.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in, %access);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
|
||||
# Read objects and summary metrics through a read-only connection.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
my $dbh = &sqlite_connect($path, 0);
|
||||
my $objects = &sqlite_objects($dbh);
|
||||
my ($journal) = $dbh->selectrow_array('PRAGMA journal_mode');
|
||||
my ($page_count) = $dbh->selectrow_array('PRAGMA page_count');
|
||||
my ($page_size) = $dbh->selectrow_array('PRAGMA page_size');
|
||||
my ($user_version) = $dbh->selectrow_array('PRAGMA user_version');
|
||||
$dbh->disconnect();
|
||||
# Put a database that opened successfully at the top of the recent list.
|
||||
&sqlite_remember_recent($path,
|
||||
scalar(grep { $_->{'type'} eq 'table' } @$objects));
|
||||
|
||||
# Show database metrics above the object list.
|
||||
&ui_print_header(&ui_tag('tt', &html_escape($path)),
|
||||
$text{'database_title'}, '', 'database');
|
||||
print &ui_page_start();
|
||||
print &ui_tag('style',
|
||||
'.sqlite_database_stats .ui_stat_value { font-size: 1.48em; }',
|
||||
{ 'type' => 'text/css' });
|
||||
my $wal = lc($journal) eq 'wal';
|
||||
my $writable = &sqlite_can_write($path);
|
||||
my $sql_writable = &sqlite_can_sql_write($path);
|
||||
my $database_writable = $writable || $sql_writable;
|
||||
print &ui_grid([
|
||||
&ui_card({
|
||||
'title' => $text{'database_size'},
|
||||
'body' => &ui_stat({
|
||||
'value_html' => &nice_size($page_count * $page_size),
|
||||
'icon' => 'hard-drive',
|
||||
}),
|
||||
}),
|
||||
&ui_card({
|
||||
'title' => $text{'database_journal'},
|
||||
'actions' => $wal ? undef : &ui_help($text{'database_wal'}),
|
||||
'body' => &ui_stat({
|
||||
'value' => uc($journal),
|
||||
'icon' => 'database',
|
||||
'state' => $wal ? 'success' : 'info',
|
||||
}),
|
||||
'state' => $wal ? 'success' : 'info',
|
||||
}),
|
||||
&ui_card({
|
||||
'title' => $text{'database_version'},
|
||||
'body' => &ui_stat({
|
||||
'value' => int($user_version),
|
||||
'icon' => 'tag',
|
||||
}),
|
||||
}),
|
||||
&ui_card({
|
||||
'title' => $text{'database_access'},
|
||||
'body' => &ui_stat({
|
||||
'value' => $database_writable ? $text{'database_readwrite'} :
|
||||
$text{'database_readonly'},
|
||||
'icon' => $database_writable ? 'edit' : 'lock',
|
||||
'state' => $database_writable ? 'success' : 'neutral',
|
||||
}),
|
||||
'state' => $database_writable ? 'success' : undef,
|
||||
}),
|
||||
], { 'min' => '210px', 'gap' => '12px',
|
||||
'class' => 'sqlite_database_stats' });
|
||||
|
||||
# Structured write access offers table creation above a populated list or in
|
||||
# the empty state.
|
||||
my $create_url = 'table_form.cgi?db='.&urlize($path);
|
||||
|
||||
# List user tables and views when present.
|
||||
if (@$objects) {
|
||||
# Offer the table form above the list to users who can write.
|
||||
if ($writable) {
|
||||
print &ui_form_start('table_form.cgi');
|
||||
print &ui_hidden('db', $path);
|
||||
print &ui_form_end([ [ undef, $text{'database_create_button'} ] ]);
|
||||
}
|
||||
print &ui_columns_start([
|
||||
$text{'database_name'}, $text{'database_type'},
|
||||
$text{'database_definition'}, $text{'database_action'} ],
|
||||
100, 0, undef, undef, 1);
|
||||
foreach my $object (@$objects) {
|
||||
my $link = 'table.cgi?db='.&urlize($path).
|
||||
'&table='.&urlize($object->{'name'});
|
||||
my $sql = $object->{'sql'} || '';
|
||||
$sql =~ s/\s+/ /g;
|
||||
# Keep large schema definitions from dominating the object list.
|
||||
$sql = substr($sql, 0, 160).$text{'value_more'}
|
||||
if (length($sql) > 160);
|
||||
print &ui_columns_row([
|
||||
&ui_tag('a', &html_escape($object->{'name'}), {
|
||||
'href' => $link,
|
||||
'style' => 'display:inline-block;white-space:nowrap;',
|
||||
}),
|
||||
&html_escape($text{'database_'.$object->{'type'}}),
|
||||
&ui_tag('tt', &html_escape($sql), {
|
||||
'style' => 'white-space:normal;overflow-wrap:anywhere;',
|
||||
}),
|
||||
&ui_tag('a', $text{'database_browse'}, { 'href' => $link }),
|
||||
]);
|
||||
}
|
||||
print &ui_columns_end();
|
||||
}
|
||||
else {
|
||||
# Show guidance instead of an empty object table: offer the table form
|
||||
# with structured write access, or point SQL-only users to that page.
|
||||
print &ui_empty_state({
|
||||
'title' => $text{'database_empty'},
|
||||
'desc' => $writable ? $text{'database_empty_create'} :
|
||||
$sql_writable ? $text{'database_empty_sql'} : undef,
|
||||
'icon' => 'database',
|
||||
'actions' => $writable ?
|
||||
&ui_action_button($text{'database_create_button'}, 'success', {
|
||||
'href' => $create_url, 'icon' => 'plus' }) : undef,
|
||||
});
|
||||
}
|
||||
|
||||
# Direct SQL is visible only when explicitly enabled in the module ACL.
|
||||
if ($access{'sql'}) {
|
||||
print &ui_form_start('exec_form.cgi');
|
||||
print &ui_hidden('db', $path);
|
||||
print &ui_form_end([ [ undef, $text{'database_sql'} ] ]);
|
||||
}
|
||||
print &ui_page_end();
|
||||
&ui_print_footer('', $text{'index_return'});
|
||||
@@ -1,6 +0,0 @@
|
||||
paths=/
|
||||
write=1
|
||||
create=1
|
||||
sql=1
|
||||
work_as_root=1
|
||||
work_as_user=
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Confirms and deletes selected SQLite table rows.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %config, %in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
&error_setup($text{'delete_err'});
|
||||
|
||||
# Validate the path and require at least one row token.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&sqlite_can_write($path) || &error($text{'error_readonly'});
|
||||
my @tokens = split(/\0/, $in{'row'});
|
||||
@tokens || &error($text{'delete_none'});
|
||||
|
||||
# Ask for confirmation when configured.
|
||||
if (!$in{'confirm'} && $config{'confirm_delete'}) {
|
||||
&ui_print_header(undef, $text{'delete_title'}, '', 'delete_rows');
|
||||
print &ui_confirmation_form('delete_rows.cgi',
|
||||
&text('delete_confirm', scalar(@tokens)),
|
||||
[ [ 'db', $path ], [ 'table', $in{'table'} ],
|
||||
[ 'start', int($in{'start'} || 0) ],
|
||||
map { [ 'row', $_ ] } @tokens ],
|
||||
[ [ 'confirm', $text{'delete_ok'} ] ]);
|
||||
&ui_print_footer('table.cgi?db='.&urlize($path).'&table='.
|
||||
&urlize($in{'table'}), $text{'table_return'});
|
||||
exit;
|
||||
}
|
||||
|
||||
# Reload the schema before decoding row identities.
|
||||
my $dbh = &sqlite_connect($path, 1);
|
||||
my $object = &sqlite_object($dbh, $in{'table'});
|
||||
$object && $object->{'type'} eq 'table' || &error($text{'table_missing'});
|
||||
my $columns = &sqlite_table_columns($dbh, $object->{'name'});
|
||||
my $identity = &sqlite_identity($dbh, $object, $columns);
|
||||
$identity || &error($text{'table_noidentity'});
|
||||
my $count = $identity->{'rowid'} ? 1 : scalar(@{$identity->{'columns'}});
|
||||
|
||||
# Decode and delete all selected rows in one transaction.
|
||||
eval {
|
||||
$dbh->begin_work();
|
||||
foreach my $token (@tokens) {
|
||||
my @keys = &sqlite_decode_key($token, $count);
|
||||
my ($where, @params) = &sqlite_identity_where($identity, \@keys);
|
||||
$dbh->do('DELETE FROM '.
|
||||
&sqlite_quote_identifier($object->{'name'}).
|
||||
' WHERE '.$where, undef, @params);
|
||||
}
|
||||
$dbh->commit();
|
||||
};
|
||||
# Roll back all deletions if any token or delete fails.
|
||||
if ($@) {
|
||||
my $error = $@;
|
||||
eval { $dbh->rollback(); };
|
||||
$dbh->disconnect();
|
||||
&error(&html_escape($error));
|
||||
}
|
||||
$dbh->disconnect();
|
||||
&sqlite_adopt_ownership($path);
|
||||
&webmin_log('delete', 'rows', scalar(@tokens),
|
||||
{ 'db' => $path, 'table' => $object->{'name'} });
|
||||
&redirect('table.cgi?db='.&urlize($path).'&table='.
|
||||
&urlize($object->{'name'}).'&start='.int($in{'start'} || 0));
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Confirms and drops one column from a table.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
&error_setup($text{'dropcol_err'});
|
||||
|
||||
# Recheck write access, library support and the column itself.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&sqlite_can_write($path) || &error($text{'error_readonly'});
|
||||
&sqlite_supports('drop_column') || &error($text{'column_eunsupported'});
|
||||
my $dbh = &sqlite_connect($path, 0);
|
||||
my $object = &sqlite_object($dbh, $in{'table'});
|
||||
$object && $object->{'type'} eq 'table' || &error($text{'table_missing'});
|
||||
my ($column) = grep { $_->{'name'} eq $in{'column'} }
|
||||
@{&sqlite_table_columns($dbh, $object->{'name'})};
|
||||
$column || &error($text{'column_emissing'});
|
||||
my $quoted_table = &sqlite_quote_identifier($object->{'name'});
|
||||
my $structure = 'table.cgi?db='.&urlize($path).'&table='.
|
||||
&urlize($object->{'name'}).'&view=structure';
|
||||
|
||||
# Always ask first, since the dropped values cannot be recovered.
|
||||
if (!$in{'confirm'}) {
|
||||
my ($rows) = $dbh->selectrow_array('SELECT count(*) FROM '.$quoted_table);
|
||||
$dbh->disconnect();
|
||||
&ui_print_header(undef, $text{'dropcol_title'}, '', 'drop_column');
|
||||
print &ui_confirmation_form('drop_column.cgi',
|
||||
&text('dropcol_confirm',
|
||||
&ui_tag('tt', &html_escape($column->{'name'})),
|
||||
&ui_tag('tt', &html_escape($object->{'name'})), $rows),
|
||||
[ [ 'db', $path ], [ 'table', $object->{'name'} ],
|
||||
[ 'column', $column->{'name'} ] ],
|
||||
[ [ 'confirm', $text{'dropcol_ok'} ] ]);
|
||||
&ui_print_footer($structure, $text{'column_return'});
|
||||
exit;
|
||||
}
|
||||
$dbh->disconnect();
|
||||
|
||||
$dbh = &sqlite_connect($path, 1);
|
||||
eval { $dbh->do('ALTER TABLE '.$quoted_table.' DROP COLUMN '.
|
||||
&sqlite_quote_identifier($column->{'name'})); };
|
||||
# SQLite refuses to drop key, unique, indexed and referenced columns, and
|
||||
# says so in its own words.
|
||||
if ($@) {
|
||||
my $message = $@;
|
||||
$dbh->disconnect();
|
||||
&error(&html_escape($message));
|
||||
}
|
||||
$dbh->disconnect();
|
||||
&sqlite_adopt_ownership($path);
|
||||
&webmin_log('drop', 'column', $column->{'name'},
|
||||
{ 'db' => $path, 'table' => $object->{'name'} });
|
||||
&redirect($structure);
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Confirms and drops one table or view.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
&error_setup($text{'drop_err'});
|
||||
|
||||
# Recheck write access and find the object to drop.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&sqlite_can_write($path) || &error($text{'error_readonly'});
|
||||
my $dbh = &sqlite_connect($path, 0);
|
||||
my $object = &sqlite_object($dbh, $in{'table'});
|
||||
$object || &error($text{'table_missing'});
|
||||
my $is_view = $object->{'type'} eq 'view';
|
||||
my $quoted = &sqlite_quote_identifier($object->{'name'});
|
||||
|
||||
# Always ask first, since a dropped table or view cannot be recovered here.
|
||||
if (!$in{'confirm'}) {
|
||||
my $message;
|
||||
# Say how many rows a table holds; a view holds none of its own.
|
||||
if ($is_view) {
|
||||
$message = &text('drop_confirm_view',
|
||||
&ui_tag('tt', &html_escape($object->{'name'})));
|
||||
}
|
||||
else {
|
||||
# Count the table's rows for the warning.
|
||||
my ($rows) = $dbh->selectrow_array('SELECT count(*) FROM '.$quoted);
|
||||
$message = &text('drop_confirm',
|
||||
&ui_tag('tt', &html_escape($object->{'name'})), $rows);
|
||||
}
|
||||
$dbh->disconnect();
|
||||
&ui_print_header(undef,
|
||||
$text{$is_view ? 'table_drop_view' : 'table_drop'}, '',
|
||||
'drop_table');
|
||||
print &ui_confirmation_form('drop_table.cgi', $message,
|
||||
[ [ 'db', $path ], [ 'table', $object->{'name'} ] ],
|
||||
[ [ 'confirm', $text{$is_view ? 'table_drop_view' : 'table_drop'} ] ]);
|
||||
&ui_print_footer('table.cgi?db='.&urlize($path).'&table='.
|
||||
&urlize($object->{'name'}), $text{'table_return'});
|
||||
exit;
|
||||
}
|
||||
$dbh->disconnect();
|
||||
|
||||
# Drop through a read-write connection.
|
||||
$dbh = &sqlite_connect($path, 1);
|
||||
eval { $dbh->do(($is_view ? 'DROP VIEW ' : 'DROP TABLE ').$quoted); };
|
||||
# Report SQLite's own objection through the normal error page.
|
||||
if ($@) {
|
||||
my $message = $@;
|
||||
$dbh->disconnect();
|
||||
&error(&html_escape($message));
|
||||
}
|
||||
$dbh->disconnect();
|
||||
&sqlite_adopt_ownership($path);
|
||||
&webmin_log('drop', $is_view ? 'view' : 'table', $object->{'name'},
|
||||
{ 'db' => $path });
|
||||
&redirect('database.cgi?db='.&urlize($path));
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Displays a form for adding or editing a table row.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
|
||||
# Require a writable table with a stable row identity.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&sqlite_can_write($path) || &error($text{'error_readonly'});
|
||||
my $dbh = &sqlite_connect($path, 0);
|
||||
my $object = &sqlite_object($dbh, $in{'table'});
|
||||
$object && $object->{'type'} eq 'table' || &error($text{'table_missing'});
|
||||
my $columns = &sqlite_table_columns($dbh, $object->{'name'});
|
||||
my $identity = &sqlite_identity($dbh, $object, $columns);
|
||||
$identity || &error($text{'table_noidentity'});
|
||||
|
||||
my @values = map { undef } @$columns;
|
||||
my @storage_types = map { undef } @$columns;
|
||||
my @storage_lengths = map { undef } @$columns;
|
||||
# Load one existing row. New rows start with undefined values.
|
||||
if (!$in{'new'}) {
|
||||
my $count = $identity->{'rowid'} ? 1 : scalar(@{$identity->{'columns'}});
|
||||
my @keys = &sqlite_decode_key($in{'key'}, $count);
|
||||
my ($where, @params) = &sqlite_identity_where($identity, \@keys);
|
||||
my ($value_selects, $type_selects, $length_selects) =
|
||||
&sqlite_column_selects($columns);
|
||||
# Measure the row before loading its full values. Run both queries in one
|
||||
# read transaction so they use the same snapshot.
|
||||
my @quoted = map { &sqlite_quote_identifier($_->{'name'}) } @$columns;
|
||||
my $row_size = &sqlite_sum_sql(64 + 64 * 3 * scalar(@$columns),
|
||||
&sqlite_bytes_sql(\@quoted));
|
||||
my $from = ' FROM '.&sqlite_quote_identifier($object->{'name'}).
|
||||
' WHERE '.$where.' LIMIT 2 OFFSET 0';
|
||||
my $loaded = eval {
|
||||
$dbh->do('BEGIN DEFERRED');
|
||||
# Fetch only the row size and storage metadata for the size check.
|
||||
my $metadata_sql = 'SELECT '.join(', ', $row_size,
|
||||
@$type_selects, @$length_selects).$from;
|
||||
my $metadata_rows = &sqlite_select_rows_bounded($dbh,
|
||||
$metadata_sql, @params);
|
||||
@$metadata_rows == 1 || die $text{'edit_missing'}."\n";
|
||||
my @metadata = @{$metadata_rows->[0]};
|
||||
my $measured_size = shift(@metadata);
|
||||
# Reject an oversized row before SQLite evaluates its full values.
|
||||
die &text('error_resultlarge', &sqlite_result_bytes_limit())."\n"
|
||||
if ($measured_size > &sqlite_result_bytes_limit());
|
||||
# Split the remaining metadata into type and length lists.
|
||||
@storage_types = splice(@metadata, 0, scalar(@$columns));
|
||||
@storage_lengths = @metadata;
|
||||
undef($metadata_rows);
|
||||
# Read full values from the same snapshot after the size check passes.
|
||||
my $value_sql = 'SELECT '.join(', ', @$value_selects).$from;
|
||||
my $value_rows = &sqlite_select_rows_bounded($dbh,
|
||||
$value_sql, @params);
|
||||
@$value_rows == 1 || die $text{'edit_missing'}."\n";
|
||||
@values = @{$value_rows->[0]};
|
||||
$dbh->do('COMMIT');
|
||||
1;
|
||||
};
|
||||
# On failure, roll back the snapshot and close the handle before reporting it.
|
||||
if (!$loaded) {
|
||||
my $error = $@ || $text{'error_unknown'};
|
||||
eval { $dbh->do('ROLLBACK'); };
|
||||
$dbh->disconnect();
|
||||
&error(&html_escape($error));
|
||||
}
|
||||
}
|
||||
$dbh->disconnect();
|
||||
|
||||
# Carry the database, table and row identity into the save request.
|
||||
&ui_print_header(undef, $in{'new'} ? $text{'edit_title_new'} :
|
||||
$text{'edit_title'}, '', 'edit_row');
|
||||
print &ui_form_start('save_row.cgi', 'post');
|
||||
print &ui_hidden('db', $path);
|
||||
print &ui_hidden('table', $object->{'name'});
|
||||
print &ui_hidden('new', $in{'new'} ? 1 : 0);
|
||||
# Existing rows must carry their opaque identity into the update request.
|
||||
print &ui_hidden('key', $in{'key'}) if (!$in{'new'});
|
||||
print &ui_hidden('start', int($in{'start'} || 0));
|
||||
print &ui_table_start($text{'edit_header'}, undef, 3);
|
||||
for(my $i = 0; $i < @$columns; $i++) {
|
||||
my $column = $columns->[$i];
|
||||
my $value = $values[$i];
|
||||
my $details = &html_escape($column->{'type'} || $text{'edit_untyped'});
|
||||
# Mark declared NOT NULL columns as required context for the user.
|
||||
$details .= ', '.$text{'edit_required'} if ($column->{'notnull'});
|
||||
# Mark generated columns before rendering them as read-only values.
|
||||
$details .= ', '.$text{'table_generated'} if ($column->{'hidden'} == 2 ||
|
||||
$column->{'hidden'} == 3);
|
||||
my $input;
|
||||
# Generated columns are displayed but never submitted for modification.
|
||||
if ($column->{'hidden'} == 2 || $column->{'hidden'} == 3) {
|
||||
$input = &sqlite_display_value($value, $storage_types[$i],
|
||||
$storage_lengths[$i]);
|
||||
}
|
||||
elsif (!$in{'new'} && ((defined($storage_types[$i]) &&
|
||||
$storage_types[$i] eq 'blob') ||
|
||||
&sqlite_value_is_binary($value))) {
|
||||
# Preserve binary data because the form cannot edit it.
|
||||
my $length = defined($storage_lengths[$i]) ?
|
||||
$storage_lengths[$i] : length($value);
|
||||
$input = &text('edit_blob', $length).
|
||||
&ui_hidden('keep_'.$i, 1);
|
||||
}
|
||||
else {
|
||||
# Text and NULL values use the normal editable controls.
|
||||
$input = &ui_textarea('value_'.$i, defined($value) ? $value : '',
|
||||
3, 60, undef, undef, "style='width: 100%'");
|
||||
$input .= &ui_tag('br').&ui_checkbox('null_'.$i, 1,
|
||||
$text{'edit_null'}, !defined($value) && !$in{'new'});
|
||||
# Let SQLite supply declared defaults and integer primary keys
|
||||
# on insert.
|
||||
if ($in{'new'} && (defined($column->{'dflt_value'}) ||
|
||||
$column->{'pk'} && $column->{'type'} =~ /^INTEGER$/i)) {
|
||||
$input .= ' '.&ui_checkbox('default_'.$i, 1,
|
||||
$text{'edit_default'}, 1);
|
||||
}
|
||||
}
|
||||
# Use the full-width row layout in themes that support it.
|
||||
print &ui_table_row(&html_escape($column->{'name'}).&ui_tag('br').
|
||||
&ui_tag('small', $details), $input, 2, undef, undef, 1);
|
||||
}
|
||||
print &ui_table_end();
|
||||
print &ui_form_end([ [ undef, $in{'new'} ? $text{'edit_create'} :
|
||||
$text{'save'} ] ]);
|
||||
|
||||
&ui_print_footer('table.cgi?db='.&urlize($path).'&table='.
|
||||
&urlize($object->{'name'}), $text{'table_return'});
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Executes one SQL statement against an SQLite database.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %config, %in, %access);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
&error_setup($text{'exec_err'});
|
||||
$access{'sql'} || &error($text{'exec_denied'});
|
||||
$in{'sql'} =~ /\S/ || &error($text{'exec_empty'});
|
||||
|
||||
# Match the connection mode to write access and install the SQL sandbox.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
my $write = &sqlite_can_sql_write($path);
|
||||
my $dbh = &sqlite_connect($path, $write ? 1 : 0, 0, $write ? 1 : 0);
|
||||
&sqlite_install_authorizer($dbh);
|
||||
my $limit = int($config{'max_query_rows'} || 1000);
|
||||
# Use one row as the minimum result limit.
|
||||
$limit = 1 if ($limit < 1);
|
||||
# Cap SQL results at 100,000 rows.
|
||||
$limit = 100000 if ($limit > 100000);
|
||||
# Execute one statement and preserve any DBI exception.
|
||||
my $result = eval {
|
||||
&sqlite_query($dbh, $in{'sql'}, $limit);
|
||||
};
|
||||
# Release the database handle before reporting a failed statement.
|
||||
if ($@) {
|
||||
my $error = $@;
|
||||
$dbh->disconnect();
|
||||
&error(&html_escape($error));
|
||||
}
|
||||
$dbh->disconnect();
|
||||
&sqlite_adopt_ownership($path);
|
||||
|
||||
&ui_print_header(undef, $text{'exec_title'}, '', 'exec');
|
||||
print &ui_tag('p', &text('exec_result',
|
||||
&ui_tag('tt', &html_escape($in{'sql'}))));
|
||||
# Statements with named columns render a result table.
|
||||
if (@{$result->{'columns'}}) {
|
||||
print &ui_columns_start([ map { &html_escape($_) }
|
||||
@{$result->{'columns'}} ], 100, 0, undef, undef, 1);
|
||||
foreach my $row (@{$result->{'rows'}}) {
|
||||
print &ui_columns_row([ map { &sqlite_display_value($_) } @$row ]);
|
||||
}
|
||||
print &ui_columns_end();
|
||||
# Report whether the row limit or byte limit shortened the output.
|
||||
if ($result->{'truncated_by'} eq 'bytes') {
|
||||
# Report the byte limit that stopped result collection.
|
||||
print &ui_alert_box(&text('exec_truncated_bytes',
|
||||
&sqlite_result_bytes_limit()), 'warn');
|
||||
}
|
||||
elsif ($result->{'truncated_by'} eq 'rows') {
|
||||
# Report the row limit that stopped result collection.
|
||||
print &ui_alert_box(&text('exec_truncated', $limit), 'warn');
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Statements without result columns report their affected-row count.
|
||||
print &ui_alert_box(&text('exec_affected',
|
||||
$result->{'affected'} < 0 ? 0 : $result->{'affected'}),
|
||||
'success', undef, undef, '');
|
||||
}
|
||||
# Log the statement, then offer to run another one.
|
||||
&webmin_log('exec', 'sql', $path, { 'sql' => $in{'sql'} });
|
||||
print &ui_form_start('exec_form.cgi');
|
||||
print &ui_hidden('db', $path);
|
||||
print &ui_form_end([ [ undef, $text{'exec_again'} ] ]);
|
||||
&ui_print_footer('database.cgi?db='.&urlize($path),
|
||||
$text{'database_return'});
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Displays the SQLite SQL command form.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in, %access);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
$access{'sql'} || &error($text{'exec_denied'});
|
||||
|
||||
# Preserve the validated database path while the user writes a statement.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&ui_print_header(&ui_tag('tt', &html_escape($path)),
|
||||
$text{'exec_title'}, '', 'exec');
|
||||
print &ui_tag('p', $text{'exec_desc'});
|
||||
print &ui_form_start('exec.cgi', 'post');
|
||||
print &ui_hidden('db', $path);
|
||||
print &ui_textarea('sql', $in{'sql'}, 14, 90, undef, undef,
|
||||
"style='width: 100%'");
|
||||
print &ui_form_end([ [ undef, $text{'exec_run'} ] ]);
|
||||
&ui_print_footer('database.cgi?db='.&urlize($path),
|
||||
$text{'database_return'});
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Removes one database from the current user's recently opened list.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
|
||||
# The path only names an entry in this user's own list, which stores the
|
||||
# resolved path, so it is matched as given and needs no ACL check.
|
||||
&sqlite_forget_recent($in{'db'}) if ($in{'db'} ne '');
|
||||
&redirect('');
|
||||
@@ -1,34 +0,0 @@
|
||||
<header>SQLite module access control</header>
|
||||
|
||||
These settings control which database files a Webmin user can open and what
|
||||
the user can do with them. They do not override the filesystem permissions
|
||||
of the selected Unix user.
|
||||
|
||||
<p>For <b>Allowed database directories</b>, enter one absolute directory per
|
||||
line. The module allows files in those directories and their subdirectories.
|
||||
Use <tt>$HOME</tt> for the selected Unix user's home directory. Paths outside
|
||||
the listed directories are rejected. In root mode, <tt>$HOME</tt> refers to
|
||||
the Unix account whose name matches the Webmin user. SQLite 3.39 or later is
|
||||
required for a restricted list because it prevents symbolic links in any path
|
||||
component from escaping these directories. A list containing <tt>/</tt> does
|
||||
not restrict database paths and does not need this check. This requirement
|
||||
applies to the SQLite library used by <tt>DBD::SQLite</tt>, which may differ
|
||||
from the system <tt>sqlite3</tt> command. Unix permissions still apply, but they
|
||||
do not enforce this module's directory list.</p>
|
||||
|
||||
<p>The row and table permission enables forms for editing rows and changing
|
||||
schemas. The file creation permission allows new files in writable allowed
|
||||
directories. The module offers file creation only when the user can also add
|
||||
a table through a form or SQL.</p>
|
||||
|
||||
<p><b>Execute SQL statements</b> permits one direct SQL statement at a time.
|
||||
This is a separate write permission: SQL may change the current database when
|
||||
its file and directory are writable. The module blocks attached databases,
|
||||
extensions, and SQL operations that access other files.</p>
|
||||
|
||||
<p><b>Unix user for database access</b> selects the filesystem identity used
|
||||
by the module. Root normally bypasses file ownership and permissions. A
|
||||
matching or specific Unix user limits access to files that account can read
|
||||
and write.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,16 +0,0 @@
|
||||
<header>Add or Rename Column</header>
|
||||
|
||||
This page adds or renames a column without rebuilding the table.
|
||||
|
||||
<p>An added column has a name, type and optional default. Existing rows
|
||||
receive the default, so a column that does not allow NULL must have one.
|
||||
SQLite does not allow an added column to be a primary key, to be unique, or
|
||||
to default to the current time or date. Rebuild the table with SQL to make
|
||||
those changes.</p>
|
||||
|
||||
<p>Renaming changes only the name. SQLite also updates dependent indexes,
|
||||
triggers and views. It rejects the rename if the updated schema would be
|
||||
ambiguous or invalid.</p>
|
||||
|
||||
<p>Changing a column's type, NULL rule, default or key requires rebuilding
|
||||
the table. Use <b>Execute SQL</b> if your module permissions allow it.</p>
|
||||
@@ -1,11 +0,0 @@
|
||||
<header>Database lock wait</header>
|
||||
|
||||
This is how long, in milliseconds, SQLite waits for another connection to
|
||||
release a database lock. The default is 5,000 milliseconds. The maximum is
|
||||
600,000 milliseconds, or 10 minutes.
|
||||
|
||||
<p>A negative value makes a locked operation fail immediately. A longer wait
|
||||
can help when another application makes short, frequent changes, but it also
|
||||
makes a blocked Webmin request take longer to fail.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,10 +0,0 @@
|
||||
<header>Confirm row deletion</header>
|
||||
|
||||
When enabled, deleting selected table rows opens a confirmation page before
|
||||
the rows are removed. Disable it only when an extra confirmation is not
|
||||
needed.
|
||||
|
||||
<p>Dropping a table, view, or column always requires confirmation because it
|
||||
can remove stored data.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,10 +0,0 @@
|
||||
<header>SQLite Database Browser configuration</header>
|
||||
|
||||
These settings control recent-file history, table paging, displayed text,
|
||||
database lock waits, SQL result limits, and row-deletion confirmation.
|
||||
|
||||
<p>They apply to all users of this module. File access and editing
|
||||
permissions are configured separately under <b>Webmin Users</b> in the
|
||||
module access-control settings.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,9 +0,0 @@
|
||||
<header>Maximum SQL result rows</header>
|
||||
|
||||
This is the maximum number of rows displayed after an SQL query. The default
|
||||
is 1,000 rows. The allowed range is 1 to 100,000 rows.
|
||||
|
||||
<p>When a result is larger, the page shows the configured number of rows and
|
||||
reports that the output was truncated.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,3 +0,0 @@
|
||||
Sets the approximate memory limit for SQLite results kept by one request. The
|
||||
estimate includes returned values and Perl row storage. Values below 1 MiB use
|
||||
1 MiB, and values above 64 MiB use 64 MiB.
|
||||
@@ -1,11 +0,0 @@
|
||||
<header>Maximum displayed text length</header>
|
||||
|
||||
This limits how many characters from a text value are displayed in a table.
|
||||
The default is 1,000 characters. Select <b>Unlimited</b> to show the complete
|
||||
value.
|
||||
|
||||
<p>If the displayed portion is longer than 128 characters, it uses an
|
||||
expandable preview. When this limit cuts a value short, the page shows how
|
||||
many characters were omitted. The stored value is not changed.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,9 +0,0 @@
|
||||
<header>Rows per page</header>
|
||||
|
||||
This is the number of table rows shown on one data page. The default is 50.
|
||||
Values are limited to between 1 and 1,000 rows.
|
||||
|
||||
<p>Smaller pages load and render faster for tables with large values or many
|
||||
columns.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,10 +0,0 @@
|
||||
<header>Recently opened databases</header>
|
||||
|
||||
This is the maximum number of databases shown in each Webmin user's
|
||||
<b>Recent</b> list. The default is 3, the maximum is 50, and 0 disables the
|
||||
list.
|
||||
|
||||
<p>Each user has a separate history. Missing files and files no longer
|
||||
allowed by that user's module access settings are not shown.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,20 +0,0 @@
|
||||
<header>Create Table</header>
|
||||
|
||||
This page creates a table in the open SQLite database. Enter a table name and
|
||||
one row per column. Rows without a column name are ignored. <b>Add More
|
||||
Columns</b> shows four more rows without discarding your entries.
|
||||
|
||||
<p>Each column uses one of these SQLite type affinities: <tt>INTEGER</tt>,
|
||||
<tt>TEXT</tt>, <tt>REAL</tt>, <tt>BLOB</tt> or <tt>NUMERIC</tt>. These are
|
||||
preferred storage types, not strict restrictions. A column that does not
|
||||
allow NULL needs a value or default when each database row is added.</p>
|
||||
|
||||
<p>The key setting controls row identity and uniqueness. An
|
||||
<tt>INTEGER</tt> primary key with autoincrement is the usual choice and is
|
||||
filled in automatically. Marking several columns as the primary key creates
|
||||
a composite key. Primary key columns do not allow NULL. A unique column
|
||||
rejects duplicate values without becoming the primary key.</p>
|
||||
|
||||
<p>A default value is stored as text unless it is a number, <tt>NULL</tt>, or
|
||||
one of <tt>CURRENT_TIME</tt>, <tt>CURRENT_DATE</tt> and
|
||||
<tt>CURRENT_TIMESTAMP</tt>.</p>
|
||||
@@ -1,9 +0,0 @@
|
||||
<header>SQLite Database</header>
|
||||
|
||||
This page summarizes the open SQLite database and lists its tables and views.
|
||||
The cards show its allocated size, journal mode, application-defined user
|
||||
version and the access available to your Webmin account.
|
||||
|
||||
<p>Select a table or view to inspect its structure and data. With structured
|
||||
write access, <b>Create Table</b> adds a table without SQL. <b>Execute SQL</b>
|
||||
runs your own statement when direct SQL access is enabled.</p>
|
||||
@@ -1,9 +0,0 @@
|
||||
<header>Delete table rows</header>
|
||||
|
||||
This page permanently deletes the selected rows. It removes them in one
|
||||
transaction, so none are removed if any deletion fails.
|
||||
|
||||
<p>The module identifies each row by its SQLite row ID or declared primary
|
||||
key. Return to the table if you need to change the selection.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,9 +0,0 @@
|
||||
<header>Drop Column</header>
|
||||
|
||||
Dropping a column permanently removes it and its value from every row. This
|
||||
operation cannot be undone through this module.
|
||||
|
||||
<p>SQLite refuses to drop columns used by constraints, indexes, foreign keys,
|
||||
triggers or views. Remove or update those dependencies first.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,9 +0,0 @@
|
||||
<header>Drop Table or View</header>
|
||||
|
||||
Dropping a table permanently removes its rows, indexes, and triggers from the
|
||||
database. This operation cannot be undone through this module.
|
||||
|
||||
<p>Dropping a view removes only the saved query. It does not remove or change
|
||||
the tables read by that view.</p>
|
||||
|
||||
<footer>
|
||||
@@ -1,12 +0,0 @@
|
||||
<header>Edit table row</header>
|
||||
|
||||
This page adds a row to an SQLite table or changes an existing row.
|
||||
|
||||
Enter a value for each editable column. Select <b>Set to NULL</b> to store an
|
||||
SQL NULL value. When adding a row, select <b>Use default value</b> to use the
|
||||
column default or generate an integer primary key.
|
||||
|
||||
Generated columns cannot be changed. The form preserves existing binary
|
||||
values because it edits text values only.
|
||||
|
||||
<footer>
|
||||
@@ -1,13 +0,0 @@
|
||||
<header>Execute SQL</header>
|
||||
|
||||
This page runs one SQL statement against the open SQLite database.
|
||||
|
||||
The module configuration limits the number of displayed result rows. The
|
||||
module blocks attached databases, changes to SQLite file directories,
|
||||
extensions and file helper functions so SQL cannot bypass the allowed paths.
|
||||
|
||||
SQL permission allows statements to change the database when its file and
|
||||
directory are writable. The separate row and schema permission controls only
|
||||
the module's editing forms.
|
||||
|
||||
<footer>
|
||||
@@ -1,17 +0,0 @@
|
||||
<header>SQLite Database Browser</header>
|
||||
|
||||
This module opens SQLite database files without a database server. It can
|
||||
inspect tables and views, change schemas, edit rows and run individual SQL
|
||||
statements. Access can be read-only or limited to selected directories. The
|
||||
module can also use a selected Unix account so its file permissions apply.
|
||||
The safe defaults for delegated users limit access to their Unix home
|
||||
directory and run the module as their matching Unix account.
|
||||
|
||||
<p>SQLite briefly locks the database file during writes. Write-ahead logging
|
||||
(WAL) can reduce blocking when Webmin and another application use the database
|
||||
at the same time.</p>
|
||||
|
||||
<p>The front page lists each Webmin user's recently opened databases for quick
|
||||
access. The module configuration sets how many entries are kept. Users can
|
||||
remove individual entries, and databases no longer allowed by their access
|
||||
settings are hidden.</p>
|
||||
@@ -1,17 +0,0 @@
|
||||
<header>SQLite Table</header>
|
||||
|
||||
This page shows the columns and rows in the selected SQLite table or view.
|
||||
Select a column heading to sort the rows. Use the page controls for large
|
||||
tables.
|
||||
|
||||
<p>Rows can be added, edited or deleted when the database is writable and the
|
||||
table has a stable row identity. Views and tables without one are read-only.</p>
|
||||
|
||||
<p>With structured write access, the <b>Structure</b> tab can add a column.
|
||||
SQLite 3.25 or later can rename one, and SQLite 3.35 or later can drop one.
|
||||
SQLite refuses to drop columns used by constraints, indexes or other schema
|
||||
objects. Changing a column's type or constraints requires rebuilding the
|
||||
table with <b>Execute SQL</b>.</p>
|
||||
|
||||
<p>The buttons at the bottom rename a table or, after confirmation, drop a
|
||||
table or view. This module cannot recover a dropped table or its rows.</p>
|
||||
146
sqlite/index.cgi
146
sqlite/index.cgi
@@ -1,146 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Lists recently opened databases and offers to open or create another.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %config, %access);
|
||||
require './sqlite-lib.pl';
|
||||
|
||||
# Show the library and driver versions under the page title.
|
||||
my $driver_error = &sqlite_load_driver();
|
||||
my ($library, $driver) = &sqlite_versions();
|
||||
my $limit_error = $driver_error ? undef : &sqlite_result_limit_error();
|
||||
&ui_print_header($library ? &text('index_version', $library, $driver) : undef,
|
||||
$text{'index_title'}, '', 'intro', 1);
|
||||
# Without a usable driver, show the dependency or upgrade choices.
|
||||
if ($driver_error || $limit_error) {
|
||||
my @needs;
|
||||
my $install_link;
|
||||
my $upgrade = $limit_error ? 1 : 0;
|
||||
# An installed driver without limit control needs an upgrade.
|
||||
if ($upgrade) {
|
||||
@needs = ( 'DBD::SQLite' );
|
||||
print &ui_tag('p', &text('index_olddriver',
|
||||
&html_escape($driver)));
|
||||
}
|
||||
else {
|
||||
# A missing driver may require DBI, DBD::SQLite or both.
|
||||
eval { require DBI; };
|
||||
# Include DBI only when its independent load check failed.
|
||||
push(@needs, 'DBI') if ($@);
|
||||
eval { require DBD::SQLite; };
|
||||
# Include the SQLite driver only when its load check failed.
|
||||
push(@needs, 'DBD::SQLite') if ($@);
|
||||
# The loader can fail for other reasons; then name the driver itself.
|
||||
@needs = ( 'DBD::SQLite' ) if (!@needs);
|
||||
print &ui_tag('p', &text(
|
||||
@needs > 1 ? 'index_nodrivers' : 'index_nodriver',
|
||||
join(', ', map { &ui_tag('tt', &html_escape($_)) } @needs)));
|
||||
}
|
||||
|
||||
# Prefer native packages only for a missing driver. Reinstalling an old
|
||||
# native package would not add the required method.
|
||||
if (!$upgrade && &foreign_available('software')) {
|
||||
&foreign_require('software', 'software-lib.pl');
|
||||
my $packages = $software::update_system eq 'apt' ?
|
||||
'libdbi-perl libdbd-sqlite3-perl' :
|
||||
$software::update_system eq 'yum' ?
|
||||
'perl-DBI perl-DBD-SQLite' : undef;
|
||||
# Omit the link when this package manager has no known mapping.
|
||||
$install_link = &software::missing_install_link(
|
||||
$packages, $text{'index_driver_package'},
|
||||
'../sqlite/', $text{'index_return'}) if ($packages);
|
||||
}
|
||||
# Offer the native package installer when available.
|
||||
if ($install_link) {
|
||||
print $install_link;
|
||||
}
|
||||
elsif (&foreign_available('cpan')) {
|
||||
# Use Webmin's CPAN module when no suitable native package is available.
|
||||
print &ui_form_start('../cpan/download.cgi', 'get');
|
||||
print &ui_tag('p', $text{$upgrade ?
|
||||
'index_driver_cpan_upgrade' : 'index_driver_cpan'});
|
||||
print &ui_hidden('source', 3);
|
||||
print &ui_hidden('cpan', join(' ', @needs));
|
||||
print &ui_hidden('mode', 2);
|
||||
print &ui_hidden('return', '/sqlite/');
|
||||
print &ui_hidden('returndesc', $text{'index_return'});
|
||||
print &ui_form_end([ [ undef, $text{$upgrade ?
|
||||
'index_driver_update' : 'index_driver_install'} ] ]);
|
||||
}
|
||||
else {
|
||||
# Give manual instructions when Webmin cannot install the dependencies.
|
||||
print &ui_tag('p', $text{$upgrade ?
|
||||
'index_driver_upgrade_manual' : 'index_driver_manual'});
|
||||
}
|
||||
&ui_print_footer('/', $text{'index'});
|
||||
exit;
|
||||
}
|
||||
print &ui_page_start();
|
||||
|
||||
my @recent = &sqlite_list_recent();
|
||||
# List the databases this user opened last, newest first, when any.
|
||||
if (@recent) {
|
||||
my @rows;
|
||||
foreach my $recent (@recent) {
|
||||
my ($name) = $recent->{'path'} =~ m!([^/]+)$!;
|
||||
# Size, table count and last-opened time make up the row
|
||||
# summary.
|
||||
my @meta = ( &nice_size($recent->{'size'}) );
|
||||
push(@meta, &html_escape($recent->{'tables'} == 1 ?
|
||||
$text{'index_table'} :
|
||||
&text('index_tables', $recent->{'tables'})))
|
||||
if (defined($recent->{'tables'}));
|
||||
push(@meta, &html_escape(&sqlite_recent_time($recent->{'time'})));
|
||||
# A small button under the summary forgets just this entry.
|
||||
push(@rows, {
|
||||
'title' => $name,
|
||||
'href' => 'database.cgi?db='.&urlize($recent->{'path'}),
|
||||
'icon' => 'database',
|
||||
'desc_html' => &ui_tag('tt', &html_escape($recent->{'path'})),
|
||||
'meta_html' => join(' · ', grep { $_ ne '' } @meta),
|
||||
'actions' => &ui_action_button($text{'index_recent_remove'},
|
||||
'neutral', {
|
||||
'href' => 'forget_recent.cgi?db='.
|
||||
&urlize($recent->{'path'}),
|
||||
'size' => 'small' }),
|
||||
'chevron' => 1,
|
||||
});
|
||||
}
|
||||
print &ui_section_title($text{'index_recent'});
|
||||
print &ui_card({
|
||||
'flush' => 1,
|
||||
'body' => &ui_list(\@rows, {
|
||||
'flush' => 1, 'icon_box' => 1, 'stacked' => 1 }),
|
||||
});
|
||||
}
|
||||
|
||||
# Show a path field, file chooser and primary action. The field help explains
|
||||
# when open.cgi can create a missing file.
|
||||
my $can_create = &sqlite_can_create();
|
||||
print &ui_form_start('open.cgi', 'post');
|
||||
print &ui_card({
|
||||
'title' => @recent ? $text{'index_open_another'} : $text{'index_header'},
|
||||
'body' => &ui_stack([
|
||||
&ui_field($text{'index_path'},
|
||||
&ui_textbox('db', undef, 60, 0, undef, "placeholder='".
|
||||
"e_escape($text{'index_path_example'})."'").
|
||||
" ".&file_chooser_button('db', 0),
|
||||
{ 'for' => 'db',
|
||||
'help' => $can_create ?
|
||||
$text{'index_path_help_create'} :
|
||||
$text{'index_path_help'} }),
|
||||
&ui_cluster([
|
||||
&ui_action_button($text{'index_open'}, 'primary', {
|
||||
'type' => 'submit',
|
||||
'icon' => 'arrow-right',
|
||||
'icon_after' => 1 }),
|
||||
], { 'align' => 'end' }),
|
||||
], { 'gap' => '12px' }),
|
||||
});
|
||||
print &ui_form_end();
|
||||
|
||||
print &ui_page_end();
|
||||
&ui_print_footer('/', $text{'index'});
|
||||
228
sqlite/lang/en
228
sqlite/lang/en
@@ -1,228 +0,0 @@
|
||||
index_title=SQLite Database Browser
|
||||
index_version=SQLite version $1 (DBD::SQLite $2)
|
||||
index_header=Open a database
|
||||
index_open_another=Open another file
|
||||
index_recent=Recent
|
||||
index_recent_remove=Remove
|
||||
index_table=1 table
|
||||
index_tables=$1 tables
|
||||
index_today=Today, $1
|
||||
index_yesterday=Yesterday, $1
|
||||
index_path=Path on server
|
||||
index_path_example=/var/lib/myapp/data.db
|
||||
index_path_help=Enter the absolute path to an existing SQLite file. The selected Unix user needs read access to browse it and write access to edit it.
|
||||
index_path_help_create=Enter the absolute path to an SQLite file. The selected Unix user needs read access to browse it and write access to edit it. Webmin creates the file if it does not exist and its directory is writable.
|
||||
index_open=Open Database
|
||||
index_driver_package=SQLite Perl driver
|
||||
index_nodriver=The required Perl module $1 could not be loaded.
|
||||
index_nodrivers=The required Perl modules $1 could not be loaded.
|
||||
index_olddriver=DBD::SQLite $1 is installed, but it cannot safely limit result data. Update to version 1.62 or later, or use a vendor package that provides sqlite_limit.
|
||||
index_driver_cpan=The missing Perl modules can be automatically installed by Webmin from CPAN.
|
||||
index_driver_cpan_upgrade=Webmin can update DBD::SQLite from CPAN.
|
||||
index_driver_install=Install Now
|
||||
index_driver_update=Update Now
|
||||
index_driver_manual=Install the missing Perl modules with your operating system's package manager or from CPAN before using this module.
|
||||
index_driver_upgrade_manual=Update DBD::SQLite to version 1.62 or later, or install a vendor package that provides sqlite_limit, before using this module.
|
||||
index_return=SQLite Database Browser
|
||||
|
||||
database_title=SQLite Database
|
||||
database_size=Allocated database size
|
||||
database_journal=Journal mode
|
||||
database_version=Application user version
|
||||
database_access=Module access
|
||||
database_readwrite=Read and write
|
||||
database_readonly=Read only
|
||||
database_wal=This database does not use write-ahead logging (WAL). Readers and writers can block each other when Webmin and another application use the file at the same time.
|
||||
database_name=Name
|
||||
database_type=Type
|
||||
database_table=Table
|
||||
database_view=View
|
||||
database_definition=Definition
|
||||
database_action=Action
|
||||
database_browse=Browse
|
||||
database_empty=This database has no tables or views.
|
||||
database_empty_sql=Use Execute SQL to create the first table.
|
||||
database_empty_create=Create the first table to start storing data.
|
||||
database_create_button=Create Table
|
||||
database_sql=Execute SQL
|
||||
database_return=database objects
|
||||
|
||||
create_title=Create Table
|
||||
create_header=New table details
|
||||
create_name=Table name
|
||||
create_column=Column name
|
||||
create_type=Type
|
||||
create_null=Allows NULL?
|
||||
create_key=Key
|
||||
create_key_none=None
|
||||
create_key_pk=Primary key
|
||||
create_key_pkai=Primary key, autoincrement
|
||||
create_key_unique=Unique
|
||||
create_default=Default value
|
||||
create_ok=Create
|
||||
create_more=Add More Columns
|
||||
create_err=Failed to create table
|
||||
create_ename=Invalid table name '$1'
|
||||
create_ecolumns=No columns were entered
|
||||
create_ecolumn=Invalid column name '$1'
|
||||
create_edup=Column '$1' is listed more than once
|
||||
create_etype=Invalid type for column '$1'
|
||||
create_eauto=Autoincrement requires '$1' to be the only primary key column and have type INTEGER
|
||||
create_eexists=A table or view named '$1' already exists
|
||||
|
||||
drop_err=Failed to drop table or view
|
||||
drop_confirm=Are you sure you want to drop the table $1? This will permanently delete its rows. Row count: $2
|
||||
drop_confirm_view=Are you sure you want to drop the view $1?
|
||||
|
||||
column_title_add=Add Column
|
||||
column_title_rename=Rename Column
|
||||
column_header_add=New column details
|
||||
column_header_rename=Column to rename
|
||||
column_name=Column name
|
||||
column_current=Current name
|
||||
column_newname=New name
|
||||
column_type=Type
|
||||
column_null=Allows NULL?
|
||||
column_default=Default value
|
||||
column_default_help=Existing rows receive this value. A column that does not allow NULL needs one.
|
||||
column_add=Add Column
|
||||
column_rename=Rename Column
|
||||
column_return=table structure
|
||||
column_err=Failed to save column
|
||||
column_emissing=The column no longer exists
|
||||
column_eexists=A column named '$1' already exists
|
||||
column_enotnull=A column that does not allow NULL needs a default value for the existing rows
|
||||
column_ecurrent=An added column cannot default to the current time or date
|
||||
column_eunsupported=The installed SQLite library is too old for this change
|
||||
|
||||
dropcol_title=Drop Column
|
||||
dropcol_err=Failed to drop column
|
||||
dropcol_confirm=Are you sure you want to drop the column $1 from the table $2? This will permanently delete its values. Affected rows: $3
|
||||
dropcol_ok=Drop Column
|
||||
|
||||
rename_err=Failed to rename table
|
||||
|
||||
table_title=Browse Table or View
|
||||
table_in=in database
|
||||
table_structure=Structure
|
||||
table_column=Column
|
||||
table_type=Type
|
||||
table_null=Allows NULL?
|
||||
table_default=Default
|
||||
table_key=Key
|
||||
table_pk=Primary key ($1)
|
||||
table_generated=Generated
|
||||
table_data=Data
|
||||
table_action=Action
|
||||
table_edit=Edit
|
||||
table_key_large=Key is too large to edit
|
||||
table_delete=Delete Selected Rows
|
||||
table_add=Add Row
|
||||
table_empty=No rows were found.
|
||||
table_missing=The requested table or view does not exist
|
||||
table_nocolumns=The requested table or view has no visible columns
|
||||
table_noidentity=Rows in this table cannot be edited because SQLite does not expose a unique identity for them.
|
||||
table_previous=Previous page
|
||||
table_next=Next page
|
||||
table_position=Rows $1 to $2 of $3
|
||||
table_pages=Table pages
|
||||
table_empty_desc=Add the first row to start storing data.
|
||||
table_rename_column=Rename
|
||||
table_drop_column=Drop
|
||||
table_add_column=Add Column
|
||||
table_rename=Rename Table
|
||||
table_rename_desc=Enter a new name for this table.
|
||||
table_drop=Drop Table
|
||||
table_drop_desc=Permanently remove this table and all of its rows from the database.
|
||||
table_drop_view=Drop View
|
||||
table_drop_view_desc=Permanently remove this view without changing the tables it reads.
|
||||
table_return=table data
|
||||
|
||||
edit_title=Edit Row
|
||||
edit_title_new=Add Row
|
||||
edit_header=Row values
|
||||
edit_untyped=No declared type
|
||||
edit_required=required
|
||||
edit_null=Set to NULL
|
||||
edit_default=Use default value
|
||||
edit_blob=This binary value ($1 bytes) cannot be edited here and will remain unchanged.
|
||||
edit_create=Add Row
|
||||
edit_missing=The selected row no longer exists, or its identity is not unique
|
||||
|
||||
save_err=Failed to save row
|
||||
save_nochanges=No editable values were submitted
|
||||
|
||||
delete_err=Failed to delete rows
|
||||
delete_title=Delete Rows
|
||||
delete_none=No rows were selected
|
||||
delete_confirm=Are you sure you want to delete these rows? Number selected: $1
|
||||
delete_ok=Delete Rows
|
||||
|
||||
exec_title=Execute SQL
|
||||
exec_desc=Enter one SQL statement. Operations that could access files outside the allowed directories are blocked.
|
||||
exec_run=Execute
|
||||
exec_err=Failed to execute SQL
|
||||
exec_denied=You are not allowed to execute SQL statements
|
||||
exec_empty=No SQL statement was entered
|
||||
exec_multiple=Only one SQL statement can be run at a time
|
||||
exec_noauthorizer=The installed DBD::SQLite module cannot safely restrict SQL to this database file, so direct SQL is disabled.
|
||||
exec_result=Results from $1
|
||||
exec_affected=Affected rows: $1
|
||||
exec_truncated=Only the first $1 rows are shown.
|
||||
exec_truncated_bytes=Additional result data was omitted after reaching the $1-byte limit.
|
||||
exec_again=Execute Another Statement
|
||||
|
||||
open_err=Failed to open database
|
||||
error_path=Invalid database path: $1
|
||||
error_notfile=$1 is not a regular file
|
||||
error_noread=The database file $1 is not readable
|
||||
error_missing=The database file $1 does not exist
|
||||
error_nocreate=You are not allowed to create database files
|
||||
error_noparent=The parent directory $1 does not exist
|
||||
error_nowrite=The directory $1 is not writable
|
||||
error_notallowed=The database file $1 is outside the allowed directories for this Webmin user. Allowed directories: $2
|
||||
error_noallowed=None
|
||||
error_driver=Failed to load the SQLite Perl driver: $1
|
||||
error_connect=Failed to open SQLite database $1: $2
|
||||
error_secureopen=SQLite 3.39 or later is required for restricted database directories.
|
||||
error_resultcontrol=The installed DBD::SQLite module cannot limit result data safely: $1
|
||||
error_resultlarge=The result exceeds the configured $1-byte limit. Request less data, reduce stored values, or increase the limit.
|
||||
error_valuelarge=SQLite stopped the query because a value exceeds the $1-byte safety limit. Simplify the query or reduce the stored value.
|
||||
error_unknown=Unknown SQLite error
|
||||
error_readonly=You do not have write access to this database
|
||||
error_key=Invalid row identity
|
||||
error_nouser=The configured Unix user $1 does not exist
|
||||
|
||||
log_database_create=Created SQLite database $1
|
||||
log_table_create=Created table $1 in database $2
|
||||
log_table_drop=Dropped table $1 from database $2
|
||||
log_table_rename=Renamed table $1 to $2 in database $3
|
||||
log_view_drop=Dropped view $1 from database $2
|
||||
log_column_create=Added column $1 to table $2 in database $3
|
||||
log_column_rename=Renamed column $1 to $2 in table $3 in database $4
|
||||
log_column_rename_old=Renamed column $1 in table $2 in database $3
|
||||
log_column_drop=Dropped column $1 from table $2 in database $3
|
||||
log_row_create=Added a row to table $1 in database $2
|
||||
log_row_modify=Updated a row in table $1 in database $2
|
||||
log_rows_delete=Deleted $1 rows from table $2 in database $3
|
||||
log_sql_exec=Executed SQL in database $1
|
||||
log_sql_exec_long=Executed SQL statement $2 in database $1
|
||||
|
||||
value_null=NULL
|
||||
value_blob=[binary value, $1 bytes]
|
||||
value_more=…
|
||||
value_more_chars=+ $1 chars
|
||||
value_more_chars_truncated=+ $1 of $2 chars
|
||||
|
||||
acl_paths=Allowed database directories
|
||||
acl_paths_desc=Enter one absolute directory per line. Users can open database files in these directories and their subdirectories. Use $HOME for the selected Unix user's home directory. In root mode, it means the home directory of the Unix user whose name matches the Webmin user.
|
||||
acl_write=Can edit rows and create, alter or drop tables?
|
||||
acl_create=Can create database files?
|
||||
acl_sql=Can execute SQL statements, including changes to writable files?
|
||||
acl_work_as=Unix user for database access
|
||||
acl_root=Root, which normally bypasses file permissions
|
||||
acl_same=Unix user with the same name as the Webmin user
|
||||
acl_user=Specific Unix user
|
||||
acl_euser=Enter a valid Unix user
|
||||
acl_epaths=At least one allowed database directory is required
|
||||
acl_epath=Invalid allowed database directory: $1
|
||||
@@ -1,68 +0,0 @@
|
||||
# log_parser.pl
|
||||
# Converts SQLite module action-log records into readable descriptions.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
do 'sqlite-lib.pl';
|
||||
our (%text);
|
||||
|
||||
# sub parse_webmin_log(user, script, action, type, object, ¶ms, [long])
|
||||
# Returns a human-readable description for one SQLite action-log record.
|
||||
sub parse_webmin_log
|
||||
{
|
||||
my ($user, $script, $action, $type, $object, $params, $long) = @_;
|
||||
$params ||= { };
|
||||
|
||||
# Escape every logged name and path before adding the intended markup.
|
||||
my $item = &ui_tag('tt', &html_escape($object));
|
||||
my $database = &ui_tag('tt', &html_escape($params->{'db'}));
|
||||
my $table = &ui_tag('tt', &html_escape($params->{'table'}));
|
||||
|
||||
# Database creation logs the full database path as the object.
|
||||
if ($type eq 'database' && $action eq 'create') {
|
||||
return &text('log_database_create', $item);
|
||||
}
|
||||
# Table and view actions include the database that contains the object.
|
||||
elsif (($type eq 'table' || $type eq 'view') &&
|
||||
($action eq 'create' || $action eq 'drop')) {
|
||||
return &text('log_'.$type.'_'.$action, $item, $database);
|
||||
}
|
||||
elsif ($type eq 'table' && $action eq 'rename') {
|
||||
return &text('log_table_rename', $item,
|
||||
&ui_tag('tt', &html_escape($params->{'name'})), $database);
|
||||
}
|
||||
# Column actions also name their table. New rename records carry both names;
|
||||
# older records fall back to the single name they stored as the object.
|
||||
elsif ($type eq 'column' && $action eq 'create') {
|
||||
return &text('log_column_create', $item, $table, $database);
|
||||
}
|
||||
elsif ($type eq 'column' && $action eq 'rename') {
|
||||
return $params->{'name'} ne '' ?
|
||||
&text('log_column_rename', $item,
|
||||
&ui_tag('tt', &html_escape($params->{'name'})),
|
||||
$table, $database) :
|
||||
&text('log_column_rename_old', $item, $table, $database);
|
||||
}
|
||||
elsif ($type eq 'column' && $action eq 'drop') {
|
||||
return &text('log_column_drop', $item, $table, $database);
|
||||
}
|
||||
# Row changes use the table name as the logged object.
|
||||
elsif ($type eq 'row' && ($action eq 'create' || $action eq 'modify')) {
|
||||
return &text('log_row_'.$action, $item, $database);
|
||||
}
|
||||
elsif ($type eq 'rows' && $action eq 'delete') {
|
||||
my $count = $object =~ /^\d+$/ ? int($object) : 0;
|
||||
return &text('log_rows_delete', $count, $table, $database);
|
||||
}
|
||||
# The short form omits SQL text from the action list; the details page can
|
||||
# show the escaped statement.
|
||||
elsif ($type eq 'sql' && $action eq 'exec') {
|
||||
return &text($long ? 'log_sql_exec_long' : 'log_sql_exec', $item,
|
||||
&ui_tag('tt', &html_escape($params->{'sql'})));
|
||||
}
|
||||
return undef;
|
||||
}
|
||||
|
||||
1;
|
||||
@@ -1,8 +0,0 @@
|
||||
name=SQLite
|
||||
desc=SQLite Database Browser
|
||||
category=servers
|
||||
longdesc=Browse and edit SQLite database files, including tables, rows and schemas.
|
||||
readonly=1
|
||||
cpan=1
|
||||
rpm_recommends=perl-DBI perl-DBD-SQLite
|
||||
deb_recommends=libdbi-perl libdbd-sqlite3-perl
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Validates and opens an SQLite database path.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in, %access);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
&error_setup($text{'open_err'});
|
||||
|
||||
my $requested = $in{'db'};
|
||||
# Check the ACL, then open the file to verify the database. A missing file
|
||||
# is created when the ACL allows this user to create and fill one.
|
||||
my $path = &sqlite_database_path($requested, &sqlite_can_create());
|
||||
my $creating = !-e $path;
|
||||
my $dbh = &sqlite_connect($path, $creating ? 1 : 0, $creating ? 1 : 0);
|
||||
$dbh->disconnect();
|
||||
# Give a new file to the non-root owner of its parent directory.
|
||||
# Opening an existing database makes no change and therefore is not logged.
|
||||
if ($creating) {
|
||||
my ($parent) = $path =~ m!^(.*)/[^/]+$!;
|
||||
$parent = '/' if ($parent eq '');
|
||||
my @pst = stat($parent);
|
||||
&sqlite_safe_chown($path, $pst[4], $pst[5])
|
||||
if (@pst && $pst[4] != 0 && $< == 0);
|
||||
&sqlite_adopt_ownership($path);
|
||||
&webmin_log('create', 'database', $path);
|
||||
}
|
||||
&redirect('database.cgi?db='.&urlize($path));
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Renames a table.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
&error_setup($text{'rename_err'});
|
||||
|
||||
# Recheck write access and find the table; SQLite cannot rename a view.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&sqlite_can_write($path) || &error($text{'error_readonly'});
|
||||
my $dbh = &sqlite_connect($path, 1);
|
||||
my $object = &sqlite_object($dbh, $in{'table'});
|
||||
$object && $object->{'type'} eq 'table' || &error($text{'table_missing'});
|
||||
my $name = $in{'name'};
|
||||
$name =~ s/^\s+|\s+$//g;
|
||||
&sqlite_valid_name($name) ||
|
||||
&error(&text('create_ename', &html_escape($name)));
|
||||
|
||||
# The same name again is not an error, just nothing to do.
|
||||
if ($name ne $object->{'name'}) {
|
||||
# Refuse to take the name of another table or view.
|
||||
if (lc($name) ne lc($object->{'name'}) && &sqlite_object($dbh, $name)) {
|
||||
$dbh->disconnect();
|
||||
&error(&text('create_eexists', &html_escape($name)));
|
||||
}
|
||||
eval { $dbh->do('ALTER TABLE '.
|
||||
&sqlite_quote_identifier($object->{'name'}).
|
||||
' RENAME TO '.&sqlite_quote_identifier($name)); };
|
||||
# Pass SQLite's own objection through the normal error page.
|
||||
if ($@) {
|
||||
my $message = $@;
|
||||
$dbh->disconnect();
|
||||
&error(&html_escape($message));
|
||||
}
|
||||
&webmin_log('rename', 'table', $object->{'name'},
|
||||
{ 'db' => $path, 'name' => $name });
|
||||
}
|
||||
$dbh->disconnect();
|
||||
&sqlite_adopt_ownership($path);
|
||||
&redirect('table.cgi?db='.&urlize($path).'&table='.&urlize($name).
|
||||
'&view=structure');
|
||||
@@ -1,6 +0,0 @@
|
||||
paths=$HOME
|
||||
write=1
|
||||
create=1
|
||||
sql=0
|
||||
work_as_root=0
|
||||
work_as_user=
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Adds a column to a table or renames an existing one.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
&error_setup($text{'column_err'});
|
||||
|
||||
# Recheck write access and reload the schema before changing it.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&sqlite_can_write($path) || &error($text{'error_readonly'});
|
||||
my $dbh = &sqlite_connect($path, 1);
|
||||
my $object = &sqlite_object($dbh, $in{'table'});
|
||||
$object && $object->{'type'} eq 'table' || &error($text{'table_missing'});
|
||||
my $columns = &sqlite_table_columns($dbh, $object->{'name'});
|
||||
# Column names are compared without regard to case, as SQLite does.
|
||||
my %names = map { lc($_->{'name'}) => 1 } @$columns;
|
||||
my $name = $in{'name'};
|
||||
$name =~ s/^\s+|\s+$//g;
|
||||
|
||||
my ($sql, $action, $old_name);
|
||||
if ($in{'mode'} eq 'rename') {
|
||||
# Renaming keeps everything about the column but its name.
|
||||
&sqlite_supports('rename_column') ||
|
||||
&error($text{'column_eunsupported'});
|
||||
my ($existing) = grep { $_->{'name'} eq $in{'column'} } @$columns;
|
||||
$existing || &error($text{'column_emissing'});
|
||||
&sqlite_valid_name($name) ||
|
||||
&error(&text('create_ecolumn', &html_escape($name)));
|
||||
# The same name again is not an error, just nothing to do.
|
||||
if ($name eq $existing->{'name'}) {
|
||||
$dbh->disconnect();
|
||||
&redirect('table.cgi?db='.&urlize($path).'&table='.
|
||||
&urlize($object->{'name'}).'&view=structure');
|
||||
exit;
|
||||
}
|
||||
!$names{lc($name)} || lc($name) eq lc($existing->{'name'}) ||
|
||||
&error(&text('column_eexists', &html_escape($name)));
|
||||
$sql = 'ALTER TABLE '.&sqlite_quote_identifier($object->{'name'}).
|
||||
' RENAME COLUMN '.&sqlite_quote_identifier($existing->{'name'}).
|
||||
' TO '.&sqlite_quote_identifier($name);
|
||||
$action = 'rename';
|
||||
$old_name = $existing->{'name'};
|
||||
}
|
||||
else {
|
||||
# Adding validates the definition the way the table form does.
|
||||
$sql = eval { &sqlite_add_column_sql($object->{'name'}, {
|
||||
'name' => $name,
|
||||
'type' => $in{'type'},
|
||||
'null' => $in{'null'} ? 1 : 0,
|
||||
'default' => $in{'default'} }) };
|
||||
# Report a definition problem as a form error.
|
||||
if ($@) {
|
||||
my $message = $@;
|
||||
chomp($message);
|
||||
&error($message);
|
||||
}
|
||||
!$names{lc($name)} ||
|
||||
&error(&text('column_eexists', &html_escape($name)));
|
||||
$action = 'create';
|
||||
}
|
||||
|
||||
# Run the change.
|
||||
eval { $dbh->do($sql); };
|
||||
# Pass SQLite's own objection through the normal error page.
|
||||
if ($@) {
|
||||
my $message = $@;
|
||||
$dbh->disconnect();
|
||||
&error(&html_escape($message));
|
||||
}
|
||||
$dbh->disconnect();
|
||||
&sqlite_adopt_ownership($path);
|
||||
# A rename stores both names so the action log can describe the change.
|
||||
&webmin_log($action, 'column', $old_name || $name,
|
||||
{ 'db' => $path, 'table' => $object->{'name'},
|
||||
$action eq 'rename' ? ( 'name' => $name ) : ( ) });
|
||||
&redirect('table.cgi?db='.&urlize($path).'&table='.
|
||||
&urlize($object->{'name'}).'&view=structure');
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Adds or updates one SQLite table row.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
&error_setup($text{'save_err'});
|
||||
|
||||
# Recheck write access, table type and row identity.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&sqlite_can_write($path) || &error($text{'error_readonly'});
|
||||
my $dbh = &sqlite_connect($path, 1);
|
||||
my $object = &sqlite_object($dbh, $in{'table'});
|
||||
$object && $object->{'type'} eq 'table' || &error($text{'table_missing'});
|
||||
my $columns = &sqlite_table_columns($dbh, $object->{'name'});
|
||||
my $identity = &sqlite_identity($dbh, $object, $columns);
|
||||
$identity || &error($text{'table_noidentity'});
|
||||
|
||||
# Build parameters from editable fields in the current schema.
|
||||
my (@names, @values);
|
||||
for(my $i = 0; $i < @$columns; $i++) {
|
||||
my $column = $columns->[$i];
|
||||
# SQLite computes generated columns.
|
||||
next if ($column->{'hidden'} == 2 || $column->{'hidden'} == 3);
|
||||
# Existing binary values carry a marker that leaves them unchanged.
|
||||
next if ($in{'keep_'.$i});
|
||||
# Omitted insert fields allow SQLite defaults to run.
|
||||
next if ($in{'new'} && $in{'default_'.$i});
|
||||
push(@names, $column->{'name'});
|
||||
push(@values, $in{'null_'.$i} ? undef : $in{'value_'.$i});
|
||||
}
|
||||
|
||||
# Run the insert or update in one transaction.
|
||||
eval {
|
||||
$dbh->begin_work();
|
||||
# Insert only submitted columns, or use DEFAULT VALUES when none remain.
|
||||
if ($in{'new'}) {
|
||||
my $sql = @names ? 'INSERT INTO '.
|
||||
&sqlite_quote_identifier($object->{'name'}).' ('.
|
||||
join(', ', map { &sqlite_quote_identifier($_) } @names).') VALUES ('.
|
||||
join(', ', map { '?' } @names).')' :
|
||||
'INSERT INTO '.&sqlite_quote_identifier($object->{'name'}).
|
||||
' DEFAULT VALUES';
|
||||
$dbh->do($sql, undef, @values);
|
||||
}
|
||||
else {
|
||||
# Update the one row named by the validated opaque identity
|
||||
# token.
|
||||
@names || die $text{'save_nochanges'};
|
||||
my $count = $identity->{'rowid'} ? 1 :
|
||||
scalar(@{$identity->{'columns'}});
|
||||
my @keys = &sqlite_decode_key($in{'key'}, $count);
|
||||
my ($where, @key_params) = &sqlite_identity_where($identity, \@keys);
|
||||
my $sql = 'UPDATE '.&sqlite_quote_identifier($object->{'name'}).
|
||||
' SET '.join(', ', map {
|
||||
&sqlite_quote_identifier($_).' = ?' } @names).
|
||||
' WHERE '.$where;
|
||||
my $changed = $dbh->do($sql, undef, @values, @key_params);
|
||||
$changed && $changed > 0 || die $text{'edit_missing'};
|
||||
}
|
||||
$dbh->commit();
|
||||
};
|
||||
# Release locks and the DBI handle before reporting a failed transaction.
|
||||
if ($@) {
|
||||
my $error = $@;
|
||||
eval { $dbh->rollback(); };
|
||||
$dbh->disconnect();
|
||||
&error(&html_escape($error));
|
||||
}
|
||||
$dbh->disconnect();
|
||||
&sqlite_adopt_ownership($path);
|
||||
&webmin_log($in{'new'} ? 'create' : 'modify', 'row',
|
||||
$object->{'name'}, { 'db' => $path });
|
||||
&redirect('table.cgi?db='.&urlize($path).'&table='.
|
||||
&urlize($object->{'name'}).'&start='.int($in{'start'} || 0));
|
||||
1308
sqlite/sqlite-lib.pl
1308
sqlite/sqlite-lib.pl
File diff suppressed because it is too large
Load Diff
1167
sqlite/t/cgi.t
1167
sqlite/t/cgi.t
File diff suppressed because it is too large
Load Diff
625
sqlite/t/lib.t
625
sqlite/t/lib.t
@@ -1,625 +0,0 @@
|
||||
#!/usr/bin/perl
|
||||
# Tests SQLite helpers, connection modes and SQL sandboxing.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use Test::More;
|
||||
use File::Path qw(make_path);
|
||||
use File::Temp qw(tempdir);
|
||||
use FindBin;
|
||||
|
||||
BEGIN { $ENV{'SQLITE_TESTING'} = 1; }
|
||||
chdir "$FindBin::Bin/.." or die "chdir: $!";
|
||||
require './sqlite-lib.pl';
|
||||
|
||||
our (%access, %config, %text, $remote_user, $error_must_die);
|
||||
# Make Webmin's error handler throw exceptions that the tests can inspect.
|
||||
$error_must_die = 1;
|
||||
|
||||
# Build isolated allowed and denied directory trees for path checks.
|
||||
my $root = tempdir(CLEANUP => 1);
|
||||
my $allowed = "$root/allowed";
|
||||
my $outside = "$root/outside";
|
||||
make_path($allowed, $outside);
|
||||
# Explicit ACL paths are canonical when saved by the access-control editor.
|
||||
$allowed = simplify_path(resolve_links($allowed));
|
||||
$outside = simplify_path(resolve_links($outside));
|
||||
$access{'paths'} = $allowed;
|
||||
$access{'create'} = 1;
|
||||
$access{'write'} = 1;
|
||||
$config{'busy_timeout'} = 100;
|
||||
$config{'max_text'} = 8;
|
||||
$config{'max_result_bytes'} = 1048576;
|
||||
$text{'error_key'} = 'bad key';
|
||||
$text{'error_resultcontrol'} = 'cannot limit results: $1';
|
||||
$text{'error_resultlarge'} = 'result exceeds $1 bytes';
|
||||
$text{'error_valuelarge'} = 'value exceeds $1';
|
||||
$text{'value_more'} = '...';
|
||||
$text{'value_more_chars'} = '+ $1 chars';
|
||||
$text{'value_more_chars_truncated'} = '+ $1 of $2 chars';
|
||||
$text{'value_blob'} = '[binary value, $1 bytes]';
|
||||
|
||||
# Check fail-closed ACL parsing and directory boundaries.
|
||||
is_deeply([ sqlite_acl_paths(undef) ], [],
|
||||
'missing path ACL grants no database access');
|
||||
my @current_user = getpwuid($<);
|
||||
SKIP: {
|
||||
# A safe ACL can resolve $HOME only when the Webmin user exists in Unix.
|
||||
skip('current Unix account has no absolute home directory', 1)
|
||||
if (!$current_user[0] || !$current_user[7] ||
|
||||
$current_user[7] !~ m!^/!);
|
||||
local $remote_user = $current_user[0];
|
||||
my $expected = simplify_path(resolve_links($current_user[7]));
|
||||
is_deeply([ sqlite_acl_paths('$HOME') ], [ $expected ],
|
||||
'safe path ACL expands the Unix user home directory');
|
||||
}
|
||||
{
|
||||
# An unknown Webmin user must not inherit the Webmin process home.
|
||||
local $remote_user = 'sqlite-safe-user-that-does-not-exist';
|
||||
is_deeply([ sqlite_acl_paths('$HOME') ], [],
|
||||
'safe path ACL fails closed without a matching Unix user');
|
||||
}
|
||||
SKIP: {
|
||||
# With a named Unix user, $HOME is that user's home even when the
|
||||
# Webmin user has no account of its own.
|
||||
skip('current Unix account has no absolute home directory', 1)
|
||||
if (!$current_user[0] || !$current_user[7] ||
|
||||
$current_user[7] !~ m!^/!);
|
||||
local $remote_user = 'sqlite-safe-user-that-does-not-exist';
|
||||
local $access{'work_as_root'} = 0;
|
||||
local $access{'work_as_user'} = $current_user[0];
|
||||
my $expected = simplify_path(resolve_links($current_user[7]));
|
||||
is_deeply([ sqlite_acl_paths('$HOME') ], [ $expected ],
|
||||
'safe path ACL follows the named Unix user home directory');
|
||||
}
|
||||
my %safe_acl;
|
||||
read_file('safeacl', \%safe_acl);
|
||||
is_deeply(\%safe_acl, {
|
||||
'paths' => '$HOME',
|
||||
'write' => '1',
|
||||
'create' => '1',
|
||||
'sql' => '0',
|
||||
'work_as_root' => '0',
|
||||
'work_as_user' => '',
|
||||
}, 'safe ACL allows structured changes only in the user home, as the user');
|
||||
|
||||
# Check which Unix user the module does its file work as.
|
||||
{
|
||||
local $remote_user = 'tester';
|
||||
is(sqlite_work_as_user({ 'work_as_root' => 1, 'paths' => '$HOME' }), undef,
|
||||
'an explicit root setting stays root');
|
||||
is(sqlite_work_as_user({ 'work_as_root' => 0, 'paths' => '/' }), 'tester',
|
||||
'an explicit same-user setting uses the Webmin user');
|
||||
is(sqlite_work_as_user({ 'work_as_root' => 0, 'work_as_user' => 'app',
|
||||
'paths' => '/' }), 'app', 'a named Unix user wins');
|
||||
is(sqlite_work_as_user({ 'paths' => '$HOME' }), 'tester',
|
||||
'an older home-only ACL implies the Webmin user');
|
||||
is(sqlite_work_as_user({ 'paths' => "/srv\t\$HOME" }), undef,
|
||||
'an older ACL with other directories stays root');
|
||||
is(sqlite_work_as_user({ 'paths' => '/' }), undef,
|
||||
'an older root ACL stays root');
|
||||
is(sqlite_work_as_user({ }), undef, 'an empty ACL stays root');
|
||||
# Once switched, the recent list moves into the user's home.
|
||||
our $sqlite_unix_home;
|
||||
local $sqlite_unix_home = '/home/tester';
|
||||
like(sqlite_recent_file(), qr{^/home/tester/\.webmin-sqlite/tester$},
|
||||
'a switched user keeps the recent list in the home directory');
|
||||
}
|
||||
|
||||
ok(sqlite_path_under("$allowed/example.db", $allowed),
|
||||
'child path is allowed');
|
||||
ok(!sqlite_path_under("$outside/example.db", $allowed),
|
||||
'neighbor path is rejected');
|
||||
my $resolved_allowed = simplify_path(resolve_links($allowed));
|
||||
is(sqlite_database_path("$allowed/new.db", 1), "$resolved_allowed/new.db",
|
||||
'new database path is accepted inside allowed directory');
|
||||
is(sqlite_filename_uri('/tmp/a;b=c%20.db'),
|
||||
'file:/tmp/a%3Bb%3Dc%2520.db',
|
||||
'filename URI escapes DBD connection syntax and literal percent signs');
|
||||
|
||||
# A saved ACL root stays fixed even if its former directory is replaced by
|
||||
# a symbolic link to an outside location.
|
||||
SKIP: {
|
||||
my $replaceable = "$root/replaceable";
|
||||
make_path($replaceable);
|
||||
my $pinned = simplify_path(resolve_links($replaceable));
|
||||
rmdir($replaceable) or die "rmdir $replaceable: $!";
|
||||
skip('symbolic links are unavailable', 2)
|
||||
if (!symlink($outside, $replaceable));
|
||||
my $retargeted = simplify_path(resolve_links("$replaceable/private.db"));
|
||||
my ($root_acl) = sqlite_acl_paths($pinned);
|
||||
ok(!sqlite_path_under($retargeted, $root_acl),
|
||||
'root keeps a saved ACL directory fixed');
|
||||
my ($user_acl) = sqlite_acl_paths($pinned);
|
||||
is($user_acl, $pinned,
|
||||
'an unprivileged worker also keeps the ACL directory fixed');
|
||||
}
|
||||
|
||||
# Restricted path ACLs require SQLite's full-path no-follow protection.
|
||||
ok(!sqlite_secure_open_available(3038999),
|
||||
'restricted access rejects SQLite before 3.39');
|
||||
ok(sqlite_secure_open_available(3039000),
|
||||
'restricted access accepts SQLite 3.39');
|
||||
# The root ACL has no narrower directory boundary to enforce.
|
||||
{
|
||||
local $access{'paths'} = '/';
|
||||
ok(sqlite_secure_open_available(3030999),
|
||||
'an unrestricted ACL needs no directory boundary');
|
||||
}
|
||||
|
||||
# The result-size limit defaults to 16 MiB and stays within its documented
|
||||
# range.
|
||||
{
|
||||
local $config{'max_result_bytes'};
|
||||
is(sqlite_result_bytes_limit(), 16777216,
|
||||
'result data defaults to a 16 MiB limit');
|
||||
local $config{'max_result_bytes'} = 0;
|
||||
is(sqlite_result_bytes_limit(), 1048576,
|
||||
'result data has a 1 MiB minimum');
|
||||
$config{'max_result_bytes'} = 999999999;
|
||||
is(sqlite_result_bytes_limit(), 67108864,
|
||||
'result data has a 64 MiB maximum');
|
||||
}
|
||||
is(sqlite_row_bytes([ 'abc', undef, 'de' ]), 261,
|
||||
'result byte counting includes row and scalar storage');
|
||||
|
||||
# Ownership changes must reject links instead of following them as root.
|
||||
SKIP: {
|
||||
my $target = "$outside/chown-target";
|
||||
my $link = "$allowed/chown-link";
|
||||
open(my $fh, '>', $target) or die "open $target: $!";
|
||||
close($fh) or die "close $target: $!";
|
||||
skip('symbolic links are unavailable', 1) if (!symlink($target, $link));
|
||||
my @st = stat($target);
|
||||
ok(!sqlite_safe_chown($link, $st[4], $st[5]),
|
||||
'ownership helper refuses a symbolic link');
|
||||
}
|
||||
|
||||
# Check row identities with NULL, punctuation and Unicode values.
|
||||
my @input = (1, undef, "a:b;c", "\x{263a}");
|
||||
my $token = sqlite_encode_key(@input);
|
||||
my @output = sqlite_decode_key($token, scalar(@input));
|
||||
is_deeply(\@output, \@input, 'row identity round trips');
|
||||
|
||||
# Check long-text disclosure markup.
|
||||
$config{'max_text'} = 1000;
|
||||
my $short_value = sqlite_display_value('unbroken-text-value');
|
||||
like($short_value, qr/overflow-wrap:anywhere;word-break:break-word/,
|
||||
'short text can shrink with its table column');
|
||||
my $long_value = sqlite_display_value('x' x 140);
|
||||
like($long_value, qr/<details class="inline inlined">/,
|
||||
'long text uses an inline details disclosure');
|
||||
like($long_value, qr/<summary>\+ 12 chars<\/summary>/,
|
||||
'long text reports the number of hidden characters');
|
||||
like($long_value, qr/^<span[^>]*>@{['x' x 128]} /,
|
||||
'long text leaves the first 128 characters visible');
|
||||
like($long_value, qr/overflow-wrap:anywhere;word-break:break-word/,
|
||||
'long text can shrink with its table column');
|
||||
unlike($long_value, qr/min-width:/,
|
||||
'long text does not force a minimum table width');
|
||||
like(sqlite_display_value('abcdefgh', 'text', 20), qr/>abcdefgh\.\.\.<\/span>/,
|
||||
'text shortened by SQLite still shows its truncation marker');
|
||||
$text{'value_more'} = '…';
|
||||
like(sqlite_display_value('abcdefgh', 'text', 20), qr/abcdefgh…/,
|
||||
'text shortened by SQLite uses the translated ellipsis');
|
||||
$text{'value_more'} = '...';
|
||||
$config{'max_text'} = 8;
|
||||
my ($full_values) = sqlite_column_selects([ { 'name' => 'note' } ]);
|
||||
unlike($full_values->[0], qr/substr/,
|
||||
'non-display queries load full text values');
|
||||
my ($bounded_values) = sqlite_column_selects(
|
||||
[ { 'name' => 'note' } ], undef, 1);
|
||||
like($bounded_values->[0], qr/substr\("note", 1, 8\)/,
|
||||
'table SQL shortens text before returning it to Perl');
|
||||
|
||||
# Later helpers receive paths returned by sqlite_database_path, which are
|
||||
# canonical even when a temporary-directory prefix is a symbolic link.
|
||||
$allowed = $resolved_allowed;
|
||||
$outside = simplify_path(resolve_links($outside));
|
||||
$access{'paths'} = $allowed;
|
||||
|
||||
# Check the per-user recent list: the limit, ordering, de-duplication, ACL
|
||||
# filtering, deleted files and clearing.
|
||||
our $module_config_directory;
|
||||
$module_config_directory = "$root/config";
|
||||
make_path($module_config_directory);
|
||||
$text{'index_today'} = 'Today, $1';
|
||||
$text{'index_yesterday'} = 'Yesterday, $1';
|
||||
# sub touch(path)
|
||||
# Creates one empty fixture file.
|
||||
sub touch
|
||||
{
|
||||
my ($path) = @_;
|
||||
open(my $fh, '>', $path) or die "open $path: $!";
|
||||
close($fh) or die "close $path: $!";
|
||||
}
|
||||
{
|
||||
local $remote_user = 'tester';
|
||||
is(sqlite_recent_limit(), 3, 'recent list keeps three entries by default');
|
||||
$config{'recent'} = 2;
|
||||
is(sqlite_recent_limit(), 2,
|
||||
'recent list limit comes from the module config');
|
||||
like(sqlite_recent_file(), qr{/recent/tester$},
|
||||
'recent list is stored per Webmin user');
|
||||
touch("$allowed/$_") foreach ('a.db', 'b.db', 'c.db');
|
||||
touch("$outside/d.db");
|
||||
sqlite_remember_recent("$allowed/a.db", 1);
|
||||
sqlite_remember_recent("$allowed/b.db", 2);
|
||||
is_deeply([ map { $_->{'path'} } sqlite_list_recent() ],
|
||||
[ "$allowed/b.db", "$allowed/a.db" ], 'recent list is newest first');
|
||||
sqlite_remember_recent("$allowed/a.db", 5);
|
||||
my @again = sqlite_list_recent();
|
||||
is_deeply([ map { $_->{'path'} } @again ],
|
||||
[ "$allowed/a.db", "$allowed/b.db" ],
|
||||
'reopening moves a database to the top without a duplicate');
|
||||
is($again[0]->{'tables'}, 5, 'reopening refreshes the table count');
|
||||
ok($again[0]->{'time'} > 0 && defined($again[0]->{'size'}),
|
||||
'entries carry the opening time and the file size');
|
||||
sqlite_remember_recent("$allowed/c.db", 0);
|
||||
is_deeply([ map { $_->{'path'} } sqlite_list_recent() ],
|
||||
[ "$allowed/c.db", "$allowed/a.db" ],
|
||||
'recent list keeps only the configured number of entries');
|
||||
$config{'recent'} = 3;
|
||||
sqlite_remember_recent("$outside/d.db", 0);
|
||||
ok(!(grep { $_->{'path'} eq "$outside/d.db" } sqlite_list_recent()),
|
||||
'databases outside the ACL are hidden from the recent list');
|
||||
unlink("$allowed/c.db");
|
||||
ok(!(grep { $_->{'path'} eq "$allowed/c.db" } sqlite_list_recent()),
|
||||
'deleted databases are hidden from the recent list');
|
||||
sqlite_forget_recent("$allowed/a.db");
|
||||
ok(!(grep { $_->{'path'} eq "$allowed/a.db" } sqlite_list_recent()),
|
||||
'forgetting one database removes only that entry');
|
||||
ok(-e sqlite_recent_file(),
|
||||
'the list file stays while other entries remain');
|
||||
sqlite_forget_recent();
|
||||
is_deeply([ sqlite_list_recent() ], [],
|
||||
'clearing the list removes every entry');
|
||||
ok(!-e sqlite_recent_file(), 'clearing the list removes the stored file');
|
||||
$config{'recent'} = 0;
|
||||
sqlite_remember_recent("$allowed/a.db", 1);
|
||||
is_deeply([ sqlite_list_recent() ], [],
|
||||
'a zero limit disables the recent list');
|
||||
ok(!-e sqlite_recent_file(), 'a zero limit records nothing');
|
||||
delete($config{'recent'});
|
||||
}
|
||||
{
|
||||
local $remote_user = '';
|
||||
is(sqlite_recent_file(), undef, 'no recent list without a logged-in user');
|
||||
}
|
||||
like(sqlite_recent_time(time()), qr/^Today, \d\d:\d\d$/,
|
||||
'a time from today shows the clock');
|
||||
like(sqlite_recent_time(time() - 86400), qr/^Yesterday, \d\d:\d\d$/,
|
||||
'a time from yesterday says so');
|
||||
my $older = sqlite_recent_time(time() - 7 * 86400);
|
||||
ok(length($older) && $older !~ /^(Today|Yesterday)/,
|
||||
'older times show a date');
|
||||
is(sqlite_recent_time(0), '', 'a missing time shows nothing');
|
||||
|
||||
# Check the statements built from the table form.
|
||||
$text{'create_ename'} = "Invalid table name '\$1'";
|
||||
$text{'create_ecolumns'} = 'No columns were entered';
|
||||
$text{'create_ecolumn'} = "Invalid column name '\$1'";
|
||||
$text{'create_edup'} = "Column '\$1' is listed more than once";
|
||||
$text{'create_etype'} = "Invalid type for column '\$1'";
|
||||
$text{'create_eauto'} = "Autoincrement requires '\$1' to be the only primary key column and have type INTEGER";
|
||||
is(sqlite_default_literal('abc'), "'abc'", 'text defaults are quoted');
|
||||
is(sqlite_default_literal("it's"), "'it''s'", 'quotes in defaults are doubled');
|
||||
is(sqlite_default_literal('42'), '42', 'integer defaults stay numeric');
|
||||
is(sqlite_default_literal('-1.5e3'), '-1.5e3', 'float defaults stay numeric');
|
||||
is(sqlite_default_literal('null'), 'NULL', 'NULL default is the keyword');
|
||||
is(sqlite_default_literal('current_timestamp'), 'CURRENT_TIMESTAMP',
|
||||
'time keywords are passed through');
|
||||
is(sqlite_default_literal('1 or 2'), "'1 or 2'",
|
||||
'anything else is a string literal');
|
||||
ok(sqlite_valid_name('users'), 'plain names are valid');
|
||||
ok(sqlite_valid_name('odd "name'), 'names needing quotes are valid');
|
||||
ok(!sqlite_valid_name(''), 'empty names are invalid');
|
||||
ok(!sqlite_valid_name("a\nb"), 'names with control characters are invalid');
|
||||
ok(!sqlite_valid_name('sqlite_master'), 'the sqlite_ namespace is reserved');
|
||||
is_deeply([ sqlite_column_types() ],
|
||||
[ 'INTEGER', 'TEXT', 'REAL', 'BLOB', 'NUMERIC' ],
|
||||
'the form offers the five storage classes');
|
||||
is(sqlite_create_table_sql('t', [
|
||||
{ 'name' => 'id', 'type' => 'INTEGER', 'null' => 0, 'key' => 'pkai' },
|
||||
{ 'name' => 'name', 'type' => 'TEXT', 'null' => 1, 'key' => '',
|
||||
'default' => '' } ]),
|
||||
'CREATE TABLE "t" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT)',
|
||||
'a single integer key becomes the rowid alias');
|
||||
is(sqlite_create_table_sql('t', [
|
||||
{ 'name' => 'code', 'type' => 'TEXT', 'null' => 1, 'key' => 'pk' },
|
||||
{ 'name' => 'email', 'type' => 'TEXT', 'null' => 0, 'key' => 'unique',
|
||||
'default' => 'x' } ]),
|
||||
'CREATE TABLE "t" ("code" TEXT PRIMARY KEY NOT NULL, "email" TEXT NOT NULL UNIQUE DEFAULT \'x\')',
|
||||
'a text key is forced NOT NULL and unique columns take a default');
|
||||
is(sqlite_create_table_sql('odd "t', [
|
||||
{ 'name' => 'a', 'type' => 'TEXT', 'null' => 1, 'key' => 'pk' },
|
||||
{ 'name' => 'b', 'type' => 'INTEGER', 'null' => 1, 'key' => 'pk' } ]),
|
||||
'CREATE TABLE "odd ""t" ("a" TEXT NOT NULL, "b" INTEGER NOT NULL, PRIMARY KEY ("a", "b"))',
|
||||
'several keys form a table-level composite key in a quoted table');
|
||||
my $column = { 'name' => 'id', 'type' => 'INTEGER', 'null' => 1, 'key' => '' };
|
||||
eval { sqlite_create_table_sql('sqlite_x', [ $column ]) };
|
||||
like($@, qr/^Invalid table name 'sqlite_x'/, 'reserved table names are rejected');
|
||||
eval { sqlite_create_table_sql('t', [ ]) };
|
||||
like($@, qr/^No columns were entered/, 'a table needs a column');
|
||||
eval { sqlite_create_table_sql('t', [ $column,
|
||||
{ 'name' => 'ID', 'type' => 'TEXT', 'null' => 1, 'key' => '' } ]) };
|
||||
like($@, qr/^Column 'ID' is listed more than once/,
|
||||
'column names are unique without regard to case');
|
||||
eval { sqlite_create_table_sql('t', [
|
||||
{ 'name' => 'x', 'type' => 'VARCHAR', 'null' => 1, 'key' => '' } ]) };
|
||||
like($@, qr/^Invalid type for column 'x'/, 'only the offered types are accepted');
|
||||
eval { sqlite_create_table_sql('t', [
|
||||
{ 'name' => 'x', 'type' => 'TEXT', 'null' => 1, 'key' => 'pkai' } ]) };
|
||||
like($@, qr/^Autoincrement requires/, 'autoincrement needs an INTEGER column');
|
||||
eval { sqlite_create_table_sql('t', [
|
||||
{ 'name' => 'x', 'type' => 'INTEGER', 'null' => 1, 'key' => 'pkai' },
|
||||
{ 'name' => 'y', 'type' => 'INTEGER', 'null' => 1, 'key' => 'pk' } ]) };
|
||||
like($@, qr/^Autoincrement requires/, 'autoincrement needs a single key column');
|
||||
eval { sqlite_create_table_sql('t', [
|
||||
{ 'name' => "a\tb", 'type' => 'TEXT', 'null' => 1, 'key' => '' } ]) };
|
||||
like($@, qr/^Invalid column name/, 'column names are checked too');
|
||||
|
||||
# Check the version gate and the statements for adding a column.
|
||||
ok(!sqlite_supports('rename_column', 3024000),
|
||||
'column renames need SQLite 3.25');
|
||||
ok(sqlite_supports('rename_column', 3025000),
|
||||
'SQLite 3.25 can rename columns');
|
||||
ok(!sqlite_supports('drop_column', 3034999),
|
||||
'column drops need SQLite 3.35');
|
||||
ok(sqlite_supports('drop_column', 3035000),
|
||||
'SQLite 3.35 can drop columns');
|
||||
ok(!sqlite_supports('alter_column', 3999000),
|
||||
'unknown features are never supported');
|
||||
ok(!sqlite_supports('nofollow', 3038999),
|
||||
'full-path no-follow needs SQLite 3.39');
|
||||
ok(sqlite_supports('nofollow', 3039000),
|
||||
'SQLite 3.39 refuses links in every path component');
|
||||
# As a normal user the ownership helper changes nothing and never dies.
|
||||
touch("$allowed/owned.db");
|
||||
touch("$allowed/owned.db-wal");
|
||||
my @before = stat("$allowed/owned.db-wal");
|
||||
sqlite_adopt_ownership("$allowed/owned.db");
|
||||
sqlite_adopt_ownership("$allowed/missing.db");
|
||||
is_deeply([ (stat("$allowed/owned.db-wal"))[4, 5] ], [ @before[4, 5] ],
|
||||
'ownership adoption leaves files alone when not running as root');
|
||||
$text{'column_enotnull'} = 'A column that does not allow NULL needs a default value for the existing rows';
|
||||
$text{'column_ecurrent'} = 'An added column cannot default to the current time or date';
|
||||
is(sqlite_add_column_sql('t', { 'name' => 'note', 'type' => 'TEXT',
|
||||
'null' => 1, 'default' => '' }),
|
||||
'ALTER TABLE "t" ADD COLUMN "note" TEXT',
|
||||
'an optional column needs no default');
|
||||
is(sqlite_add_column_sql('t', { 'name' => 'count', 'type' => 'INTEGER',
|
||||
'null' => 0, 'default' => '0' }),
|
||||
'ALTER TABLE "t" ADD COLUMN "count" INTEGER NOT NULL DEFAULT 0',
|
||||
'a required column carries its default');
|
||||
eval { sqlite_add_column_sql('t', { 'name' => 'count', 'type' => 'INTEGER',
|
||||
'null' => 0, 'default' => '' }) };
|
||||
like($@, qr/^A column that does not allow NULL needs a default/,
|
||||
'a required column without a default is rejected');
|
||||
eval { sqlite_add_column_sql('t', { 'name' => 'count', 'type' => 'INTEGER',
|
||||
'null' => 0, 'default' => 'null' }) };
|
||||
like($@, qr/^A column that does not allow NULL needs a default/,
|
||||
'a NULL default does not satisfy NOT NULL');
|
||||
eval { sqlite_add_column_sql('t', { 'name' => 'when', 'type' => 'TEXT',
|
||||
'null' => 1, 'default' => 'current_timestamp' }) };
|
||||
like($@, qr/^An added column cannot default to the current time/,
|
||||
'time defaults are rejected for added columns');
|
||||
eval { sqlite_add_column_sql('t', { 'name' => 'x', 'type' => 'JSON',
|
||||
'null' => 1, 'default' => '' }) };
|
||||
like($@, qr/^Invalid type for column 'x'/, 'added columns take the offered types');
|
||||
eval { sqlite_add_column_sql('t', { 'name' => 'sqlite_x', 'type' => 'TEXT',
|
||||
'null' => 1, 'default' => '' }) };
|
||||
like($@, qr/^Invalid column name 'sqlite_x'/, 'added column names are checked');
|
||||
|
||||
# Run database-level checks only when the optional SQLite driver is installed.
|
||||
SKIP: {
|
||||
my $driver_error = sqlite_load_driver();
|
||||
# Skip only database tests when DBD::SQLite is unavailable.
|
||||
skip("DBD::SQLite unavailable: $driver_error", 54) if ($driver_error);
|
||||
my $limit_error = sqlite_result_limit_error();
|
||||
# Verify the compatibility error before skipping capability-dependent tests.
|
||||
if ($limit_error) {
|
||||
like($limit_error, qr/does not provide sqlite_limit$/,
|
||||
'an older driver loads but reports the missing capability');
|
||||
skip("SQLite result limits unavailable: $limit_error", 53);
|
||||
}
|
||||
ok(!$limit_error,
|
||||
'the installed driver exposes result-limit control');
|
||||
{
|
||||
no warnings 'once';
|
||||
local *DBD::SQLite::db::sqlite_limit;
|
||||
like(sqlite_result_limit_error(),
|
||||
qr/^DBD::SQLite \S+ does not provide sqlite_limit$/,
|
||||
'a loaded driver reports the missing capability');
|
||||
}
|
||||
# Database helper tests use an unrestricted ACL so they can run with an
|
||||
# older local SQLite; restricted-path version gates are tested above.
|
||||
local $access{'paths'} = '/';
|
||||
# Create a writable fixture and test schema and query helpers.
|
||||
my $database = "$allowed/example.db";
|
||||
my $dbh = sqlite_connect($database, 1, 1);
|
||||
my ($foreign_keys) = $dbh->selectrow_array('PRAGMA foreign_keys');
|
||||
is($foreign_keys, 1, 'foreign-key enforcement is enabled');
|
||||
my ($wide_sum) = $dbh->selectrow_array(
|
||||
'SELECT '.sqlite_sum_sql(('1') x 2000));
|
||||
is($wide_sum, 2000, 'wide schemas use a shallow size expression');
|
||||
# The size guard must prevent SQLite from evaluating oversized expressions.
|
||||
my $guarded_calls = 0;
|
||||
# sub webmin_guarded_value()
|
||||
# Counts calls when SQLite evaluates the registered test function.
|
||||
$dbh->sqlite_create_function('webmin_guarded_value', 0, sub {
|
||||
$guarded_calls++;
|
||||
return 'guarded';
|
||||
});
|
||||
my $guarded_sql = sqlite_guarded_select('1', 0, [],
|
||||
[ 'webmin_guarded_value()' ],
|
||||
'FROM (SELECT 1) LIMIT 1 OFFSET 0');
|
||||
my $guarded_rows = sqlite_select_rows_bounded($dbh, $guarded_sql);
|
||||
is_deeply($guarded_rows, [ [ 0, undef ] ],
|
||||
'an oversized guarded expression returns NULL');
|
||||
is($guarded_calls, 0,
|
||||
'an oversized guarded expression is not evaluated');
|
||||
$dbh->do('CREATE TABLE sample (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, data BLOB)');
|
||||
$dbh->do('INSERT INTO sample (name, data) VALUES (?, ?)', undef,
|
||||
'first', "a\0b");
|
||||
my $objects = sqlite_objects($dbh);
|
||||
is(scalar(@$objects), 1, 'one table listed');
|
||||
is($objects->[0]->{'name'}, 'sample', 'table name listed');
|
||||
my $columns = sqlite_table_columns($dbh, 'sample');
|
||||
is(scalar(@$columns), 3, 'table columns listed');
|
||||
my $identity = sqlite_identity($dbh, $objects->[0], $columns);
|
||||
is($identity->{'rowid'}, 'rowid', 'rowid selected for stable identity');
|
||||
my $result = sqlite_query($dbh, 'SELECT name FROM sample', 10);
|
||||
is_deeply($result->{'rows'}, [ [ 'first' ] ], 'query returns row data');
|
||||
my $normal_limit = $dbh->sqlite_limit(
|
||||
DBD::SQLite::Constants::SQLITE_LIMIT_LENGTH(), -1);
|
||||
cmp_ok($normal_limit, '>', 1048576,
|
||||
'database connections keep SQLite value limits unchanged');
|
||||
# Schema SQL and default values must stay within the same result budgets as
|
||||
# table data before Perl retains them.
|
||||
my $oversized_default = 'x' x
|
||||
(sqlite_result_bytes_limit() + 1024);
|
||||
$dbh->do("CREATE TABLE oversized_schema (payload TEXT DEFAULT '".
|
||||
$oversized_default."')");
|
||||
my $objects_ok = eval { sqlite_objects($dbh); 1 };
|
||||
ok(!$objects_ok, 'object lists reject oversized schema definitions');
|
||||
like($@, qr/^result exceeds 1048576 bytes/,
|
||||
'object-list failure reports the aggregate metadata limit');
|
||||
my $object_ok = eval { sqlite_object($dbh, 'oversized_schema'); 1 };
|
||||
ok(!$object_ok, 'single-object reads reject oversized schema definitions');
|
||||
like($@, qr/^result exceeds 1048576 bytes/,
|
||||
'single-object failure reports the aggregate metadata limit');
|
||||
my $columns_ok = eval {
|
||||
sqlite_table_columns($dbh, 'oversized_schema');
|
||||
1;
|
||||
};
|
||||
ok(!$columns_ok, 'column reads reject oversized default expressions');
|
||||
like($@, qr/^result exceeds 1048576 bytes/,
|
||||
'column failure reports the aggregate metadata limit');
|
||||
is($dbh->sqlite_limit(DBD::SQLite::Constants::SQLITE_LIMIT_LENGTH(), -1),
|
||||
$normal_limit, 'metadata failures restore SQLite value limits');
|
||||
$dbh->do('DROP TABLE oversized_schema');
|
||||
my $result_dbh = sqlite_connect($database, 0);
|
||||
is($result_dbh->sqlite_limit(
|
||||
DBD::SQLite::Constants::SQLITE_LIMIT_LENGTH(), -1), $normal_limit,
|
||||
'read connections restore SQLite value limits');
|
||||
$result_dbh->disconnect();
|
||||
# Exercise aggregate and hard value ceilings without restricting writes.
|
||||
$dbh->do('CREATE TABLE result_sample (payload TEXT)');
|
||||
my $result_insert = $dbh->prepare(
|
||||
'INSERT INTO result_sample (payload) VALUES (?)');
|
||||
$result_insert->execute('a' x 600000);
|
||||
$result_insert->execute('b' x 600000);
|
||||
my ($prefix, $full_length) = $dbh->selectrow_array(
|
||||
'SELECT substr(payload, 1, 10), length(payload) '.
|
||||
'FROM result_sample ORDER BY rowid LIMIT 1');
|
||||
is($prefix, 'a' x 10,
|
||||
'a shortened read succeeds when its source exceeds the budget');
|
||||
is($full_length, 600000, 'the shortened read retains the source length');
|
||||
my $byte_limited = sqlite_query($dbh,
|
||||
'SELECT payload FROM result_sample ORDER BY rowid', 10);
|
||||
is(scalar(@{$byte_limited->{'rows'}}), 1,
|
||||
'query retains only rows inside the aggregate byte limit');
|
||||
is($byte_limited->{'truncated_by'}, 'bytes',
|
||||
'aggregate byte limit reports byte truncation');
|
||||
is($dbh->sqlite_limit(DBD::SQLite::Constants::SQLITE_LIMIT_LENGTH(), -1),
|
||||
$normal_limit, 'direct query restores SQLite value limits');
|
||||
my $hard_limited = eval {
|
||||
sqlite_query($dbh, 'SELECT zeroblob(67108865)', 10);
|
||||
1;
|
||||
};
|
||||
ok(!$hard_limited, 'direct query rejects a value above the hard ceiling');
|
||||
like($@, qr/^value exceeds 67108864/,
|
||||
'hard ceiling failure reports the value limit');
|
||||
is($dbh->sqlite_limit(DBD::SQLite::Constants::SQLITE_LIMIT_LENGTH(), -1),
|
||||
$normal_limit, 'failed query restores SQLite value limits');
|
||||
my $large_write = sqlite_query($dbh,
|
||||
'INSERT INTO result_sample (payload) VALUES (zeroblob(2000000))', 10);
|
||||
is($large_write->{'truncated'}, 0,
|
||||
'direct SQL writes do not use the temporary result-value limit');
|
||||
|
||||
# A filename that resembles extra DBD parameters remains the literal
|
||||
# validated filename instead of selecting a second database path.
|
||||
my $injected = "$outside/injected.db";
|
||||
my $crafted = "$allowed/literal;dbname=$injected";
|
||||
(my $crafted_parent = $crafted) =~ s!/[^/]+$!!;
|
||||
make_path($crafted_parent);
|
||||
my $crafted_dbh = sqlite_connect($crafted, 1, 1);
|
||||
my $resolved_crafted = simplify_path(resolve_links($crafted));
|
||||
is($crafted_dbh->sqlite_db_filename(), $resolved_crafted,
|
||||
'DBD opens the validated filename containing connection syntax');
|
||||
$crafted_dbh->disconnect();
|
||||
ok(-f $crafted, 'literal crafted filename is created inside the ACL');
|
||||
ok(!-e $injected, 'second dbname text cannot escape the path ACL');
|
||||
# A second statement is refused before anything runs, and the
|
||||
# single-statement rule stays in force afterwards.
|
||||
$text{'exec_multiple'} = 'Only one SQL statement can be run at a time';
|
||||
eval { sqlite_query($dbh, 'SELECT name FROM sample; DROP TABLE sample', 10) };
|
||||
like($@, qr/^Only one SQL statement/, 'a second statement is refused');
|
||||
ok(sqlite_object($dbh, 'sample'), 'and the refused statement did not run');
|
||||
ok(!$dbh->{'sqlite_allow_multiple_statements'},
|
||||
'multiple statements stay disabled after the check');
|
||||
$dbh->do('INSERT INTO sample (name) VALUES (?)', undef, 'second');
|
||||
my $limited = sqlite_query($dbh, 'SELECT name FROM sample ORDER BY id', 1);
|
||||
is_deeply($limited->{'rows'}, [ [ 'first' ] ], 'query result honors row limit');
|
||||
is($limited->{'truncated_by'}, 'rows',
|
||||
'limited query reports row truncation');
|
||||
ok(sqlite_value_is_binary("a\0b"), 'binary value detected');
|
||||
ok(!sqlite_value_is_binary('plain text'), 'text value detected');
|
||||
|
||||
# Keep an ASCII-only BLOB binary without fetching its bytes.
|
||||
$dbh->do('CREATE TABLE blob_sample (id INTEGER PRIMARY KEY, data BLOB)');
|
||||
my $blob_insert = $dbh->prepare(
|
||||
'INSERT INTO blob_sample (data) VALUES (?)');
|
||||
$blob_insert->bind_param(1, 'ascii blob', DBI::SQL_BLOB());
|
||||
$blob_insert->execute();
|
||||
my $blob_columns = sqlite_table_columns($dbh, 'blob_sample');
|
||||
my ($blob_values, $blob_types, $blob_lengths) =
|
||||
sqlite_column_selects($blob_columns);
|
||||
my $blob_row = $dbh->selectrow_arrayref('SELECT '.join(', ',
|
||||
@$blob_values, @$blob_types, @$blob_lengths).' FROM blob_sample');
|
||||
is($blob_row->[1], undef, 'bounded row query omits the BLOB payload');
|
||||
is($blob_row->[3], 'blob', 'bounded row query preserves the storage type');
|
||||
is($blob_row->[5], 10, 'bounded row query preserves the BLOB length');
|
||||
is(sqlite_display_value($blob_row->[1], $blob_row->[3], $blob_row->[5]),
|
||||
'[binary value, 10 bytes]', 'ASCII BLOB is displayed as binary');
|
||||
|
||||
# Check quoted identifiers and WITHOUT ROWID identities.
|
||||
my $odd_table = 'odd "table';
|
||||
$dbh->do('CREATE TABLE '.sqlite_quote_identifier($odd_table).
|
||||
' (first TEXT, second INTEGER, PRIMARY KEY (first, second)) WITHOUT ROWID');
|
||||
my $odd_object = sqlite_object($dbh, $odd_table);
|
||||
is($odd_object->{'type'}, 'table', 'quoted table name is found');
|
||||
my $odd_columns = sqlite_table_columns($dbh, $odd_table);
|
||||
is(scalar(@$odd_columns), 2, 'quoted table columns are listed');
|
||||
is_deeply(sqlite_identity($dbh, $odd_object, $odd_columns),
|
||||
{ 'columns' => [ 'first', 'second' ] },
|
||||
'WITHOUT ROWID table uses its composite primary key');
|
||||
$dbh->do('CREATE VIEW sample_view AS SELECT name FROM sample');
|
||||
my $view = sqlite_object($dbh, 'sample_view');
|
||||
ok(!sqlite_identity($dbh, $view, sqlite_table_columns($dbh, 'sample_view')),
|
||||
'view is not editable');
|
||||
ok(!sqlite_object($dbh, 'sqlite_sequence'),
|
||||
'internal SQLite table cannot be browsed directly');
|
||||
|
||||
# Check read-only connections and authorizer restrictions.
|
||||
my $readonly = sqlite_connect($database, 0);
|
||||
my ($query_only) = $readonly->selectrow_array('PRAGMA query_only');
|
||||
is($query_only, 1, 'read-only connection enables query-only mode');
|
||||
my $wrote_readonly = eval {
|
||||
$readonly->do("INSERT INTO sample (name) VALUES ('blocked')");
|
||||
1;
|
||||
};
|
||||
ok(!$wrote_readonly, 'read-only connection rejects writes');
|
||||
$readonly->disconnect();
|
||||
sqlite_install_authorizer($dbh);
|
||||
my $attached = eval { $dbh->do("ATTACH DATABASE '$outside/other.db' AS other"); 1 };
|
||||
ok(!$attached, 'authorizer rejects attached database');
|
||||
ok(!-e "$outside/other.db", 'rejected attachment creates no file');
|
||||
my $vacuumed = eval { $dbh->do("VACUUM INTO '$outside/vacuum.db'"); 1 };
|
||||
ok(!$vacuumed, 'authorizer rejects VACUUM INTO');
|
||||
ok(!-e "$outside/vacuum.db", 'rejected VACUUM creates no file');
|
||||
$dbh->disconnect();
|
||||
}
|
||||
|
||||
done_testing();
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/perl
|
||||
# Tests readable, escaped SQLite action-log descriptions.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use Test::More;
|
||||
use FindBin;
|
||||
use lib "$FindBin::Bin/..";
|
||||
|
||||
BEGIN { $ENV{'SQLITE_TESTING'} = 1; }
|
||||
chdir "$FindBin::Bin/.." or die "chdir: $!";
|
||||
require 'log_parser.pl';
|
||||
|
||||
our %text;
|
||||
%text = (
|
||||
'log_database_create' => 'Created SQLite database $1',
|
||||
'log_table_create' => 'Created table $1 in database $2',
|
||||
'log_table_drop' => 'Dropped table $1 from database $2',
|
||||
'log_table_rename' => 'Renamed table $1 to $2 in database $3',
|
||||
'log_view_drop' => 'Dropped view $1 from database $2',
|
||||
'log_column_create' => 'Added column $1 to table $2 in database $3',
|
||||
'log_column_rename' =>
|
||||
'Renamed column $1 to $2 in table $3 in database $4',
|
||||
'log_column_rename_old' =>
|
||||
'Renamed column $1 in table $2 in database $3',
|
||||
'log_column_drop' => 'Dropped column $1 from table $2 in database $3',
|
||||
'log_row_create' => 'Added a row to table $1 in database $2',
|
||||
'log_row_modify' => 'Updated a row in table $1 in database $2',
|
||||
'log_rows_delete' => 'Deleted $1 rows from table $2 in database $3',
|
||||
'log_sql_exec' => 'Executed SQL in database $1',
|
||||
'log_sql_exec_long' => 'Executed SQL statement $2 in database $1',
|
||||
);
|
||||
|
||||
# sub describe(action, type, object, [¶ms], [long])
|
||||
# Calls the parser with fixed user and script fields for a concise test.
|
||||
sub describe
|
||||
{
|
||||
my ($action, $type, $object, $params, $long) = @_;
|
||||
return parse_webmin_log('root', 'test.cgi', $action, $type, $object,
|
||||
$params || { }, $long);
|
||||
}
|
||||
|
||||
# sub normalize_description(html)
|
||||
# Removes ui_tag's marker class and folds formatting whitespace so expected
|
||||
# sentences stay readable in these tests.
|
||||
sub normalize_description
|
||||
{
|
||||
my ($html) = @_;
|
||||
$html =~ s/ class="ui--tt"//g;
|
||||
$html =~ s/\s+/ /g;
|
||||
$html =~ s/^ | $//g;
|
||||
return $html;
|
||||
}
|
||||
|
||||
is(normalize_description(describe('create', 'database', '/srv/app.db')),
|
||||
'Created SQLite database <tt>/srv/app.db</tt>',
|
||||
'database creation has a readable description');
|
||||
is(normalize_description(describe('create', 'table', 'jobs', {
|
||||
'db' => '/srv/app.db' })),
|
||||
'Created table <tt>jobs</tt> in database <tt>/srv/app.db</tt>',
|
||||
'table creation names its database');
|
||||
is(normalize_description(describe('rename', 'table', 'jobs', {
|
||||
'db' => '/srv/app.db', 'name' => 'tasks' })),
|
||||
'Renamed table <tt>jobs</tt> to <tt>tasks</tt> in database <tt>/srv/app.db</tt>',
|
||||
'table rename shows both names');
|
||||
is(normalize_description(describe('rename', 'column', 'owner', {
|
||||
'db' => '/srv/app.db', 'table' => 'jobs', 'name' => 'owner_name' })),
|
||||
'Renamed column <tt>owner</tt> to <tt>owner_name</tt> in table <tt>jobs</tt> in database <tt>/srv/app.db</tt>',
|
||||
'column rename shows both names and its table');
|
||||
is(normalize_description(describe('rename', 'column', 'owner2', {
|
||||
'db' => '/srv/app.db', 'table' => 'jobs' })),
|
||||
'Renamed column <tt>owner2</tt> in table <tt>jobs</tt> in database <tt>/srv/app.db</tt>',
|
||||
'older column rename records still get a useful description');
|
||||
is(normalize_description(describe('delete', 'rows', '2', {
|
||||
'db' => '/srv/app.db', 'table' => 'jobs' })),
|
||||
'Deleted 2 rows from table <tt>jobs</tt> in database <tt>/srv/app.db</tt>',
|
||||
'row deletion names its count, table and database');
|
||||
is(normalize_description(describe('exec', 'sql', '/srv/app.db', {
|
||||
'sql' => 'SELECT <script>' }, 1)),
|
||||
'Executed SQL statement <tt>SELECT <script></tt> in database <tt>/srv/app.db</tt>',
|
||||
'long SQL descriptions escape statement text');
|
||||
is(normalize_description(describe('rename', 'column', '<b>', {
|
||||
'db' => '/srv/<db>', 'table' => '<table>', 'name' => '<new>' })),
|
||||
'Renamed column <tt><b></tt> to <tt><new></tt> in table <tt><table></tt> in database <tt>/srv/<db></tt>',
|
||||
'logged names and paths cannot inject HTML');
|
||||
is(describe('unknown', 'thing', 'item'), undef,
|
||||
'unknown records use the action-log fallback');
|
||||
like(describe('create', 'database', '/srv/app.db'),
|
||||
qr/<tt class="ui--tt">\/srv\/app\.db<\/tt>/,
|
||||
'log descriptions build markup through ui_tag');
|
||||
|
||||
done_testing();
|
||||
321
sqlite/table.cgi
321
sqlite/table.cgi
@@ -1,321 +0,0 @@
|
||||
#!/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'});
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/usr/local/bin/perl
|
||||
# Displays the form for creating a new table.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'redefine';
|
||||
no warnings 'uninitialized';
|
||||
our (%text, %in);
|
||||
require './sqlite-lib.pl';
|
||||
&ReadParse();
|
||||
|
||||
# Only a writable, valid database can take a new table.
|
||||
my $path = &sqlite_database_path($in{'db'});
|
||||
&sqlite_can_write($path) || &error($text{'error_readonly'});
|
||||
my $dbh = &sqlite_connect($path, 0);
|
||||
$dbh->disconnect();
|
||||
|
||||
# Start with four column rows, or as many as the caller asked for.
|
||||
my $count = int($in{'fields'} || 4);
|
||||
$count = 1 if ($count < 1);
|
||||
$count = 50 if ($count > 50);
|
||||
|
||||
&ui_print_header(&ui_tag('tt', &html_escape($path)), $text{'create_title'},
|
||||
'', 'create');
|
||||
print &ui_page_start();
|
||||
print &sqlite_table_form($path, \%in, $count);
|
||||
print &ui_page_end();
|
||||
&ui_print_footer('database.cgi?db='.&urlize($path),
|
||||
$text{'database_return'}, '', $text{'index_return'});
|
||||
@@ -2636,44 +2636,6 @@ my ($user) = @_;
|
||||
return run_user_systemctl($user, "daemon-reload");
|
||||
}
|
||||
|
||||
=head2 list_login_sessions()
|
||||
|
||||
Returns an array reference of unfiltered loginctl session property hashes.
|
||||
Returns no value if loginctl is unavailable or either query fails. An empty
|
||||
array reference means no sessions were found.
|
||||
|
||||
=cut
|
||||
sub list_login_sessions
|
||||
{
|
||||
# Allow callers to use another backend when loginctl is not installed.
|
||||
my $loginctl = has_command("loginctl");
|
||||
return if (!$loginctl);
|
||||
|
||||
# Use untranslated output to identify sessions and read their timestamps.
|
||||
my $cmd = "LC_ALL=C ".quotemeta($loginctl)." --no-pager";
|
||||
my $out = backquote_command($cmd." list-sessions --no-legend 2>/dev/null");
|
||||
# A failed listing must not be mistaken for a successful empty result.
|
||||
return if ($?);
|
||||
my @ids = $out =~ /^\s*(\S+)\s+\d+\s+/mg;
|
||||
# Without session IDs, show-session would return manager properties instead.
|
||||
return [] if (!@ids);
|
||||
|
||||
# Read named properties so column layout and missing TTYs do not matter.
|
||||
$out = backquote_command($cmd." show-session ".
|
||||
join(" ", map { quotemeta($_) } @ids)." 2>/dev/null");
|
||||
# A failed query may contain partial output; let callers fall back.
|
||||
return if ($?);
|
||||
|
||||
# Each blank-line-separated block contains one session's properties.
|
||||
my @rv;
|
||||
foreach my $block (split(/\n\n+/, $out)) {
|
||||
my %session = $block =~ /^(\w+)=(.*)$/mg;
|
||||
# Only return records that identify a session.
|
||||
push(@rv, \%session) if ($session{'Id'});
|
||||
}
|
||||
return \@rv;
|
||||
}
|
||||
|
||||
=head2 set_user_linger(user, enabled)
|
||||
|
||||
Enables or disables lingering for a user.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user