From 593fb94f112bf9c99c45f2f68e8d25a725fe4397 Mon Sep 17 00:00:00 2001 From: ciltocruz Date: Thu, 8 Oct 2026 11:47:52 +0200 Subject: [PATCH 01/10] fix: typo --- fsdump/lang/es | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fsdump/lang/es b/fsdump/lang/es index a34c91650..ca5957ad5 100644 --- a/fsdump/lang/es +++ b/fsdump/lang/es @@ -7,7 +7,7 @@ index_ecommands=Ninguno de los comandos de respaldo de sistemas de archivo (como index_return=lista de respaldos dump_dir=Directorio a respaldar -dump_dirs=Directorios a rspaldar +dump_dirs=Directorios a respaldar dump_fs=Sistema de archivos dump_sched=¿Programado? dump_when=Horas programadas para respaldar From 39786bae179446a779596211f6cef2c305acfabc Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Thu, 8 Oct 2026 15:28:28 +0200 Subject: [PATCH 02/10] Add `ui_svg_icon` sub to support CSS class for targeted styling --- ui-lib.pl | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui-lib.pl b/ui-lib.pl index e9838a20d..ba0bd5e3a 100755 --- a/ui-lib.pl +++ b/ui-lib.pl @@ -4442,7 +4442,8 @@ return &ui_tag($tag, "\n".$content."\n", $attrs); Returns inline SVG for a named icon from the built-in set, drawn with the current text color. Unlike ui_icon, this does not depend on any theme -icon font. Options are : +icon font. Each icon has a CSS class identifying its name, such as +C, for targeted styling. Options are : =item size - Pixel size, defaulting to 16. @@ -4460,7 +4461,8 @@ $opts ||= {}; my $paths = $ui_svg_icons{$name}; return "" if (!$paths); my $size = int($opts->{'size'} || 16); -my %attrs = ( 'class' => &_ui_class('ui_svg_icon', $opts->{'class'}), +my %attrs = ( 'class' => &_ui_class('ui_svg_icon', 'ui_svg_icon_'.$name, + $opts->{'class'}), 'width' => $size, 'height' => $size, 'viewBox' => '0 0 16 16', From 1fed13ba32aed1a3e49c7bf64c97509092c70091 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Thu, 8 Oct 2026 15:53:06 +0200 Subject: [PATCH 03/10] FIx alignment for non-standard small trash icon --- unauthenticated/css/ui-lib.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/unauthenticated/css/ui-lib.css b/unauthenticated/css/ui-lib.css index 607748248..5fcac2459 100644 --- a/unauthenticated/css/ui-lib.css +++ b/unauthenticated/css/ui-lib.css @@ -609,6 +609,9 @@ a.ui_stat:hover .ui_stat_value { color: var(--ui-accent); } :is(button, a).ui_action_rounded { border-radius: 4px; } :is(button, a).ui_action_pill { border-radius: 999px; } .ui_action .ui_svg_icon { width: 1em; height: 1em; flex-shrink: 0; } +.ui_action_small .ui_svg_icon_trash { + transform: translateY(-0.25px); +} /* Focus follows the visible surface, even when a theme resets link outlines. */ .ui_action:not(:disabled):not([aria-disabled="true"]):hover .ui_action_content { From cfe8d72d853a908a0040028942fab7d0007f08c5 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Thu, 8 Oct 2026 17:49:54 +0200 Subject: [PATCH 04/10] Add configurable terminal colors and per-user preferences [no-build] --- xterm/config | 1 + xterm/config.info | 1 + xterm/index.cgi | 54 ++++++++++++++++++++-------- xterm/prefs.info | 1 + xterm/xterm-lib.pl | 89 ++++++++++++++++++++++++++++++++++++++++++++++ xterm/xterm.css | 2 ++ 6 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 xterm/prefs.info diff --git a/xterm/config b/xterm/config index 2afe49516..b34c99ebd 100644 --- a/xterm/config +++ b/xterm/config @@ -1,5 +1,6 @@ xterm=xterm-256color fontsize=14 +theme=dark base_port=555 rcfile=0 locale=0 diff --git a/xterm/config.info b/xterm/config.info index 518833c33..ad91928af 100644 --- a/xterm/config.info +++ b/xterm/config.info @@ -1,6 +1,7 @@ xterm=Set TERM environmental variable to,4,xterm+256color-xterm-256color,xterm+16color-xterm-16color,xterm-xterm,vt102-vt102,vt100-vt100,vt52-vt52,rxvt-rxvt,nsterm-nsterm,dtterm-dtterm,ansi-ansi size=Terminal width and height in characters,3,Automatic,5,,,Static (80x24) fontsize=Adjust font size,4,0-Auto,12-Small,14-Normal,16-Large,18-Huge +theme=Color scheme,4,dark-Dark,light-Light,auto-Auto locale=Set shell character encoding,10,0-Shell default,1-en_US.UTF-8,Custom rcfile=Execute initialization commands from file,10,0-Shell default,1-Module default,Custom screen_reader=Enable screen reader mode,1,true-Yes,false-No diff --git a/xterm/index.cgi b/xterm/index.cgi index 8921cc6d2..4b949e570 100755 --- a/xterm/index.cgi +++ b/xterm/index.cgi @@ -80,12 +80,26 @@ if ($conf_cols_n && $conf_rows_n && !$xmlhr) { $ENV{'LINES'} = $conf_rows_n; } +# Resolve colors once for both the standalone terminal and theme clients. +my $terminal_options = get_terminal_options($config{'theme'}); +# Auto clients choose between the same palettes used by explicit selections. +my $terminal_themes = $config{'theme'} eq 'auto' ? { + 'dark' => get_terminal_options('dark'), + 'light' => get_terminal_options('light'), + } : undef; +my $terminal_theme_json = convert_to_json($terminal_options->{'theme'}); +my $terminal_background = $terminal_options->{'theme'}->{'background'}; +my $terminal_foreground = $terminal_options->{'theme'}->{'foreground'}; +my $terminal_contrast = $terminal_options->{'minimumContrastRatio'}; + # Define columns and rows my $conf_screen_reader = $config{'screen_reader'} eq 'true' ? 'true' : 'false'; my %term_opts; $term_opts{'Options'} = "{ cols: $env_cols, rows: $env_rows, ". "screenReaderMode: $conf_screen_reader, ". "overviewRuler: { width: 9 }, ". + "theme: $terminal_theme_json, ". + "minimumContrastRatio: $terminal_contrast, ". "fontSize: $font_size }"; my $term_size = " @@ -104,8 +118,9 @@ body[style='height:100%'] { white-space: nowrap; } #terminal { - border: 1px solid #000; - background-color: #000; + border: 1px solid var(--terminal-background, #000); + background-color: var(--terminal-background, #000); + color: var(--terminal-foreground, #fff); padding: 2px; margin: 0 auto; $term_size @@ -126,8 +141,8 @@ body[style='height:100%'] { box-sizing: border-box; border: 1px solid transparent; - border-top-color: rgba(255, 255, 255, 0.8); - border-bottom-color: rgba(255, 255, 255, 0.8); + border-top-color: var(--terminal-foreground, #fff); + border-bottom-color: var(--terminal-foreground, #fff); animation: jumping-spinner 1s ease infinite; } @@ -138,7 +153,7 @@ body[style='height:100%'] { margin-left: 24px; margin-top: -16px; font-weight: 100; - color: rgba(255, 255, 255, 0.8); + color: var(--terminal-foreground, #fff); font-family: "Lucida Console", Courier, monospace; font-size: 14px; text-transform: uppercase; @@ -167,6 +182,9 @@ body[style='height:100%'] { EOF +# Share loading colors and Auto updates with other terminal modules. +my $terminal_loading_script = get_terminal_theme_script($config{'theme'}); + # Print header ui_print_header(undef, $text{'index_title'}, "", undef, 1, 1, 0, undef, "{'css'}[0]\">\n". @@ -177,7 +195,10 @@ ui_print_header(undef, $text{'index_title'}, "", undef, 1, 1, 0, undef, ); # Print main container -print "
\n"; +print "
\n". + ($terminal_themes ? $terminal_loading_script : ''); # Get a free port that can be used for the socket. Normal browser sessions # are revalidated by miniserv while the websocket stays open. Proxied or @@ -236,6 +257,9 @@ my $term_script = <'; }; socket.onclose = function() { + if (termcont.xtermTheme) termcont.xtermTheme.dispose(); termcont.innerHTML = 'Error: ' + err_conn_lost + ''; }; @@ -284,21 +310,21 @@ my $term_script = <\n"; -if ($xmlhr) { - print "var xterm_argv = ". +print "var xterm_argv = ". convert_to_json( { 'conf' => \%config, + 'options' => $terminal_options, + 'themes' => $terminal_themes, 'files' => $termlinks, 'socket_url' => $url, 'port' => $port, 'cols' => $env_cols, 'rows' => $env_rows, - 'uinfo' => \@uinfo }); - } -else { - print $term_script; - } + 'uinfo' => \@uinfo }).";\n"; +# Only standalone pages start their own terminal client. +print $term_script if (!$xmlhr); print "\n"; ui_print_footer(); diff --git a/xterm/prefs.info b/xterm/prefs.info new file mode 100644 index 000000000..4664791b9 --- /dev/null +++ b/xterm/prefs.info @@ -0,0 +1 @@ +allowed=theme,fontsize,screen_reader,size diff --git a/xterm/xterm-lib.pl b/xterm/xterm-lib.pl index 2861c1c7a..6aadd2155 100644 --- a/xterm/xterm-lib.pl +++ b/xterm/xterm-lib.pl @@ -25,6 +25,95 @@ if (($ENV{'HTTP_X_REQUESTED_WITH'} || '') eq "XMLHttpRequest") { } } +# get_terminal_options([theme]) +# Return xterm.js color options for a Dark or Light scheme. Missing or +# unrecognized names use Dark, preserving the default for older installs. +# Auto starts with Dark until the client resolves the page color scheme. +sub get_terminal_options +{ +my ($theme) = @_; +if ($theme ne 'light') { + # Keep xterm.js defaults for the dark palette and text contrast. + return { 'theme' => { 'background' => '#000000', + 'foreground' => '#ffffff' }, + 'minimumContrastRatio' => 1 }; + } + +# Darker ANSI colors and contrast adjustment keep light terminals readable. +return { + 'theme' => { + 'background' => '#ffffff', 'foreground' => '#242424', + 'cursor' => '#242424', 'cursorAccent' => '#ffffff', + 'selectionBackground' => '#add6ff', + 'selectionInactiveBackground' => '#d0d0d0', + 'black' => '#242424', 'red' => '#a31515', 'green' => '#256c18', + 'yellow' => '#795e00', 'blue' => '#0451a5', 'magenta' => '#7a287d', + 'cyan' => '#007070', 'white' => '#bfbfbf', + 'brightBlack' => '#666666', 'brightRed' => '#b52020', + 'brightGreen' => '#287028', 'brightYellow' => '#806000', + 'brightBlue' => '#005cc5', 'brightMagenta' => '#8b298f', + 'brightCyan' => '#007575', 'brightWhite' => '#ffffff', + }, + 'minimumContrastRatio' => 4.5, + }; +} + +# get_terminal_theme_script(theme) +# Return the initial Auto palette and its change listener for #terminal. +# Clients pass their xterm.js instance to the container's xtermTheme function +# and call xtermTheme.dispose() when the connection closes. +sub get_terminal_theme_script +{ +my ($theme) = @_; +return '' if ($theme ne 'auto'); +my $themes_json = convert_to_json({ + 'dark' => get_terminal_options('dark'), + 'light' => get_terminal_options('light'), + }); +return < +(function() { + const termcont = document.getElementById('terminal'), + root = document.documentElement, + themes = $themes_json, + schemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); + let terminal; + // Keep the loading surface and renderer on the same palette. + const update = function() { + const pageScheme = getComputedStyle(document.body).colorScheme, + background = root.getAttribute('data-bgs'), + mode = background ? (background === 'nightRider' ? 'dark' : 'light') : + (pageScheme === 'dark' || pageScheme === 'light' ? pageScheme : + (schemeQuery.matches ? 'dark' : 'light')), + colors = themes[mode]; + if (terminal) { + terminal.options.theme = colors.theme; + terminal.options.minimumContrastRatio = colors.minimumContrastRatio; + } + termcont.setAttribute('data-terminal-theme', mode); + termcont.style.setProperty('--terminal-background', colors.theme.background); + termcont.style.setProperty('--terminal-foreground', colors.theme.foreground); + }; + // xtermTheme(term) attaches the renderer after the loading colors are set. + termcont.xtermTheme = function(term) { terminal = term; update(); }; + const observer = new MutationObserver(update), + observeOptions = { attributes: true, + attributeFilter: ['data-bgs', 'data-scheme', 'class', 'style'] }; + observer.observe(root, observeOptions); + observer.observe(document.body, observeOptions); + schemeQuery.addEventListener('change', update); + // dispose() stops palette notifications after disconnect or a theme takeover. + termcont.xtermTheme.dispose = function() { + observer.disconnect(); + schemeQuery.removeEventListener('change', update); + terminal = null; + }; + update(); +})(); + +EOF +} + # verify_websocket_key(client-key, session-id) # Returns 1 if the client's Sec-WebSocket-Key matches the base64-encoded # session ID, 0 otherwise. miniserv.pl rewrites the inbound handshake key diff --git a/xterm/xterm.css b/xterm/xterm.css index 80166484a..fece03ad8 100644 --- a/xterm/xterm.css +++ b/xterm/xterm.css @@ -7,4 +7,6 @@ */ .xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none;}.xterm.focus,.xterm:focus{outline:none;}.xterm .xterm-helpers{position:absolute;top:0;z-index:5;}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none;}.xterm .composition-view{background:#000;color:#FFF;display:none;position:absolute;white-space:nowrap;z-index:1;}.xterm .composition-view.active{display:block;}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0;}.xterm .xterm-screen{position:relative;}.xterm .xterm-screen canvas{position:absolute;left:0;top:0;}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal;}.xterm.enable-mouse-events{cursor:default;}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer;}.xterm.column-select.focus{cursor:crosshair;}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none;}.xterm .xterm-accessibility-tree:not(.debug) *::selection{color:transparent;}.xterm .xterm-accessibility-tree{font-family:monospace;user-select:text;white-space:pre;}.xterm .xterm-accessibility-tree>div{transform-origin:left;width:fit-content;}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;}.xterm-dim{opacity:1!important;}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline;}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through;}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute;}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7;}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none;}.xterm-decoration-top{z-index:2;position:relative;}.xterm .xterm-scrollable-element>.scrollbar{cursor:default;}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important;}.xterm .xterm-scrollable-element>.visible{opacity:1;background:rgba(0,0,0,0);transition:opacity 100ms linear;z-index:11;}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none;}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity 800ms linear;}.xterm .xterm-scrollable-element>.shadow{position:absolute;display:none;}.xterm .xterm-scrollable-element>.shadow.top{display:block;top:0;left:3px;height:3px;width:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;}.xterm .xterm-scrollable-element>.shadow.left{display:block;top:3px;left:0;height:100%;width:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;}.xterm .xterm-scrollable-element>.shadow.top-left-corner{display:block;top:0;left:0;height:3px;width:3px;}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;} /* custom */ +/* Match the viewport around the canvas to the module's selected background. */ +.xterm .xterm-viewport { background-color: var(--terminal-background, #000); } .xterm .xterm-decoration-overview-ruler { display: none; } From f759798e7f772ed6f5f48e90ea0b961d20f81107 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Thu, 8 Oct 2026 17:50:13 +0200 Subject: [PATCH 05/10] Add tests for terminal appearance preferences and per-user settings [no-build] --- xterm/t/run-tests.t | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/xterm/t/run-tests.t b/xterm/t/run-tests.t index a6adeac12..99a539f9d 100644 --- a/xterm/t/run-tests.t +++ b/xterm/t/run-tests.t @@ -115,6 +115,50 @@ subtest 'config_pre_load' => sub { } }; +# Terminal appearance settings use the module's native per-user preferences. +subtest 'terminal appearance preferences' => sub { + my $dark = get_terminal_options('dark'); + my $light = get_terminal_options('light'); + is($dark->{'theme'}->{'background'}, '#000000', 'Dark background'); + is($light->{'theme'}->{'background'}, '#ffffff', 'Light background'); + is($light->{'theme'}->{'foreground'}, '#242424', 'Light uses dark text'); + is($light->{'theme'}->{'cursorAccent'}, '#ffffff', 'block cursor text'); + cmp_ok($light->{'minimumContrastRatio'}, '>=', 4.5, + 'bright and indexed text remains readable'); + is_deeply(get_terminal_options(undef), $dark, 'missing theme defaults to Dark'); + is_deeply(get_terminal_options('gray'), $dark, 'unknown theme defaults to Dark'); + is_deeply(get_terminal_options('auto'), $dark, 'Auto starts Dark until resolved by the client'); + + # Save appearance settings per user without saving the shell account. + require '../config-lib.pl'; + local $main::remote_user = 'color-test-one'; + local $WebminCore::remote_user = $main::remote_user; + my %defaults = (theme => 'dark', fontsize => 14, + screen_reader => 'false', size => '80X24'); + save_module_preferences('xterm', { theme => 'light', fontsize => 16, + screen_reader => 'true', size => '100X30', user => 'ignored' }); + my %first = (%defaults, user => 'unchanged'); + load_module_preferences('xterm', \%first); + is($first{'theme'}, 'light', 'first user reloads Light'); + is_deeply([ @first{qw(fontsize screen_reader size)} ], + [ 16, 'true', '100X30' ], + 'font size, screen reader and dimensions reload for this user'); + is($first{'user'}, 'unchanged', 'shell account is not a personal preference'); + { + local $main::remote_user = 'color-test-two'; + local $WebminCore::remote_user = $main::remote_user; + my %second = %defaults; + load_module_preferences('xterm', \%second); + is_deeply(\%second, \%defaults, 'new user keeps all module defaults'); + save_module_preferences('xterm', { theme => 'dark' }); + } + load_module_preferences('xterm', \%first); + is($first{'theme'}, 'light', 'another user saving preserves the first choice'); + save_module_preferences('xterm', { theme => 'auto' }); + load_module_preferences('xterm', \%first); + is($first{'theme'}, 'auto', 'Auto is saved as a personal preference'); +}; + # verify_websocket_key — handshake auth # # miniserv.pl rewrites the inbound Sec-WebSocket-Key to base64(session_id) From 0befd2663ca1ebb1e6623144bb656c7c13bd3bad Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Thu, 8 Oct 2026 18:30:26 +0200 Subject: [PATCH 06/10] Update changelog [no-build] --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 958394743..95217f678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Changelog #### 2.671 (October, 2026) +* Add color schemes to the Terminal module +* Add per-user Terminal preferences for color scheme, font size, screen reader mode and terminal dimensions * Add a Cloudmin virtualization host profile to the nftables module * 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 @@ -7,6 +9,10 @@ * Fix path traversal and command injection in parameterized Custom Commands file editors * Fix MySQL/MariaDB manual editor ACL bypass * Add optional address-family selection to HTTP downloads and socket connections, preserved across redirects [#1300](https://github.com/virtualmin/virtualmin-gpl/issues/1300) +* Update the Authentic theme to the latest version with various improvements: + - Add support for Terminal module color preferences + - Add support for the new Cloudmin navigation, graphical console and live virtual machine controls + - Fix spacing and borders in widgets, tables and tab panes #### 2.670 (September 20, 2026) * Add new Hardware Information module for inspecting system, firmware, security, PCI, USB, storage, network, processor, sensor, driver, and kernel module details From 5e90e1438a078bc4fcd07d10ffd0509d6cc45367 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Thu, 8 Oct 2026 22:40:36 +0200 Subject: [PATCH 07/10] Fix module name validation (once again) This patch fixes `get_module_info()` accepting module names with a trailing newline after the regex anchors were changed. It preserves the strict validation introduced in my `2f4f017ac` to prevent loading files outside installed modules. The check directly rejects undefined or empty names and any character outside ASCII letters, digits, underscores, and hyphens, including newlines anywhere in the name. Regression tests pass and verify that invalid names are rejected before module lookup while valid names remain accepted. --- t/web-lib-funcs-paths.t | 37 +++++++++++++++++++++++++++++++++++++ web-lib-funcs.pl | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/t/web-lib-funcs-paths.t b/t/web-lib-funcs-paths.t index 18e1b5fdc..f45619565 100644 --- a/t/web-lib-funcs-paths.t +++ b/t/web-lib-funcs-paths.t @@ -17,6 +17,43 @@ my $script = File::Spec->rel2abs( File::Spec->catfile(dirname(__FILE__), '..', 'web-lib-funcs.pl')); require $script; +# get_module_info — validate names before looking up module files. +subtest 'get_module_info name validation' => sub { + no warnings qw(redefine once); + my @lookups; + # Record accepted names and stop before any filesystem access. + local *main::module_root_directory = sub { + push(@lookups, $_[0]); + return '/unused'; + }; + local *main::load_language_auto = sub { return 0; }; + local *main::load_language_neutral = sub { return 0; }; + local *main::read_file_cached = sub { return 0; }; + + # Invalid names must return empty without reaching the module lookup. + foreach my $case ( + [ undef, 'undefined' ], [ '', 'empty' ], + [ "module\n", 'trailing newline' ], + [ "module\r\n", 'trailing CRLF' ], + [ "mod\nule", 'embedded newline' ], + [ 'module ', 'trailing space' ], + [ "module\t", 'trailing tab' ], + [ 'mod.ule', 'dot' ], [ 'mod/ule', 'slash' ], + [ "mod\x{e9}ule", 'non-ASCII letter' ]) { + @lookups = (); + is_deeply([main::get_module_info($case->[0])], [], + "$case->[1] returns no module info"); + is_deeply(\@lookups, [], "$case->[1] skips module lookup"); + } + + # Valid names, including the string "0", must still reach the lookup. + foreach my $name ('module', 'Module_123-test', '0') { + @lookups = (); + my %info = main::get_module_info($name); + is_deeply(\@lookups, [$name], "$name reaches module lookup"); + } +}; + # simplify_path — strip ./ and resolve ../, refusing to escape root. # # Contract: diff --git a/web-lib-funcs.pl b/web-lib-funcs.pl index d01c314c7..84a0686fb 100755 --- a/web-lib-funcs.pl +++ b/web-lib-funcs.pl @@ -6540,7 +6540,7 @@ sub get_module_info { my ($mod, $noclone, $forcache) = @_; -return () if (!defined($mod) || $mod !~ /^[A-Za-z0-9_-]+$/); +return () if (!defined($mod) || $mod eq "" || $mod =~ /[^A-Za-z0-9_-]/); my (%rv, $clone, $o); my $mdir = &module_root_directory($mod); my $auto = load_language_auto(); From 51b5e9c15a8798aa3085d048eb651be04a978780 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Fri, 9 Oct 2026 03:14:44 +0200 Subject: [PATCH 08/10] Fix systemd validation with dependency warnings --- systemd/systemd-lib.pl | 31 +++++++++++++++++++++++++------ systemd/t/run-tests.t | 31 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/systemd/systemd-lib.pl b/systemd/systemd-lib.pl index 2ca27a756..6ae138144 100644 --- a/systemd/systemd-lib.pl +++ b/systemd/systemd-lib.pl @@ -1553,6 +1553,19 @@ return $name && $name !~ /[\0\r\n\/]/ && $name !~ /^\./ && $name =~ /^[a-z0-9\.\_\-\@:]+\.($units_piped)$/i; } +=head2 unit_verify_options(analyze) + +Returns validation options supported by the given systemd-analyze executable. + +=cut +sub unit_verify_options +{ +my ($analyze) = @_; +# Version 250 added an exit status for warnings in the submitted unit only. +my $help = backquote_command(quotemeta($analyze)." --help 2>/dev/null"); +return !$? && $help =~ /--recursive-errors=/ ? ("--recursive-errors=no") : (); +} + =head2 verify_unit_data(file, data, [user-scope], [user]) Runs C against unit-file contents before a manual save. @@ -1600,11 +1613,12 @@ if (!$write_ok) { # User units have slightly different directive rules, so verify them through # the target user's manager environment when the owner is known. +my @verify_options = unit_verify_options($analyze); my $cmd = $uinfo ? user_manager_command($user, quotemeta($analyze), "--user", - "verify", quotemeta($tmpfile)) : + "verify", @verify_options, quotemeta($tmpfile)) : quotemeta($analyze)." ".($user_scope ? "--user " : ""). - "verify ".quotemeta($tmpfile); + "verify ".join("", map { $_." " } @verify_options).quotemeta($tmpfile); return (0, $bad_user) if (!$cmd); $cmd .= " 2>&1 [0], 'alice', 'verify_dropin_data verifies as the target user'); + + # Modern analyzers distinguish submitted-unit warnings from dependency noise. + local *main::backquote_command = sub { + $? = 0; + return " --recursive-errors=MODE Control verification\n"; + }; + foreach my $dropin (0, 1) { + my $status = 0; + local *main::backquote_logged = sub { + push(@verify_commands, $_[0]); + $? = $status; + return "vendor.service:2: Support for option CPUAccounting= has been removed\n"; + }; + my $validate = sub { + return $dropin ? verify_dropin_data( + '/etc/systemd/system/scoped.service', + "[Service]\nExecStart=/bin/true\n", "[Service]\nRestart=always\n", 0) + : verify_unit_data('/etc/systemd/system/scoped.service', + "[Service]\nExecStart=/bin/true\n", 0); + }; + ($ok, $err) = $validate->(); + ok($ok, 'scoped verification accepts dependency warnings (drop-in='.$dropin.')'); + like($verify_commands[-1], qr/--recursive-errors=no/, + 'scoped verification asks for submitted-unit warning status'); + $status = 256; + ($ok, $err) = $validate->(); + ok(!$ok, 'scoped verification rejects submitted-unit warnings (drop-in='.$dropin.')'); + like($err, qr/CPUAccounting/, 'scoped validation failure retains diagnostics'); + } } { From 3bb7eeb14402c3ff5d8c461b79e259333b5f89e3 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Fri, 9 Oct 2026 03:22:15 +0200 Subject: [PATCH 09/10] Fix UI action alignment and neutral colors --- unauthenticated/css/ui-lib.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/unauthenticated/css/ui-lib.css b/unauthenticated/css/ui-lib.css index 5fcac2459..2469c018f 100644 --- a/unauthenticated/css/ui-lib.css +++ b/unauthenticated/css/ui-lib.css @@ -64,7 +64,7 @@ --ui-info-line: var(--border-color-info, #b8d6f2); --ui-neutral: var(--text-color-muted, #4b5563); --ui-neutral-text: var(--text-color-neutral-stressed, #40474f); - --ui-neutral-soft: var(--bg-color-neutral, #eff1f3); + --ui-neutral-soft: var(--ui-surface-2); --ui-neutral-line: var(--border-color-neutral, #d7dbe0); /* Controls */ @@ -342,6 +342,10 @@ body:has(.ui_page[data-ui-scheme="dark"]) { } .ui_cluster { --ui-gap: 10px; } +/* Separate action forms share the cluster's gap. Theme spacing between stacked + * forms must not offset one action vertically inside a horizontal cluster. */ +.ui_cluster > form.ui_form { margin: 0 !important; } + /* Themes that regroup adjacent submit buttons into one box, as Authentic * does with its btn-group, wrap them inside that box with no vertical * space. Give those rows a gap while keeping the buttons joined. */ @@ -608,6 +612,7 @@ a.ui_stat:hover .ui_stat_value { color: var(--ui-accent); } } :is(button, a).ui_action_rounded { border-radius: 4px; } :is(button, a).ui_action_pill { border-radius: 999px; } +.ui_action .ui_action_content > span { align-self: baseline; } .ui_action .ui_svg_icon { width: 1em; height: 1em; flex-shrink: 0; } .ui_action_small .ui_svg_icon_trash { transform: translateY(-0.25px); From 8f9663fd9cac9f0af0761d500e33af9390b3718f Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Fri, 9 Oct 2026 04:27:59 +0200 Subject: [PATCH 10/10] Add stat tile shapes and consistent spacing --- t/ui-lib-widgets.t | 17 +++++++++++++++++ ui-lib.pl | 12 +++++++++--- unauthenticated/css/ui-lib.css | 6 ++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/t/ui-lib-widgets.t b/t/ui-lib-widgets.t index 53d399055..7022b0158 100644 --- a/t/ui-lib-widgets.t +++ b/t/ui-lib-widgets.t @@ -113,6 +113,23 @@ assert_no_handler_injection( assert_no_handler_injection( main::ui_stat({ 'value' => $xss, 'label' => $xss, 'href' => $xss }), 'ui_stat'); + +# Row defaults must not replace explicit tile shapes or mutate input options. +{ + my $tile = { 'value' => 4 }; + my $square = { 'value' => 2, 'shape' => 'square' }; + my $html = main::ui_stats([$tile, $square], { 'shape' => 'rounded' }); + my @shapes = $html =~ /\bui_stat_(rounded|square)\b/g; + is_deeply(\@shapes, ['rounded', 'square'], + 'ui_stats applies its shape only when a tile has no override'); + is_deeply($tile, { 'value' => 4 }, 'ui_stats preserves caller options'); + like(main::ui_stat({ 'value' => 1 }), qr/\bui_stat_square\b/, + 'ui_stat defaults to square corners'); + $html = main::ui_stat({ 'value' => 1, 'shape' => $xss }); + like($html, qr/\bui_stat_square\b/, 'unknown shapes fall back to square'); + assert_no_handler_injection($html, 'ui_stat shape'); +} + assert_no_handler_injection( main::ui_empty_state({ 'title' => $xss, 'desc' => $xss }), 'ui_empty_state'); diff --git a/ui-lib.pl b/ui-lib.pl index ba0bd5e3a..5ffc3d9bb 100755 --- a/ui-lib.pl +++ b/ui-lib.pl @@ -4852,6 +4852,8 @@ Options are : =item icon - Optional icon name shown before the value. +=item shape - Square corners (square, the default) or rounded corners (rounded). + =cut sub ui_stat { @@ -4873,7 +4875,8 @@ my $desc = &_ui_text($opts, 'desc'); $body .= &ui_tag('span', $desc, { 'class' => 'ui_stat_desc' }) if (defined($desc)); return &_ui_block($opts->{'href'} ? 'a' : 'div', $body, &_ui_attrs({ - 'class' => 'ui_stat', + 'class' => &_ui_class('ui_stat', ($opts->{'shape'} || '') eq 'rounded' ? + 'ui_stat_rounded' : 'ui_stat_square'), 'href' => $opts->{'href'} })); } @@ -4881,7 +4884,8 @@ return &_ui_block($opts->{'href'} ? 'a' : 'div', $body, &_ui_attrs({ Returns HTML for a responsive row of statistic tiles. Each element of the stats array is a hash reference in ui_stat format. Options are C -and C for the minimum tile width. +and C for the minimum tile width, and C for the default corner +shape. An individual tile's C overrides the row's setting. =cut sub ui_stats @@ -4890,7 +4894,9 @@ return &theme_ui_stats(@_) if (defined(&theme_ui_stats)); my ($stats, $opts) = @_; $opts ||= {}; return "" if (!$stats || !@$stats); -return &_ui_block('div', join("", map { &ui_stat($_) } @$stats), +# Apply row defaults to a copy so caller-owned tile options stay unchanged. +return &_ui_block('div', join("", map { + &ui_stat({ 'shape' => $opts->{'shape'}, %$_ }) } @$stats), &_ui_attrs({ 'class' => &_ui_class('ui_stats', $opts->{'class'}), 'style' => $opts->{'min'} ? diff --git a/unauthenticated/css/ui-lib.css b/unauthenticated/css/ui-lib.css index 2469c018f..a1062936f 100644 --- a/unauthenticated/css/ui-lib.css +++ b/unauthenticated/css/ui-lib.css @@ -218,9 +218,9 @@ body:has(.ui_page[data-ui-scheme="dark"]) { .ui_page > * + * { margin-top: var(--ui-gap); } .ui_page :not(.ui_grid, .ui_stack, .ui_cluster, .ui_stats) > - * + :is(.ui_grid, .ui_stack, .ui_card, .ui_empty), + * + :is(.ui_grid, .ui_stack, .ui_card, .ui_empty, .ui_stats), .ui_page :not(.ui_grid, .ui_stack, .ui_cluster, .ui_stats) > - :is(.ui_grid, .ui_stack, .ui_card, .ui_empty) + * { + :is(.ui_grid, .ui_stack, .ui_card, .ui_empty, .ui_stats) + * { margin-top: var(--ui-gap); } .ui_page [hidden] { display: none !important; } @@ -431,6 +431,8 @@ body:has(.ui_page[data-ui-scheme="dark"]) { color: inherit; text-decoration: none; } +.ui_stat_square { border-radius: 0; } +.ui_stat_rounded { border-radius: 0.5em; } .ui_stat_value { font-size: 1.85em; font-weight: 500;