Files
gniza4cp/whm/gniza-whm/lib/GnizaWHM/UI.pm
shuki 43fdf116cc Make navbar responsive with hamburger dropdown on small screens
Desktop (lg+) shows horizontal menu links. Smaller screens show
a dropdown hamburger button with the same navigation items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:44:14 +02:00

855 lines
28 KiB
Perl

package GnizaWHM::UI;
# Shared UI helpers: navigation, flash messages, CSRF, HTML escaping,
# account list, version detection, mode detection, schedule discovery.
use strict;
use warnings;
use Fcntl qw(:flock O_WRONLY O_CREAT O_EXCL);
use IPC::Open3;
use Symbol 'gensym';
my $CSRF_DIR = '/var/cpanel/.gniza-whm-csrf';
my $FLASH_DIR = '/var/cpanel/.gniza-whm-flash';
my $CONSTANTS_FILE = '/usr/local/gniza/lib/constants.sh';
my $MAIN_CONFIG = '/etc/gniza/gniza.conf';
my $REMOTES_DIR = '/etc/gniza/remotes.d';
my $SCHEDULES_DIR = '/etc/gniza/schedules.d';
my $TRUEUSERDOMAINS = '/etc/trueuserdomains';
my $REMOTE_EXAMPLE = '/usr/local/gniza/etc/remote.conf.example';
my $SCHEDULE_EXAMPLE = '/usr/local/gniza/etc/schedule.conf.example';
my $SSH_DIR = '/root/.ssh';
my $CSS_FILE = '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/assets/gniza-whm.css';
my $LOGO_FILE = '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/assets/gniza-logo.svg';
my $_logo_data_uri = ''; # populated by page_header(), used by render_nav()
# ── HTML Escaping ─────────────────────────────────────────────
sub esc {
my ($str) = @_;
$str //= '';
$str =~ s/&/&amp;/g;
$str =~ s/</&lt;/g;
$str =~ s/>/&gt;/g;
$str =~ s/"/&quot;/g;
$str =~ s/'/&#39;/g;
return $str;
}
# ── Navigation ────────────────────────────────────────────────
my @NAV_ITEMS = (
{ url => 'index.cgi', label => 'Dashboard' },
{ url => 'remotes.cgi', label => 'Remotes' },
{ url => 'schedules.cgi', label => 'Schedules' },
{ url => 'restore.cgi', label => 'Restore' },
{ url => 'logs.cgi', label => 'Logs' },
{ url => 'settings.cgi', label => 'Settings' },
);
sub render_nav {
my ($current_page) = @_;
my $logo = '';
if ($_logo_data_uri) {
$logo = qq{<div class="flex items-center gap-2">}
. qq{<img src="$_logo_data_uri" alt="" class="h-12 w-auto">}
. qq{<span class="text-3xl font-bold leading-none">GNIZA <span class="text-secondary">Backup</span></span>}
. qq{</div>};
}
# Build menu items for reuse in both mobile dropdown and desktop menu
my $menu_items = '';
for my $item (@NAV_ITEMS) {
my $active = ($item->{url} eq $current_page) ? ' active' : '';
my $label = esc($item->{label});
$menu_items .= qq{<li><a class="no-underline$active" href="$item->{url}">$label</a></li>\n};
}
my $html = qq{<div class="navbar bg-base-200 rounded-box mb-5">\n};
$html .= qq{ <div class="navbar-start">\n};
# Mobile dropdown (visible on small screens)
$html .= qq{ <details class="dropdown lg:hidden">\n};
$html .= qq{ <summary class="btn btn-ghost btn-sm"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg></summary>\n};
$html .= qq{ <ul class="dropdown-content menu bg-base-200 rounded-box shadow-sm mt-2 w-52 p-2">\n};
$html .= qq{ $menu_items};
$html .= qq{ </ul>\n};
$html .= qq{ </details>\n};
$html .= qq{ $logo\n} if $logo;
$html .= qq{ </div>\n};
$html .= qq{ <div class="navbar-end hidden lg:flex">\n};
$html .= qq{ <ul class="menu menu-horizontal gap-1">\n};
$html .= qq{ $menu_items};
$html .= qq{ </ul>\n};
$html .= qq{ </div>\n};
$html .= qq{</div>\n};
return $html;
}
# ── Safe file I/O (symlink-attack resistant) ─────────────────
sub _safe_write {
my ($file, $content) = @_;
# Remove existing file/symlink before writing (prevents symlink attacks)
unlink $file if -e $file || -l $file;
# Create new file with O_CREAT|O_EXCL (fails if file already exists/race)
if (sysopen my $fh, $file, O_WRONLY | O_CREAT | O_EXCL, 0600) {
print $fh $content;
close $fh;
return 1;
}
return 0;
}
sub _safe_read {
my ($file) = @_;
# Refuse to read symlinks
return if -l $file;
return unless -f $file;
if (open my $fh, '<', $file) {
local $/;
my $content = <$fh>;
close $fh;
return $content;
}
return;
}
# ── Flash Messages ────────────────────────────────────────────
sub _flash_file {
return "$FLASH_DIR/gniza-whm-flash";
}
sub set_flash {
my ($type, $text) = @_;
_ensure_dir($FLASH_DIR);
_safe_write(_flash_file(), "$type\n$text\n");
return;
}
sub get_flash {
my $file = _flash_file();
my $content = _safe_read($file);
return unless defined $content;
unlink $file;
my ($type, $text) = split /\n/, $content, 2;
return unless defined $type && defined $text;
chomp $type;
chomp $text;
return ($type, $text);
}
sub render_flash {
my @flash = get_flash();
return '' unless defined $flash[0];
my ($type, $text) = @flash;
# Validate flash type against allowlist
my %valid_types = map { $_ => 1 } qw(success error info warning);
$type = 'info' unless $valid_types{$type // ''};
my $escaped = esc($text);
return qq{<div class="alert alert-$type mb-4">$escaped</div>\n};
}
sub _ensure_dir {
my ($dir) = @_;
# Remove stale plain file left by older versions (upgrade path)
unlink $dir if -e $dir && ! -d $dir;
unless (-d $dir) {
mkdir $dir, 0700;
}
return;
}
# ── CSRF Protection ──────────────────────────────────────────
sub _csrf_file {
return $CSRF_DIR;
}
my $_current_csrf_token;
sub generate_csrf_token {
# Reuse the same token within a single request so multiple forms
# on one page all share the same valid token.
return $_current_csrf_token if defined $_current_csrf_token;
my $token = '';
if (open my $ufh, '<:raw', '/dev/urandom') {
my $bytes;
read $ufh, $bytes, 32;
close $ufh;
$token = unpack('H*', $bytes);
} else {
# Fallback (should not happen on Linux)
for (1..32) {
$token .= sprintf('%02x', int(rand(256)));
}
}
_ensure_dir($CSRF_DIR);
unless (_safe_write("$CSRF_DIR/token", time() . "\n" . $token . "\n")) {
# O_EXCL can fail if unlink didn't fully remove the file;
# fall back to plain overwrite so the token is always persisted.
if (open my $fh, '>', "$CSRF_DIR/token") {
print $fh time() . "\n" . $token . "\n";
close $fh;
}
}
$_current_csrf_token = $token;
return $token;
}
sub verify_csrf_token {
my ($submitted) = @_;
return 0 unless defined $submitted && $submitted ne '';
my $file = "$CSRF_DIR/token";
my $content = _safe_read($file);
return 0 unless defined $content;
my ($stored_time, $stored_token) = split /\n/, $content, 2;
return 0 unless defined $stored_time && defined $stored_token;
chomp $stored_time;
chomp $stored_token;
# Delete after use (single-use)
unlink $file;
# Check expiry (1 hour)
return 0 if (time() - $stored_time) > 3600;
# Constant-time comparison
return 0 if length($submitted) != length($stored_token);
my $result = 0;
for my $i (0 .. length($submitted) - 1) {
$result |= ord(substr($submitted, $i, 1)) ^ ord(substr($stored_token, $i, 1));
}
return $result == 0;
}
sub csrf_hidden_field {
my $token = generate_csrf_token();
return qq{<input type="hidden" name="gniza_csrf" value="} . esc($token) . qq{">};
}
# ── Account List ──────────────────────────────────────────────
sub get_cpanel_accounts {
my @accounts;
if (open my $fh, '<', $TRUEUSERDOMAINS) {
while (my $line = <$fh>) {
chomp $line;
if ($line =~ /:\s*(\S+)/) {
push @accounts, $1;
}
}
close $fh;
}
my %seen;
@accounts = sort grep { !$seen{$_}++ } @accounts;
return @accounts;
}
# ── gniza Version ────────────────────────────────────────────
sub get_gniza_version {
if (open my $fh, '<', $CONSTANTS_FILE) {
while (my $line = <$fh>) {
if ($line =~ /GNIZA_VERSION="([^"]+)"/) {
close $fh;
return $1;
}
}
close $fh;
}
return 'unknown';
}
# ── Remote Discovery ─────────────────────────────────────────
sub has_remotes {
return 0 unless -d $REMOTES_DIR;
if (opendir my $dh, $REMOTES_DIR) {
while (my $entry = readdir $dh) {
if ($entry =~ /\.conf$/) {
closedir $dh;
return 1;
}
}
closedir $dh;
}
return 0;
}
sub list_remotes {
my @remotes;
if (-d $REMOTES_DIR && opendir my $dh, $REMOTES_DIR) {
while (my $entry = readdir $dh) {
if ($entry =~ /^(.+)\.conf$/) {
push @remotes, $1;
}
}
closedir $dh;
}
return (sort @remotes);
}
sub remote_conf_path {
my ($name) = @_;
return "$REMOTES_DIR/$name.conf";
}
sub remote_example_path {
return $REMOTE_EXAMPLE;
}
# ── Schedule Discovery ───────────────────────────────────────
sub has_schedules {
return 0 unless -d $SCHEDULES_DIR;
if (opendir my $dh, $SCHEDULES_DIR) {
while (my $entry = readdir $dh) {
if ($entry =~ /\.conf$/) {
closedir $dh;
return 1;
}
}
closedir $dh;
}
return 0;
}
sub list_schedules {
my @schedules;
if (-d $SCHEDULES_DIR && opendir my $dh, $SCHEDULES_DIR) {
while (my $entry = readdir $dh) {
if ($entry =~ /^(.+)\.conf$/) {
push @schedules, $1;
}
}
closedir $dh;
}
return (sort @schedules);
}
sub schedule_conf_path {
my ($name) = @_;
return "$SCHEDULES_DIR/$name.conf";
}
sub schedule_example_path {
return $SCHEDULE_EXAMPLE;
}
# ── Configuration Detection ──────────────────────────────────
sub is_configured {
return has_remotes();
}
# ── SSH Key Detection ────────────────────────────────────────
my @SSH_KEY_TYPES = (
{ file => 'id_ed25519', type => 'ed25519' },
{ file => 'id_rsa', type => 'rsa' },
{ file => 'id_ecdsa', type => 'ecdsa' },
{ file => 'id_dsa', type => 'dsa' },
);
sub detect_ssh_keys {
my @keys;
for my $kt (@SSH_KEY_TYPES) {
my $path = "$SSH_DIR/$kt->{file}";
next unless -f $path;
push @keys, {
path => $path,
type => $kt->{type},
has_pub => (-f "$path.pub") ? 1 : 0,
};
}
return \@keys;
}
sub render_ssh_guidance {
my $keys = detect_ssh_keys();
my $html = qq{<div class="collapse collapse-arrow bg-base-200 shadow-sm border border-base-300 mb-6">\n};
$html .= qq{<input type="checkbox">\n};
$html .= qq{<div class="collapse-title font-medium text-sm">SSH Key Setup</div>\n};
$html .= qq{<div class="collapse-content">\n};
if (@$keys) {
$html .= qq{<p>Existing SSH keys detected on this server:</p>\n};
$html .= qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
$html .= qq{<tr><th>Type</th><th>Path</th><th>Public Key</th></tr>\n};
for my $k (@$keys) {
my $pub = $k->{has_pub} ? 'Available' : 'Missing';
$html .= qq{<tr><td>} . esc($k->{type}) . qq{</td>};
$html .= qq{<td><code>} . esc($k->{path}) . qq{</code></td>};
$html .= qq{<td>$pub</td></tr>\n};
}
$html .= qq{</table></div>\n};
} else {
$html .= qq{<p>No SSH keys found in <code>/root/.ssh/</code>.</p>\n};
}
$html .= qq{<p class="mt-3"><strong>Generate a new key</strong> (if needed):</p>\n};
$html .= qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto my-2">ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""</pre>\n};
$html .= qq{<p><strong>Copy the public key</strong> to the remote server:</p>\n};
$html .= qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto my-2">ssh-copy-id -i /root/.ssh/id_ed25519.pub user\@host</pre>\n};
$html .= qq{<p class="text-xs text-base-content/60 mt-2">Run these commands in WHM &rarr; Server Configuration &rarr; Terminal, or via SSH.</p>\n};
$html .= qq{</div>\n</div>\n};
return $html;
}
# ── SSH Connection Test ──────────────────────────────────────
sub test_ssh_connection {
my @params = @_;
# Support legacy positional args: ($host, $port, $user, $key)
my %args;
if (@params == 4 && !ref $params[0]) {
%args = (host => $params[0], port => $params[1], user => $params[2], key => $params[3]);
} else {
%args = @params;
}
my $host = $args{host};
my $port = $args{port} || '22';
my $user = $args{user} || 'root';
my $auth_method = $args{auth_method} || 'key';
my $key = $args{key} // '';
my $password = $args{password} // '';
my @ssh_args = (
'ssh', '-n',
'-p', $port,
'-o', 'StrictHostKeyChecking=accept-new',
'-o', 'ConnectTimeout=10',
'-o', 'ServerAliveInterval=60',
'-o', 'ServerAliveCountMax=3',
);
my @cmd;
if ($auth_method eq 'password') {
push @ssh_args, "$user\@$host", 'echo ok';
@cmd = ('sshpass', '-p', $password, @ssh_args);
} else {
push @ssh_args, '-i', $key, '-o', 'BatchMode=yes';
push @ssh_args, "$user\@$host", 'echo ok';
@cmd = @ssh_args;
}
my ($in, $out, $err_fh) = (undef, undef, gensym);
my $pid = eval { open3($in, $out, $err_fh, @cmd) };
unless ($pid) {
return (0, "Failed to run ssh: $@");
}
close $in if $in;
my $stdout = do { local $/; <$out> } // '';
my $stderr = do { local $/; <$err_fh> } // '';
close $out;
close $err_fh;
waitpid($pid, 0);
my $exit_code = $? >> 8;
chomp $stdout;
chomp $stderr;
if ($exit_code == 0 && $stdout eq 'ok') {
return (1, undef);
}
my $msg = $stderr || "SSH exited with code $exit_code";
return (0, $msg);
}
# ── Rclone Connection Test ────────────────────────────────────
sub test_rclone_connection {
my (%args) = @_;
my $type = $args{type} // 's3';
# Build temp rclone config
my $tmpfile = "/tmp/gniza-rclone-test-$$.conf";
my $conf_content = '';
my $test_path = '';
if ($type eq 's3') {
my $key_id = $args{s3_access_key_id} // '';
my $secret = $args{s3_secret_access_key} // '';
my $region = $args{s3_region} || 'us-east-1';
my $endpoint = $args{s3_endpoint} // '';
my $bucket = $args{s3_bucket} // '';
$conf_content = "[remote]\ntype = s3\nprovider = AWS\naccess_key_id = $key_id\nsecret_access_key = $secret\nregion = $region\n";
$conf_content .= "endpoint = $endpoint\n" if $endpoint ne '';
$test_path = "remote:$bucket";
}
elsif ($type eq 'gdrive') {
my $sa_file = $args{gdrive_service_account_file} // '';
my $folder_id = $args{gdrive_root_folder_id} // '';
$conf_content = "[remote]\ntype = drive\nscope = drive\nservice_account_file = $sa_file\n";
$conf_content .= "root_folder_id = $folder_id\n" if $folder_id ne '';
$test_path = "remote:";
}
else {
return (0, "Unknown remote type: $type");
}
# Write config
if (open my $fh, '>', $tmpfile) {
print $fh $conf_content;
close $fh;
chmod 0600, $tmpfile;
} else {
return (0, "Failed to write temp rclone config: $!");
}
# Run rclone lsd
my @cmd = ('rclone', '--config', $tmpfile, 'lsd', $test_path);
my ($in, $out, $err_fh) = (undef, undef, gensym);
my $pid = eval { open3($in, $out, $err_fh, @cmd) };
unless ($pid) {
unlink $tmpfile;
return (0, "Failed to run rclone: $@");
}
close $in if $in;
my $stdout = do { local $/; <$out> } // '';
my $stderr = do { local $/; <$err_fh> } // '';
close $out;
close $err_fh;
waitpid($pid, 0);
my $exit_code = $? >> 8;
unlink $tmpfile;
chomp $stderr;
if ($exit_code == 0) {
return (1, undef);
}
my $msg = $stderr || "rclone exited with code $exit_code";
return (0, $msg);
}
# ── Remote Init (create base directory) ──────────────────────
sub init_remote_dir {
my (%args) = @_;
my $type = $args{type} // 'ssh';
my $base = $args{remote_base} // '/backups';
chomp(my $hostname = `hostname -f 2>/dev/null` || `hostname`);
my $remote_path = "$base/$hostname/accounts";
if ($type eq 'ssh') {
my @ssh_args = (
'ssh', '-n',
'-p', ($args{port} || '22'),
'-o', 'StrictHostKeyChecking=accept-new',
'-o', 'ConnectTimeout=10',
'-o', 'BatchMode=yes',
);
if (($args{auth_method} // 'key') eq 'password') {
push @ssh_args, "$args{user}\@$args{host}", "mkdir -p '$remote_path'";
my @cmd = ('sshpass', '-p', $args{password}, @ssh_args);
system(@cmd);
} else {
push @ssh_args, '-i', $args{key};
push @ssh_args, "$args{user}\@$args{host}", "mkdir -p '$remote_path'";
system(@ssh_args);
}
return ($? == 0, $? == 0 ? undef : "Failed to create remote directory");
}
elsif ($type eq 's3' || $type eq 'gdrive') {
my $tmpfile = "/tmp/gniza-rclone-init-$$.conf";
my $conf_content = '';
my $rclone_path = '';
if ($type eq 's3') {
$conf_content = "[remote]\ntype = s3\nprovider = AWS\naccess_key_id = $args{s3_access_key_id}\nsecret_access_key = $args{s3_secret_access_key}\nregion = " . ($args{s3_region} || 'us-east-1') . "\n";
$conf_content .= "endpoint = $args{s3_endpoint}\n" if ($args{s3_endpoint} // '') ne '';
$rclone_path = "remote:$args{s3_bucket}$remote_path";
} else {
$conf_content = "[remote]\ntype = drive\nscope = drive\nservice_account_file = $args{gdrive_service_account_file}\n";
$conf_content .= "root_folder_id = $args{gdrive_root_folder_id}\n" if ($args{gdrive_root_folder_id} // '') ne '';
$rclone_path = "remote:$remote_path";
}
if (open my $fh, '>', $tmpfile) {
print $fh $conf_content;
close $fh;
chmod 0600, $tmpfile;
} else {
return (0, "Failed to write temp rclone config: $!");
}
system('rclone', '--config', $tmpfile, 'mkdir', $rclone_path);
my $ok = ($? == 0);
unlink $tmpfile;
return ($ok, $ok ? undef : "Failed to create remote directory");
}
return (0, "Unknown remote type: $type");
}
# ── SMTP Connection Test ──────────────────────────────────────
sub test_smtp_connection {
my (%args) = @_;
my $host = $args{host} // '';
my $port = $args{port} || '587';
my $user = $args{user} // '';
my $password = $args{password} // '';
my $from = $args{from} // $user;
my $security = $args{security} || 'tls';
my $to = $args{to} // '';
return (0, 'SMTP host is required') if $host eq '';
return (0, 'Recipient email is required') if $to eq '';
return (0, 'From address or SMTP user is required') if $from eq '';
# Build the test email
chomp(my $hostname = `hostname -f 2>/dev/null` || `hostname`);
my $date = `date -R`; chomp $date;
my $message = "From: $from\r\nTo: $to\r\nSubject: [gniza] SMTP Test from $hostname\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\nDate: $date\r\n\r\n"
. "This is a test email from gniza on $hostname.\r\n"
. "If you received this, SMTP is configured correctly.\r\n";
# Write message to temp file
my $tmpfile = "/tmp/gniza-smtp-test-$$.eml";
if (open my $fh, '>', $tmpfile) {
print $fh $message;
close $fh;
} else {
return (0, "Failed to write temp file: $!");
}
# Build curl command
my @cmd = ('curl', '--silent', '--show-error', '--connect-timeout', '30', '--max-time', '60');
if ($security eq 'ssl') {
push @cmd, '--url', "smtps://$host:$port";
} elsif ($security eq 'tls') {
push @cmd, '--url', "smtp://$host:$port", '--ssl-reqd';
} else {
push @cmd, '--url', "smtp://$host:$port";
}
if ($user ne '') {
push @cmd, '--user', "$user:$password";
}
push @cmd, '--mail-from', $from;
# Support multiple recipients
my @recipients = split /\s*,\s*/, $to;
for my $rcpt (@recipients) {
next if $rcpt eq '';
push @cmd, '--mail-rcpt', $rcpt;
}
push @cmd, '-T', $tmpfile;
my ($in, $out, $err_fh) = (undef, undef, gensym);
my $pid = eval { open3($in, $out, $err_fh, @cmd) };
unless ($pid) {
unlink $tmpfile;
return (0, "Failed to run curl: $@");
}
close $in if $in;
my $stdout = do { local $/; <$out> } // '';
my $stderr = do { local $/; <$err_fh> } // '';
close $out;
close $err_fh;
waitpid($pid, 0);
my $exit_code = $? >> 8;
unlink $tmpfile;
chomp $stderr;
if ($exit_code == 0) {
return (1, undef);
}
my $msg = $stderr || "curl exited with code $exit_code";
return (0, $msg);
}
# ── Page Wrappers ────────────────────────────────────────────
sub page_header {
my ($title) = @_;
$title = esc($title // 'GNIZA Backup Manager');
my $css = '';
if (open my $fh, '<', $CSS_FILE) {
local $/;
$css = <$fh>;
close $fh;
}
# Strip @layer wrappers so our styles are un-layered and compete
# with WHM's CSS on normal specificity instead of losing to it.
$css = _unwrap_layers($css);
# Scope :root/:host to our container so DaisyUI base styles
# (background, color, overflow, scrollbar) don't leak into WHM.
$css = _scope_to_container($css);
# Pre-compute logo data URI for render_nav()
if (!$_logo_data_uri && open my $lfh, '<', $LOGO_FILE) {
local $/;
my $svg_data = <$lfh>;
close $lfh;
require MIME::Base64;
$_logo_data_uri = 'data:image/svg+xml;base64,' . MIME::Base64::encode_base64($svg_data, '');
}
return qq{<style>$css</style>\n}
. qq{<div data-theme="gniza" class="font-sans text-[1.6rem]" style="padding:30px 10px 10px 10px">\n};
}
sub _unwrap_layers {
my ($css) = @_;
# Loop until all @layer wrappers are removed (handles nesting)
while ($css =~ /\@layer\s/) {
# Remove @layer order declarations: @layer components; @layer theme, base;
$css =~ s/\@layer\s+[\w.,\s]+\s*;//g;
# Unwrap @layer name { ... } blocks, keeping inner contents
my $out = '';
my $i = 0;
my $len = length($css);
while ($i < $len) {
if (substr($css, $i, 6) eq '@layer') {
my $brace = index($css, '{', $i);
if ($brace == -1) { $out .= substr($css, $i); last; }
my $semi = index($css, ';', $i);
if ($semi != -1 && $semi < $brace) {
$i = $semi + 1;
next;
}
my $depth = 1;
my $j = $brace + 1;
while ($j < $len && $depth > 0) {
my $c = substr($css, $j, 1);
$depth++ if $c eq '{';
$depth-- if $c eq '}';
$j++;
}
$out .= substr($css, $brace + 1, $j - $brace - 2);
$i = $j;
} else {
$out .= substr($css, $i, 1);
$i++;
}
}
$css = $out;
}
return $css;
}
sub _scope_to_container {
my ($css) = @_;
# Step 1: Replace :root/:host with & so CSS variables and base styles
# attach to our container element (not lost as descendant selectors).
$css =~ s/:root,\s*:host/\&/g;
$css =~ s/:where\(:root,\s*\[data-theme[^\]]*\]\)/\&/g;
$css =~ s/:where\(:root\)/\&/g;
$css =~ s/:root,\s*\[data-theme[^\]]*\]/\&/g;
$css =~ s/\[data-theme=light\]/\&/g;
$css =~ s/\[data-theme=gniza\]/\&/g;
$css =~ s/:root:not\(span\)/\&/g;
$css =~ s/:root:has\(/\&:has(/g;
$css =~ s/:root\b/\&/g;
# Step 2: Extract @keyframes and @property to keep at top level
my @top_level;
my $scoped = '';
my $i = 0;
my $len = length($css);
while ($i < $len) {
if (substr($css, $i, 1) eq '@') {
if (substr($css, $i, 11) eq '@keyframes '
|| substr($css, $i, 10) eq '@property ') {
my $brace = index($css, '{', $i);
if ($brace == -1) { $scoped .= substr($css, $i); last; }
my $depth = 1;
my $j = $brace + 1;
while ($j < $len && $depth > 0) {
my $c = substr($css, $j, 1);
$depth++ if $c eq '{';
$depth-- if $c eq '}';
$j++;
}
push @top_level, substr($css, $i, $j - $i);
$i = $j;
next;
}
}
$scoped .= substr($css, $i, 1);
$i++;
}
# Step 3: Wrap in container scope — & references resolve to this selector
return join('', @top_level) . '[data-theme="gniza"]{' . $scoped . '}';
}
sub page_footer {
return qq{<script>
(function(){
document.querySelectorAll('table.table').forEach(function(table){
var headers = table.querySelectorAll('thead th');
headers.forEach(function(th, colIdx){
if(!th.textContent.trim()) return;
th.style.cursor='pointer';
th.style.userSelect='none';
th.dataset.sortDir='';
th.addEventListener('click',function(){
var tbody=table.querySelector('tbody');
if(!tbody) return;
var rows=Array.from(tbody.querySelectorAll('tr'));
headers.forEach(function(h){if(h!==th){h.dataset.sortDir='';h.textContent=h.textContent.replace(/ [\\u25B2\\u25BC]\$/,'')}});
var dir=th.dataset.sortDir==='asc'?'desc':'asc';
th.dataset.sortDir=dir;
th.textContent=th.textContent.replace(/ [\\u25B2\\u25BC]\$/,'')+(dir==='asc'?' \\u25B2':' \\u25BC');
rows.sort(function(a,b){
var ca=a.cells[colIdx],cb=b.cells[colIdx];
if(!ca||!cb) return 0;
var va=ca.textContent.trim(),vb=cb.textContent.trim();
var na=parseFloat(va.replace(/[^0-9.\\-]/g,'')),nb=parseFloat(vb.replace(/[^0-9.\\-]/g,''));
if(!isNaN(na)&&!isNaN(nb)&&va.match(/^[0-9.,\\-\\s]+[A-Za-z\\s]*\$/)){
return dir==='asc'?na-nb:nb-na;
}
return dir==='asc'?va.localeCompare(vb):vb.localeCompare(va);
});
rows.forEach(function(r){tbody.appendChild(r)});
});
});
});
})();
</script>\n</div>\n};
}
sub render_errors {
my ($errors) = @_;
return '' unless $errors && @$errors;
my $html = qq{<div class="alert alert-error mb-4">\n<ul class="list-disc pl-5">\n};
for my $err (@$errors) {
$html .= ' <li>' . esc($err) . "</li>\n";
}
$html .= "</ul>\n</div>\n";
return $html;
}
1;