- Fix CRITICAL: safe config parser replacing shell source, sshpass -e, CSRF with /dev/urandom, symlink-safe file I/O - Fix HIGH: input validation for timestamps/accounts, path traversal prevention in Runner.pm, AJAX CSRF on all endpoints - Fix MEDIUM: umask 077, chmod 700 on config dirs, Config.pm TOCTOU lock, rsync exit code capture bug, RSYNC_EXTRA_OPTS character validation - ShellCheck: fix word-splitting in notify.sh, safe rm in pkgacct.sh, suppress cross-file SC2034 false positives - Perl::Critic: return undef→bare return, return (sort), unpack @_, explicit return on void subs, rename Config::write→save - Remove dead code: enforce_retention_all(), rsync_dry_run() - Add require_cmd checks for rsync/ssh/hostname/gzip at startup - Escape $hint/$tip in CGI helper functions for defense-in-depth - Expand tests from 17→40: validate_timestamp, validate_account_name, _safe_source_config (including malicious input), numeric validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
823 lines
26 KiB
Perl
823 lines
26 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';
|
|
|
|
# ── 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;
|
|
}
|
|
|
|
# ── 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) = @_;
|
|
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);
|
|
_safe_write("$CSRF_DIR/token", time() . "\n" . $token . "\n");
|
|
|
|
$_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 → Server Configuration → 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);
|
|
# Inline logo as base64 data URI
|
|
my $logo_html = '';
|
|
if (open my $lfh, '<', $LOGO_FILE) {
|
|
local $/;
|
|
my $svg_data = <$lfh>;
|
|
close $lfh;
|
|
require MIME::Base64;
|
|
my $b64 = MIME::Base64::encode_base64($svg_data, '');
|
|
$logo_html = qq{<div class="flex items-center justify-center gap-3 mb-4"><img src="data:image/svg+xml;base64,$b64" alt="GNIZA" style="height:48px;width:auto"></div>\n};
|
|
}
|
|
return qq{<style>$css</style>\n}
|
|
. qq{<div data-theme="gniza" class="font-sans text-[1.6rem]" style="padding:30px 10px 10px 10px">\n}
|
|
. $logo_html;
|
|
}
|
|
|
|
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;
|