737 lines
23 KiB
Perl
737 lines
23 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);
|
|
use IPC::Open3;
|
|
use Symbol 'gensym';
|
|
|
|
my $CSRF_DIR = '/var/cpanel/.gniza-whm-csrf';
|
|
my $FLASH_DIR = '/tmp';
|
|
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';
|
|
|
|
# ── HTML Escaping ─────────────────────────────────────────────
|
|
|
|
sub esc {
|
|
my ($str) = @_;
|
|
$str //= '';
|
|
$str =~ s/&/&/g;
|
|
$str =~ s/</</g;
|
|
$str =~ s/>/>/g;
|
|
$str =~ s/"/"/g;
|
|
$str =~ s/'/'/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 $html = qq{<div role="tablist" class="tabs tabs-box tabs-lg mb-5 mx-auto w-fit">\n};
|
|
for my $item (@NAV_ITEMS) {
|
|
my $active = ($item->{url} eq $current_page) ? ' tab-active' : '';
|
|
my $label = esc($item->{label});
|
|
$html .= qq{ <a role="tab" class="tab no-underline$active" href="$item->{url}">$label</a>\n};
|
|
}
|
|
$html .= qq{</div>\n};
|
|
return $html;
|
|
}
|
|
|
|
# ── Flash Messages ────────────────────────────────────────────
|
|
|
|
sub _flash_file {
|
|
return "$FLASH_DIR/gniza-whm-flash-$$";
|
|
}
|
|
|
|
sub set_flash {
|
|
my ($type, $text) = @_;
|
|
my $file = "$FLASH_DIR/gniza-whm-flash";
|
|
if (open my $fh, '>', $file) {
|
|
print $fh "$type\n$text\n";
|
|
close $fh;
|
|
}
|
|
}
|
|
|
|
sub get_flash {
|
|
my $file = "$FLASH_DIR/gniza-whm-flash";
|
|
return undef unless -f $file;
|
|
|
|
my ($type, $text);
|
|
if (open my $fh, '<', $file) {
|
|
$type = <$fh>;
|
|
$text = <$fh>;
|
|
close $fh;
|
|
}
|
|
unlink $file;
|
|
|
|
return undef 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;
|
|
my $escaped = esc($text);
|
|
return qq{<div class="alert alert-$type mb-4">$escaped</div>\n};
|
|
}
|
|
|
|
# ── 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 = '';
|
|
for (1..32) {
|
|
$token .= sprintf('%02x', int(rand(256)));
|
|
}
|
|
|
|
if (open my $fh, '>', $CSRF_DIR) {
|
|
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 '';
|
|
return 0 unless -f $CSRF_DIR;
|
|
|
|
my ($stored_time, $stored_token);
|
|
if (open my $fh, '<', $CSRF_DIR) {
|
|
$stored_time = <$fh>;
|
|
$stored_token = <$fh>;
|
|
close $fh;
|
|
}
|
|
return 0 unless defined $stored_time && defined $stored_token;
|
|
|
|
chomp $stored_time;
|
|
chomp $stored_token;
|
|
|
|
# Delete after use (single-use)
|
|
unlink $CSRF_DIR;
|
|
|
|
# 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="card bg-base-200 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
|
|
$html .= qq{<h2 class="card-title text-sm">SSH Key Setup</h2>\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 → Server Configuration → Terminal, or via SSH.</p>\n};
|
|
$html .= qq{</div>\n</div>\n};
|
|
|
|
return $html;
|
|
}
|
|
|
|
# ── SSH Connection Test ──────────────────────────────────────
|
|
|
|
sub test_ssh_connection {
|
|
my (%args) = @_;
|
|
|
|
# Support legacy positional args: ($host, $port, $user, $key)
|
|
if (@_ == 4 && !ref $_[0]) {
|
|
%args = (host => $_[0], port => $_[1], user => $_[2], key => $_[3]);
|
|
}
|
|
|
|
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);
|
|
return qq{<style>$css</style>\n}
|
|
. qq{<div data-theme="gniza" class="font-sans text-[2.3rem]" style="padding: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 "</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;
|