Compare commits

..

92 Commits

Author SHA1 Message Date
Ilia Ross
ea46275f4d Fix CGI response status handling
This PR fixes MiniServ ignoring CGI Status headers and returning the wrong HTTP status.

Both CGI execution modes now honor explicit statuses, preserve default redirects, and record the correct status in request logs.

The Status control header is no longer forwarded to clients.

Validation passed: 102 local tests and 68 live checks on Debian 13, covering both CGI modes.
2026-10-23 20:51:19 +02:00
Ilia Ross
1cade5b59e Fix dropdown menu positioning
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
2026-10-23 05:37:52 +02:00
Ilia Ross
16148ed27e Fix button height and box-sizing for dropdowns and update display property
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
2026-10-23 05:20:31 +02:00
Ilia Ross
1d2ed64a36 Fix button styling and update link underlining rules for dropdown items 2026-10-23 04:41:51 +02:00
Ilia Ross
8953ff12b4 Fix underlines on links, button icon alignment and add pause icon 2026-10-23 03:54:44 +02:00
Ilia Ross
17a393a589 Fix MySQL import fallback for loopback and tunneled servers 2026-10-23 01:30:47 +02:00
Ilia Ross
c12fb7d438 Fix MySQL data imports with private temporary directories
This patch fixes CSV imports on systems such as AlmaLinux where MariaDB's `PrivateTmp` setting hides Webmin's temporary files.

The earlier permissions fix could not solve this because the database service still could not see the file.

Imports now use a unique directory under `secure_file_priv`, or the database's data directory, while retaining restricted file permissions and automatic cleanup.

The directory uses the `#sql-` prefix so MariaDB does not treat it as a database during listings, backups, or checks.

Verified live on Debian 13, AlmaLinux 10, Ubuntu 26.04 MariaDB and MySQL 8.4, including database listings and full dumps taken while an import is in progress.
2026-10-23 00:47:32 +02:00
Ilia Ross
3ccad807df Fix SMTP plaintext fallback after STARTTLS failure
This patch fixes mail sending and SMTP monitoring silently continuing without encryption when STARTTLS is rejected.

That fallback could expose passwords and email contents despite encryption being selected.

Both paths now require a valid STARTTLS success response and stop on failure, while successful TLS and explicitly configured plaintext connections remain supported.
2026-10-22 22:51:22 +02:00
Ilia Ross
3441040461 Fix mailbox attachment XSS bypasses 2026-10-22 22:23:23 +02:00
Ilia Ross
85b91de232 Update changelog 2026-10-22 22:15:59 +02:00
Ilia Ross
0cc60f0f4b Fix unsafe commands in existing Webalizer schedules 2026-10-22 21:47:59 +02:00
Ilia Ross
baad8643ae Fix log path HTML injection in Webalizer 2026-10-22 21:32:31 +02:00
Ilia Ross
1c2cff183e Fix username XSS in Fetchmail pages 2026-10-22 21:22:52 +02:00
Ilia Ross
01d70fdc53 Update changelog 2026-10-22 21:09:38 +02:00
Ilia Ross
130987be89 Fix MySQL server-side import of temporary files
This patch fixes MySQL imports that fail because the database server cannot read the temporary file.

Since the temporary copy is created with mode 0600, a server-side `mysqlimport` fails for imports from a local file, and for uploads by users with a Unix user restriction.

Before running the import as root against a local server, the file is now given the group of the running server process with mode 0640, so the server can read it while other users still cannot. The process is found through the server's reported PID file and must be `mysqld` or `mariadbd`; otherwise the permissions are left unchanged.
2026-10-22 21:05:39 +02:00
Ilia Ross
f6544c2700 Fix editing MySQL views with special-character names 2026-10-22 19:43:39 +02:00
Ilia Ross
9bad05a55d Fix XSS across MySQL database pages 2026-10-22 19:31:58 +02:00
Ilia Ross
400c326a1b Fix XSS in printer test forms and job lists 2026-10-22 18:30:42 +02:00
Ilia Ross
ef47bda30c Fix XSS in service monitor error messages
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
2026-10-22 18:16:32 +02:00
Ilia Ross
4bf0c7d652 Fix mail queue attachment XSS (more) 2026-10-22 17:35:27 +02:00
Jamie Cameron
670ed84b14 Escape more inputs
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
2026-10-21 21:35:49 -07:00
Jamie Cameron
4f72773842 Fix incorrect parameter name 2026-10-21 20:54:41 -07:00
Jamie Cameron
5d9bbd8401 Escape potentially untrusted HTTP responses 2026-10-21 20:48:19 -07:00
Jamie Cameron
d59f5326a1 Merge pull request #2886 from webmin/dev/fix-rpc-deserialization
Fix RPC deserialization
2026-10-21 20:35:07 -07:00
Ilia Ross
dbb360b127 Add Data::Dumper parser module 2026-10-22 01:45:44 +02:00
Ilia Ross
ad4d56f050 Fix optional subtitle handling in log views
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
2026-10-20 23:19:08 +02:00
Jamie Cameron
1d8608d61b Fix a bunch of missing escapes
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
2026-10-19 21:44:06 -07:00
Ilia Ross
f59a235d17 Fix stale bytes in input reads
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
This patch fixes leaving old bytes in the input buffer when a later read is shorter.

Those extra bytes can corrupt ordinary HTTP RPC requests. Fast RPC is unaffected (discovered in deep tests with Virtualmin WP Workbench).

The reader now matches Perl's native read behavior, with regression tests for buffered and socket reads, offsets, and zero-length reads.
2026-10-20 03:10:24 +02:00
Ilia Ross
e764166ad4 Fix misleading temporary directory placeholder #2787 2026-10-19 21:50:30 +02:00
Ilia Ross
dfce125874 Fix server login and logout cookie validation to be numeric
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
2026-10-19 15:58:59 +02:00
Ilia Ross
aa82e2ebd5 Fix escaping too when updating MySQL backup schedules
c76255be5f
2026-10-19 15:44:25 +02:00
Ilia Ross
3466d09069 Fix printer-name XSS in empty job lists too 2026-10-19 15:41:25 +02:00
Ilia Ross
99a4aaf52c Update disk-only discovery wording and use named include-cds argument
#2880 clean up
2026-10-19 15:34:34 +02:00
Ilia Ross
9dd52da198 Merge pull request #2880 from Racerx323/fix/fdisk-disk-only-discovery
fdisk: add disk-only inventory discovery
2026-10-19 15:27:25 +02:00
Ilia Ross
91d3dd843b Fix unsafe fast RPC deserialization without dropping Data::Dumper support
https://github.com/webmin/webmin/pull/2886#issuecomment-6207458026
2026-10-19 01:49:05 +02:00
Jamie Cameron
db4d5d671c Delete unused support for a top frame
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
2026-10-18 14:11:19 -07:00
Jamie Cameron
21c03e9120 Ensure cookie names are valid 2026-10-18 11:41:58 -07:00
Jamie Cameron
c76255be5f Escape DB name in cron command 2026-10-18 11:32:37 -07:00
Jamie Cameron
b35339094a Delete unused strings 2026-10-18 11:16:54 -07:00
Jamie Cameron
b661b5d163 Escape param included in header 2026-10-18 11:03:07 -07:00
Jamie Cameron
77f43d4f91 Merge branch 'master' of github.com:webmin/webmin 2026-10-18 10:59:57 -07:00
Jamie Cameron
1f5b440c71 Escape linktitle param for inclusion in the header 2026-10-18 10:59:17 -07:00
Ilia Ross
1a939a8e0a Fix to allow extractions for safe users
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
https://github.com/webmin/webmin/pull/2888
2026-10-18 19:19:25 +02:00
Jamie Cameron
c528155764 Merge pull request #2888 from webmin/dev/fix-restricted-archive-extraction
Fix archive extraction for directory-restricted users
2026-10-18 08:57:54 -07:00
Ilia Ross
4605a1b13f Update changelog for Ubuntu login detection fix and add tests 2026-10-18 16:12:21 +02:00
Ilia Ross
7522fbb249 Fix logged-in user detection without utmp
This patch fixes missing SSH logins on systems without `utmp`, including Ubuntu 26.04.

It adds a Systemd session helper, excludes manager and Webmin and Usermin sessions keeping `who` as the fallback and existing backend where `utmp` is present.

This complements mine earlier https://github.com/webmin/webmin/commit/8417d5c patch, which fixed parsing but could not get sessions missing from `who` output.

Tested on Ubuntu 26.04 Pro and AlmaLinux 10 Pro with both local live test and suites pass.
2026-10-18 16:11:20 +02:00
Ilia Ross
328b6ca7ba Fix logged-in user date rendering
https://forum.virtualmin.com/t/webmin-2-670-does-not-show-logged-in-users-from-the-users-and-groups-system-module-button-on-ubuntu-26-04-1-lts/138162/9
2026-10-18 14:32:55 +02:00
Ilia Ross
371d62bbc8 Fix JavaScript escaping in mailbox search form 2026-10-18 13:39:39 +02:00
Jamie Cameron
f429e674ad Merge branch 'master' of github.com:webmin/webmin
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
2026-10-17 22:01:17 -07:00
Jamie Cameron
412c0bfb38 Use more meaningful labels when searching by size
https://github.com/webmin/webmin/issues/2892
2026-10-17 21:59:53 -07:00
Ilia Ross
988a5d6ab8 Update changelog
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
2026-10-18 01:48:14 +02:00
Ilia Ross
5ef56e5a77 Change Cloudmin loader to crimson 2026-10-18 01:48:14 +02:00
Ilia Ross
34d926587c Fix Gray Theme toolbar button labels 2026-10-18 01:48:14 +02:00
Ilia Ross
ce1b58f55e Add Gray Theme byte-size input styling 2026-10-18 01:48:14 +02:00
Ilia Ross
ff040074b0 Update Gray Theme action controls 2026-10-18 01:48:14 +02:00
Ilia Ross
16584efe83 Fix Gray Theme form and table overflow 2026-10-18 01:48:14 +02:00
Ilia Ross
d9b3c31ac1 Add Gray Theme keyboard shortcuts 2026-10-18 01:48:13 +02:00
Jamie Cameron
07d19496da Escape log file path 2026-10-17 16:26:22 -07:00
Ilia Ross
7ebf5c474c Fix UI library theme styling
This patch fixes UI library buttons, badges, dropdowns, alerts, and other stateful elements to follow the active theme consistently.

It maps action states and sizes to native theme button classes while retaining safe CSS fallbacks.
2026-10-17 23:21:06 +02:00
Ilia Ross
14bf169b46 Fix navigation menu spacing and dividers 2026-10-17 18:54:47 +02:00
Ilia Ross
5658f99e63 Update product creation button styles 2026-10-17 18:52:02 +02:00
Ilia Ross
2de2f590e3 Update product logos for dark mode 2026-10-17 18:50:24 +02:00
Ilia Ross
5173289ea2 Fix SQLite row selection controls 2026-10-17 17:12:34 +02:00
Ilia Ross
2aca3f56e3 Fix to limit SQLite table and editor data
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
2026-10-17 17:08:20 +02:00
Ilia Ross
d51b820fbe Add SQLite result size limits 2026-10-17 17:08:20 +02:00
Ilia Ross
b4129ed700 Fix SQLite restricted path enforcement 2026-10-17 17:08:20 +02:00
Jamie Cameron
6a67d6f4be Merge pull request #2893 from webmin/dev/fix-rpm-package-install
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
Fix RPM package install completion
2026-10-16 20:41:17 -07:00
Ilia Ross
1b6b029393 Fix tooltip and form control spacing for better UX 2026-10-17 04:35:23 +02:00
Ilia Ross
6a5325b754 Add SQLite database browser and editor
Ref.: https://forum.virtualmin.com/t/sqlite-database-browser-and-editor/137508/
2026-10-17 04:35:05 +02:00
Ilia Ross
5833f878e8 Update changelog
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
[no-build] [no-review]
2026-10-17 02:23:24 +02:00
Ilia Ross
57cb547ead Add section title, segmented control and stacked field widgets to the UI library 2026-10-17 02:17:20 +02:00
Ilia Ross
2568bc5f07 Fix mixed DNF package installation
This patch fixes package installation when a request contains both installed and missing packages.

DNF 4 uses one `install` transaction, while DNF 5 updates installed packages first and installs missing packages only after the update succeeds.

It preserves architecture package handling, always installs required repository packages, and prevents an empty package list from becoming a full system update.

The complete transaction reads from `/dev/null`, and tests cover DNF 4, DNF 5, installed, missing, mixed, and empty package sets.

Live AlmaLinux testing also confirmed both the single-command and chained-command paths.
2026-10-17 02:03:27 +02:00
Ilia Ross
fb70225187 Fix RPM package install completion
This PR fixes package installation pages stopping after "Re-fetching available packages .." on RPM systems, before the System Status refresh, reboot check, and return link appeared.

The regression was introduced by https://github.com/webmin/webmin/commit/658f930, which wrapped RPM queries in `clean_environment` while the Package Updates installer had already cleaned the environment.

The inner clean overwrote the only saved environment, leaving `WEBMIN_CONFIG` unset and causing the System Status library to fail after the page headers had been sent.

Nested `clean_environment` calls now save separate snapshots, allowing each `reset_environment` call to restore the matching environment exactly while preserving Virtualmin's API script runner behavior.
2026-10-17 01:27:12 +02:00
Ilia Ross
a864ec9a6f Fix tooltip positioning for help bubbles in card actions 2026-10-17 01:01:05 +02:00
Jamie Cameron
19cd8a1039 Overlay themes are no longer used, and we don't link to these CGIs
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
2026-10-15 21:22:36 -07:00
Jamie Cameron
7de3c52103 Merge pull request #2891 from webmin/dev/fix-rpc-certificate-checks
Fix SSL certificate checks for regular RPC
2026-10-15 20:27:23 -07:00
Ilia Ross
7f4a2cf5cd Fix SSL certificate checks for regular RPC
This PR fixes SSL certificate verification for regular `/rpc.cgi` connections.

It reuses the existing certificate requirements already passed to Fast RPC, so both transports honor `checkssl`.

<!-- ANT-2026-1G97HEC6: Reported by Adam via email on October 11, 2026 at 12:08. -->
2026-10-16 05:25:50 +02:00
Ilia Ross
5231d6de0f Drop certificate checks from RPC deserialization fix 2026-10-16 05:16:40 +02:00
Ilia Ross
72b657ce86 Update changelog 2026-10-16 05:11:13 +02:00
Jamie Cameron
98f10775a0 Merge pull request #2890 from webmin/dev/fix-mysql-backup-privilege-boundaries
Fix MySQL backup privilege boundary bypasses
2026-10-15 20:01:00 -07:00
Ilia Ross
31e251ce1c Fix chip expectations in the UI library widget tests 2026-10-16 04:34:28 +02:00
Ilia Ross
c5b94588ec Add new chip components and styles for it 2026-10-16 04:28:03 +02:00
Ilia Ross
0843de05cb Fix MySQL backup privilege boundary bypasses
This PR fixes privilege boundary bypasses in MySQL database backups.

It restricts custom dump parameters and shared backup settings to users with unrestricted root backup access.

It also prevents database names, table names, and compatibility values from being interpreted as dump options.

The changes include focused tests and passed live regression testing with MySQL and MariaDB.
2026-10-16 03:08:07 +02:00
Ilia Ross
ae8feb1854 Fix remaining mail queue attachment XSS
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Follow up to: https://github.com/webmin/webmin/commit/dda9b97
2026-10-16 01:46:06 +02:00
Jamie Cameron
937c3873ce Merge pull request #2887 from webmin/dev/fix-rpc-transfer-authentication
Fix Fast RPC transfer authentication
2026-10-15 16:17:59 -07:00
Ilia Ross
00dcc18cfd Fix RPM output parsing across locales
This further fixes 658f930d69.
2026-10-16 00:12:38 +02:00
Ilia Ross
9b494b93f6 Fix PostgreSQL SQL argument quoting 2026-10-15 21:14:39 +02:00
Ilia Ross
1bd44f6b1f Fix archive extraction for directory-restricted users
This PR fixes archive extraction bypassing Upload and Download directory restrictions.

Automatic extraction is hidden and rejected when uploads are limited to specific directories.

Unrestricted users keep the existing extraction behavior.

ANT-2026-TPYPGDF0: Reported by Adam in email on October 11, 2026 at 01:05.
2026-10-15 17:47:58 +02:00
Ilia Ross
0678fbfa6b Fix Fast RPC transfer authentication
This PR fixes unauthenticated Fast RPC transfer sockets by requiring the transfer connection to present the active RPC session ID before file data moves.

Patched peers use authentication, while mixed-version transfers retain existing behavior for compatibility.

ANT-2026-1G97HEC6: Reported by Adam email on October 11 2026 at 12:08.
2026-10-15 16:36:32 +02:00
Ilia Ross
ffa551a24b Fix RPC deserialization and certificate checks
This PR fixes unsafe RPC deserialization by dropping `Data::Dumper` from fast RPC, rejecting `$VAR1` input, and keeping remote objects as plain data.

Original report by Adam emailed October 11, 2026 at 05:34.
2026-10-15 15:02:39 +02:00
Aaron Anderson
74298f01d8 Preserve controller-qualified disk identities and canonical test fixtures 2026-10-14 10:30:22 -05:00
Aaron Anderson
9506ad1fcd fdisk: add disk-only inventory discovery 2026-10-12 09:36:00 -05:00
188 changed files with 11751 additions and 951 deletions

View File

@@ -1,19 +1,26 @@
## Changelog
#### 2.680 (October, 2026)
#### 2.680 (October 23, 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/MariaDB database backups allowing restricted users to write files or save root-run settings outside their access limits
* Fix MySQL/MariaDB imports to use server-accessible temporary files with restricted permissions, including when systemd isolates temporary directories or Webmin connects through a loopback address
* 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:

File diff suppressed because one or more lines are too long

View File

@@ -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,6 +213,12 @@ 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;
@@ -240,7 +248,9 @@ while(1) {
$xfer_kids{$cpid} = 1;
print STDERR "fastrpc[$$]: tcpwrite $file started\n"
if ($gconfig{'rpcdebug'});
$rv = { 'status' => 1, 'rv' => [ $file, $tport ] };
$rv = { 'status' => 1,
'rv' => [ $file, $tport,
$arg->{'xferauth'} ? $sid : undef ] };
}
close($tsock);
close($tsock6) if ($use_ipv6);
@@ -295,6 +305,12 @@ 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;
@@ -309,7 +325,8 @@ while(1) {
"started\n"
if ($gconfig{'rpcdebug'});
$rv = { 'status' => 1,
'rv' => [ $arg->{'file'}, $tport ] };
'rv' => [ $arg->{'file'}, $tport,
$arg->{'xferauth'} ? $sid : undef ] };
}
close(FILE);
close($tsock);

View File

@@ -40,11 +40,17 @@ else {
}
$| = 1;
# list_disks_partitions([include-cds])
# Returns a structure containing the details of all disks and partitions
# 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);
sub list_disks_partitions
{
if (scalar(@list_disks_partitions_cache)) {
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)) {
return @list_disks_partitions_cache;
}
@@ -122,10 +128,13 @@ 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
@@ -157,7 +166,7 @@ if (open(PARTS, "</proc/partitions")) {
if (open(MEDIA, "</proc/ide/hd$n/media")) {
local $media = <MEDIA>;
close(MEDIA);
if ($media =~ /^disk/ && !$_[0]) {
if ($media =~ /^disk/ && !$include_cds) {
push(@devs, "/dev/hd$n");
}
}
@@ -213,17 +222,36 @@ 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 && !$_[0]) {
if (@cdstat && !$include_cds) {
@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;
@@ -240,6 +268,246 @@ 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);
@@ -297,154 +565,7 @@ while(<FDISK>) {
local @st = stat($disk->{'device'});
next if (@cdstat && $st[1] == $cdstat[1]);
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"};
$set_metadata->($disk);
push(@disks, $disk);
}
@@ -600,66 +721,40 @@ while(<FDISK>) {
}
close(FDISK);
# 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;
}
}
}
}
$enrich_metadata->(@disks);
@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

9
fdisk/t/README.md Normal file
View File

@@ -0,0 +1,9 @@
# 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.

View File

@@ -0,0 +1,16 @@
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();

223
fdisk/t/disk-discovery.t Normal file
View File

@@ -0,0 +1,223 @@
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();

View File

@@ -0,0 +1,90 @@
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});

View File

@@ -12,7 +12,7 @@ else {
&can_edit_user($in{'user'}) || &error($text{'poll_ecannot'});
@uinfo = getpwnam($in{'user'});
$file = "$uinfo[7]/.fetchmailrc";
$uheader = &text('poll_foruser', "<tt>$in{'user'}</tt>");
$uheader = &text('poll_foruser', "<tt>".&html_escape($in{'user'})."</tt>");
}
&ui_print_unbuffered_header($uheader, $text{'check_title'}, "");
@@ -54,7 +54,7 @@ else { print "$text{'check_ok'} <p>\n"; }
$config{'config_file'} ? $file : $in{'user'}, $poll);
if (!$fetchmail_config && $config{'view_mode'}) {
&ui_print_footer("edit_user.cgi?user=$in{'user'}", $text{'user_return'},
&ui_print_footer("edit_user.cgi?user=".&urlize($in{'user'}), $text{'user_return'},
"", $text{'index_return'});
}
else {

View File

@@ -12,7 +12,7 @@ else {
&can_edit_user($in{'user'}) || &error($text{'poll_ecannot'});
@uinfo = getpwnam($in{'user'});
$file = "$uinfo[7]/.fetchmailrc";
$uheader = &text('poll_foruser', "<tt>$in{'user'}</tt>");
$uheader = &text('poll_foruser', "<tt>".&html_escape($in{'user'})."</tt>");
}
&ui_print_header($uheader, $text{'global_title'}, "");
@@ -54,7 +54,7 @@ print &ui_table_end();
print &ui_form_end([ [ undef, $text{'save'} ] ]);
if (!$fetchmail_config && $config{'view_mode'}) {
&ui_print_footer("edit_user.cgi?user=$in{'user'}", $text{'user_return'},
&ui_print_footer("edit_user.cgi?user=".&urlize($in{'user'}), $text{'user_return'},
"", $text{'index_return'});
}
else {

View File

@@ -11,7 +11,7 @@ else {
&can_edit_user($in{'user'}) || &error($text{'poll_ecannot'});
@uinfo = getpwnam($in{'user'});
$file = "$uinfo[7]/.fetchmailrc";
$uheader = &text('poll_foruser', "<tt>$in{'user'}</tt>");
$uheader = &text('poll_foruser', "<tt>".&html_escape($in{'user'})."</tt>");
}
if ($in{'new'}) {
@@ -139,7 +139,7 @@ else {
print &ui_form_end(\@buts);
if (!$fetchmail_config && $config{'view_mode'}) {
&ui_print_footer("edit_user.cgi?user=$in{'user'}", $text{'user_return'},
&ui_print_footer("edit_user.cgi?user=".&urlize($in{'user'}), $text{'user_return'},
"", $text{'index_return'});
}
else {

View File

@@ -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>$in{'user'}</tt>"), $text{'user_title'}, "");
&ui_print_header(&text('user_header', "<tt>".&html_escape($in{'user'})."</tt>"), $text{'user_title'}, "");
print &show_polls(\@conf, $file, $in{'user'});

View File

@@ -1,24 +0,0 @@
#!/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'});

View File

@@ -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="#e67e38"/>
<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"/>
</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="#fff">
<g transform="matrix(1 0 0 -1 142.87 284.15)" fill="#df7935">
<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="#fff">
<g transform="matrix(1 0 0 -1 241.04 335.48)" fill="#df7935">
<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

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -63,25 +63,6 @@ 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();
@@ -93,15 +74,15 @@ if ($current_lang_info->{'rtl'} || $current_lang eq "ar") {
$fscols = "*,$cols";
}
# 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.
# 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.
$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() if (!$scheme);
print &theme_scheme_script($scheme);
print "<link rel='stylesheet' type='text/css' href='".&get_webprefix().
"/unauthenticated/gray-theme.css?".
&theme_asset_key("gray-theme.css")."'>\n";
@@ -123,19 +104,6 @@ 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";
@@ -149,9 +117,6 @@ print "</body>\n";
print "</noframes>\n";
# End of the frames and page
if ($upperframe) {
print "</frameset>\n";
}
print "</frameset>\n";
print "</html>\n";

View File

@@ -75,13 +75,4 @@ 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

View File

@@ -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'>\n";
print "<div class='menu-panel menu-panel-$prod'>\n";
if ($mode eq "modules") {
# Only showing Webmin modules

View File

@@ -1,33 +0,0 @@
#!/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'});

View File

@@ -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() if (!$scheme);
print &theme_scheme_script($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,42 +290,157 @@ $tconfig{'inbody'} = (@cls ? "class='".join(" ", @cls)."'" : "").
($scheme ? " data-scheme='$scheme'" : "");
}
# 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.
# 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.
sub theme_scheme_script
{
return <<'EOF';
my ($forced) = @_;
$forced = $forced && $forced =~ /^(light|dark)$/ ? $1 : "";
my $rv = <<'EOF';
<script type='text/javascript'>
/* initializeGrayTheme(): Apply the scheme and bind frame shortcuts. */
(function() {
var key = 'gray-theme-scheme', root = document.documentElement,
var key = 'gray-theme-scheme', overrideKey = key + '-override',
forced = '__GRAY_THEME_FORCED_SCHEME__',
root = document.documentElement,
mq = window.matchMedia('(prefers-color-scheme: dark)'),
stored = null, changed = false;
try { stored = localStorage.getItem(key); } catch(e) { }
function current() { return mq.matches ? 'dark' : 'light'; }
function apply(v) {
root.setAttribute('data-scheme', v);
if (self == top) root.style.colorScheme = v;
try { localStorage.setItem(key, v); } 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; }
}
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)
/* 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;
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) { }
}
}
/* 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. */
window.addEventListener('load', function() {
setTimeout(function() { if (!changed) apply(current()); }, 1000);
/* refreshUnchangedScheme(): Correct untouched automatic state. */
setTimeout(function() {
/* Preserve user changes and tab overrides. */
if (!changed && !valid(getOverride()))
applyEverywhere(browserScheme());
}, 1000);
});
})();
</script>
EOF
$rv =~ s/__GRAY_THEME_FORCED_SCHEME__/$forced/g;
return $rv;
}
# theme_frame_script()
@@ -404,6 +519,8 @@ 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
@@ -425,9 +542,11 @@ $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++;
@@ -435,8 +554,8 @@ if (defined($heading) || defined($rightheading)) {
$rv .= "</tr></thead>\n";
}
$rv .= "<tbody> <tr class='ui_table_body'> <td colspan=$colspan>".
"<table width=100%>\n";
$main::ui_table_cols = $cols || 4;
"<table class='ui_form_rows ui_form_rows_$formcols' width=100%>\n";
$main::ui_table_cols = $formcols;
$main::ui_table_pos = 0;
$main::ui_table_default_tds = $tds;
return $rv;
@@ -571,8 +690,10 @@ my ($border) = @_;
return $border ? "</div>\n" : "";
}
# theme_ui_columns_start(&headings, [width-percent], [noborder], [&tdtags], [title])
# Returns HTML for a multi-column table, with the given headings
# 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.
sub theme_ui_columns_start
{
my ($heads, $width, $noborder, $tdtags, $title, $sortable, $class) = @_;
@@ -580,11 +701,12 @@ 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
# The outermost bordered table opens the card and a container that
# scrolls wide columns without widening the page.
$rv .= "<table class='wrapper' width="
. ($width ? $width : "100")
. "%>\n";
$rv .= "<tr><td>\n";
$rv .= "<tr><td><div class='ui_columns_scroll'>\n";
}
if (!$noborder) {
# Nested bordered tables share that card
@@ -651,13 +773,15 @@ return $rv;
}
# theme_ui_columns_end()
# Returns HTML to end a table started by ui_columns_start
# Ends a table from theme_ui_columns_start. Also closes the scroll container
# and card opened for the outermost bordered table.
sub theme_ui_columns_end
{
my $rv;
$rv = "</tbody> </table>\n";
if ($main::COLUMNS_WRAPPER_OPEN == 1) { # Last wrapper
$rv .= "</td> </tr> </table>\n";
# Close the shared scroll container with the outermost table.
if ($main::COLUMNS_WRAPPER_OPEN == 1) {
$rv .= "</div></td> </tr> </table>\n";
}
$main::COLUMNS_WRAPPER_OPEN--;
return $rv;
@@ -858,14 +982,21 @@ else {
}
# theme_ui_links_row(&links)
# 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
# Returns links in a toolbar wrapper. Gray Theme hides the legacy separators
# and styles each link as a button.
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> ", @$links).
join(" <span class='ui_links_sep'>|</span> ", @button_links).
"</div>\n";
}

View File

@@ -1,15 +0,0 @@
#!/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);

View File

@@ -416,12 +416,19 @@ 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);
@@ -442,6 +449,29 @@ 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;
@@ -490,6 +520,11 @@ 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,
@@ -499,8 +534,7 @@ input[type="file"] { font: inherit; }
/* ---- Buttons ---- */
/* Every button, from the core classes to plain button tags, is a white
* pill with a gray edge */
/* Form buttons use Gray Theme's compact raised surface and small radius. */
input[type="submit"], input[type="button"], input[type="reset"], button,
input.ui_submit, input.ui_button, input.ui_reset {
display: inline-block;
@@ -542,6 +576,142 @@ 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;
/* Let the border round separately at fractional browser zoom levels. */
box-sizing: content-box;
min-height: 1lh;
}
/* Center compact labels by their cap height without shrinking the button. */
:is(button, a, summary).ui_action.ui_action_small > .ui_action_content > span {
text-box: trim-both cap alphabetic;
}
: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,
@@ -743,7 +913,7 @@ table.header > tbody > tr > td {
color: var(--text-color);
font-size: 12.5px;
font-weight: 500;
line-height: 1.2;
line-height: 1.333;
white-space: nowrap;
text-decoration: none;
}
@@ -935,6 +1105,14 @@ 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%;
@@ -948,9 +1126,29 @@ 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; }
/* A fixed layout keeps the label and value columns steady whatever the
* values hold */
table.shrinkwrapper > tbody > tr > td > .ui_table { table-layout: fixed; }
/* 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%;
}
/* ---- Forms with a heading (ui_table_start) ---- */
/* Markup: table.shrinkwrapper > td > table.ui_table with the heading in
@@ -1107,9 +1305,32 @@ 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 */
@@ -1327,6 +1548,14 @@ 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
@@ -1465,6 +1694,11 @@ 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;
@@ -1480,6 +1714,8 @@ 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);
@@ -1496,11 +1732,13 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
font-weight: normal;
text-align: center;
text-shadow: none;
word-break: break-all;
/* Wrap between words, breaking one only when it is too long. */
overflow-wrap: anywhere;
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 {
@@ -1508,6 +1746,12 @@ 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) ---- */
@@ -1597,10 +1841,12 @@ 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 {
@@ -1624,12 +1870,17 @@ 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 {
@@ -1756,6 +2007,29 @@ 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 {
@@ -1843,6 +2117,9 @@ 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;

View File

@@ -478,4 +478,6 @@ 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

View File

@@ -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>",
$in{'linktitle'} || $view_title, "", undef,
&html_escape($in{'linktitle'}) || $view_title, "", undef,
!$no_navlinks && $skip_index,
($no_navlinks || $skip_index) ? 1 : undef,
0, $help_link);

View File

@@ -6,7 +6,8 @@ require './lpadmin-lib.pl';
&ReadParse();
print "Refresh: $config{'queue_refresh'}\r\n"
if ($config{'queue_refresh'});
&ui_print_header(&text('jobs_on', "<tt>$in{'name'}</tt>"), $text{'jobs_title'}, "");
&ui_print_header(&text('jobs_on', "<tt>".&html_escape($in{'name'})."</tt>"),
$text{'jobs_title'}, "");
@jobs = &get_jobs($in{'name'});
if (@jobs) {
@@ -33,8 +34,9 @@ if (@jobs) {
push(@cols, &html_escape($j->{'id'}));
if ($j->{'printfile'} && &can_edit_jobs($in{'name'}, $ju)) {
push(@cols,
"<a href='view_job.cgi?name=$in{'name'}".
"&id=$j->{'id'}'>".&nice_size($j->{'size'})."</a>");
"<a href='view_job.cgi?name=".&urlize($in{'name'}).
"&id=".&urlize($j->{'id'})."'>".
&nice_size($j->{'size'})."</a>");
$printfile++;
}
else {
@@ -50,7 +52,8 @@ if (@jobs) {
push(@cols, $j->{'when'});
}
if ($j->{'file'}) {
push(@cols, $j->{'file'});
# Job titles and filenames can be supplied by print clients.
push(@cols, &html_escape($j->{'file'}));
}
if (&can_edit_jobs($in{'name'}, $ju) &&
$access{'cancel'}) {
@@ -74,7 +77,8 @@ if (@jobs) {
}
}
else {
print "<b>",&text('jobs_none', "<tt>$in{'name'}</tt>"),"</b><p>\n";
print "<b>",&text('jobs_none', "<tt>".&html_escape($in{'name'})."</tt>"),
"</b><p>\n";
}
# Test print button

View File

@@ -5,12 +5,12 @@
require './lpadmin-lib.pl';
&ReadParse();
$access{'test'} || &error($text{'test_ecannot'});
&ui_print_header(&text('jobs_on', "<tt>$in{'name'}</tt>"),
&ui_print_header(&text('jobs_on', "<tt>".&html_escape($in{'name'})."</tt>"),
$text{'test_title'}, "");
print "<form action=test_print.cgi method=post enctype=multipart/form-data>\n";
print "<input type=hidden name=name value='$in{'name'}'>\n";
print &text('test_desc', "<tt>$in{'name'}</tt>"),"<p>\n";
print &ui_hidden("name", $in{'name'});
print &text('test_desc', "<tt>".&html_escape($in{'name'})."</tt>"),"<p>\n";
print "<input type=radio name=mode value=0 checked> $text{'test_0'}<br>\n";
print "<input type=radio name=mode value=1> $text{'test_1'}<br>\n";

View File

@@ -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>$in{'name'}</tt>"),
&ui_print_header(&text('jobs_on', "<tt>".&html_escape($in{'name'})."</tt>"),
$text{'test_title'}, "");
if ($in{'mode'} == 0) {

View File

@@ -1019,14 +1019,15 @@ elsif ($sm) {
if ($ssl == 2) {
# Request a TLS upgrade with STARTTLS
my $rv = &smtp_command($h, "starttls\r\n", 1);
if ($rv =~ /^2\d+/) {
if ($rv =~ /^220(?:[ -]|$)/) {
# Enable TLS and repeat the greeting on the secure connection
&switch_smtp_to_ssl($h);
&smtp_command($h, $helocmd, 0);
}
else {
# Continue without TLS if STARTTLS is unavailable
$ssl = 0;
# Never send credentials or mail without the requested TLS.
&close_http_connection($h);
&error("SMTP STARTTLS failed : ".&html_escape($rv));
}
}

View File

@@ -68,40 +68,41 @@ if ($in{'scale'}) {
else {
# Just output the attachment
print "X-no-links: 1\n";
print "X-Content-Type-Options: nosniff\n";
$fn = $attach->{'filename'} ? &decode_mimewords($attach->{'filename'})
: "attachment";
$fn =~ s/[\r\n\0"\\\/]//g;
$fn ||= "attachment";
@download = split(/\t+/, $config{'download'});
if ($attach->{'type'} =~ /^image\/svg(\+xml)?/i ||
$in{'type'} =~ /^image\/svg(\+xml)?/i ||
$fn =~ /\.svgz?$/i) {
# SVG can execute scripts when served from the Webmin origin.
print "Content-Disposition: Attachment; filename=\"$fn\"\n"
if ($in{'save'});
print "Content-type: text/plain\n\n";
print $attach->{'data'};
my $type = lc($attach->{'type'} || 'application/octet-stream');
my $download = $in{'save'};
if ($download) {
# Downloads must keep the original bytes without rendering them.
$type = 'application/octet-stream';
}
elsif ($in{'type'}) {
# Display as a specific MIME type
print "Content-type: $in{'type'}\n\n";
print $attach->{'data'};
}
else {
# Auto-detect type
if ($in{'save'}) {
# Force download
print "Content-Disposition: Attachment; filename=\"$fn\"\n";
}
if ($attach->{'type'} eq 'message/delivery-status') {
print "Content-type: text/plain\n\n";
}
else {
print "Content-type: $attach->{'type'}\n\n";
}
elsif ($in{'type'} || $type =~ /(?:\/|\+)xml\z/ ||
$type eq 'image/svg' || $type eq 'message/delivery-status' ||
$fn =~ /\.svgz?$/i) {
# Render XML/SVG and the UI's plain-text override as inert text.
$type = 'text/plain';
}
if ($attach->{'type'} =~ /^(text\/html|image\/svg|application\/xhtml)/i
&& !$in{'save'}) {
elsif ($type eq 'text/html') {
# Keep HTML previews isolated even if the text filters miss markup.
print "Content-Security-Policy: sandbox; script-src 'none'; ".
"object-src 'none'; frame-src 'none'; base-uri 'none'; ".
"form-action 'none'\n";
}
elsif ($type =~ /^text\//) {
# Other text formats must not be interpreted as active content.
$type = 'text/plain';
}
elsif ($type !~ /\Aimage\/(?:gif|jpeg|png|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)\z/) {
# Only known raster images may be served inline without a sandbox.
$type = 'application/octet-stream';
$download = 1;
}
print "Content-Disposition: Attachment; filename=\"$fn\"\n" if ($download);
print "Content-type: $type\n\n";
if ($type eq 'text/html') {
print &safe_urls(&filter_javascript($attach->{'data'}));
}
else {

View File

@@ -351,6 +351,8 @@ 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=&lt;All folders&gt;

View File

@@ -11,6 +11,27 @@ 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 = &quote_javascript($text{'sform_larger'});
my $sform_smaller_js = &quote_javascript($text{'sform_smaller'});
my $sform_neg0_js = &quote_javascript($text{'sform_neg0'});
my $sform_neg1_js = &quote_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'});
@@ -33,7 +54,8 @@ for($i=0; $i<=4; $i++) {
map { [ $_, $_ eq 'all' ? $text{'sform_allmsg'}
: $text{"sform_".$_} ] }
( 'from', 'subject', 'to', 'cc', 'date',
'body', 'headers', 'all', 'size') ]));
'body', 'headers', 'all', 'size') ],
1, 0, 0, undef, "onChange='set_neg(value, form.neg_$i)'"));
push(@cols, &ui_select("neg_$i", 0,
[ [ 0, $text{'sform_neg0'} ],

View File

@@ -1565,8 +1565,23 @@ if (&get_type($full) eq "internal/cgi" && $validated != 4) {
$cgiheader{lc($1)} = $2;
push(@cgiheader, [ $1, $2 ]);
}
# A CGI can reject a request before producing normal
# output. Use its explicit status instead of the default
# success code.
my $cgi_status = $cgiheader{'status'} =~
/\A([1-5][0-9][0-9])\s+([^\r\n]+)\z/;
if ($cgi_status) {
($ok_code, $ok_message) = ($1, $2);
}
else {
# Invalid control headers must not affect
# redirect logging.
delete($cgiheader{'status'});
}
if ($cgiheader{"location"}) {
&write_data("HTTP/1.0 302 Moved Temporarily\r\n");
&write_data("HTTP/1.0 ".($cgi_status
? "$ok_code $ok_message"
: "302 Moved Temporarily")."\r\n");
&write_data("Date: $datestr\r\n");
&write_data("Server: @{[&server_info()]}\r\n");
&write_keep_alive(0);
@@ -1588,6 +1603,7 @@ if (&get_type($full) eq "internal/cgi" && $validated != 4) {
&write_keep_alive(0);
}
foreach $h (@cgiheader) {
next if (lc($h->[0]) eq 'status');
&write_data("$h->[0]: $h->[1]\r\n");
}
&write_data("\r\n");
@@ -1671,7 +1687,9 @@ else {
# log the request
&log_request($loghost, $authuser, $reqline,
$logged_code ? $logged_code :
$cgiheader{"location"} ? "302" : $ok_code, &byte_count());
$cgiheader{"location"} && !$cgiheader{"status"}
? "302"
: $ok_code, &byte_count());
return $rv;
}
@@ -2383,18 +2401,31 @@ $miniserv::page_capture_out .= $buf
if ($miniserv::page_capture);
}
# Send back already read data while we have it, then read from SOCK
# READ(handle, buffer, length, [offset])
# Read buffered POST data or SOCK, replacing the buffer tail like Perl read.
sub READ
{
my $r = shift;
my $bufref = \$_[0];
my $len = $_[1];
my $offset = $_[2];
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;
}
if ($postpos < length($postinput)) {
# Reading from already fetched array
my $left = length($postinput) - $postpos;
my $canread = $len > $left ? $left : $len;
substr($$bufref, $offset, $canread) =
substr($$bufref, $offset) =
substr($postinput, $postpos, $canread);
$postpos += $canread;
return $canread;
@@ -2406,7 +2437,7 @@ else {
# End of socket
shutdown(SOCK, 0);
}
substr($$bufref, $offset, length($data)) = $data;
substr($$bufref, $offset) = $data;
return length($data);
}
}
@@ -2486,9 +2517,22 @@ foreach $d (@_) {
}
}
if ($doneheaders) {
# Match forked CGI status handling for in-process
# scripts.
my $cgi_status = $cgiheader{'status'} =~
/\A([1-5][0-9][0-9])\s+([^\r\n]+)\z/;
if ($cgi_status) {
($ok_code, $ok_message) = ($1, $2);
}
else {
# Invalid control headers must not affect
# redirect logging.
delete($cgiheader{'status'});
}
if ($cgiheader{"location"}) {
&write_data(
"HTTP/1.0 302 Moved Temporarily\r\n");
&write_data("HTTP/1.0 ".($cgi_status
? "$ok_code $ok_message"
: "302 Moved Temporarily")."\r\n");
&write_data("Date: $datestr\r\n");
&write_data("Server: @{[&server_info()]}\r\n");
&write_keep_alive(0);
@@ -2503,6 +2547,7 @@ foreach $d (@_) {
&write_keep_alive(0);
}
foreach $h (@cgiheader) {
next if (lc($h->[0]) eq 'status');
&write_data("$h->[0]: $h->[1]\r\n");
}
&write_data("\r\n");
@@ -2540,7 +2585,9 @@ if ($doing_cgi_eval && $$ == $main_process_id) {
close(SOCK);
close($PASSINw); close($PASSOUTw);
&log_request($loghost, $authuser, $reqline,
$cgiheader{"location"} ? "302" : $ok_code, &byte_count());
$cgiheader{"location"} && !$cgiheader{"status"}
? "302"
: $ok_code, &byte_count());
}
}

View File

@@ -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 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 sqlite squid sshd status stunnel syslog syslog-ng system-status tcpwrappers time tunnel updown useradmin usermin webalizer webmin webmincron webminlog xinetd xterm zones

View File

@@ -23,16 +23,30 @@ 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'});
@@ -40,11 +54,8 @@ 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'});
}
@@ -53,6 +64,10 @@ 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) {
@@ -69,12 +84,22 @@ if ($cron) {
&foreign_require("cron");
@jobs = &cron::list_cron_jobs();
$cmd = $in{'all'} ? "$cron_cmd --all" : "$cron_cmd $in{'db'}";
($job) = grep { $_->{'command'} eq $cmd } @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;
}
$oldjob = $job;
$job ||= { 'command' => $cmd,
'user' => 'root',
$cmd = $in{'all'} ? "$cron_cmd --all"
: $cron_cmd." ".quotemeta($in{'db'});
$job ||= { 'user' => 'root',
'active' => 1 };
# Replace legacy unquoted commands when updating existing jobs.
$job->{'command'} = $cmd;
&cron::parse_times_input($job, \%in);
}
@@ -218,8 +243,8 @@ if (!$in{'save'}) {
}
elsif (!$in{'dest'}) {
@st = stat($file);
print &text('backup_done', "<tt>$db</tt>",
"<tt>$file</tt>", int($st[7])),"<p>\n";
print &text('backup_done', "<tt>".&html_escape($db)."</tt>",
"<tt>".&html_escape($file)."</tt>", int($st[7])),"<p>\n";
}
&execute_after($db, STDOUT, 1, $file, $in{'all'} ? undef : $db,
$access{'buser'})
@@ -277,7 +302,7 @@ if (!$in{'dest'}) {
}
else {
&ui_print_footer(
"edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
"/$module_name/edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});
}
}

View File

@@ -22,7 +22,7 @@ $access{'buser'} || &error($text{'dbase_ecannot'});
if (!-x $cmd) {
print &text('backup_edump', "<tt>$cmd</tt>",
"../config.cgi?$module_name"),"<p>\n";
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'});
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'});
exit;
}
@@ -33,7 +33,7 @@ if ($in{'all'}) {
print "$text{'backup_desc3'}\n";
}
else {
print &text('backup_desc', "<tt>$in{'db'}</tt>"),"\n";
print &text('backup_desc', "<tt>".&html_escape($in{'db'})."</tt>"),"\n";
}
if ($cron) {
print "$text{'backup_desc2'}\n";
@@ -42,7 +42,7 @@ print "<p>\n";
%c = $module_info{'usermin'} ? %userconfig : %config;
$download = $in{'all'} ? 'database' : $in{'db'};
print &ui_form_start("backup_db.cgi/$download.sql", "post");
print &ui_form_start("backup_db.cgi/".&urlize($download).".sql", "post");
print &ui_hidden("db", $in{'db'});
print &ui_hidden("all", $in{'all'});
print &ui_hidden_table_start($text{'backup_header1'}, "width=100%", 2, "main",
@@ -152,10 +152,12 @@ $q = $c{'backup_quick_'.$in{'db'}};
print &ui_table_row($text{'backup_quick'},
&ui_yesno_radio("quick", $q ? 1 : 0));
# 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));
# 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));
}
if ($commands) {
# Show before/after commands
@@ -199,8 +201,15 @@ if ($cron) {
# Show cron time
&foreign_require("cron", "cron-lib.pl");
@jobs = &cron::list_cron_jobs();
$cmd = $in{'all'} ? "$cron_cmd --all" : "$cron_cmd $in{'db'}";
($job) = grep { $_->{'command'} eq $cmd } @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;
}
print &ui_table_row($text{'backup_sched'},
&ui_radio("sched", $job ? 1 : 0,
@@ -228,7 +237,7 @@ if ($in{'all'}) {
&ui_print_footer("", $text{'index_return'});
}
else {
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});
}

View File

@@ -64,15 +64,16 @@ foreach $r (@{$rv->{'data'}}) {
# All done .. tell the user
if ($in{'dest'}) {
&close_tempfile(OUT);
$desc = &text('table_header', "<tt>$in{'table'}</tt>", "<tt>$in{'db'}</tt>");
$desc = &text('table_header', "<tt>".&html_escape($in{'table'})."</tt>",
"<tt>".&html_escape($in{'db'})."</tt>");
&ui_print_header($desc, $text{'csv_title'}, "", "csv");
@st = stat($in{'file'});
print &text('csv_done', "<tt>$in{'file'}</tt>",
print &text('csv_done', "<tt>".&html_escape($in{'file'})."</tt>",
&nice_size($st[7])),"<p>\n";
&ui_print_footer(
"@{[&get_webprefix()]}/$module_name/edit_dbase.cgi?db=$in{'db'}",
"@{[&get_webprefix()]}/$module_name/edit_dbase.cgi?db=".&urlize($in{'db'}),
$text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});
}

View File

@@ -6,10 +6,11 @@ require './mysql-lib.pl';
&can_edit_db($in{'db'}) || &error($text{'dbase_ecannot'});
$access{'edonly'} && &error($text{'dbase_ecannot'});
$desc = &text('table_header', "<tt>$in{'table'}</tt>", "<tt>$in{'db'}</tt>");
$desc = &text('table_header', "<tt>".&html_escape($in{'table'})."</tt>",
"<tt>".&html_escape($in{'db'})."</tt>");
&ui_print_header($desc, $text{'csv_title'}, "", "csv");
print &ui_form_start("csv.cgi/$in{'table'}.csv", "post");
print &ui_form_start("csv.cgi/".&urlize($in{'table'}).".csv", "post");
print &ui_hidden("db", $in{'db'}),"\n";
print &ui_hidden("table", $in{'table'}),"\n";
print &ui_table_start($text{'csv_header'}, undef, 2);
@@ -45,5 +46,5 @@ print &ui_table_row($text{'csv_cols'},
print &ui_table_end();
print &ui_form_end([ [ "ok", $text{'csv_ok'} ] ]);
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});

View File

@@ -21,7 +21,7 @@ elsif ($in{'empty'}) {
&execute_sql_logged($in{'db'}, "drop table ".&quotestr($t));
}
&webmin_log("delete", "db", $in{'db'});
&redirect("edit_dbase.cgi?db=$in{'db'}");
&redirect("edit_dbase.cgi?db=".&urlize($in{'db'}));
}
else {
# Ask the user if he is sure..
@@ -35,7 +35,7 @@ else {
if (!$access{'delete'}) {
# Offer to drop tables only
$msg = &text('ddrop_rusure2', "<tt>$in{'db'}</tt>", scalar(@tables), $rows);
$msg = &text('ddrop_rusure2', "<tt>".&html_escape($in{'db'})."</tt>", scalar(@tables), $rows);
$msg .= " ".$text{'ddrop_mysql'} if ($in{'db'} eq $master_db);
print &ui_confirmation_form(
"drop_dbase.cgi", $msg,
@@ -45,7 +45,7 @@ else {
}
else {
# Offer to drop DB or tables
$msg = &text('ddrop_rusure', "<tt>$in{'db'}</tt>", scalar(@tables), $rows);
$msg = &text('ddrop_rusure', "<tt>".&html_escape($in{'db'})."</tt>", scalar(@tables), $rows);
$msg .= " ".$text{'ddrop_mysql'} if ($in{'db'} eq $master_db);
print &ui_confirmation_form(
"drop_dbase.cgi", $msg,
@@ -56,7 +56,7 @@ else {
);
}
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});
}

View File

@@ -11,7 +11,7 @@ if ($in{'confirm'}) {
&error_setup($text{'tdrop_err'});
&execute_sql_logged($in{'db'}, "drop table ".&quotestr($in{'table'}));
&webmin_log("delete", "table", $in{'table'}, \%in);
&redirect("edit_dbase.cgi?db=$in{'db'}");
&redirect("edit_dbase.cgi?db=".&urlize($in{'db'}));
}
else {
# Ask the user if he is sure..
@@ -21,16 +21,19 @@ else {
"select count(*) from ".&quotestr($in{'table'}));
$rows = $d->{'data'}->[0]->[0];
print "<center><b>",&text('tdrop_rusure', "<tt>$in{'table'}</tt>",
"<tt>$in{'db'}</tt>", $rows),"</b><p>\n";
print "<form action=drop_table.cgi>\n";
print "<input type=hidden name=db value='$in{'db'}'>\n";
print "<input type=hidden name=table value='$in{'table'}'>\n";
print "<input type=submit name=confirm value='$text{'tdrop_ok'}'>\n";
print "</form></center>\n";
&ui_print_footer("edit_table.cgi?db=$in{'db'}&table=$in{'table'}",
print "<center>\n";
print "<b>",&text('tdrop_rusure',
"<tt>".&html_escape($in{'table'})."</tt>",
"<tt>".&html_escape($in{'db'})."</tt>", $rows),"</b><p>\n";
print &ui_form_start("drop_table.cgi");
print &ui_hidden("db", $in{'db'});
print &ui_hidden("table", $in{'table'});
print &ui_form_end([ [ "confirm", $text{'tdrop_ok'} ] ]);
print "</center>\n";
&ui_print_footer(
"edit_table.cgi?db=".&urlize($in{'db'})."&table=".&urlize($in{'table'}),
$text{'table_return'},
"edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
"edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});
}

View File

@@ -27,7 +27,7 @@ if ($in{'confirm'}) {
&execute_sql_logged($in{'db'}, "drop table ".&quotestr($t));
}
&webmin_log("delete", "tables", scalar(@tables), \%in);
&redirect("edit_dbase.cgi?db=$in{'db'}");
&redirect("edit_dbase.cgi?db=".&urlize($in{'db'}));
}
else {
# Ask the user if he is sure..
@@ -42,16 +42,16 @@ else {
$msg = defined($rows) ? 'tdrops_rusure' : 'tdrops_rusure2';
print "<center><b>", &text($msg, scalar(@tables),
"<tt>$in{'db'}</tt>", $rows),"</b><p>\n";
"<tt>".&html_escape($in{'db'})."</tt>", $rows),"</b><p>\n";
print "<form action=drop_tables.cgi>\n";
print "<input type=hidden name=db value='$in{'db'}'>\n";
print &ui_hidden("db", $in{'db'}),"\n";
print "<input type=submit name=confirm value='$text{'tdrops_ok'}'>\n";
foreach $t (@tables) {
print &ui_hidden("d", $t),"\n";
}
print "</form></center>\n";
&ui_print_footer(
"edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
"edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});
}

View File

@@ -47,8 +47,8 @@ else {
print &ui_table_start($text{'cpriv_header'}, undef, 2);
# Apply to DB and table
print &ui_table_row($text{'cpriv_db'}, "<tt>$d</tt>");
print &ui_table_row($text{'cpriv_table'}, "<tt>$t</tt>");
print &ui_table_row($text{'cpriv_db'}, "<tt>".&html_escape($d)."</tt>");
print &ui_table_row($text{'cpriv_table'}, "<tt>".&html_escape($t)."</tt>");
# Table field
print &ui_table_row($text{'cpriv_field'},

View File

@@ -6,7 +6,7 @@ require './mysql-lib.pl';
&ReadParse();
&can_edit_db($in{'db'}) || &error($text{'dbase_ecannot'});
@titles = grep { &can_edit_db($_) } &list_databases();
$desc = "<tt>$in{'db'}</tt>";
$desc = "<tt>".&html_escape($in{'db'})."</tt>";
if (@titles == 1 && $module_info{'usermin'}) {
# Single-DB mode
&ui_print_header($desc, $text{'dbase_title'}, "", "edit_dbase", 1, 1);
@@ -30,8 +30,9 @@ if ($in{'search'}) {
@views = grep { /\Q$in{'search'}\E/i } @views;
print "<table width=100%><tr>\n";
print "<td> <b>",&text('dbase_showing',
"<tt>$in{'search'}</tt>"),"</b></td>\n";
print "<td align=right><a href='edit_dbase.cgi?db=$in{'db'}'>",
"<tt>".&html_escape($in{'search'})."</tt>"),"</b></td>\n";
print "<td align=right><a href='edit_dbase.cgi?db=",
&urlize($in{'db'}),"'>",
"$text{'view_searchreset'}</a></td>\n";
print "</tr></table>\n";
}
@@ -82,11 +83,11 @@ elsif (@titles || @indexes) {
( map { "images/index.gif" } @indexes ),
( map { "images/view.gif" } @views ),
);
@links = ( ( map { "edit_table.cgi?db=$in{'db'}&table=".&urlize($_) }
@links = ( ( map { "edit_table.cgi?db=".&urlize($in{'db'})."&table=".&urlize($_) }
@titles ),
( map { "edit_index.cgi?db=$in{'db'}&index=".&urlize($_) }
( map { "edit_index.cgi?db=".&urlize($in{'db'})."&index=".&urlize($_) }
@indexes ),
( map { "edit_view.cgi?db=$in{'db'}&view=".&urlize($_) }
( map { "edit_view.cgi?db=".&urlize($in{'db'})."&view=".&urlize($_) }
@views ),
);
@descs = ( ( map { "" } @titles ),

View File

@@ -6,7 +6,8 @@ require './mysql-lib.pl';
&ReadParse();
&can_edit_db($in{'db'}) || &error($text{'dbase_ecannot'});
$access{'edonly'} && &error($text{'dbase_ecannot'});
$desc = &text('field_in', "<tt>$in{'table'}</tt>", "<tt>$in{'db'}</tt>");
$desc = &text('field_in', "<tt>".&html_escape($in{'table'})."</tt>",
"<tt>".&html_escape($in{'db'})."</tt>");
if ($in{'type'}) {
# Creating a new field
&ui_print_header($desc, $text{'field_title1'}, "", "create_field");
@@ -41,7 +42,7 @@ if ($type =~ /^(\S+)\((.*)\)(.*)/) {
print &ui_hidden("type", $type);
if ($in{'type'}) {
# New field .. just show chosen type
$tsel = $type;
$tsel = &html_escape($type);
}
else {
# Existing field .. allow type change
@@ -154,8 +155,9 @@ else {
@desc > 1 ? ( [ 'delete', $text{'delete'} ] ): ( ) ]);
}
&ui_print_footer("edit_table.cgi?db=$in{'db'}&table=".&urlize($in{'table'}),
&ui_print_footer("edit_table.cgi?db=".&urlize($in{'db'}).
"&table=".&urlize($in{'table'}),
$text{'table_return'},
"edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
"edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});

View File

@@ -16,7 +16,8 @@ else {
# Creating an index
$table = $in{'table'};
}
$desc = &text('table_header', "<tt>$table</tt>", "<tt>$in{'db'}</tt>");
$desc = &text('table_header', "<tt>".&html_escape($table)."</tt>",
"<tt>".&html_escape($in{'db'})."</tt>");
&ui_print_header($desc, $in{'index'} ? $text{'index_title2'}
: $text{'index_title1'}, "");
@@ -56,5 +57,5 @@ else {
print &ui_form_end([ [ "create", $text{'create'} ] ]);
}
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});

View File

@@ -6,10 +6,12 @@ require './mysql-lib.pl';
&ReadParse();
&can_edit_db($in{'db'}) || &error($text{'dbase_ecannot'});
if ($access{'edonly'}) {
&redirect("view_table.cgi?db=$in{'db'}&table=".&urlize($in{'table'}));
&redirect("view_table.cgi?db=".&urlize($in{'db'}).
"&table=".&urlize($in{'table'}));
exit;
}
$desc = &text('table_header', "<tt>$in{'table'}</tt>", "<tt>$in{'db'}</tt>");
$desc = &text('table_header', "<tt>".&html_escape($in{'table'})."</tt>",
"<tt>".&html_escape($in{'db'})."</tt>");
&ui_print_header($desc, $text{'table_title'}, "", "edit_table");
print &ui_form_start("delete_fields.cgi", "post");
@@ -29,7 +31,7 @@ print &ui_columns_start([ "",
$i = 0;
foreach $r (@desc) {
local @cols;
push(@cols, "<a href='edit_field.cgi?db=$in{'db'}&table=".
push(@cols, "<a href='edit_field.cgi?db=".&urlize($in{'db'})."&table=".
&urlize($in{'table'})."&".
"idx=$i'>".&html_escape($r->{'field'})."</a>");
push(@cols, &html_escape($r->{'type'}));
@@ -96,6 +98,6 @@ print "</td>\n";
print "</tr></table>\n";
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});

View File

@@ -30,7 +30,7 @@ else {
print &ui_table_start($text{'tpriv_header'}, undef, 2);
# Apply to DB
print &ui_table_row($text{'tpriv_db'}, $in{'db'} || $u->[1]);
print &ui_table_row($text{'tpriv_db'}, &html_escape($in{'db'} || $u->[1]));
# Apply to table
print &ui_table_row($text{'tpriv_table'},

View File

@@ -11,7 +11,7 @@ if ($in{'view'}) {
# Editing a view
$str = &view_structure($in{'db'}, $in{'view'});
}
$desc = "<tt>$in{'db'}</tt>";
$desc = "<tt>".&html_escape($in{'db'})."</tt>";
&ui_print_header($desc, $in{'view'} ? $text{'view_title2'}
: $text{'view_title1'}, "");
@@ -22,7 +22,7 @@ print &ui_table_start($text{'view_header1'}, undef, 2);
# View name
print &ui_table_row($text{'view_name'},
$in{'view'} ? "<tt>$str->{'name'}</tt>" :
$in{'view'} ? "<tt>".&html_escape($str->{'name'})."</tt>" :
&ui_textbox("name", $str->{'name'}, 20));
# Selection query
@@ -65,6 +65,6 @@ else {
print &ui_form_end([ [ "create", $text{'create'} ] ]);
}
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});

View File

@@ -203,7 +203,7 @@ print &ui_tag('pre', &html_escape($detail),
if ($in{'clear'}) {
# Delete the history file
&unlink_file($commands_file.".".$in{'db'});
&redirect("exec_form.cgi?db=$in{'db'}");
&redirect("exec_form.cgi?db=".&urlize($in{'db'}));
}
else {
# Run some SQL
@@ -262,7 +262,7 @@ else {
if (@data) {
print &text('exec_out',
"<tt>".&html_escape($cmd)."</tt>"),"<p>\n";
print &ui_columns_start($d->{'titles'});
print &ui_columns_start([ map { &html_escape($_) } @{$d->{'titles'}} ]);
foreach $r (@data) {
print &ui_columns_row([
map { &html_escape($_) } @$r ]);
@@ -293,8 +293,8 @@ else {
}
&webmin_log("exec", undef, $in{'db'}, \%in);
&ui_print_footer("exec_form.cgi?db=$in{'db'}", $text{'exec_return'},
"edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
&ui_print_footer("exec_form.cgi?db=".&urlize($in{'db'}), $text{'exec_return'},
"edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});
}

View File

@@ -31,7 +31,7 @@ else {
&mysql_command_unix_user()) ||
&error($text{'exec_efile'});
&ui_print_header(undef, $text{'exec_title'}, "");
print &text('exec_fileout', "<tt>$in{'file'}</tt>"),"<p>\n";
print &text('exec_fileout', "<tt>".&html_escape($in{'file'})."</tt>"),"<p>\n";
$need_unlink = 1;
}
@@ -102,7 +102,7 @@ if (!$ex) {
'file' => $in{'file'} });
unlink($file) if ($need_unlink);
&ui_print_footer("exec_form.cgi?db=$in{'db'}&mode=file", $text{'exec_return'},
"edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
&ui_print_footer("exec_form.cgi?db=".&urlize($in{'db'})."&mode=file", $text{'exec_return'},
"edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});

View File

@@ -31,7 +31,7 @@ $csel = &ui_select("charset", $sql_charset,
# Form for executing an SQL command
print &ui_tabs_start_tab("mode", "exec");
print &text('exec_header', "<tt>$in{'db'}</tt>"),"<p>\n";
print &text('exec_header', "<tt>".&html_escape($in{'db'})."</tt>"),"<p>\n";
print &ui_form_start("exec.cgi", "form-data");
print &ui_hidden("db", $in{'db'});
print &ui_textarea("cmd", undef, 10, 70),"<br>\n";
@@ -58,7 +58,7 @@ print &ui_tabs_end_tab();
# Form for executing commands from a file
print &ui_tabs_start_tab("mode", "file");
print &text('exec_header2', "<tt>$in{'db'}</tt>"),"<br>\n";
print &text('exec_header2', "<tt>".&html_escape($in{'db'})."</tt>"),"<br>\n";
print $text{'exec_header2a'},"<p>\n";
print &ui_form_start("exec_file.cgi", "form-data");
print &ui_hidden("db", $in{'db'});
@@ -72,7 +72,7 @@ print &ui_tabs_end_tab();
# Form for loading a CSV or tab-separated file
print &ui_tabs_start_tab("mode", "import");
print &text('exec_header3', "<tt>$in{'db'}</tt>"),"<br>",
print &text('exec_header3', "<tt>".&html_escape($in{'db'})."</tt>"),"<br>",
$text{'exec_header4'},"<p>\n";
print &ui_form_start("import.cgi", "form-data");
@@ -110,7 +110,7 @@ print &ui_tabs_end_tab();
print &ui_tabs_end(1);
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});

View File

@@ -2,6 +2,7 @@
# import.cgi
# Import data from a text file
use Fcntl qw(O_WRONLY O_CREAT O_EXCL);
require './mysql-lib.pl';
&ReadParseMime();
&can_edit_db($in{'db'}) || &error($text{'dbase_ecannot'});
@@ -9,13 +10,24 @@ $access{'edonly'} && &error($text{'dbase_ecannot'});
&error_setup($text{'import_err'});
$sql_charset = $in{'charset'};
# Use server-visible storage, not a /tmp hidden by the database service.
# If the server's directories are not on this host, keep the usual temp path.
my $import_tmpdir;
if ($< == 0 && &supports_users() && &is_mysql_local()) {
$import_tmpdir = &mysql_import_tempdir($in{'db'});
}
if ($in{'mode'}) {
# From uploaded file
$in{'upload'} || &error($text{'import_eupload'});
$in{'upload_filename'} =~ /([^\/\\\s]+)$/ ||
&error($text{'import_eupload'});
$file = &transname($1);
open(TEMP, ">$file");
$file = &mysql_import_tempname($import_tmpdir, $1);
# Create a new upload file. Keep it owner-only when the server will be
# granted access below; otherwise the umask applies as before.
sysopen(TEMP, $file, O_WRONLY | O_CREAT | O_EXCL,
$import_tmpdir ? 0600 : 0666) ||
&error(&text('efileopen', &html_escape($file), &html_escape("$!")));
print TEMP $in{'upload'};
close(TEMP);
&set_mysql_command_file_permissions($file) ||
@@ -26,18 +38,18 @@ if ($in{'mode'}) {
}
else {
# From local file
$file = &transname();
$file = &mysql_import_tempname($import_tmpdir);
&copy_file_under_global_acl($in{'file'}, $file, undef,
&mysql_command_unix_user()) ||
&error($text{'import_efile'});
$need_unlink = 1;
&ui_print_header(undef, $text{'import_title'}, "");
print &text('import_fileout', "<tt>$in{'file'}</tt>"),"<p>\n";
print &text('import_fileout', "<tt>".&html_escape($in{'file'})."</tt>"),"<p>\n";
}
# Build the import command
if ($in{'table'}) {
$nfile = &transname("$in{'table'}.txt");
$nfile = &mysql_import_tempname($import_tmpdir, "$in{'table'}.txt");
&copy_source_dest($file, $nfile);
&set_mysql_command_file_permissions($nfile) ||
&error($text{'import_efile'});
@@ -45,6 +57,11 @@ if ($in{'table'}) {
$file = $nfile;
$need_unlink = 1;
}
# A server-side import needs the local database server to read the file
if ($< == 0 && &supports_users() && &is_mysql_local()) {
&set_mysql_import_file_permissions($in{'db'}, $file) ||
&error($text{'import_eperms'});
}
$delete = $in{'delete'} ? "-d" : "";
$ignore = $in{'ignore'} ? "-i" : "";
if ($in{'format'} == 0) {
@@ -72,5 +89,5 @@ print "</pre>\n";
'file' => $in{'file'} });
unlink($file) if ($need_unlink);
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'});
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'});

View File

@@ -136,7 +136,7 @@ else {
if (@titles == 1 && $access{'dbs'} ne '*' && !$access{'perms'} &&
!$access{'stop'} && !$access{'create'} && $access{'noconfig'}) {
# Only one DB, so go direct to it!
&redirect("edit_dbase.cgi?db=$titles[0]");
&redirect("edit_dbase.cgi?db=".&urlize($titles[0]));
exit;
}
@@ -162,7 +162,7 @@ else {
# DB is running .. list databases
@icons = map { "images/db.gif" } @titles;
@links = map { "edit_dbase.cgi?db=$_" } @titles;
@links = map { "edit_dbase.cgi?db=".&urlize($_) } @titles;
$can_create = $access{'create'} == 1 ||
$access{'create'} == 2 && @titles < $access{'max'};

View File

@@ -653,6 +653,8 @@ 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
@@ -706,6 +708,7 @@ import_uploadout=Importing data from uploaded file ..
import_fileout=Importing data from file $1 ..
import_eupload=Missing uploaded import file
import_efile=Missing import filename
import_eperms=Failed to set permissions on the temporary import file
adv_title=Advanced Search
adv_and=Find records matching all criteria ..

View File

@@ -28,9 +28,9 @@ if (@procs) {
foreach $r (@procs) {
print &ui_checked_columns_row([
$r->[0],
&ui_link("edit_user.cgi?user=$r->[1]",$r->[1]),
$r->[2],
&ui_link("edit_dbase.cgi?db=$r->[3]",$r->[3]),
&ui_link("edit_user.cgi?user=".&urlize($r->[1]),&html_escape($r->[1])),
&html_escape($r->[2]),
&ui_link("edit_dbase.cgi?db=".&urlize($r->[3]),&html_escape($r->[3])),
$r->[4],
&nice_time($r->[5]),
&html_escape($r->[7])

View File

@@ -716,10 +716,12 @@ return &quotestr($rv);
}
# quotestr(string)
# Quotes a single SQL identifier, including any embedded backticks.
sub quotestr
{
my ($rv) = @_;
if (&supports_quoting()) {
$rv =~ s/`/``/g;
return "`$rv`";
}
else {
@@ -1183,6 +1185,70 @@ return 0 if (!@uinfo);
return &set_ownership_permissions($user, undef, 0600, $file);
}
# mysql_import_tempdir(database)
# Creates a per-import directory in the local server's permitted file directory,
# or its data directory when no file directory is configured. Called as root.
sub mysql_import_tempdir
{
my ($db) = @_;
my $d = &execute_sql_safe($db,
"show variables where Variable_name in ('secure_file_priv', 'datadir')");
my %vars = map { @$_ } @{$d->{'data'}};
my $dir = $vars{'secure_file_priv'};
$dir = $vars{'datadir'} if (!$dir || $dir eq 'NULL');
return if (!$dir || $dir !~ m{^/} || !-d $dir);
# A unique directory preserves the table filename without concurrent collisions.
# Its name uses the server's temporary-table prefix, so MariaDB does not list
# it as a database or fail dumps and checks while an import is running.
require File::Temp;
my $tmp = eval { File::Temp::tempdir('#sql-webmin-import-XXXXXX', DIR => $dir) };
return if (!$tmp);
push(@main::temporary_files, $tmp);
# The client and daemon can traverse it; CSV contents remain owner/group-only.
return if (!chmod(0755, $tmp));
return $tmp;
}
# mysql_import_tempname(directory, [filename])
# Keeps Webmin's filename handling, using the per-import directory when provided.
sub mysql_import_tempname
{
my ($dir, $filename) = @_;
# The per-import directory is already registered for cleanup.
my $file = $dir ? &tempname($filename) : &transname($filename);
$file =~ s{^.*/}{$dir/} if ($dir);
return $file;
}
# set_mysql_import_file_permissions(database, file)
# Called as root for a local server. Share the temporary file with the daemon's
# group, keeping its owner so the import-command user can still read it.
sub set_mysql_import_file_permissions
{
my ($db, $file) = @_;
# Do not change a symlink's target or a directory's permissions.
my @st = lstat($file);
return 0 if (!@st || !-f _);
my $d = &execute_sql_safe($db, "show variables like 'pid_file'");
my $pidfile = $d->{'data'}->[0]->[1];
my $pid = $pidfile ? &read_file_contents($pidfile) : undef;
# Leave permissions alone if the local daemon cannot be identified.
return 1 if (!defined($pid) || $pid !~ /\A([0-9]+)\s*\z/);
$pid = $1;
return 1 if (!&foreign_check("proc"));
&foreign_require("proc", "proc-lib.pl");
my %proc = &proc::process_info($pid);
return 1 if (!$proc{'args'} ||
$proc{'args'} !~ m{\A(?:\S*/)?(?:mysqld|mariadbd)(?:\s|\z)});
# The socket can have a different group, so use the running process instead.
my $group = $proc{'_group'};
$group = (getpwnam($proc{'user'}))[3] if (!defined($group) || $group eq '');
return 1 if (!defined($group));
return &set_ownership_permissions($st[4], $group, 0640, $file);
}
# execute_sql_file(database, file, [user, pass], [unix-user])
# Executes some file of SQL commands, and returns the exit status and output
sub execute_sql_file
@@ -1717,7 +1783,7 @@ sub view_structure
{
local ($db, $view) = @_;
local $info = { 'name' => $view };
local $d = &execute_sql($db, "show create view $view");
local $d = &execute_sql($db, "show create view ".&quotestr($view));
local $c = $d->{'data'}->[0]->[1];
if ($c =~ /algorithm\s*=\s*(\S+)/i) {
$info->{'algorithm'} = lc($1);
@@ -1734,7 +1800,8 @@ if ($c =~ /sql\s+security\s+(\S+)/i) {
if ($c =~ s/\s+with\s+(cascaded|local)\s+check\s+option//i) {
$info->{'check'} = lc($1);
}
if ($c =~ /view\s+(`\S+`|\S+)\s+as\s+(.*)/i) {
# Quoted view names can contain spaces and escaped backticks.
if ($c =~ /view\s+(`(?:``|[^`])*`|\S+)\s+as\s+(.*)/i) {
$info->{'query'} = $2;
}
return $info;
@@ -1900,25 +1967,30 @@ 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)
# Backs up a database to the given file, optionally with 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])
# Writes a database backup to a file, with optional 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" : "";
@@ -1928,13 +2000,15 @@ 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=".join(",", @$compatible) : "";
"--compatible=".quotemeta(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 {
@@ -1945,7 +2019,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) {
# Add flag to support GTIDs
# Omit GTID_PURGED statements when backing up a GTID server.
$gtidsql = "--set-gtid-purged=OFF";
}
};
@@ -1955,12 +2029,15 @@ if ($user && $user ne "root") {
$writer = &command_as_user($user, 0, $writer);
}
&set_authstr_env();
my $cmd = "$config{'mysqldump'} $authstr $dropsql $singlesql $forcesql $quicksql $parameterssql $wheresql $charsetsql $compatiblesql $quotingsql $routinessql ".quotemeta($db)." $tablessql $eventssql $gtidsql | $writer";
# 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";
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;
@@ -1973,8 +2050,8 @@ sub delete_database_backup_job
my ($db) = @_;
&foreign_require("cron");
my @jobs = &cron::list_cron_jobs();
my $cmd = "$cron_cmd $db";
my ($job) = grep { $_->{'command'} eq $cmd } @jobs;
my ($job) = grep { $_->{'command'} eq $cron_cmd." ".$db ||
$_->{'command'} eq $cron_cmd." ".quotemeta($db) } @jobs;
if ($job) {
&lock_file(&cron::cron_file($job));
&cron::delete_cron_job($job);

View File

@@ -8,14 +8,14 @@ $access{'perms'} == 1 || &error($text{'perms_ecannot'});
$mode = &mysql_login_type($mysql_login || 'root');
if ($mode eq 'socket') {
print &ui_alert_box(&text('root_socket', $mysql_login), 'warn');
print &ui_alert_box(&text('root_socket', &html_escape($mysql_login)), 'warn');
}
else {
print &ui_form_start("save_root.cgi", "post");
print &ui_table_start($text{'root_header'}, undef, 2);
print &ui_table_row($text{'root_user'},
$mysql_login ? "<tt>$mysql_login</tt>"
$mysql_login ? "<tt>".&html_escape($mysql_login)."</tt>"
: "<label>$text{'root_auto'}</label>");
print &ui_table_row($text{'root_pass'},
$mysql_pass ? "<tt>".&ui_text_mask($mysql_pass)."</tt>"

View File

@@ -11,7 +11,8 @@ else {
&can_edit_db($in{'db'}) || &error($text{'dbase_ecannot'});
@str = &table_structure($in{'db'}, $in{'table'});
$desc = &text('table_header', "<tt>$in{'table'}</tt>", "<tt>$in{'db'}</tt>");
$desc = &text('table_header', "<tt>".&html_escape($in{'table'})."</tt>",
"<tt>".&html_escape($in{'db'})."</tt>");
&ui_print_header($desc, $text{'adv_title'}, "");
print &ui_form_start("view_table.cgi", "post");
@@ -35,13 +36,14 @@ print "</table>\n";
print &ui_form_end([ [ "advanced", $text{'adv_ok'} ] ]);
if ($access{'edonly'}) {
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}",$text{'dbase_return'},
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}),$text{'dbase_return'},
"", $text{'index_return'});
}
else {
&ui_print_footer("edit_table.cgi?db=$in{'db'}&table=$in{'table'}",
&ui_print_footer("edit_table.cgi?db=".&urlize($in{'db'}).
"&table=".&urlize($in{'table'}),
$text{'table_return'},
"edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
"edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
"", $text{'index_return'});
}

View File

@@ -25,7 +25,7 @@ elsif ($in{'exec'}) {
}
$access{'edonly'} && &error($text{'dbase_ecannot'});
$desc = "<tt>$in{'db'}</tt>";
$desc = "<tt>".&html_escape($in{'db'})."</tt>";
&ui_print_header($desc, $text{'table_title2'}, "", "table_form");
print &ui_form_start("create_table.cgi", "post");
@@ -67,6 +67,6 @@ print &ui_table_row(undef, &show_table_form($in{"fields"} || 4), 2);
print &ui_table_end();
print &ui_form_end([ [ "create", $text{'create'} ] ]);
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});

View File

@@ -38,7 +38,7 @@ elsif ($in{'advanced'}) {
@adv).")";
$searchhids .= &ui_hidden("and", $in{'and'})."\n".
&ui_hidden("advanced", 1)."\n";
$searchargs .= "&and=".$in{'and'}.
$searchargs .= "&and=".&urlize($in{'and'}).
"&advanced=1";
}
}
@@ -71,7 +71,7 @@ if ($in{'sortfield'}) {
$sorthids = &ui_hidden("sortfield", $in{'sortfield'})."\n".
&ui_hidden("sortdir", $in{'sortdir'})."\n";
$sortargs = "&sortfield=".&urlize($in{'sortfield'}).
"&sortdir=$in{'sortdir'}";
"&sortdir=".&urlize($in{'sortdir'});
return ($sort, $sorthids, $sortargs);
}
else {

View File

@@ -104,7 +104,7 @@ if ($in{'delete'}) {
$count++;
}
&webmin_log("delete", "data", $count, \%in);
&redirect("view_table.cgi?db=$in{'db'}&".
&redirect("view_table.cgi?db=".&urlize($in{'db'})."&".
"table=".&urlize($in{'table'})."&start=$in{'start'}".
$searchargs.$sortargs);
}
@@ -154,7 +154,7 @@ elsif ($in{'save'}) {
$count++;
}
&webmin_log("modify", "data", $count, \%in);
&redirect("view_table.cgi?db=$in{'db'}&".
&redirect("view_table.cgi?db=".&urlize($in{'db'})."&".
"table=".&urlize($in{'table'})."&start=$in{'start'}".
$searchargs.$sortargs);
}
@@ -168,7 +168,7 @@ elsif ($in{'savenew'}) {
}
&execute_sql_logged($in{'db'}, "insert into ".&quote_table($in{'table'}).
" values (".join(" , ", map { "?" } @set).")", @set);
&redirect("view_table.cgi?db=$in{'db'}&".
&redirect("view_table.cgi?db=".&urlize($in{'db'})."&".
"table=".&urlize($in{'table'})."&start=$in{'start'}".
$searchargs.$sortargs);
&webmin_log("create", "data", undef, \%in);
@@ -177,7 +177,8 @@ elsif ($in{'cancel'} || $in{'new'}) {
undef($in{'row'});
}
$desc = &text('table_header', "<tt>$in{'table'}</tt>", "<tt>$in{'db'}</tt>");
$desc = &text('table_header', "<tt>".&html_escape($in{'table'})."</tt>",
"<tt>".&html_escape($in{'db'})."</tt>");
&ui_print_header($desc, $text{'view_title'}, "");
if ($in{'start'} || $total > $displayconfig{'perpage'}) {
@@ -185,7 +186,7 @@ if ($in{'start'} || $total > $displayconfig{'perpage'}) {
if ($in{'start'}) {
printf "<a href='view_table.cgi?db=%s&table=%s&start=%s%s%s'>".
"<img src=../images/left.gif border=0 align=middle></a>\n",
$in{'db'}, $in{'table'},
&urlize($in{'db'}), &urlize($in{'table'}),
$in{'start'} - $displayconfig{'perpage'},
$searchargs, $sortargs;
}
@@ -195,7 +196,7 @@ if ($in{'start'} || $total > $displayconfig{'perpage'}) {
if ($in{'start'}+$displayconfig{'perpage'} < $total) {
printf "<a href='view_table.cgi?db=%s&table=%s&start=%s%s%s'>".
"<img src=../images/right.gif border=0 align=middle></a> ",
$in{'db'}, $in{'table'},
&urlize($in{'db'}), &urlize($in{'table'}),
$in{'start'} + $displayconfig{'perpage'},
$searchargs, $sortargs;
}
@@ -208,23 +209,23 @@ if ($in{'field'}) {
# Show details of simple search
my $msg = $in{'match'} == 2 || $in{'match'} == 3 ?
'view_searchheadnot' : 'view_searchhead';
print "<tr> <td><b>",&text($msg, "<tt>$in{'for'}</tt>",
"<tt>$in{'field'}</tt>"),"</b></td>\n";
print "<td align=right><a href='view_table.cgi?db=$in{'db'}&",
"table=$in{'table'}$sortargs'>$text{'view_searchreset'}</a></td> </tr>\n";
print "<tr> <td><b>",&text($msg, "<tt>".&html_escape($in{'for'})."</tt>",
"<tt>".&html_escape($in{'field'})."</tt>"),"</b></td>\n";
print "<td align=right><a href='view_table.cgi?db=",&urlize($in{'db'}),
"&table=",&urlize($in{'table'}),"$sortargs'>$text{'view_searchreset'}</a></td> </tr>\n";
}
elsif ($in{'advanced'}) {
# Show details of advanced search
print "<tr> <td><b>",&text('view_searchhead2', $advcount),"</b></td>\n";
print "<td align=right><a href='view_table.cgi?db=$in{'db'}&",
"table=$in{'table'}$sortargs'>$text{'view_searchreset'}</a></td> </tr>\n";
print "<td align=right><a href='view_table.cgi?db=",&urlize($in{'db'}),
"&table=",&urlize($in{'table'}),"$sortargs'>$text{'view_searchreset'}</a></td> </tr>\n";
}
if ($in{'sortfield'}) {
# Show current sort order
print "<tr> <td><b>",&text($in{'sortdir'} ? 'view_sorthead2' : 'view_sorthead1',
"<tt>$in{'sortfield'}</tt>"),"</b></td>\n";
print "<td align=right><a href='view_table.cgi?db=$in{'db'}&",
"table=$in{'table'}$searchargs'>$text{'view_sortreset'}</a></td> </tr>\n";
"<tt>".&html_escape($in{'sortfield'})."</tt>"),"</b></td>\n";
print "<td align=right><a href='view_table.cgi?db=",&urlize($in{'db'}),
"&table=",&urlize($in{'table'}),"$searchargs'>$text{'view_sortreset'}</a></td> </tr>\n";
}
print "</table>\n";
@@ -281,7 +282,8 @@ if ($total || $in{'new'}) {
for($j=0; $j<@str; $j++) {
local $nm = $i == $realrows ? $j :
"${i}_$str[$j]->{'field'}";
$et .= "<tr $cb> <td><b>$str[$j]->{'field'}</b></td> <td>\n";
$et .= "<tr $cb> <td><b>".&html_escape($str[$j]->{'field'}).
"</b></td> <td>\n";
if ($displayconfig{'blob_mode'} &&
&is_blob($str[$j]) && $d[$j]) {
# Show as keep/upload inputs
@@ -394,7 +396,8 @@ if ($total || $in{'new'}) {
push(@cols, &ui_link(
"@{[&get_webprefix()]}/".
"$module_name/download.cgi?".
"db=$in{'db'}&table=$in{'table'}".
"db=".&urlize($in{'db'}).
"&table=".&urlize($in{'table'}).
"&start=$in{'start'}".
$searchargs.$sortargs.
"&row=$i&col=$j",
@@ -489,14 +492,14 @@ if (!$in{'field'} && $total > $displayconfig{'perpage'}) {
}
if ($access{'edonly'}) {
&ui_print_footer("edit_dbase.cgi?db=$in{'db'}",$text{'dbase_return'},
&ui_print_footer("edit_dbase.cgi?db=".&urlize($in{'db'}),$text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});
}
else {
&ui_print_footer("edit_table.cgi?db=$in{'db'}&table=".
&ui_print_footer("edit_table.cgi?db=".&urlize($in{'db'})."&table=".
&urlize($in{'table'}),
$text{'table_return'},
"edit_dbase.cgi?db=$in{'db'}", $text{'dbase_return'},
"edit_dbase.cgi?db=".&urlize($in{'db'}), $text{'dbase_return'},
&get_databases_return_link($in{'db'}), $text{'index_return'});
}
@@ -509,8 +512,9 @@ local $dir = $in{'sortfield'} eq $field ? !$in{'sortdir'} : 0;
local $img = $in{'sortfield'} eq $field && $dir ? "sortascgrey.gif" :
$in{'sortfield'} eq $field && !$dir ? "sortdescgrey.gif" :
$dir ? "sortasc.gif" : "sortdesc.gif";
return "<a href='view_table.cgi?db=$in{'db'}&table=".
&urlize($in{'table'})."&start=$in{'start'}&sortfield=$field&sortdir=$dir$searchargs'>".
"<b>$field</b><img valign=middle src=../images/$img border=0>";
return "<a href='view_table.cgi?db=".&urlize($in{'db'})."&table=".
&urlize($in{'table'})."&start=$in{'start'}&sortfield=".
&urlize($field)."&sortdir=$dir$searchargs'>".
"<b>".&html_escape($field)."</b><img valign=middle src=../images/$img border=0>";
}

View File

@@ -6,7 +6,8 @@ require './phpini-lib.pl';
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
$conf = &get_config($in{'file'});
&ui_print_header("<tt>$in{'file'}</tt>", $text{'dirs_title'}, "");
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
$text{'dirs_title'}, "");
print &ui_form_start("save_dirs.cgi", "post");
print &ui_hidden("file", $in{'file'}),"\n";

View File

@@ -6,7 +6,8 @@ require './phpini-lib.pl';
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
$conf = &get_config($in{'file'});
&ui_print_header("<tt>$in{'file'}</tt>", $text{'disable_title'}, "");
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
$text{'disable_title'}, "");
print &ui_form_start("save_disable.cgi", "post");
print &ui_hidden("file", $in{'file'}),"\n";

View File

@@ -6,7 +6,8 @@ require './phpini-lib.pl';
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
$conf = &get_config($in{'file'});
&ui_print_header("<tt>$in{'file'}</tt>", $text{'errors_title'}, "");
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
$text{'errors_title'}, "");
print &ui_form_start("save_errors.cgi", "post");
print &ui_hidden("file", $in{'file'}),"\n";

View File

@@ -6,7 +6,8 @@ require './phpini-lib.pl';
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
$conf = &get_config($in{'file'});
&ui_print_header("<tt>$in{'file'}</tt>", $text{'limits_title'}, "");
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
$text{'limits_title'}, "");
print &ui_form_start("save_limits.cgi", "post");
print &ui_hidden("file", $in{'file'}),"\n";

View File

@@ -7,7 +7,8 @@ require './phpini-lib.pl';
&can_php_config($in{'file'}) || &error($text{'manual_ecannot'});
$access{'manual'} || &error($text{'manual_ecannot'});
&ui_print_header("<tt>$in{'file'}</tt>", $text{'manual_title'}, "");
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
$text{'manual_title'}, "");
print $text{'manual_desc'},"<p>\n";
print &ui_form_start("save_manual.cgi", "form-data");

View File

@@ -6,7 +6,8 @@ require './phpini-lib.pl';
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
$conf = &get_config($in{'file'});
&ui_print_header("<tt>$in{'file'}</tt>", $text{'misc_title'}, "");
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
$text{'misc_title'}, "");
# Build list of timezones
my %tzlist;

View File

@@ -6,7 +6,8 @@ require './phpini-lib.pl';
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
$conf = &get_config($in{'file'});
&ui_print_header("<tt>$in{'file'}</tt>", $text{'session_title'}, "");
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
$text{'session_title'}, "");
print &ui_form_start("save_session.cgi", "post");
print &ui_hidden("file", $in{'file'}),"\n";

View File

@@ -6,7 +6,8 @@ require './phpini-lib.pl';
&can_php_config($in{'file'}) || &error($text{'list_ecannot'});
$conf = &get_config_as_user($in{'file'});
&ui_print_header("<tt>$in{'file'}</tt>", $text{'vars_title'}, "");
&ui_print_header("<tt>".&html_escape($in{'file'})."</tt>",
$text{'vars_title'}, "");
print &ui_form_start("save_vars.cgi", "post");
print &ui_hidden("file", $in{'file'}),"\n";

View File

@@ -15,17 +15,22 @@ $mail || &error($text{'mailq_egone'});
&parse_mail($mail);
$attach = $mail->{'attach'}->[$in{'attach'}];
my $type = lc($attach->{'type'} || 'application/octet-stream');
print "X-no-links: 1\n";
if ($attach->{'type'} eq 'message/delivery-status') {
print "X-Content-Type-Options: nosniff\n";
if ($type =~ /^text\// || $type =~ /(?:\/|\+)xml\z/ ||
$type eq 'image/svg' || $type eq 'message/delivery-status') {
# Display text and XML as plain text, never as active documents.
print "Content-type: text/plain\n\n";
}
else {
print "Content-type: $attach->{'type'}\n\n";
}
if ($attach->{'type'} =~ /^(text\/html|image\/svg|application\/xhtml)/i) {
print &filter_javascript($attach->{'data'});
elsif ($type =~ /\Aimage\/(?:gif|jpeg|png|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)\z/) {
# Only known raster image types may be rendered inline.
print "Content-type: $type\n\n";
}
else {
print $attach->{'data'};
# Download other formats without trusting the sender's MIME type.
print "Content-Disposition: Attachment\n";
print "Content-type: application/octet-stream\n\n";
}
print $attach->{'data'};

View File

@@ -10,7 +10,7 @@ our (%access, %in, %text);
$access{'smtpd'} || &error($text{'smtpd_ecannot'});
&ReadParse();
&ui_print_header(undef, $in{'title'}, "");
&ui_print_header(undef, &html_escape($in{'title'}), "");
if (&get_current_value($in{'name'}) eq "")
{

View File

@@ -159,7 +159,6 @@ exec_tabimport=Import text file
exec_return=execute SQL form
exec_ecompress=The SQL file is compressed, but the program $1 needed to un-compress it is not installed.
exec_ecompress2=Un-compression failed : $1
exec_erestrict=The psql client is too old to safely execute SQL files. Update the PostgreSQL client and try again.
stop_err=Failed to stop database server
stop_epidfile=Failed to open PID file $1

View File

@@ -373,7 +373,10 @@ else {
}
elsif ($sql =~ /^\s*\\/ ) {
$break_f = 1 ;
&error ( &text ( 'r_command', ) ) ;
if ($sql !~ /^\s*\\copy\s+/ &&
$sql !~ /^\s*\\i\s+/) {
&error ( &text ( 'r_command', ) ) ;
}
}
if (@params) {
@@ -402,7 +405,7 @@ else {
&host_port_flags().
(!&supports_pgpass() ? " -u" :
" -U ".quotemeta($postgres_login)).
" -c ".&quote_path($sql).
" -c ".quotemeta($sql).
" ".quotemeta($_[0]);
if ($postgres_sameunix && defined(getpwnam($postgres_login))) {
$cmd = &command_as_user($postgres_login, 0, $cmd);
@@ -829,22 +832,6 @@ return 1 if (!$user);
return &set_ownership_permissions($user, undef, 0600, $file);
}
# supports_psql_restrict()
# Returns 1 if psql supports restricted mode.
sub supports_psql_restrict
{
local $out = &backquote_command(
&quote_path($config{'psql'})." -V 2>&1 <$null_file");
local ($version) = $out =~ /psql.*?\s(\d+(?:\.\d+)*)/i;
return 0 if (!defined($version));
local ($major) = split(/\./, $version);
local %minimum = ( 13 => "13.22", 14 => "14.19", 15 => "15.14",
16 => "16.10", 17 => "17.6" );
return &compare_version_numbers($version, "18") >= 0 ||
$minimum{$major} &&
&compare_version_numbers($version, $minimum{$major}) >= 0;
}
# execute_sql_file(database, file, [user, pass], [unix-user])
# Executes some file of SQL statements, and returns the exit status and output
sub execute_sql_file
@@ -857,59 +844,7 @@ if (!defined($user)) {
$user = $postgres_login;
$pass = $postgres_pass;
}
&supports_psql_restrict() || return (1, $text{'exec_erestrict'});
# Copy a plain PostgreSQL dump without its outer restriction wrapper.
local (*SQLFILE, *SAFEFILE);
local ($line, $dump_key);
open(SQLFILE, "<$file") || return (1, $text{'exec_efile'});
while(defined($line = <SQLFILE>)) {
if ($line =~ /^\s*\\restrict\s+([A-Za-z0-9]+)\s*$/) {
$dump_key = $1;
last;
}
elsif ($line !~ /^\s*(?:--.*)?$/) {
last;
}
}
local $safe_file;
if ($dump_key) {
$safe_file = &transname();
&open_tempfile(SAFEFILE, ">$safe_file");
local ($last_line, $last_pos);
while(defined($line = <SQLFILE>)) {
if ($line =~ /\S/) {
$last_line = $line;
$last_pos = tell(SAFEFILE);
}
&print_tempfile(SAFEFILE, $line);
}
&close_tempfile(SAFEFILE);
if (!defined($last_line) ||
$last_line !~ /^\s*\\unrestrict\s+\Q$dump_key\E\s*$/ ||
!truncate($safe_file, $last_pos)) {
unlink($safe_file);
undef($safe_file);
}
}
close(SQLFILE);
if ($safe_file) {
local $fileuser = &postgresql_command_unix_user();
$fileuser ||= $unixuser if ($unixuser && $unixuser ne 'root' && $< == 0);
if ($fileuser &&
!&set_ownership_permissions($fileuser, undef, 0600, $safe_file)) {
unlink($safe_file);
return (1, $text{'exec_efile'});
}
$file = $safe_file;
}
# Restrict psql commands while executing the user-supplied file.
local $restrict_key = &generate_miniserv_websocket_token();
local $cmd = &quote_path($config{'psql'}).
" -X -c ".
quotemeta("\\restrict $restrict_key").
" -f ".&quote_path($file).
local $cmd = &quote_path($config{'psql'})." -f ".&quote_path($file).
&host_port_flags().
(&supports_pgpass() ? " -U $user" : " -u").
" ".$db;
@@ -920,12 +855,8 @@ elsif ($unixuser && $unixuser ne 'root' && $< == 0) {
$cmd = &command_as_user($unixuser, 0, $cmd);
}
$cmd = &command_with_login($cmd, $user, $pass);
&clean_language();
local $out = &backquote_logged("$cmd 2>&1");
local $ex = $? ? 1 : 0;
&reset_environment();
unlink($safe_file) if ($safe_file);
return ($ex || $out =~ /ERROR|backslash commands are restricted|\\unrestrict:\s+wrong key/i ? 1 : 0, $out);
return ($out =~ /ERROR/i ? 1 : 0, $out);
}
# split_table(&titles, &checkboxes, &links, &col1, &col2, ...)

View File

@@ -9,12 +9,22 @@ $mail = &read_mail_file($in{'file'});
&parse_mail($mail);
$attach = $mail->{'attach'}->[$in{'attach'}];
my $type = lc($attach->{'type'} || 'application/octet-stream');
print "X-no-links: 1\n";
print "Content-type: $attach->{'type'}\n\n";
if ($attach->{'type'} =~ /^(text\/html|image\/svg|application\/xhtml)/i) {
print &filter_javascript($attach->{'data'});
print "X-Content-Type-Options: nosniff\n";
if ($type =~ /^text\// || $type =~ /(?:\/|\+)xml\z/ ||
$type eq 'image/svg' || $type eq 'message/delivery-status') {
# Display text and XML as plain text, never as active documents.
print "Content-type: text/plain\n\n";
}
elsif ($type =~ /\Aimage\/(?:gif|jpeg|png|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)\z/) {
# Only known raster image types may be rendered inline.
print "Content-type: $type\n\n";
}
else {
print $attach->{'data'};
# Download other formats without trusting the sender's MIME type.
print "Content-Disposition: Attachment\n";
print "Content-type: application/octet-stream\n\n";
}
print $attach->{'data'};

View File

@@ -27,17 +27,22 @@ foreach $s (@sub) {
}
$attach = $mail->{'attach'}->[$in{'attach'}];
my $type = lc($attach->{'type'} || 'application/octet-stream');
print "X-no-links: 1\n";
if ($attach->{'type'} eq 'message/delivery-status') {
print "X-Content-Type-Options: nosniff\n";
if ($type =~ /^text\// || $type =~ /(?:\/|\+)xml\z/ ||
$type eq 'image/svg' || $type eq 'message/delivery-status') {
# Display text and XML as plain text, never as active documents.
print "Content-type: text/plain\n\n";
}
else {
print "Content-type: $attach->{'type'}\n\n";
}
if ($attach->{'type'} =~ /^(text\/html|image\/svg|application\/xhtml)/i) {
print &filter_javascript($attach->{'data'});
elsif ($type =~ /\Aimage\/(?:gif|jpeg|png|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)\z/) {
# Only known raster image types may be rendered inline.
print "Content-type: $type\n\n";
}
else {
print $attach->{'data'};
# Download other formats without trusting the sender's MIME type.
print "Content-Disposition: Attachment\n";
print "Content-type: application/octet-stream\n\n";
}
print $attach->{'data'};

View File

@@ -10,7 +10,9 @@ require './servers-lib.pl';
our (%in);
&ReadParse();
my $enc = &encode_base64($in{'user'}.":".$in{'pass'});
$enc =~ s/\r|\n//g;
$in{'id'} =~ s/\r|\n|\0//g;
$in{'id'} =~ /\A[0-9]+\z/ || &error("Invalid server ID");
$enc =~ s/\r|\n|\0//g;
print "Set-Cookie: $in{'id'}=$enc; path=/";
if (uc($ENV{'HTTPS'}) eq 'ON') {
print "; secure";

View File

@@ -9,6 +9,8 @@ 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";

View File

@@ -6,7 +6,8 @@ require './software-lib.pl';
&ReadParse();
&ui_print_header(undef, $text{'list_title'}, "", "list_pack");
print &ui_subheading(&text('list_files', "<tt>$in{'package'}</tt>"));
print &ui_subheading(&text('list_files',
"<tt>".&html_escape($in{'package'})."</tt>"));
print &ui_columns_start([
$text{'list_path'},
$text{'list_owner'},

View File

@@ -14,6 +14,7 @@ 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$/);
@@ -50,6 +51,7 @@ 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);
@@ -57,6 +59,7 @@ 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);
@@ -77,6 +80,7 @@ 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>;
@@ -113,12 +117,14 @@ 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>) {
@@ -139,6 +145,7 @@ 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;
@@ -151,6 +158,7 @@ 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;
@@ -163,6 +171,7 @@ 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//;
@@ -223,6 +232,7 @@ 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$/);
@@ -244,6 +254,7 @@ 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);
@@ -259,6 +270,7 @@ 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();
@@ -298,6 +310,7 @@ 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();
@@ -318,6 +331,7 @@ 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}) (.) (.*)$/;
@@ -360,6 +374,7 @@ 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;
@@ -380,11 +395,13 @@ 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);
@@ -433,6 +450,7 @@ $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>"; }
@@ -457,6 +475,7 @@ 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>"; }
@@ -469,6 +488,7 @@ 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();

View File

@@ -61,7 +61,10 @@ return &get_dnf_version() >= 5 ? '--setopt=disable_excludes=*' :
}
# update_system_install([packages], [&in], [no-force], [flags])
# Installs or updates packages with YUM or DNF.
# 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.
sub update_system_install
{
local $update = $_[0] || $in{'update'};
@@ -76,15 +79,30 @@ $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);
# If there are multiple architectures to update for a package, split them out
# Split installed packages by architecture and record missing packages.
local @updates = split(/\s+/, $update);
local @names = map { &append_architectures($_) } 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);
}
}
if (@names == 1) {
# Keep the original specification when no architecture split was needed.
@names = ( $update );
}
$update = join(" ", map { quotemeta($_) } @names);
@@ -110,30 +128,39 @@ if ($versionlock_update) {
}
}
# 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";
}
# 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 ] );
}
else {
$cmd = "install";
# Use one action for YUM and non-mixed DNF sets.
my $cmd = $yum_command =~ /dnf$/ && @installed && !@missing ?
'update' : 'install';
@actions = ( [ $cmd, \@names ] );
}
# 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);
# 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);
foreach my $u (@updates) {
my $repo = &update_system_repo($u);
if ($repo) {
$fullcmd = "$yum_command -y $cmd $repo ; $fullcmd";
# Install a required repository package first.
$fullcmd = "$yum_command -y install $repo ; $fullcmd";
}
}
@@ -141,7 +168,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+/) {
@@ -220,21 +247,25 @@ else {
}
}
# append_architectures(package)
# Given a package name, if it has multiple architectures return the name with
# each appended
# 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.
sub append_architectures
{
my ($name) = @_;
my ($name, $installed) = @_;
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'});
}
}

75
sqlite/acl_security.pl Executable file
View File

@@ -0,0 +1,75 @@
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'} = '';
}
}

68
sqlite/column_form.cgi Executable file
View File

@@ -0,0 +1,68 @@
#!/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'});

7
sqlite/config Normal file
View File

@@ -0,0 +1,7 @@
recent=3
perpage=50
max_text=1000
busy_timeout=5000
max_query_rows=1000
max_result_bytes=16777216
confirm_delete=1

7
sqlite/config.info Normal file
View File

@@ -0,0 +1,7 @@
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

8
sqlite/cpan_modules.pl Executable file
View File

@@ -0,0 +1,8 @@
require 'sqlite-lib.pl';
# sub cpan_recommended()
# Returns the Perl modules that provide SQLite database access.
sub cpan_recommended
{
return ( "DBI", "DBD::SQLite" );
}

77
sqlite/create_table.cgi Executable file
View File

@@ -0,0 +1,77 @@
#!/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');

133
sqlite/database.cgi Executable file
View File

@@ -0,0 +1,133 @@
#!/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'});

6
sqlite/defaultacl Normal file
View File

@@ -0,0 +1,6 @@
paths=/
write=1
create=1
sql=1
work_as_root=1
work_as_user=

66
sqlite/delete_rows.cgi Executable file
View File

@@ -0,0 +1,66 @@
#!/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));

58
sqlite/drop_column.cgi Executable file
View File

@@ -0,0 +1,58 @@
#!/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);

62
sqlite/drop_table.cgi Executable file
View File

@@ -0,0 +1,62 @@
#!/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));

133
sqlite/edit_row.cgi Executable file
View File

@@ -0,0 +1,133 @@
#!/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'});

72
sqlite/exec.cgi Executable file
View File

@@ -0,0 +1,72 @@
#!/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'});

24
sqlite/exec_form.cgi Executable file
View File

@@ -0,0 +1,24 @@
#!/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'});

15
sqlite/forget_recent.cgi Executable file
View File

@@ -0,0 +1,15 @@
#!/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('');

34
sqlite/help/acl_info.html Normal file
View File

@@ -0,0 +1,34 @@
<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>

16
sqlite/help/column.html Normal file
View File

@@ -0,0 +1,16 @@
<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>

View File

@@ -0,0 +1,11 @@
<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>

View File

@@ -0,0 +1,10 @@
<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>

View File

@@ -0,0 +1,10 @@
<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>

View File

@@ -0,0 +1,9 @@
<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>

Some files were not shown because too many files have changed in this diff Show More