diff --git a/CLAUDE.md b/CLAUDE.md index fdd3461..83ebd13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ etc/ └── schedule.conf.example # Schedule config template scripts/ ├── install.sh # Install to /usr/local/gniza, create dirs/symlinks -└── uninstall.sh # Remove install dir and symlink +└── uninstall.sh # Remove install dir, symlink, cron entries, WHM plugin tests/ └── test_utils.sh # Unit tests for utils.sh, accounts.sh, config.sh whm/ @@ -63,6 +63,19 @@ whm/ ├── Cron.pm # Cron read + allowlisted gniza schedule commands ├── Runner.pm # Pattern-based safe CLI command runner for WHM └── UI.pm # Nav, flash, CSRF, HTML escaping, CSS delivery +cpanel/ +├── gniza/ +│ ├── index.live.cgi # Category grid — 8 restore type cards +│ ├── restore.live.cgi # Multi-step restore workflow (4 steps) +│ ├── install.json # cPanel plugin registration (Files section) +│ ├── assets/ +│ │ ├── gniza-whm.css # Built CSS (copy of WHM CSS) +│ │ └── gniza-logo.svg # Logo (copy of WHM logo) +│ └── lib/GnizaCPanel/ +│ └── UI.pm # Page wrapper, CSRF, flash, CSS delivery +└── admin/Gniza/ + ├── Restore # AdminBin module (runs as root, privilege escalation) + └── Restore.conf # AdminBin config (mode=full) ``` ## Architecture @@ -170,6 +183,46 @@ Cron entries are tagged with `# gniza:` comment lines. `install_schedules( `get_target_remotes()` accepts comma-separated remote names via `--remote=nas,offsite`. It splits on commas, verifies each remote exists, and outputs one name per line. This enables both CLI usage and schedule configs targeting multiple remotes. +### cPanel User Restore Plugin + +Allows cPanel account owners to restore their own data (files, databases, email, etc.) without WHM admin access. + +**Privilege escalation:** Uses cPanel's AdminBin framework. CGIs run as the logged-in cPanel user; the AdminBin module (`cpanel/admin/Gniza/Restore`) runs as root. The account parameter is always forced to `$ENV{'REMOTE_USER'}` (cPanel-authenticated), never from user input. + +**Security model:** +- Account isolation: AdminBin forces the authenticated username — users can only restore their own data +- No `--terminate`: AdminBin never passes the terminate flag, preventing destructive full restores +- Remote filtering: `USER_RESTORE_REMOTES` config controls which remotes users can access (`"all"`, comma-separated names, or empty to disable) +- Strict regex validation on all arguments (mirrors `GnizaWHM::Runner` patterns) +- Per-user CSRF tokens at `/tmp/.gniza-cpanel-csrf-$user` + +**Install locations:** +- CGIs: `/usr/local/cpanel/base/frontend/jupiter/gniza/` +- AdminBin: `/usr/local/cpanel/bin/admin/Gniza/` +- Plugin registration: via `install_plugin` with `install.json` + +**Workflow:** Category grid (`index.live.cgi`) → 4-step restore (`restore.live.cgi`): select remote/snapshot → select items → confirm → execute + +### GnizaCPanel::UI + +| Function | Description | +|----------|-------------| +| `esc($str)` | HTML-escape a string | +| `get_current_user()` | Returns `$ENV{'REMOTE_USER'}` | +| `page_header($title)` | Inline CSS + `data-theme="gniza"` wrapper + logo | +| `page_footer()` | Close wrapper div | +| `set_flash($type, $text)` | Store flash message for next page load | +| `render_flash()` | Render and consume stored flash message | +| `csrf_hidden_field()` | Generate CSRF token + hidden input | +| `verify_csrf_token($token)` | Validate submitted CSRF token | +| `render_errors(\@errors)` | Render error list as HTML | + +### AdminBin Module (Gniza::Restore) + +Actions: `LIST_ALLOWED_REMOTES`, `LIST_SNAPSHOTS`, `LIST_DATABASES`, `LIST_MAILBOXES`, `LIST_FILES`, `LIST_DBUSERS`, `LIST_CRON`, `LIST_DNS`, `LIST_SSL`, `RESTORE_ACCOUNT`, `RESTORE_FILES`, `RESTORE_DATABASE`, `RESTORE_MAILBOX`, `RESTORE_CRON`, `RESTORE_DBUSERS`, `RESTORE_DOMAINS`, `RESTORE_SSL` + +Called from CGI via: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION', @args)` + ## Coding Conventions ### Bash Style @@ -271,6 +324,7 @@ Contains only local settings. Remote destinations are configured in `remotes.d/` | `SSH_TIMEOUT` | No | `30` | SSH connection timeout (seconds) | | `SSH_RETRIES` | No | `3` | rsync retry attempts | | `RSYNC_EXTRA_OPTS` | No | (empty) | Extra rsync options | +| `USER_RESTORE_REMOTES` | No | `all` | Remotes for cPanel user restore (`all`, comma-separated names, or empty to disable) | ### Remote Config (`/etc/gniza/remotes.d/.conf`) diff --git a/README.md b/README.md index e366cae..5852fd4 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,12 @@ sudo bash scripts/install.sh To uninstall: ```bash -sudo bash /usr/local/gniza/scripts/uninstall.sh # from installed copy +sudo bash /usr/local/gniza/uninstall.sh # from installed copy # or -sudo bash scripts/uninstall.sh # from repo clone +sudo bash scripts/uninstall.sh # from repo clone ``` -The uninstall script removes the CLI, WHM plugin, and symlink. Config (`/etc/gniza/`), logs (`/var/log/gniza/`), and cron entries are left for manual cleanup. +The uninstall script removes the CLI, symlink, cron entries, and WHM plugin. Config (`/etc/gniza/`) and logs (`/var/log/gniza/`) are preserved — remove manually if desired. ## Quick Start diff --git a/bin/gniza b/bin/gniza index 578192e..7950047 100755 --- a/bin/gniza +++ b/bin/gniza @@ -3,6 +3,7 @@ # CLI entrypoint and command routing set -euo pipefail +umask 077 # Resolve install directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -94,6 +95,10 @@ _backup_to_current_remote() { cmd_backup() { require_root + require_cmd rsync + require_cmd ssh + require_cmd hostname + require_cmd gzip local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" load_config "$config_file" validate_config || die "Invalid configuration" @@ -286,6 +291,9 @@ _test_connection() { cmd_restore() { require_root + require_cmd rsync + require_cmd ssh + require_cmd hostname local subcommand="${1:-}" shift 2>/dev/null || true @@ -294,6 +302,7 @@ cmd_restore() { local name="${1:-}" shift 2>/dev/null || true [[ -z "$name" ]] && die "Usage: gniza restore account [--remote=NAME] [--timestamp=TS] [--terminate]" + validate_account_name "$name" || die "Invalid account name" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" load_config "$config_file" @@ -304,6 +313,7 @@ cmd_restore() { _restore_load_remote "$remote_flag" local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp="" + [[ -n "$timestamp" ]] && { validate_timestamp "$timestamp" || die "Invalid timestamp"; } local exclude; exclude=$(get_opt exclude "$@" 2>/dev/null) || exclude="" local terminate_val; terminate_val=$(get_opt terminate "$@" 2>/dev/null) || terminate_val="" local terminate=false @@ -316,6 +326,7 @@ cmd_restore() { local name="${1:-}" shift 2>/dev/null || true [[ -z "$name" ]] && die "Usage: gniza restore files [--remote=NAME] [--path=subpath] [--timestamp=TS]" + validate_account_name "$name" || die "Invalid account name" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" load_config "$config_file" @@ -327,6 +338,7 @@ cmd_restore() { local subpath; subpath=$(get_opt path "$@" 2>/dev/null) || subpath="" local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp="" + [[ -n "$timestamp" ]] && { validate_timestamp "$timestamp" || die "Invalid timestamp"; } local exclude; exclude=$(get_opt exclude "$@" 2>/dev/null) || exclude="" _test_connection @@ -336,6 +348,7 @@ cmd_restore() { local name="${1:-}" local dbname="${2:-}" [[ -z "$name" ]] && die "Usage: gniza restore database [] [--remote=NAME] [--timestamp=TS]" + validate_account_name "$name" || die "Invalid account name" shift 2>/dev/null || true # If dbname looks like a flag, it's not a dbname if [[ -n "$dbname" && "$dbname" != --* ]]; then @@ -353,6 +366,7 @@ cmd_restore() { _restore_load_remote "$remote_flag" local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp="" + [[ -n "$timestamp" ]] && { validate_timestamp "$timestamp" || die "Invalid timestamp"; } _test_connection if [[ -n "$dbname" ]]; then @@ -365,6 +379,7 @@ cmd_restore() { local name="${1:-}" local email="${2:-}" [[ -z "$name" ]] && die "Usage: gniza restore mailbox [] [--remote=NAME] [--timestamp=TS]" + validate_account_name "$name" || die "Invalid account name" shift 2>/dev/null || true # If email looks like a flag, it's not an email if [[ -n "$email" && "$email" != --* ]]; then @@ -382,6 +397,7 @@ cmd_restore() { _restore_load_remote "$remote_flag" local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp="" + [[ -n "$timestamp" ]] && { validate_timestamp "$timestamp" || die "Invalid timestamp"; } _test_connection if [[ -n "$email" ]]; then @@ -1579,7 +1595,8 @@ EOF # ── Main routing ─────────────────────────────────────────────── main() { - # Global --debug flag + # Global --debug flag (used by config.sh load_config) + # shellcheck disable=SC2034 has_flag debug "$@" && GNIZA_DEBUG=true || GNIZA_DEBUG=false local command="${1:-}" diff --git a/cpanel/admin/Gniza/Restore b/cpanel/admin/Gniza/Restore new file mode 100644 index 0000000..7a5c89d --- /dev/null +++ b/cpanel/admin/Gniza/Restore @@ -0,0 +1,409 @@ +package Cpanel::AdminBin::Script::Call::Gniza::Restore; + +use strict; +use warnings; +use parent 'Cpanel::AdminBin::Script::Call'; +use IPC::Open3; +use Symbol 'gensym'; + +my $GNIZA_BIN = '/usr/local/bin/gniza'; +my $MAIN_CONFIG = '/etc/gniza/gniza.conf'; +my $REMOTES_DIR = '/etc/gniza/remotes.d'; + +# Argument validation patterns (mirrors GnizaWHM::Runner) +my %OPT_PATTERNS = ( + remote => qr/^[a-zA-Z0-9_,-]+$/, + timestamp => qr/^\d{4}-\d{2}-\d{2}T\d{6}$/, + path => qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/, + exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/, +); + +my $ACCOUNT_RE = qr/^[a-z][a-z0-9_-]*$/; +my $REMOTE_RE = qr/^[a-zA-Z0-9_-]+$/; +my $DBNAME_RE = qr/^[a-zA-Z0-9_]+$/; +my $EMAIL_RE = qr/^[a-zA-Z0-9._+-]+\@[a-zA-Z0-9._-]+$/; +my $DOMAIN_RE = qr/^[a-zA-Z0-9._-]+$/; +my $TS_RE = qr/^\d{4}-\d{2}-\d{2}T\d{6}$/; + +# ── Allowed remotes for user restore ────────────────────────── + +sub _get_allowed_remotes { + my $setting = ''; + if (open my $fh, '<', $MAIN_CONFIG) { + while (my $line = <$fh>) { + if ($line =~ /^USER_RESTORE_REMOTES=(?:"([^"]*)"|'([^']*)'|(\S*))$/) { + $setting = defined $1 ? $1 : (defined $2 ? $2 : ($3 // '')); + } + } + close $fh; + } + # Default to "all" if not set + $setting = 'all' if !defined $setting || $setting eq ''; + + return $setting; +} + +sub _list_all_remotes { + my @remotes; + if (-d $REMOTES_DIR && opendir my $dh, $REMOTES_DIR) { + while (my $entry = readdir $dh) { + if ($entry =~ /^([a-zA-Z0-9_-]+)\.conf$/) { + push @remotes, $1; + } + } + closedir $dh; + } + return sort @remotes; +} + +sub _is_remote_allowed { + my ($remote) = @_; + my $setting = _get_allowed_remotes(); + return 0 if $setting eq ''; # disabled + + if ($setting eq 'all') { + # Check it actually exists + return -f "$REMOTES_DIR/$remote.conf" ? 1 : 0; + } + + my %allowed = map { $_ => 1 } split /,/, $setting; + return $allowed{$remote} ? 1 : 0; +} + +sub _get_filtered_remotes { + my $setting = _get_allowed_remotes(); + return () if $setting eq ''; + + my @all = _list_all_remotes(); + return @all if $setting eq 'all'; + + my %allowed = map { $_ => 1 } split /,/, $setting; + return grep { $allowed{$_} } @all; +} + +# ── Command execution ───────────────────────────────────────── + +sub _run_gniza { + my (@args) = @_; + + my $err_fh = gensym; + my $pid = eval { open3(my $in, my $out, $err_fh, $GNIZA_BIN, @args) }; + unless ($pid) { + return (0, '', "Failed to execute gniza: $@"); + } + 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; + + return ($exit_code == 0, $stdout, $stderr); +} + +# ── Action dispatch ─────────────────────────────────────────── + +sub _actions { + return qw( + LIST_ALLOWED_REMOTES + LIST_SNAPSHOTS + LIST_DATABASES + LIST_MAILBOXES + LIST_FILES + LIST_DBUSERS + LIST_CRON + LIST_DNS + LIST_SSL + RESTORE_ACCOUNT + RESTORE_FILES + RESTORE_DATABASE + RESTORE_MAILBOX + RESTORE_CRON + RESTORE_DBUSERS + RESTORE_DOMAINS + RESTORE_SSL + ); +} + +sub LIST_ALLOWED_REMOTES { + my ($self) = @_; + my @remotes = _get_filtered_remotes(); + return join("\n", @remotes); +} + +sub LIST_SNAPSHOTS { + my ($self, $remote) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my ($ok, $stdout, $stderr) = _run_gniza('list', "--remote=$remote", "--account=$user"); + return $ok ? $stdout : "ERROR: $stderr"; +} + +sub LIST_DATABASES { + my ($self, $remote, $timestamp) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-databases', $user, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? $stdout : "ERROR: $stderr"; +} + +sub LIST_MAILBOXES { + my ($self, $remote, $timestamp) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-mailboxes', $user, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? $stdout : "ERROR: $stderr"; +} + +sub LIST_FILES { + my ($self, $remote, $timestamp, $path) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my @opts = ("--remote=$remote", "--timestamp=$timestamp"); + if (defined $path && $path ne '') { + return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path}; + push @opts, "--path=$path"; + } + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-files', $user, @opts); + return $ok ? $stdout : "ERROR: $stderr"; +} + +sub LIST_DBUSERS { + my ($self, $remote, $timestamp) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-dbusers', $user, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? $stdout : "ERROR: $stderr"; +} + +sub LIST_CRON { + my ($self, $remote, $timestamp) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-cron', $user, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? $stdout : "ERROR: $stderr"; +} + +sub LIST_DNS { + my ($self, $remote, $timestamp) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-dns', $user, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? $stdout : "ERROR: $stderr"; +} + +sub LIST_SSL { + my ($self, $remote, $timestamp) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-ssl', $user, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? $stdout : "ERROR: $stderr"; +} + +sub RESTORE_ACCOUNT { + my ($self, $remote, $timestamp, $exclude) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my @opts = ("--remote=$remote", "--timestamp=$timestamp"); + # NOTE: --terminate is NEVER passed for user restore + if (defined $exclude && $exclude ne '') { + return "ERROR: Invalid exclude" unless $exclude =~ $OPT_PATTERNS{exclude}; + push @opts, "--exclude=$exclude"; + } + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'account', $user, @opts); + return $ok ? "OK\n$stdout" : "ERROR: $stderr"; +} + +sub RESTORE_FILES { + my ($self, $remote, $timestamp, $path, $exclude) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my @opts = ("--remote=$remote", "--timestamp=$timestamp"); + if (defined $path && $path ne '') { + return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path}; + push @opts, "--path=$path"; + } + if (defined $exclude && $exclude ne '') { + return "ERROR: Invalid exclude" unless $exclude =~ $OPT_PATTERNS{exclude}; + push @opts, "--exclude=$exclude"; + } + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'files', $user, @opts); + return $ok ? "OK\n$stdout" : "ERROR: $stderr"; +} + +sub RESTORE_DATABASE { + my ($self, $remote, $timestamp, $dbname) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my @args = ($user); + if (defined $dbname && $dbname ne '') { + return "ERROR: Invalid database name" unless $dbname =~ $DBNAME_RE; + push @args, $dbname; + } + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'database', @args, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? "OK\n$stdout" : "ERROR: $stderr"; +} + +sub RESTORE_MAILBOX { + my ($self, $remote, $timestamp, $email) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my @args = ($user); + if (defined $email && $email ne '') { + return "ERROR: Invalid email" unless $email =~ $EMAIL_RE; + push @args, $email; + } + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'mailbox', @args, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? "OK\n$stdout" : "ERROR: $stderr"; +} + +sub RESTORE_CRON { + my ($self, $remote, $timestamp) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'cron', $user, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? "OK\n$stdout" : "ERROR: $stderr"; +} + +sub RESTORE_DBUSERS { + my ($self, $remote, $timestamp, $dbuser) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my @args = ($user); + if (defined $dbuser && $dbuser ne '') { + return "ERROR: Invalid database user" unless $dbuser =~ $DBNAME_RE; + push @args, $dbuser; + } + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'dbusers', @args, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? "OK\n$stdout" : "ERROR: $stderr"; +} + +sub RESTORE_DOMAINS { + my ($self, $remote, $timestamp, $domain) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my @args = ($user); + if (defined $domain && $domain ne '') { + return "ERROR: Invalid domain" unless $domain =~ $DOMAIN_RE; + push @args, $domain; + } + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'domains', @args, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? "OK\n$stdout" : "ERROR: $stderr"; +} + +sub RESTORE_SSL { + my ($self, $remote, $timestamp, $domain) = @_; + my $user = $ENV{'REMOTE_USER'} // ''; + + return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; + return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; + return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; + return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); + + my @args = ($user); + if (defined $domain && $domain ne '') { + return "ERROR: Invalid domain" unless $domain =~ $DOMAIN_RE; + push @args, $domain; + } + + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'ssl', @args, + "--remote=$remote", "--timestamp=$timestamp"); + return $ok ? "OK\n$stdout" : "ERROR: $stderr"; +} + +1; diff --git a/cpanel/admin/Gniza/Restore.conf b/cpanel/admin/Gniza/Restore.conf new file mode 100644 index 0000000..e9eb084 --- /dev/null +++ b/cpanel/admin/Gniza/Restore.conf @@ -0,0 +1 @@ +mode=full diff --git a/cpanel/gniza/assets/gniza-logo.svg b/cpanel/gniza/assets/gniza-logo.svg new file mode 100644 index 0000000..cc4dc5a --- /dev/null +++ b/cpanel/gniza/assets/gniza-logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + +GNIZA Backup + diff --git a/cpanel/gniza/assets/gniza-whm.css b/cpanel/gniza/assets/gniza-whm.css new file mode 100644 index 0000000..77384a3 --- /dev/null +++ b/cpanel/gniza/assets/gniza-whm.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}:root,:host{--font-sans:"Helvetica Neue", Helvetica, Arial, sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--leading-relaxed:1.625;--radius-lg:.5rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite}@layer daisyui.l1.l2.l3{.modal{pointer-events:none!important;visibility:hidden!important;width:100%!important;max-width:none!important;height:100%!important;max-height:none!important;color:inherit!important;transition:visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out!important;overscroll-behavior:contain!important;z-index:999!important;scrollbar-gutter:auto!important;background-color:#0000!important;place-items:center!important;margin:0!important;padding:0!important;display:grid!important;position:fixed!important;inset:0!important;overflow:clip!important}.modal::backdrop{display:none!important}.tooltip{--tt-bg:var(--color-neutral)!important;--tt-off:calc(100% + .5rem)!important;--tt-tail:calc(100% + 1px + .25rem)!important;display:inline-block!important;position:relative!important}.tooltip>.tooltip-content,.tooltip[data-tip]:before{border-radius:var(--radius-field)!important;text-align:center!important;white-space:normal!important;max-width:20rem!important;color:var(--color-neutral-content)!important;opacity:0!important;background-color:var(--tt-bg)!important;pointer-events:none!important;z-index:2!important;--tw-content:attr(data-tip)!important;content:var(--tw-content)!important;width:max-content!important;padding-block:.25rem!important;padding-inline:.5rem!important;font-size:.875rem!important;line-height:1.25!important;position:absolute!important}.tooltip:after{opacity:0!important;background-color:var(--tt-bg)!important;content:""!important;pointer-events:none!important;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A")!important;width:.625rem!important;height:.25rem!important;-webkit-mask-position:-1px 0!important;mask-position:-1px 0!important;-webkit-mask-repeat:no-repeat!important;mask-repeat:no-repeat!important;-webkit-mask-image:var(--mask-tooltip)!important;-webkit-mask-image:var(--mask-tooltip)!important;mask-image:var(--mask-tooltip)!important;display:block!important;position:absolute!important}@media (prefers-reduced-motion:no-preference){.tooltip>.tooltip-content,.tooltip[data-tip]:before,.tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms!important}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1!important;--tt-pos:0rem!important}@media (prefers-reduced-motion:no-preference){:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)!important}}.tab{cursor:pointer!important;appearance:none!important;text-align:center!important;webkit-user-select:none!important;-webkit-user-select:none!important;user-select:none!important;flex-wrap:wrap!important;justify-content:center!important;align-items:center!important;display:inline-flex!important;position:relative!important}@media (hover:hover){.tab:hover{color:var(--color-base-content)!important}}.tab{--tab-p:.75rem!important;--tab-bg:var(--color-base-100)!important;--tab-border-color:var(--color-base-300)!important;--tab-radius-ss:0!important;--tab-radius-se:0!important;--tab-radius-es:0!important;--tab-radius-ee:0!important;--tab-order:0!important;--tab-radius-min:calc(.75rem - var(--border))!important;--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min))!important;--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px)!important;order:var(--tab-order)!important;height:var(--tab-height)!important;padding-inline:var(--tab-p)!important;border-color:#0000!important;font-size:.875rem!important}.tab:is(input[type=radio]){min-width:fit-content!important}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label)!important;content:var(--tw-content)!important}.tab:is(label){position:relative!important}.tab:is(label) input{cursor:pointer!important;appearance:none!important;opacity:0!important;position:absolute!important;inset:0!important}:is(.tab:checked,.tab:is(label:has(:checked)),.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content{display:block!important}.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)!important}}.tab:not(input):empty{cursor:default!important;flex-grow:1!important}.tab:focus{--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.tab:focus{outline-offset:2px!important;outline:2px solid #0000!important}}.tab:focus-visible,.tab:is(label:has(:checked:focus-visible)){outline-offset:-5px!important;outline:2px solid!important}.tab[disabled]{pointer-events:none!important;opacity:.4!important}:where(.btn){width:unset!important}.btn{cursor:pointer!important;text-align:center!important;vertical-align:middle!important;outline-offset:2px!important;webkit-user-select:none!important;-webkit-user-select:none!important;user-select:none!important;padding-inline:var(--btn-p)!important;color:var(--btn-fg)!important;--tw-prose-links:var(--btn-fg)!important;height:var(--size)!important;font-size:var(--fontsize,.875rem)!important;outline-color:var(--btn-color,var(--color-base-content))!important;background-color:var(--btn-bg)!important;background-size:auto, calc(var(--noise) * 100%)!important;background-image:none, var(--btn-noise)!important;border-width:var(--border)!important;border-style:solid!important;border-color:var(--btn-border)!important;text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15))!important;touch-action:manipulation!important;box-shadow:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%)) inset, var(--btn-shadow)!important;--size:calc(var(--size-field,.25rem) * 10)!important;--btn-bg:var(--btn-color,var(--color-base-200))!important;--btn-fg:var(--color-base-content)!important;--btn-p:1rem!important;--btn-border:var(--btn-bg)!important;border-start-start-radius:var(--join-ss,var(--radius-field))!important;border-start-end-radius:var(--join-se,var(--radius-field))!important;border-end-end-radius:var(--join-ee,var(--radius-field))!important;border-end-start-radius:var(--join-es,var(--radius-field))!important;flex-wrap:nowrap!important;flex-shrink:0!important;justify-content:center!important;align-items:center!important;gap:.375rem!important;font-weight:600!important;transition-property:color,background-color,border-color,box-shadow!important;transition-duration:.2s!important;transition-timing-function:cubic-bezier(0,0,.2,1)!important;display:inline-flex!important}@supports (color:color-mix(in lab, red, red)){.btn{--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%))!important}}.btn{--btn-shadow:0 3px 2px -2px var(--btn-bg), 0 4px 3px -2px var(--btn-bg)!important}@supports (color:color-mix(in lab, red, red)){.btn{--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)!important}}.btn{--btn-noise:var(--fx-noise)!important}@media (hover:hover){.btn:hover{--btn-bg:var(--btn-color,var(--color-base-200))!important}@supports (color:color-mix(in lab, red, red)){.btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)!important}}}.btn:focus-visible,.btn:has(:focus-visible){isolation:isolate!important;outline-width:2px!important;outline-style:solid!important}.btn:active:not(.btn-active){--btn-bg:var(--btn-color,var(--color-base-200))!important;translate:0 .5px!important}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%)!important}}.btn:active:not(.btn-active){--btn-border:var(--btn-color,var(--color-base-200))!important}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)!important}}.btn:active:not(.btn-active){--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0)!important}.btn:is(input[type=checkbox],input[type=radio]){appearance:none!important}.btn:is(input[type=checkbox],input[type=radio])[aria-label]:after{--tw-content:attr(aria-label)!important;content:var(--tw-content)!important}.btn:where(input:checked:not(.filter .btn)){--btn-color:var(--color-primary)!important;--btn-fg:var(--color-primary-content)!important;isolation:isolate!important}.loading{pointer-events:none!important;aspect-ratio:1!important;vertical-align:middle!important;width:calc(var(--size-selector,.25rem) * 6)!important;background-color:currentColor!important;display:inline-block!important;-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")!important;mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")!important;-webkit-mask-position:50%!important;mask-position:50%!important;-webkit-mask-size:100%!important;mask-size:100%!important;-webkit-mask-repeat:no-repeat!important;mask-repeat:no-repeat!important}.collapse{border-radius:var(--radius-box,1rem)!important;isolation:isolate!important;grid-template-rows:max-content 0fr!important;grid-template-columns:minmax(0,1fr)!important;width:100%!important;display:grid!important;position:relative!important;overflow:hidden!important}@media (prefers-reduced-motion:no-preference){.collapse{transition:grid-template-rows .2s!important}}.collapse>input:is([type=checkbox],[type=radio]){appearance:none!important;opacity:0!important;z-index:1!important;grid-row-start:1!important;grid-column-start:1!important;width:100%!important;min-height:1lh!important;padding:1rem!important;padding-inline-end:3rem!important;transition:background-color .2s ease-out!important}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),.collapse:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr!important}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){content-visibility:visible!important;min-height:fit-content!important}@supports not (content-visibility:visible){.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){visibility:visible!important}}.collapse:focus-visible,.collapse:has(>input:is([type=checkbox],[type=radio]):focus-visible),.collapse:has(summary:focus-visible){outline-color:var(--color-base-content)!important;outline-offset:2px!important;outline-width:2px!important;outline-style:solid!important}.collapse:not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-close)>input[type=radio]:not(:checked),.collapse:not(.collapse-close)>.collapse-title{cursor:pointer!important}:is(.collapse[tabindex]:focus:not(.collapse-close,.collapse[open]),.collapse[tabindex]:focus-within:not(.collapse-close,.collapse[open]))>.collapse-title{cursor:unset!important}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem!important}.collapse:is(details){width:100%!important}@media (prefers-reduced-motion:no-preference){.collapse:is(details)::details-content{transition:content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s!important;interpolate-size:allow-keywords!important;height:0!important}.collapse:is(details):where([open])::details-content{height:auto!important}}.collapse:is(details) summary{display:block!important;position:relative!important}.collapse:is(details) summary::-webkit-details-marker{display:none!important}.collapse:is(details)>.collapse-content{content-visibility:visible!important}.collapse:is(details) summary{outline:none!important}.collapse-content{content-visibility:hidden!important;min-height:0!important;cursor:unset!important;grid-row-start:2!important;grid-column-start:1!important;padding-left:1rem!important;padding-right:1rem!important}@supports not (content-visibility:hidden){.collapse-content{visibility:hidden!important}}@media (prefers-reduced-motion:no-preference){.collapse-content{transition:content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out!important}}.toggle{border:var(--border) solid currentColor!important;color:var(--input-color)!important;cursor:pointer!important;appearance:none!important;vertical-align:middle!important;webkit-user-select:none!important;-webkit-user-select:none!important;user-select:none!important;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector))!important;border-radius:calc(var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)))!important;padding:var(--toggle-p)!important;flex-shrink:0!important;grid-template-columns:0fr 1fr 1fr!important;place-content:center!important;display:inline-grid!important;position:relative!important;box-shadow:inset 0 1px!important}@supports (color:color-mix(in lab, red, red)){.toggle{box-shadow:0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset!important}}.toggle{--input-color:var(--color-base-content)!important;transition:color .3s,grid-template-columns .2s!important}@supports (color:color-mix(in lab, red, red)){.toggle{--input-color:color-mix(in oklab, var(--color-base-content) 50%, #0000)!important}}.toggle{--toggle-p:calc(var(--size) * .125)!important;--size:calc(var(--size-selector,.25rem) * 6)!important;width:calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2)!important;height:var(--size)!important}.toggle>*{z-index:1!important;cursor:pointer!important;appearance:none!important;background-color:#0000!important;border:none!important;grid-column:2/span 1!important;grid-row-start:1!important;height:100%!important;padding:.125rem!important;transition:opacity .2s,rotate .4s!important}.toggle>:focus{--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.toggle>:focus{outline-offset:2px!important;outline:2px solid #0000!important}}.toggle>:nth-child(2){color:var(--color-base-100)!important;rotate:none!important}.toggle>:nth-child(3){color:var(--color-base-100)!important;opacity:0!important;rotate:-15deg!important}.toggle:has(:checked)>:nth-child(2){opacity:0!important;rotate:15deg!important}.toggle:has(:checked)>:nth-child(3){opacity:1!important;rotate:none!important}.toggle:before{aspect-ratio:1!important;border-radius:var(--radius-selector)!important;--tw-content:""!important;content:var(--tw-content)!important;width:100%!important;height:100%!important;box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor!important;background-color:currentColor!important;grid-row-start:1!important;grid-column-start:2!important;transition:background-color .1s,translate .2s,inset-inline-start .2s!important;position:relative!important;inset-inline-start:0!important;translate:0!important}@supports (color:color-mix(in lab, red, red)){.toggle:before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000)!important}}.toggle:before{background-size:auto, calc(var(--noise) * 100%)!important;background-image:none, var(--fx-noise)!important}@media (forced-colors:active){.toggle:before{outline-style:var(--tw-outline-style)!important;outline-offset:calc(1px * -1)!important;outline-width:1px!important}}@media print{.toggle:before{outline-offset:-1rem!important;outline:.25rem solid!important}}.toggle:focus-visible,.toggle:has(:focus-visible){outline-offset:2px!important;outline:2px solid!important}.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked){background-color:var(--color-base-100)!important;--input-color:var(--color-base-content)!important;grid-template-columns:1fr 1fr 0fr!important}:is(.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked)):before{background-color:currentColor!important}@starting-style{:is(.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked)):before{opacity:0!important}}.toggle:indeterminate{grid-template-columns:.5fr 1fr .5fr!important}.toggle:disabled{cursor:not-allowed!important;opacity:.3!important}.toggle:disabled:before{border:var(--border) solid currentColor!important;background-color:#0000!important}.input{cursor:text!important;border:var(--border) solid #0000!important;appearance:none!important;background-color:var(--color-base-100)!important;vertical-align:middle!important;white-space:nowrap!important;width:clamp(3rem,20rem,100%)!important;height:var(--size)!important;font-size:max(var(--font-size,.875rem), .875rem)!important;touch-action:manipulation!important;border-color:var(--input-color)!important;box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset!important;border-start-start-radius:var(--join-ss,var(--radius-field))!important;border-start-end-radius:var(--join-se,var(--radius-field))!important;border-end-end-radius:var(--join-ee,var(--radius-field))!important;border-end-start-radius:var(--join-es,var(--radius-field))!important;flex-shrink:1!important;align-items:center!important;gap:.5rem!important;padding-inline:.75rem!important;display:inline-flex!important;position:relative!important}@supports (color:color-mix(in lab, red, red)){.input{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset!important}}.input{--size:calc(var(--size-field,.25rem) * 10)!important;--input-color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.input{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)!important}}.input:where(input){display:inline-flex!important}.input :where(input){appearance:none!important;background-color:#0000!important;border:none!important;width:100%!important;height:100%!important;display:inline-flex!important}.input :where(input):focus,.input :where(input):focus-within{--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.input :where(input):focus,.input :where(input):focus-within{outline-offset:2px!important;outline:2px solid #0000!important}}.input :where(input[type=url]),.input :where(input[type=email]){direction:ltr!important}.input :where(input[type=date]){display:inline-flex!important}.input:focus,.input:focus-within{--input-color:var(--color-base-content)!important;box-shadow:0 1px var(--input-color)!important}@supports (color:color-mix(in lab, red, red)){.input:focus,.input:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)!important}}.input:focus,.input:focus-within{outline:2px solid var(--input-color)!important;outline-offset:2px!important;isolation:isolate!important}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.input:focus,.input:focus-within{--font-size:1rem!important}}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed!important;border-color:var(--color-base-200)!important;background-color:var(--color-base-200)!important;color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)!important}}:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)!important}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{box-shadow:none!important}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed!important}.input::-webkit-date-and-time-value{text-align:inherit!important}.input[type=number]::-webkit-inner-spin-button{margin-block:-.75rem!important;margin-inline-end:-.75rem!important}.input::-webkit-calendar-picker-indicator{position:absolute!important;inset-inline-end:.75em!important}.input:has(>input[type=date]) :where(input[type=date]){webkit-appearance:none!important;appearance:none!important;display:inline-flex!important}.input:has(>input[type=date]) input[type=date]::-webkit-calendar-picker-indicator{cursor:pointer!important;width:1em!important;height:1em!important;position:absolute!important;inset-inline-end:.75em!important}.table{border-collapse:separate!important;--tw-border-spacing-x:calc(.25rem * 0)!important;--tw-border-spacing-y:calc(.25rem * 0)!important;width:100%!important;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)!important;border-radius:var(--radius-box)!important;text-align:left!important;font-size:.875rem!important;position:relative!important}.table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right!important}@media (hover:hover){:is(.table tr.row-hover,.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)!important}}.table :where(th,td){vertical-align:middle!important;padding-block:.75rem!important;padding-inline:1rem!important}.table :where(thead,tfoot){white-space:nowrap!important;color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.table :where(thead,tfoot){color:color-mix(in oklab, var(--color-base-content) 60%, transparent)!important}}.table :where(thead,tfoot){font-size:.875rem!important;font-weight:600!important}.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)!important}}.table :where(.table-pin-rows thead tr){z-index:1!important;background-color:var(--color-base-100)!important;position:sticky!important;top:0!important}.table :where(.table-pin-rows tfoot tr){z-index:1!important;background-color:var(--color-base-100)!important;position:sticky!important;bottom:0!important}.table :where(.table-pin-cols tr th){background-color:var(--color-base-100)!important;position:sticky!important;left:0!important;right:0!important}.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)!important}}.steps{counter-reset:step!important;grid-auto-columns:1fr!important;grid-auto-flow:column!important;display:inline-grid!important;overflow:auto hidden!important}.steps .step{text-align:center!important;--step-bg:var(--color-base-300)!important;--step-fg:var(--color-base-content)!important;grid-template-rows:40px 1fr!important;grid-template-columns:auto!important;place-items:center!important;min-width:4rem!important;display:grid!important}.steps .step:before{width:100%!important;height:.5rem!important;color:var(--step-bg)!important;background-color:var(--step-bg)!important;content:""!important;border:1px solid!important;grid-row-start:1!important;grid-column-start:1!important;margin-inline-start:-100%!important;top:0!important}.steps .step>.step-icon,.steps .step:not(:has(.step-icon)):after{--tw-content:counter(step)!important;content:var(--tw-content)!important;counter-increment:step!important;z-index:1!important;color:var(--step-fg)!important;background-color:var(--step-bg)!important;border:1px solid var(--step-bg)!important;border-radius:3.40282e38px!important;grid-row-start:1!important;grid-column-start:1!important;place-self:center!important;place-items:center!important;width:2rem!important;height:2rem!important;display:grid!important;position:relative!important}.steps .step:first-child:before{--tw-content:none!important;content:var(--tw-content)!important}.steps .step[data-content]:after{--tw-content:attr(data-content)!important;content:var(--tw-content)!important}.select{border:var(--border) solid #0000!important;appearance:none!important;background-color:var(--color-base-100)!important;vertical-align:middle!important;width:clamp(3rem,20rem,100%)!important;height:var(--size)!important;touch-action:manipulation!important;white-space:nowrap!important;text-overflow:ellipsis!important;box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset!important;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%)!important;background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%)!important;background-repeat:no-repeat!important;background-size:4px 4px,4px 4px!important;border-start-start-radius:var(--join-ss,var(--radius-field))!important;border-start-end-radius:var(--join-se,var(--radius-field))!important;border-end-end-radius:var(--join-ee,var(--radius-field))!important;border-end-start-radius:var(--join-es,var(--radius-field))!important;flex-shrink:1!important;align-items:center!important;gap:.375rem!important;padding-inline:.75rem 1.75rem!important;font-size:.875rem!important;display:inline-flex!important;position:relative!important;overflow:hidden!important}@supports (color:color-mix(in lab, red, red)){.select{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset!important}}.select{border-color:var(--input-color)!important;--input-color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.select{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)!important}}.select{--size:calc(var(--size-field,.25rem) * 10)!important}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)!important}[dir=rtl] .select::picker(select){translate:.5rem!important}[dir=rtl] .select select::picker(select){translate:.5rem!important}.select[multiple]{background-image:none!important;height:auto!important;padding-block:.75rem!important;padding-inline-end:.75rem!important;overflow:auto!important}.select select{appearance:none!important;width:calc(100% + 2.75rem)!important;height:calc(100% - calc(var(--border) * 2))!important;background:inherit!important;border-radius:inherit!important;border-style:none!important;align-items:center!important;margin-inline:-.75rem -1.75rem!important;padding-inline:.75rem 1.75rem!important}.select select:focus,.select select:focus-within{--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.select select:focus,.select select:focus-within{outline-offset:2px!important;outline:2px solid #0000!important}}.select select:not(:last-child){background-image:none!important;margin-inline-end:-1.375rem!important}.select:focus,.select:focus-within{--input-color:var(--color-base-content)!important;box-shadow:0 1px var(--input-color)!important}@supports (color:color-mix(in lab, red, red)){.select:focus,.select:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)!important}}.select:focus,.select:focus-within{outline:2px solid var(--input-color)!important;outline-offset:2px!important;isolation:isolate!important}.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed!important;border-color:var(--color-base-200)!important;background-color:var(--color-base-200)!important;color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)!important}}:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)!important}}.select:has(>select[disabled])>select[disabled]{cursor:not-allowed!important}@supports (appearance:base-select){.select,.select select{appearance:base-select!important}:is(.select,.select select)::picker(select){appearance:base-select!important}}:is(.select,.select select)::picker(select){color:inherit!important;border:var(--border) solid var(--color-base-200)!important;border-radius:var(--radius-box)!important;background-color:inherit!important;max-height:min(24rem,70dvh)!important;box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2)!important;box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1))!important;margin-block:.5rem!important;margin-inline:.5rem!important;padding:.5rem!important;translate:-.5rem!important}:is(.select,.select select)::picker-icon{display:none!important}:is(.select,.select select) optgroup{padding-top:.5em!important}:is(.select,.select select) optgroup option:first-child{margin-top:.5em!important}:is(.select,.select select) option{border-radius:var(--radius-field)!important;white-space:normal!important;padding-block:.375rem!important;padding-inline:.75rem!important;transition-property:color,background-color!important;transition-duration:.2s!important;transition-timing-function:cubic-bezier(0,0,.2,1)!important}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{cursor:pointer!important;background-color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)!important}}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{outline-offset:2px!important;outline:2px solid #0000!important}}:is(.select,.select select) option:not(:disabled):active{background-color:var(--color-neutral)!important;color:var(--color-neutral-content)!important;box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)!important}.collapse-title{grid-row-start:1!important;grid-column-start:1!important;width:100%!important;min-height:1lh!important;padding:1rem!important;padding-inline-end:3rem!important;transition:background-color .2s ease-out!important;position:relative!important}.checkbox{border:var(--border) solid var(--input-color,var(--color-base-content))!important}@supports (color:color-mix(in lab, red, red)){.checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))!important}}.checkbox{cursor:pointer!important;appearance:none!important;border-radius:var(--radius-selector)!important;vertical-align:middle!important;color:var(--color-base-content)!important;box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000!important;--size:calc(var(--size-selector,.25rem) * 6)!important;width:var(--size)!important;height:var(--size)!important;background-size:auto, calc(var(--noise) * 100%)!important;background-image:none, var(--fx-noise)!important;flex-shrink:0!important;padding:.25rem!important;transition:background-color .2s,box-shadow .2s!important;display:inline-block!important;position:relative!important}.checkbox:before{--tw-content:""!important;content:var(--tw-content)!important;opacity:0!important;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%)!important;width:100%!important;height:100%!important;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset!important;background-color:currentColor!important;font-size:1rem!important;line-height:.75!important;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s!important;display:block!important;rotate:45deg!important}.checkbox:focus-visible{outline:2px solid var(--input-color,currentColor)!important;outline-offset:2px!important}.checkbox:checked,.checkbox[aria-checked=true]{background-color:var(--input-color,#0000)!important;box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1))!important}:is(.checkbox:checked,.checkbox[aria-checked=true]):before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%)!important;opacity:1!important}@media (forced-colors:active){:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎"!important;clip-path:none!important;background-color:#0000!important;rotate:none!important}}@media print{:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎"!important;clip-path:none!important;background-color:#0000!important;rotate:none!important}}.checkbox:indeterminate{background-color:var(--input-color,var(--color-base-content))!important}@supports (color:color-mix(in lab, red, red)){.checkbox:indeterminate{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))!important}}.checkbox:indeterminate:before{opacity:1!important;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%)!important;translate:0 -35%!important;rotate:none!important}.radio{cursor:pointer!important;appearance:none!important;vertical-align:middle!important;border:var(--border) solid var(--input-color,currentColor)!important;border-radius:3.40282e38px!important;flex-shrink:0!important;padding:.25rem!important;display:inline-block!important;position:relative!important}@supports (color:color-mix(in lab, red, red)){.radio{border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000))!important}}.radio{box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset!important;--size:calc(var(--size-selector,.25rem) * 6)!important;width:var(--size)!important;height:var(--size)!important;color:var(--input-color,currentColor)!important}.radio:before{--tw-content:""!important;content:var(--tw-content)!important;background-size:auto, calc(var(--noise) * 100%)!important;background-image:none, var(--fx-noise)!important;border-radius:3.40282e38px!important;width:100%!important;height:100%!important;display:block!important}.radio:focus-visible{outline:2px solid!important}.radio:checked,.radio[aria-checked=true]{background-color:var(--color-base-100)!important;border-color:currentColor!important}@media (prefers-reduced-motion:no-preference){.radio:checked,.radio[aria-checked=true]{animation:.2s ease-out radio!important}}:is(.radio:checked,.radio[aria-checked=true]):before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1))!important;background-color:currentColor!important}@media (forced-colors:active){:is(.radio:checked,.radio[aria-checked=true]):before{outline-style:var(--tw-outline-style)!important;outline-offset:calc(1px * -1)!important;outline-width:1px!important}}@media print{:is(.radio:checked,.radio[aria-checked=true]):before{outline-offset:-1rem!important;outline:.25rem solid!important}}.card{border-radius:var(--radius-box)!important;outline-offset:2px!important;outline:0 solid #0000!important;flex-direction:column!important;transition:outline .2s ease-in-out!important;display:flex!important;position:relative!important}.card:focus{--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.card:focus{outline-offset:2px!important;outline:2px solid #0000!important}}.card:focus-visible{outline-color:currentColor!important}.card :where(figure:first-child){border-start-start-radius:inherit!important;border-start-end-radius:inherit!important;border-end-end-radius:unset!important;border-end-start-radius:unset!important;overflow:hidden!important}.card :where(figure:last-child){border-start-start-radius:unset!important;border-start-end-radius:unset!important;border-end-end-radius:inherit!important;border-end-start-radius:inherit!important;overflow:hidden!important}.card figure{justify-content:center!important;align-items:center!important;display:flex!important}.card:has(>input:is(input[type=checkbox],input[type=radio])){cursor:pointer!important;-webkit-user-select:none!important;user-select:none!important}.card:has(>:checked){outline:2px solid!important}.textarea{border:var(--border) solid #0000!important;appearance:none!important;border-radius:var(--radius-field)!important;background-color:var(--color-base-100)!important;vertical-align:middle!important;width:clamp(3rem,20rem,100%)!important;min-height:5rem!important;font-size:max(var(--font-size,.875rem), .875rem)!important;touch-action:manipulation!important;border-color:var(--input-color)!important;box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset!important;flex-shrink:1!important;padding-block:.5rem!important;padding-inline:.75rem!important}@supports (color:color-mix(in lab, red, red)){.textarea{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset!important}}.textarea{--input-color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.textarea{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)!important}}.textarea textarea{appearance:none!important;background-color:#0000!important;border:none!important}.textarea textarea:focus,.textarea textarea:focus-within{--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.textarea textarea:focus,.textarea textarea:focus-within{outline-offset:2px!important;outline:2px solid #0000!important}}.textarea:focus,.textarea:focus-within{--input-color:var(--color-base-content)!important;box-shadow:0 1px var(--input-color)!important}@supports (color:color-mix(in lab, red, red)){.textarea:focus,.textarea:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)!important}}.textarea:focus,.textarea:focus-within{outline:2px solid var(--input-color)!important;outline-offset:2px!important;isolation:isolate!important}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.textarea:focus,.textarea:focus-within{--font-size:1rem!important}}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){cursor:not-allowed!important;border-color:var(--color-base-200)!important;background-color:var(--color-base-200)!important;color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)!important}}:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)!important}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){box-shadow:none!important}.textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed!important}.modal-backdrop{color:#0000!important;z-index:-1!important;grid-row-start:1!important;grid-column-start:1!important;place-self:stretch stretch!important;display:grid!important}.modal-backdrop button{cursor:pointer!important}.tab-content{order:var(--tabcontent-order)!important;--tabcontent-radius-ss:var(--radius-box)!important;--tabcontent-radius-se:var(--radius-box)!important;--tabcontent-radius-es:var(--radius-box)!important;--tabcontent-radius-ee:var(--radius-box)!important;--tabcontent-order:1!important;width:100%!important;height:calc(100% - var(--tab-height) + var(--border))!important;margin:var(--tabcontent-margin)!important;border-color:#0000!important;border-width:var(--border)!important;border-start-start-radius:var(--tabcontent-radius-ss)!important;border-start-end-radius:var(--tabcontent-radius-se)!important;border-end-end-radius:var(--tabcontent-radius-ee)!important;border-end-start-radius:var(--tabcontent-radius-es)!important;display:none!important}.modal-box{background-color:var(--color-base-100)!important;border-top-left-radius:var(--modal-tl,var(--radius-box))!important;border-top-right-radius:var(--modal-tr,var(--radius-box))!important;border-bottom-left-radius:var(--modal-bl,var(--radius-box))!important;border-bottom-right-radius:var(--modal-br,var(--radius-box))!important;opacity:0!important;overscroll-behavior:contain!important;grid-row-start:1!important;grid-column-start:1!important;width:91.6667%!important;max-width:32rem!important;max-height:100vh!important;padding:1.5rem!important;transition:translate .3s ease-out,scale .3s ease-out,opacity .2s ease-out 50ms,box-shadow .3s ease-out!important;overflow-y:auto!important;scale:95%!important;box-shadow:0 25px 50px -12px oklch(0% 0 0/.25)!important}.breadcrumbs{max-width:100%!important;padding-block:.5rem!important;overflow-x:auto!important}.breadcrumbs>menu,.breadcrumbs>ul,.breadcrumbs>ol{white-space:nowrap!important;align-items:center!important;min-height:min-content!important;display:flex!important}:is(.breadcrumbs>menu,.breadcrumbs>ul,.breadcrumbs>ol)>li{align-items:center!important;display:flex!important}:is(.breadcrumbs>menu,.breadcrumbs>ul,.breadcrumbs>ol)>li>*{cursor:pointer!important;align-items:center!important;gap:.5rem!important;display:flex!important}@media (hover:hover){:is(.breadcrumbs>menu,.breadcrumbs>ul,.breadcrumbs>ol)>li>:hover{text-decoration-line:underline!important}}:is(.breadcrumbs>menu,.breadcrumbs>ul,.breadcrumbs>ol)>li>:focus{--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){:is(.breadcrumbs>menu,.breadcrumbs>ul,.breadcrumbs>ol)>li>:focus{outline-offset:2px!important;outline:2px solid #0000!important}}:is(.breadcrumbs>menu,.breadcrumbs>ul,.breadcrumbs>ol)>li>:focus-visible{outline-offset:2px!important;outline:2px solid!important}:is(.breadcrumbs>menu,.breadcrumbs>ul,.breadcrumbs>ol)>li+:before{content:""!important;opacity:.4!important;background-color:#0000!important;border-top:1px solid!important;border-right:1px solid!important;width:.375rem!important;height:.375rem!important;margin-inline:.5rem .75rem!important;display:block!important;rotate:45deg!important}[dir=rtl] :is(:is(.breadcrumbs>menu,.breadcrumbs>ul,.breadcrumbs>ol)>li)+:before{rotate:-135deg!important}.modal-action{justify-content:flex-end!important;gap:.5rem!important;margin-top:1.5rem!important;display:flex!important}.badge{border-radius:var(--radius-selector)!important;vertical-align:middle!important;color:var(--badge-fg)!important;border:var(--border) solid var(--badge-color,var(--color-base-200))!important;background-size:auto, calc(var(--noise) * 100%)!important;background-image:none, var(--fx-noise)!important;background-color:var(--badge-bg)!important;--badge-bg:var(--badge-color,var(--color-base-100))!important;--badge-fg:var(--color-base-content)!important;--size:calc(var(--size-selector,.25rem) * 6)!important;width:fit-content!important;height:var(--size)!important;padding-inline:calc(var(--size) / 2 - var(--border))!important;justify-content:center!important;align-items:center!important;gap:.5rem!important;font-size:.875rem!important;display:inline-flex!important}.tabs{--tabs-height:auto!important;--tabs-direction:row!important;--tab-height:calc(var(--size-field,.25rem) * 10)!important;height:var(--tabs-height)!important;flex-wrap:wrap!important;flex-direction:var(--tabs-direction)!important;display:flex!important}.card-body{padding:var(--card-p,1.5rem)!important;font-size:var(--card-fs,.875rem)!important;flex-direction:column!important;flex:auto!important;gap:.5rem!important;display:flex!important}.card-body :where(p){flex-grow:1!important}.alert{--alert-border-color:var(--color-base-200)!important;border-radius:var(--radius-box)!important;color:var(--color-base-content)!important;background-color:var(--alert-color,var(--color-base-200))!important;text-align:start!important;background-size:auto, calc(var(--noise) * 100%)!important;background-image:none, var(--fx-noise)!important;box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px #000, 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08))!important;border-style:solid!important;grid-template-columns:auto!important;grid-auto-flow:column!important;justify-content:start!important;place-items:center start!important;gap:1rem!important;padding-block:.75rem!important;padding-inline:1rem!important;font-size:.875rem!important;line-height:1.25rem!important;display:grid!important}@supports (color:color-mix(in lab, red, red)){.alert{box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08))!important}}.alert:has(:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)!important}.card-title{font-size:var(--cardtitle-fs,1.125rem)!important;align-items:center!important;gap:.5rem!important;font-weight:600!important;display:flex!important}.link{cursor:pointer!important;text-decoration-line:underline!important}.link:focus{--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.link:focus{outline-offset:2px!important;outline:2px solid #0000!important}}.link:focus-visible{outline-offset:2px!important;outline:2px solid!important}.btn-error{--btn-color:var(--color-error)!important;--btn-fg:var(--color-error-content)!important}.btn-info{--btn-color:var(--color-info)!important;--btn-fg:var(--color-info-content)!important}.btn-primary{--btn-color:var(--color-primary)!important;--btn-fg:var(--color-primary-content)!important}.btn-secondary{--btn-color:var(--color-secondary)!important;--btn-fg:var(--color-secondary-content)!important}.btn-warning{--btn-color:var(--color-warning)!important;--btn-fg:var(--color-warning-content)!important}}@layer daisyui.l1.l2{.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal{pointer-events:auto!important;visibility:visible!important;opacity:1!important;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out!important;background-color:oklch(0% 0 0/.4)!important}:is(.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal) .modal-box{opacity:1!important;translate:0!important;scale:1!important}:root:has(:is(.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal)){--page-has-backdrop:1!important;--page-overflow:hidden!important;--page-scroll-bg:var(--page-scroll-bg-on)!important;--page-scroll-gutter:stable!important;--page-scroll-transition:var(--page-scroll-transition-on)!important;animation:forwards set-page-has-scroll!important;animation-timeline:scroll()!important}@starting-style{.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal{opacity:0!important}}.tooltip>.tooltip-content,.tooltip[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,.25rem))!important;inset:auto auto var(--tt-off) 50%!important}.tooltip:after{transform:translateX(-50%) translateY(var(--tt-pos,.25rem))!important;inset:auto auto var(--tt-tail) 50%!important}.collapse-arrow>.collapse-title:after{width:.5rem!important;height:.5rem!important;display:block!important;position:absolute!important;transform:translateY(-100%)rotate(45deg)!important}@media (prefers-reduced-motion:no-preference){.collapse-arrow>.collapse-title:after{transition-property:all!important;transition-duration:.2s!important;transition-timing-function:cubic-bezier(.4,0,.2,1)!important}}.collapse-arrow>.collapse-title:after{content:""!important;transform-origin:75% 75%!important;pointer-events:none!important;top:50%!important;inset-inline-end:1.4rem!important;box-shadow:2px 2px!important}.btn:disabled:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.btn:disabled:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)!important}}.btn:disabled:not(.btn-link,.btn-ghost){box-shadow:none!important}.btn:disabled{pointer-events:none!important;--btn-border:#0000!important;--btn-noise:none!important;--btn-fg:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.btn:disabled{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)!important}}.btn[disabled]:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.btn[disabled]:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)!important}}.btn[disabled]:not(.btn-link,.btn-ghost){box-shadow:none!important}.btn[disabled]{pointer-events:none!important;--btn-border:#0000!important;--btn-noise:none!important;--btn-fg:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.btn[disabled]{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)!important}}@media (prefers-reduced-motion:no-preference){.collapse[open].collapse-arrow>.collapse-title:after,.collapse.collapse-open.collapse-arrow>.collapse-title:after{transform:translateY(-50%)rotate(225deg)!important}}.collapse.collapse-open.collapse-plus>.collapse-title:after{--tw-content:"−"!important;content:var(--tw-content)!important}:is(.collapse[tabindex].collapse-arrow:focus:not(.collapse-close),.collapse.collapse-arrow[tabindex]:focus-within:not(.collapse-close))>.collapse-title:after,.collapse.collapse-arrow:not(.collapse-close)>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)!important}.collapse[open].collapse-plus>.collapse-title:after,.collapse[tabindex].collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse.collapse-plus:not(.collapse-close)>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−"!important;content:var(--tw-content)!important}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after,.steps .step-neutral>.step-icon{--step-bg:var(--color-neutral)!important;--step-fg:var(--color-neutral-content)!important}.steps .step-primary+.step-primary:before,.steps .step-primary:after,.steps .step-primary>.step-icon{--step-bg:var(--color-primary)!important;--step-fg:var(--color-primary-content)!important}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after,.steps .step-secondary>.step-icon{--step-bg:var(--color-secondary)!important;--step-fg:var(--color-secondary-content)!important}.steps .step-accent+.step-accent:before,.steps .step-accent:after,.steps .step-accent>.step-icon{--step-bg:var(--color-accent)!important;--step-fg:var(--color-accent-content)!important}.steps .step-info+.step-info:before,.steps .step-info:after,.steps .step-info>.step-icon{--step-bg:var(--color-info)!important;--step-fg:var(--color-info-content)!important}.steps .step-success+.step-success:before,.steps .step-success:after,.steps .step-success>.step-icon{--step-bg:var(--color-success)!important;--step-fg:var(--color-success-content)!important}.steps .step-warning+.step-warning:before,.steps .step-warning:after,.steps .step-warning>.step-icon{--step-bg:var(--color-warning)!important;--step-fg:var(--color-warning-content)!important}.steps .step-error+.step-error:before,.steps .step-error:after,.steps .step-error>.step-icon{--step-bg:var(--color-error)!important;--step-fg:var(--color-error-content)!important}.checkbox:disabled,.radio:disabled{cursor:not-allowed!important;opacity:.2!important}.tooltip-top>.tooltip-content,.tooltip-top[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,.25rem))!important;inset:auto auto var(--tt-off) 50%!important}.tooltip-top:after{transform:translateX(-50%) translateY(var(--tt-pos,.25rem))!important;inset:auto auto var(--tt-tail) 50%!important}.btn-active{--btn-bg:var(--btn-color,var(--color-base-200))!important}@supports (color:color-mix(in lab, red, red)){.btn-active{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)!important}}.btn-active{--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0)!important;isolation:isolate!important}.tabs-box{background-color:var(--color-base-200)!important;--tabs-box-radius:calc(3 * var(--radius-field))!important;border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)))!important;box-shadow:0 -.5px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 .5px oklch(0% 0 0 / calc(var(--depth) * .05)) inset!important;padding:.25rem!important}.tabs-box>.tab{border-radius:var(--radius-field)!important;border-style:none!important}.tabs-box>.tab:focus-visible,.tabs-box>.tab:is(label:has(:checked:focus-visible)){outline-offset:2px!important}.tabs-box>.tab:focus-visible{z-index:1!important}.tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-box>:is(input:checked),.tabs-box>:is(label:has(:checked)){background-color:var(--tab-bg,var(--color-base-100))!important;box-shadow:0 1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 1px -1px var(--color-neutral), 0 1px 6px -4px var(--color-neutral)!important}@supports (color:color-mix(in lab, red, red)){.tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-box>:is(input:checked),.tabs-box>:is(label:has(:checked)){box-shadow:0 1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000)!important}}@media (forced-colors:active){.tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-box>:is(input:checked),.tabs-box>:is(label:has(:checked)){border:1px solid!important}}.tabs-box>.tab-content{height:calc(100% - var(--tab-height) + var(--border) - .5rem)!important;border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)) - var(--border))!important;margin-top:.25rem!important}.input-sm{--size:calc(var(--size-field,.25rem) * 8)!important;font-size:max(var(--font-size,.75rem), .75rem)!important}.input-sm[type=number]::-webkit-inner-spin-button{margin-block:-.5rem!important;margin-inline-end:-.75rem!important}.btn-circle{width:var(--size)!important;height:var(--size)!important;border-radius:3.40282e38px!important;padding-inline:0!important}.loading-xs{width:calc(var(--size-selector,.25rem) * 4)!important}.badge-outline{color:var(--badge-color)!important;--badge-bg:#0000!important;background-image:none!important;border-color:currentColor!important}.loading-spinner{-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")!important;mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")!important}.checkbox-sm{--size:calc(var(--size-selector,.25rem) * 5)!important;padding:.1875rem!important}.radio-sm{padding:.1875rem!important}.radio-sm[type=radio]{--size:calc(var(--size-selector,.25rem) * 5)!important}.select-sm{--size:calc(var(--size-field,.25rem) * 8)!important;font-size:.75rem!important}.select-sm option{padding-block:.25rem!important;padding-inline:.625rem!important}.tabs-lg{--tab-height:calc(var(--size-field,.25rem) * 12)!important}.tabs-lg>.tab{--tab-p:1rem!important;--tab-radius-min:calc(1.5rem - var(--border))!important;font-size:1.125rem!important}.badge-sm{--size:calc(var(--size-selector,.25rem) * 5)!important;font-size:.75rem!important}.textarea-sm{font-size:max(var(--font-size,.75rem), .75rem)!important}.alert-error{color:var(--color-error-content)!important;--alert-border-color:var(--color-error)!important;--alert-color:var(--color-error)!important}.alert-info{color:var(--color-info-content)!important;--alert-border-color:var(--color-info)!important;--alert-color:var(--color-info)!important}.alert-success{color:var(--color-success-content)!important;--alert-border-color:var(--color-success)!important;--alert-color:var(--color-success)!important}.alert-warning{color:var(--color-warning-content)!important;--alert-border-color:var(--color-warning)!important;--alert-color:var(--color-warning)!important}.btn-sm{--fontsize:.75rem!important;--btn-p:.75rem!important;--size:calc(var(--size-field,.25rem) * 8)!important}.btn-xs{--fontsize:.6875rem!important;--btn-p:.5rem!important;--size:calc(var(--size-field,.25rem) * 6)!important}.badge-error{--badge-color:var(--color-error)!important;--badge-fg:var(--color-error-content)!important}.badge-info{--badge-color:var(--color-info)!important;--badge-fg:var(--color-info-content)!important}.badge-neutral{--badge-color:var(--color-neutral)!important;--badge-fg:var(--color-neutral-content)!important}.badge-success{--badge-color:var(--color-success)!important;--badge-fg:var(--color-success-content)!important}.badge-warning{--badge-color:var(--color-warning)!important;--badge-fg:var(--color-warning-content)!important}.toggle-sm[type=checkbox],.toggle-sm:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 5)!important}.toggle-success:checked,.toggle-success[aria-checked=true]{--input-color:var(--color-success)!important}}.prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none!important}.collapse:not(td,tr,colgroup){visibility:revert-layer!important}.collapse{visibility:collapse!important}.join{--join-ss:0!important;--join-se:0!important;--join-es:0!important;--join-ee:0!important;align-items:stretch!important;display:inline-flex!important}.join :where(.join-item){border-start-start-radius:var(--join-ss,0)!important;border-start-end-radius:var(--join-se,0)!important;border-end-end-radius:var(--join-ee,0)!important;border-end-start-radius:var(--join-es,0)!important}.join :where(.join-item) *{--join-ss:var(--radius-field)!important;--join-se:var(--radius-field)!important;--join-es:var(--radius-field)!important;--join-ee:var(--radius-field)!important}.join>.join-item:where(:first-child),.join :first-child:not(:last-child) :where(.join-item){--join-ss:var(--radius-field)!important;--join-se:0!important;--join-es:var(--radius-field)!important;--join-ee:0!important}.join>.join-item:where(:last-child),.join :last-child:not(:first-child) :where(.join-item){--join-ss:0!important;--join-se:var(--radius-field)!important;--join-es:0!important;--join-ee:var(--radius-field)!important}.join>.join-item:where(:only-child),.join :only-child :where(.join-item){--join-ss:var(--radius-field)!important;--join-se:var(--radius-field)!important;--join-es:var(--radius-field)!important;--join-ee:var(--radius-field)!important}.join>:where(:focus,:has(:focus)){z-index:1!important}@media (hover:hover){.join>:where(.btn:hover,:has(.btn:hover)){isolation:isolate!important}}.m-0{margin:calc(var(--spacing) * 0)!important}.mx-auto{margin-inline:auto!important}.my-2{margin-block:calc(var(--spacing) * 2)!important}.my-4{margin-block:calc(var(--spacing) * 4)!important}.join-item:where(:not(:first-child,:disabled,[disabled],.btn-disabled)){margin-block-start:0!important;margin-inline-start:calc(var(--border,1px) * -1)!important}.join-item:where(:is(:disabled,[disabled],.btn-disabled)){border-width:var(--border,1px) 0 var(--border,1px) var(--border,1px)!important}.mt-1{margin-top:calc(var(--spacing) * 1)!important}.mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-3{margin-top:calc(var(--spacing) * 3)!important}.mt-4{margin-top:calc(var(--spacing) * 4)!important}.mt-5{margin-top:calc(var(--spacing) * 5)!important}.mb-1{margin-bottom:calc(var(--spacing) * 1)!important}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)!important}.mb-3{margin-bottom:calc(var(--spacing) * 3)!important}.mb-4{margin-bottom:calc(var(--spacing) * 4)!important}.mb-5{margin-bottom:calc(var(--spacing) * 5)!important}.mb-6{margin-bottom:calc(var(--spacing) * 6)!important}.ml-2{margin-left:calc(var(--spacing) * 2)!important}.alert{border-width:var(--border)!important;border-color:var(--alert-border-color,var(--color-base-200))!important}.flex{display:flex!important}.hidden{display:none!important}.inline{display:inline!important}.inline-flex{display:inline-flex!important}.table{display:table!important}.h-40{height:calc(var(--spacing) * 40)!important}.max-h-48{max-height:calc(var(--spacing) * 48)!important}.max-h-\[360px\]{max-height:360px!important}.w-11\/12{width:91.6667%!important}.w-44{width:calc(var(--spacing) * 44)!important}.w-52{width:calc(var(--spacing) * 52)!important}.w-fit{width:fit-content!important}.w-full{width:100%!important}.max-w-2xl{max-width:var(--container-2xl)!important}.max-w-xs{max-width:var(--container-xs)!important}.flex-1{flex:1!important}.animate-pulse{animation:var(--animate-pulse)!important}.cursor-pointer{cursor:pointer!important}.list-disc{list-style-type:disc!important}.flex-col{flex-direction:column!important}.flex-wrap{flex-wrap:wrap!important}.items-center{align-items:center!important}.items-start{align-items:flex-start!important}.items-stretch{align-items:stretch!important}.gap-1{gap:calc(var(--spacing) * 1)!important}.gap-2{gap:calc(var(--spacing) * 2)!important}.gap-3{gap:calc(var(--spacing) * 3)!important}.overflow-x-auto{overflow-x:auto!important}.overflow-y-auto{overflow-y:auto!important}.rounded-box{border-radius:var(--radius-box)!important;border-radius:var(--radius-box)!important}.rounded-lg{border-radius:var(--radius-lg)!important}.border{border-style:var(--tw-border-style)!important;border-width:1px!important}.border-base-300{border-color:var(--color-base-300)!important}.border-base-content\/5{border-color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.border-base-content\/5{border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent)!important}}.bg-\[\#fafafa\]{background-color:#fafafa!important}.bg-base-100{background-color:var(--color-base-100)!important}.bg-base-200{background-color:var(--color-base-200)!important}.bg-neutral{background-color:var(--color-neutral)!important}.bg-primary\/10{background-color:var(--color-primary)!important}@supports (color:color-mix(in lab, red, red)){.bg-primary\/10{background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)!important}}.bg-white{background-color:var(--color-white)!important}.p-2\.5{padding:calc(var(--spacing) * 2.5)!important}.p-3{padding:calc(var(--spacing) * 3)!important}.p-4{padding:calc(var(--spacing) * 4)!important}.px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-5{padding-inline:calc(var(--spacing) * 5)!important}.py-1{padding-block:calc(var(--spacing) * 1)!important}.py-3{padding-block:calc(var(--spacing) * 3)!important}.py-4{padding-block:calc(var(--spacing) * 4)!important}.pt-1{padding-top:calc(var(--spacing) * 1)!important}.pt-2{padding-top:calc(var(--spacing) * 2)!important}.pl-5{padding-left:calc(var(--spacing) * 5)!important}.text-center{text-align:center!important}.font-mono{font-family:var(--font-mono)!important}.font-sans{font-family:var(--font-sans)!important}.text-lg{font-size:var(--text-lg)!important;line-height:var(--tw-leading,var(--text-lg--line-height))!important}.text-sm{font-size:var(--text-sm)!important;line-height:var(--tw-leading,var(--text-sm--line-height))!important}.text-xl{font-size:var(--text-xl)!important;line-height:var(--tw-leading,var(--text-xl--line-height))!important}.text-xs{font-size:var(--text-xs)!important;line-height:var(--tw-leading,var(--text-xs--line-height))!important}.text-\[1\.6rem\]{font-size:1.6rem!important}.leading-relaxed{--tw-leading:var(--leading-relaxed)!important;line-height:var(--leading-relaxed)!important}.font-bold{--tw-font-weight:var(--font-weight-bold)!important;font-weight:var(--font-weight-bold)!important}.font-medium{--tw-font-weight:var(--font-weight-medium)!important;font-weight:var(--font-weight-medium)!important}.font-semibold{--tw-font-weight:var(--font-weight-semibold)!important;font-weight:var(--font-weight-semibold)!important}.whitespace-nowrap{white-space:nowrap!important}.whitespace-pre-wrap{white-space:pre-wrap!important}.text-base-content\/60{color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.text-base-content\/60{color:color-mix(in oklab, var(--color-base-content) 60%, transparent)!important}}.text-error{color:var(--color-error)!important}.text-neutral-content{color:var(--color-neutral-content)!important}.text-warning{color:var(--color-warning)!important}.no-underline{text-decoration-line:none!important}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}@layer daisyui.l1{.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)){--btn-shadow:""!important;--btn-bg:#0000!important;--btn-border:#0000!important;--btn-noise:none!important}.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)):not(:disabled,[disabled],.btn-disabled){--btn-fg:var(--btn-color,currentColor)!important;outline-color:currentColor!important}@media (hover:none){.btn-ghost:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-shadow:""!important;--btn-bg:#0000!important;--btn-fg:var(--btn-color,currentColor)!important;--btn-border:#0000!important;--btn-noise:none!important;outline-color:currentColor!important}}}@layer base{:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}@media (prefers-color-scheme:dark){:root:not([data-theme]){color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}}:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dark]:checked),[data-theme=dark]{color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root{--fx-noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E");scrollbar-color:currentColor #0000}@supports (color:color-mix(in lab, red, red)){:root{scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) #0000}}@property --radialprogress{syntax:"";inherits:true;initial-value:0%}:root:not(span){overflow:var(--page-overflow)}:root{background:var(--page-scroll-bg,var(--root-bg));--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) var(--root-bg,#0000)}@supports (color:color-mix(in lab, red, red)){:root{--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) color-mix(in srgb, var(--root-bg,#0000), oklch(0% 0 0) calc(var(--page-has-backdrop,0) * 40%))}}:root{--page-scroll-transition-on:background-color .3s ease-out;transition:var(--page-scroll-transition);scrollbar-gutter:var(--page-scroll-gutter,unset);scrollbar-gutter:if(style(--page-has-scroll: 1): var(--page-scroll-gutter,unset) ; else: unset)}@keyframes set-page-has-scroll{0%,to{--page-has-scroll:1}}:root,[data-theme]{background:var(--page-scroll-bg,var(--root-bg));color:var(--color-base-content)}:where(:root,[data-theme]){--root-bg:var(--color-base-100)}:where(:root),:root:has(input.theme-controller[value=gniza]:checked),[data-theme=gniza]{color-scheme:light;--color-base-100:transparent;--color-base-200:oklch(97% 0 0);--color-base-300:oklch(89.8% 0 0);--color-base-content:oklch(30.9% .116 258.9);--color-primary:oklch(38.2% .145 259.4);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(69.5% .169 47.8);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(86.4% .177 90.8);--color-accent-content:oklch(30.9% .116 258.9);--color-neutral:oklch(30.9% .116 258.9);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(69% .083 217.5);--color-info-content:oklch(100% 0 0);--color-success:oklch(65% .25 140);--color-success-content:oklch(100% 0 0);--color-warning:oklch(86.4% .177 90.8);--color-warning-content:oklch(30.9% .116 258.9);--color-error:oklch(57.7% .245 27.3);--color-error-content:oklch(100% 0 0);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}}@keyframes rating{0%,40%{filter:brightness(1.05)contrast(1.05);scale:1.1}}@keyframes dropdown{0%{opacity:0}}@keyframes radio{0%{padding:5px}50%{padding:3px}}@keyframes toast{0%{opacity:0;scale:.9}to{opacity:1;scale:1}}@keyframes rotator{89.9999%,to{--first-item-position:0 0%}90%,99.9999%{--first-item-position:0 calc(var(--items) * 100%)}to{translate:0 -100%}}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}@keyframes menu{0%{opacity:0}}@keyframes progress{50%{background-position-x:-115%}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/cpanel/gniza/index.live.cgi b/cpanel/gniza/index.live.cgi new file mode 100644 index 0000000..05aedd8 --- /dev/null +++ b/cpanel/gniza/index.live.cgi @@ -0,0 +1,105 @@ +#!/usr/local/cpanel/3rdparty/bin/perl +# gniza cPanel Plugin — Restore Category Grid +use strict; +use warnings; + +BEGIN { + # Find our lib directory relative to this CGI + my $base; + if ($0 =~ m{^(.*)/}) { + $base = $1; + } else { + $base = '.'; + } + unshift @INC, "$base/lib"; +} + +use Cpanel::AdminBin::Call (); +use GnizaCPanel::UI; + +print "Content-Type: text/html\r\n\r\n"; + +# Get allowed remotes via AdminBin +my $remotes_raw = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'LIST_ALLOWED_REMOTES') } // ''; +my @remotes = grep { $_ ne '' } split /\n/, $remotes_raw; + +print GnizaCPanel::UI::page_header('gniza Restore'); + +if (!@remotes) { + print qq{
No backup remotes are available for restore. Please contact your server administrator.
\n}; + print GnizaCPanel::UI::page_footer(); + exit; +} + +# Category cards +my @categories = ( + { + type => 'account', + label => 'Full Backup', + desc => 'Restore entire account from backup', + icon => '', + }, + { + type => 'files', + label => 'Home Directory', + desc => 'Restore files and folders', + icon => '', + }, + { + type => 'database', + label => 'Databases', + desc => 'Restore MySQL databases', + icon => '', + }, + { + type => 'dbusers', + label => 'Database Users', + desc => 'Restore database users and grants', + icon => '', + }, + { + type => 'cron', + label => 'Cron Jobs', + desc => 'Restore scheduled tasks', + icon => '', + }, + { + type => 'domains', + label => 'Domains', + desc => 'Restore domain and DNS configuration', + icon => '', + }, + { + type => 'ssl', + label => 'SSL Certificates', + desc => 'Restore SSL certificates', + icon => '', + }, + { + type => 'mailbox', + label => 'Email Accounts', + desc => 'Restore mailboxes and email', + icon => '', + }, +); + +print qq{
\n}; + +for my $cat (@categories) { + my $type = GnizaCPanel::UI::esc($cat->{type}); + my $label = GnizaCPanel::UI::esc($cat->{label}); + my $desc = GnizaCPanel::UI::esc($cat->{desc}); + my $icon = $cat->{icon}; # Already safe SVG + + print qq{\n}; + print qq{
\n}; + print qq{
$icon
\n}; + print qq{

$label

\n}; + print qq{

$desc

\n}; + print qq{
\n}; + print qq{
\n}; +} + +print qq{
\n}; + +print GnizaCPanel::UI::page_footer(); diff --git a/cpanel/gniza/install.json b/cpanel/gniza/install.json new file mode 100644 index 0000000..eeae194 --- /dev/null +++ b/cpanel/gniza/install.json @@ -0,0 +1,12 @@ +[ + { + "target_type": "link", + "name": "gniza Restore", + "category": "files", + "description": "Restore files, databases, email, and more from gniza backups", + "url": "gniza/index.live.cgi", + "feature": "gniza_restore", + "order": 1, + "icon": "gniza/assets/gniza-logo.svg" + } +] diff --git a/cpanel/gniza/lib/GnizaCPanel/UI.pm b/cpanel/gniza/lib/GnizaCPanel/UI.pm new file mode 100644 index 0000000..82719b2 --- /dev/null +++ b/cpanel/gniza/lib/GnizaCPanel/UI.pm @@ -0,0 +1,286 @@ +package GnizaCPanel::UI; +# Shared UI helpers for the gniza cPanel user restore plugin. +# Adapted from GnizaWHM::UI for cPanel context (runs as user, not root). + +use strict; +use warnings; +use Fcntl qw(:DEFAULT); + +my $CSS_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza/assets/gniza-whm.css'; +my $LOGO_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza/assets/gniza-logo.svg'; + +# ── HTML Escaping ───────────────────────────────────────────── + +sub esc { + my ($str) = @_; + $str //= ''; + $str =~ s/&/&/g; + $str =~ s//>/g; + $str =~ s/"/"/g; + $str =~ s/'/'/g; + return $str; +} + +# ── Current User ───────────────────────────────────────────── + +sub get_current_user { + return $ENV{'REMOTE_USER'} // ''; +} + +# ── Safe File I/O (symlink-safe) ────────────────────────────── + +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 { + my $user = get_current_user(); + return "/tmp/.gniza-cpanel-flash-$user"; +} + +sub set_flash { + my ($type, $text) = @_; + _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{
$escaped
\n}; +} + +# ── CSRF Protection ────────────────────────────────────────── + +my $_current_csrf_token; + +sub _csrf_file { + my $user = get_current_user(); + return "/tmp/.gniza-cpanel-csrf-$user"; +} + +sub generate_csrf_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))); + } + } + + _safe_write(_csrf_file(), 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_file(); + 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{}; +} + +# ── Page Wrappers ──────────────────────────────────────────── + +sub page_header { + my ($title) = @_; + $title = esc($title // 'gniza Restore'); + my $css = ''; + if (open my $fh, '<', $CSS_FILE) { + local $/; + $css = <$fh>; + close $fh; + } + $css = _unwrap_layers($css); + $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{
gniza
\n}; + } + + return qq{\n} + . qq{
\n} + . $logo_html; +} + +sub page_footer { + return qq{
\n}; +} + +sub _unwrap_layers { + my ($css) = @_; + while ($css =~ /\@layer\s/) { + $css =~ s/\@layer\s+[\w.,\s]+\s*;//g; + 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) = @_; + $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; + + 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++; + } + + return join('', @top_level) . '[data-theme="gniza"]{' . $scoped . '}'; +} + +sub render_errors { + my ($errors) = @_; + return '' unless $errors && @$errors; + my $html = qq{
\n
    \n}; + for my $err (@$errors) { + $html .= '
  • ' . esc($err) . "
  • \n"; + } + $html .= "
\n
\n"; + return $html; +} + +1; diff --git a/cpanel/gniza/restore.live.cgi b/cpanel/gniza/restore.live.cgi new file mode 100644 index 0000000..a6c2388 --- /dev/null +++ b/cpanel/gniza/restore.live.cgi @@ -0,0 +1,849 @@ +#!/usr/local/cpanel/3rdparty/bin/perl +# gniza cPanel Plugin — Restore Workflow +# Multi-step restore with dynamic dropdowns via AdminBin +use strict; +use warnings; + +BEGIN { + my $base; + if ($0 =~ m{^(.*)/}) { + $base = $1; + } else { + $base = '.'; + } + unshift @INC, "$base/lib"; +} + +use Cpanel::AdminBin::Call (); +use Cpanel::Form (); +use GnizaCPanel::UI; + +my $form = Cpanel::Form::parseform(); +my $method = $ENV{'REQUEST_METHOD'} // 'GET'; +my $step = $form->{'step'} // '1'; + +my %TYPE_LABELS = ( + account => 'Full Backup', + files => 'Home Directory', + database => 'Databases', + mailbox => 'Email Accounts', + cron => 'Cron Jobs', + dbusers => 'Database Users', + domains => 'Domains', + ssl => 'SSL Certificates', +); + +my %SIMPLE_TYPES = map { $_ => 1 } qw(account cron); + +# JSON endpoints +if ($step eq 'fetch_snapshots') { handle_fetch_snapshots() } +elsif ($step eq 'fetch_options') { handle_fetch_options() } +elsif ($step eq '2') { handle_step2() } +elsif ($step eq '3') { handle_step3() } +elsif ($step eq '4') { handle_step4() } +else { handle_step1() } + +exit; + +# ── Helpers ─────────────────────────────────────────────────── + +sub _json_escape { + my ($str) = @_; + $str //= ''; + $str =~ s/\\/\\\\/g; + $str =~ s/"/\\"/g; + $str =~ s/\n/\\n/g; + $str =~ s/\r/\\r/g; + $str =~ s/\t/\\t/g; + $str =~ s/[\x00-\x1f]//g; + return $str; +} + +sub _adminbin_call { + my ($action, @args) = @_; + my $result = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', $action, @args) }; + if ($@) { + return (0, '', "AdminBin call failed: $@"); + } + $result //= ''; + if ($result =~ /^ERROR:\s*(.*)/) { + return (0, '', $1); + } + return (1, $result, ''); +} + +# ── JSON: fetch snapshots ───────────────────────────────────── + +sub handle_fetch_snapshots { + my $remote = $form->{'remote'} // ''; + + print "Content-Type: application/json\r\n\r\n"; + + if ($remote eq '') { + print qq({"error":"Remote is required"}); + return; + } + + my ($ok, $stdout, $err) = _adminbin_call('LIST_SNAPSHOTS', $remote); + + unless ($ok) { + my $msg = _json_escape($err || 'Failed to list snapshots'); + print qq({"error":"$msg"}); + return; + } + + my @snapshots; + for my $line (split /\n/, $stdout) { + if ($line =~ /^\s+(\d{4}-\d{2}-\d{2}T\d{6})/) { + push @snapshots, $1; + } + } + + my $json_arr = join(',', map { qq("$_") } reverse sort @snapshots); + print qq({"snapshots":[$json_arr]}); +} + +# ── JSON: fetch item options ────────────────────────────────── + +sub handle_fetch_options { + my $remote = $form->{'remote'} // ''; + my $timestamp = $form->{'timestamp'} // ''; + my $type = $form->{'type'} // ''; + + print "Content-Type: application/json\r\n\r\n"; + + if ($remote eq '' || $timestamp eq '' || $type eq '') { + print qq({"error":"Missing required parameters"}); + return; + } + + my %action_map = ( + database => 'LIST_DATABASES', + mailbox => 'LIST_MAILBOXES', + files => 'LIST_FILES', + dbusers => 'LIST_DBUSERS', + cron => 'LIST_CRON', + domains => 'LIST_DNS', + ssl => 'LIST_SSL', + ); + + my $action = $action_map{$type}; + unless ($action) { + print qq({"error":"Invalid type"}); + return; + } + + my @call_args = ($remote, $timestamp); + if ($type eq 'files') { + my $path = $form->{'path'} // ''; + push @call_args, $path if $path ne ''; + } + + my ($ok, $stdout, $err) = _adminbin_call($action, @call_args); + + unless ($ok) { + my $msg = _json_escape($err || 'Failed to list options'); + print qq({"error":"$msg"}); + return; + } + + my @options; + for my $line (split /\n/, $stdout) { + $line =~ s/^\s+|\s+$//g; + next unless $line ne ''; + push @options, $line; + } + + my $json_arr = join(',', map { my $v = $_; $v =~ s/\\/\\\\/g; $v =~ s/"/\\"/g; qq("$v") } @options); + print qq({"options":[$json_arr]}); +} + +# ── Step 1: Select Remote + Snapshot ────────────────────────── + +sub handle_step1 { + my $type = $form->{'type'} // 'account'; + unless (exists $TYPE_LABELS{$type}) { + $type = 'account'; + } + + print "Content-Type: text/html\r\n\r\n"; + print GnizaCPanel::UI::page_header('Restore: ' . ($TYPE_LABELS{$type} // $type)); + print GnizaCPanel::UI::render_flash(); + + # Get allowed remotes + my $remotes_raw = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'LIST_ALLOWED_REMOTES') } // ''; + my @remotes = grep { $_ ne '' } split /\n/, $remotes_raw; + + unless (@remotes) { + print qq{
No backup remotes are available. Please contact your server administrator.
\n}; + print qq{Back\n}; + print GnizaCPanel::UI::page_footer(); + return; + } + + my $esc_type = GnizaCPanel::UI::esc($type); + my $type_label = GnizaCPanel::UI::esc($TYPE_LABELS{$type} // $type); + + print qq{
\n
\n}; + print qq{

Step 1: Select Source

\n}; + print qq{

Restore type: $type_label

\n}; + + # Remote dropdown + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + + # Snapshot dropdown (populated via AJAX) + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + + print qq{
\n
\n}; + + # For simple types, go directly to step 3 (confirm); others go to step 2 + my $next_step = $SIMPLE_TYPES{$type} ? '3' : '2'; + + print qq{
\n}; + print qq{ \n}; + print qq{ Back\n}; + print qq{
\n}; + + # JavaScript for snapshot loading and navigation + _print_step1_js($esc_type, $next_step); + + print GnizaCPanel::UI::page_footer(); +} + +sub _print_step1_js { + my ($esc_type, $next_step) = @_; + print qq{\n}; +} + +# ── Step 2: Select specific item ───────────────────────────── + +sub handle_step2 { + my $type = $form->{'type'} // 'account'; + my $remote = $form->{'remote'} // ''; + my $timestamp = $form->{'timestamp'} // ''; + + $type = 'account' unless exists $TYPE_LABELS{$type}; + + if ($remote eq '' || $timestamp eq '') { + GnizaCPanel::UI::set_flash('error', 'Remote and snapshot are required.'); + print "Status: 302 Found\r\n"; + print "Location: restore.live.cgi?type=$type\r\n\r\n"; + exit; + } + + print "Content-Type: text/html\r\n\r\n"; + print GnizaCPanel::UI::page_header('Restore: ' . ($TYPE_LABELS{$type} // $type)); + print GnizaCPanel::UI::render_flash(); + + my $esc_type = GnizaCPanel::UI::esc($type); + my $esc_remote = GnizaCPanel::UI::esc($remote); + my $esc_timestamp = GnizaCPanel::UI::esc($timestamp); + + print qq{
\n
\n}; + print qq{

Step 2: Select Items

\n}; + print qq{

Remote: $esc_remote · Snapshot: $esc_timestamp

\n}; + + if ($type eq 'files') { + _render_file_picker(); + } + elsif ($type eq 'database' || $type eq 'dbusers' || $type eq 'mailbox' || $type eq 'domains' || $type eq 'ssl') { + my $item_label = { + database => 'Databases', + dbusers => 'Database Users', + mailbox => 'Mailboxes', + domains => 'Domains', + ssl => 'SSL Certificates', + }->{$type}; + + print qq{

$item_label

\n}; + print qq{\n}; + print qq{
\n}; + print qq{ Loading...\n}; + print qq{
\n}; + } + elsif ($type eq 'cron') { + print qq{

Cron Jobs Preview

\n}; + print qq{
\n}; + print qq{ Loading...\n}; + print qq{
\n}; + } + + print qq{
\n
\n}; + + print qq{
\n}; + print qq{ \n}; + print qq{ Back\n}; + print qq{
\n}; + + _print_step2_js($esc_type, $esc_remote, $esc_timestamp); + + print GnizaCPanel::UI::page_footer(); +} + +sub _render_file_picker { + print qq{
\n}; + print qq{ \n}; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + print qq{
\n}; + print qq{

Leave empty to restore all files.

\n}; + + # File browser modal + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; +} + +sub _print_step2_js { + my ($esc_type, $esc_remote, $esc_timestamp) = @_; + print qq{\n}; +} + +# ── Step 3: Confirmation ───────────────────────────────────── + +sub handle_step3 { + my $type = $form->{'type'} // 'account'; + my $remote = $form->{'remote'} // ''; + my $timestamp = $form->{'timestamp'} // ''; + my $path = $form->{'path'} // ''; + my $items = $form->{'items'} // ''; + + $type = 'account' unless exists $TYPE_LABELS{$type}; + + if ($remote eq '' || $timestamp eq '') { + GnizaCPanel::UI::set_flash('error', 'Remote and snapshot are required.'); + print "Status: 302 Found\r\n"; + print "Location: restore.live.cgi?type=$type\r\n\r\n"; + exit; + } + + print "Content-Type: text/html\r\n\r\n"; + print GnizaCPanel::UI::page_header('Restore: Confirm'); + print GnizaCPanel::UI::render_flash(); + + my $esc_type = GnizaCPanel::UI::esc($type); + my $esc_remote = GnizaCPanel::UI::esc($remote); + my $esc_timestamp = GnizaCPanel::UI::esc($timestamp); + my $type_label = GnizaCPanel::UI::esc($TYPE_LABELS{$type} // $type); + my $user = GnizaCPanel::UI::esc(GnizaCPanel::UI::get_current_user()); + + print qq{
\n
\n}; + print qq{

Step 3: Confirm Restore

\n}; + print qq{
\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + + if ($type eq 'files') { + my $path_display = $path ne '' ? GnizaCPanel::UI::esc($path) : 'All files'; + print qq{\n}; + } elsif ($type ne 'account' && $type ne 'cron' && $items ne '') { + my $items_display = $items eq '__ALL__' ? 'All' : GnizaCPanel::UI::esc($items); + $items_display =~ s/,/, /g; + print qq{\n}; + } + + print qq{
Account$user
Remote$esc_remote
Snapshot$esc_timestamp
Restore Type$type_label
Path$path_display
Items$items_display
\n}; + print qq{
\n
\n}; + + print qq{
\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + print GnizaCPanel::UI::csrf_hidden_field(); + + print qq{
\n}; + print qq{ \n}; + print qq{ Cancel\n}; + print qq{
\n}; + print qq{
\n}; + + print GnizaCPanel::UI::page_footer(); +} + +# ── Step 4: Execute ─────────────────────────────────────────── + +sub handle_step4 { + unless ($method eq 'POST' && GnizaCPanel::UI::verify_csrf_token($form->{'gniza_csrf'})) { + GnizaCPanel::UI::set_flash('error', 'Invalid or expired form token.'); + print "Status: 302 Found\r\n"; + print "Location: index.live.cgi\r\n\r\n"; + exit; + } + + my $type = $form->{'type'} // 'account'; + my $remote = $form->{'remote'} // ''; + my $timestamp = $form->{'timestamp'} // ''; + my $path = $form->{'path'} // ''; + my $items = $form->{'items'} // ''; + + $type = 'account' unless exists $TYPE_LABELS{$type}; + + print "Content-Type: text/html\r\n\r\n"; + print GnizaCPanel::UI::page_header('Restore: Results'); + + my $type_label = GnizaCPanel::UI::esc($TYPE_LABELS{$type}); + + print qq{
\n
\n}; + print qq{

Restore Results: $type_label

\n}; + + my @results; + + my %action_map = ( + account => 'RESTORE_ACCOUNT', + files => 'RESTORE_FILES', + database => 'RESTORE_DATABASE', + mailbox => 'RESTORE_MAILBOX', + cron => 'RESTORE_CRON', + dbusers => 'RESTORE_DBUSERS', + domains => 'RESTORE_DOMAINS', + ssl => 'RESTORE_SSL', + ); + + my $action = $action_map{$type}; + unless ($action) { + push @results, { ok => 0, label => $type, msg => 'Unknown restore type' }; + _render_results(\@results); + print qq{
\n
\n}; + print qq{Back to Categories\n}; + print GnizaCPanel::UI::page_footer(); + return; + } + + if ($type eq 'account') { + my ($ok, $stdout, $err) = _adminbin_call($action, $remote, $timestamp, ''); + push @results, { ok => $ok, label => 'Full Account', msg => $ok ? $stdout : $err }; + } + elsif ($type eq 'cron') { + my ($ok, $stdout, $err) = _adminbin_call($action, $remote, $timestamp); + push @results, { ok => $ok, label => 'Cron Jobs', msg => $ok ? $stdout : $err }; + } + elsif ($type eq 'files') { + my ($ok, $stdout, $err) = _adminbin_call($action, $remote, $timestamp, $path, ''); + push @results, { ok => $ok, label => 'Files', msg => $ok ? $stdout : $err }; + } + elsif ($type eq 'database' || $type eq 'dbusers' || $type eq 'mailbox' || $type eq 'domains' || $type eq 'ssl') { + if ($items eq '' || $items eq '__ALL__') { + my ($ok, $stdout, $err) = _adminbin_call($action, $remote, $timestamp, ''); + push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err }; + } else { + for my $item (split /,/, $items) { + next if $item eq ''; + my ($ok, $stdout, $err) = _adminbin_call($action, $remote, $timestamp, $item); + push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err }; + } + } + } + + _render_results(\@results); + + print qq{\n\n}; + + print qq{Back to Categories\n}; + + print GnizaCPanel::UI::page_footer(); +} + +sub _render_results { + my ($results) = @_; + for my $r (@$results) { + my $icon_class = $r->{ok} ? 'text-success' : 'text-error'; + my $icon = $r->{ok} ? '✓' : '✗'; + my $label = GnizaCPanel::UI::esc($r->{label}); + my $msg = GnizaCPanel::UI::esc($r->{msg} // ''); + # Clean up the "OK\n" prefix from successful results + $msg =~ s/^OK\s*//; + + print qq{
\n}; + print qq{ $icon\n}; + print qq{
\n}; + print qq{
$label
\n}; + if ($msg ne '') { + print qq{
$msg
\n}; + } + print qq{
\n}; + print qq{
\n}; + } +} diff --git a/etc/gniza.conf.example b/etc/gniza.conf.example index b724587..1f2cb36 100644 --- a/etc/gniza.conf.example +++ b/etc/gniza.conf.example @@ -33,3 +33,7 @@ LOCK_FILE="/var/run/gniza.lock" SSH_TIMEOUT=30 # SSH connection timeout in seconds SSH_RETRIES=3 # Number of rsync retry attempts RSYNC_EXTRA_OPTS="" # Extra options to pass to rsync + +# ── User Restore (cPanel Plugin) ───────────────────────────── +USER_RESTORE_REMOTES="all" # Remotes available for cPanel user self-service restore + # "all" = all remotes, comma-separated names, empty = disabled diff --git a/lib/config.sh b/lib/config.sh index 388c127..ba08be5 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -1,6 +1,29 @@ #!/usr/bin/env bash # gniza/lib/config.sh — Shell-variable config loading & validation +# Safe config parser — reads KEY=VALUE lines without executing arbitrary code. +# Only processes lines matching ^[A-Z_][A-Z_0-9]*= and strips surrounding quotes. +_safe_source_config() { + local filepath="$1" + local line key value + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip blank lines and comments + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + # Match KEY=VALUE (optional quotes) + if [[ "$line" =~ ^([A-Z_][A-Z_0-9]*)=(.*) ]]; then + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + # Strip surrounding double or single quotes + if [[ "$value" =~ ^\"(.*)\"$ ]]; then + value="${BASH_REMATCH[1]}" + elif [[ "$value" =~ ^\'(.*)\'$ ]]; then + value="${BASH_REMATCH[1]}" + fi + declare -g "$key=$value" + fi + done < "$filepath" +} + load_config() { local config_file="${1:-$DEFAULT_CONFIG_FILE}" @@ -8,9 +31,8 @@ load_config() { die "Config file not found: $config_file (run 'gniza init' to create one)" fi - # Source the config (shell variables) - # shellcheck disable=SC1090 - source "$config_file" || die "Failed to parse config file: $config_file" + # Parse the config (safe key=value reader, no code execution) + _safe_source_config "$config_file" || die "Failed to parse config file: $config_file" # Apply defaults for optional settings TEMP_DIR="${TEMP_DIR:-$DEFAULT_TEMP_DIR}" @@ -31,6 +53,7 @@ load_config() { SSH_TIMEOUT="${SSH_TIMEOUT:-$DEFAULT_SSH_TIMEOUT}" SSH_RETRIES="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" RSYNC_EXTRA_OPTS="${RSYNC_EXTRA_OPTS:-}" + USER_RESTORE_REMOTES="${USER_RESTORE_REMOTES:-$DEFAULT_USER_RESTORE_REMOTES}" # --debug flag overrides config [[ "${GNIZA_DEBUG:-false}" == "true" ]] && LOG_LEVEL="debug" @@ -38,7 +61,7 @@ load_config() { export TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS BWLIMIT RETENTION_COUNT export LOG_DIR LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON export SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASSWORD SMTP_FROM SMTP_SECURITY - export LOCK_FILE SSH_TIMEOUT SSH_RETRIES RSYNC_EXTRA_OPTS + export LOCK_FILE SSH_TIMEOUT SSH_RETRIES RSYNC_EXTRA_OPTS USER_RESTORE_REMOTES } validate_config() { @@ -70,6 +93,28 @@ validate_config() { fi fi + # Validate numeric fields + if [[ -n "${SSH_TIMEOUT:-}" ]] && [[ ! "$SSH_TIMEOUT" =~ ^[0-9]+$ ]]; then + log_error "SSH_TIMEOUT must be a non-negative integer, got: $SSH_TIMEOUT" + ((errors++)) || true + fi + + if [[ -n "${SSH_RETRIES:-}" ]] && [[ ! "$SSH_RETRIES" =~ ^[0-9]+$ ]]; then + log_error "SSH_RETRIES must be a non-negative integer, got: $SSH_RETRIES" + ((errors++)) || true + fi + + if [[ -n "${LOG_RETAIN:-}" ]] && [[ ! "$LOG_RETAIN" =~ ^[0-9]+$ ]]; then + log_error "LOG_RETAIN must be a non-negative integer, got: $LOG_RETAIN" + ((errors++)) || true + fi + + # Validate RSYNC_EXTRA_OPTS characters (prevent flag injection) + if [[ -n "${RSYNC_EXTRA_OPTS:-}" ]] && [[ ! "$RSYNC_EXTRA_OPTS" =~ ^[a-zA-Z0-9\ ._=/,-]+$ ]]; then + log_error "RSYNC_EXTRA_OPTS contains invalid characters: $RSYNC_EXTRA_OPTS" + ((errors++)) || true + fi + if (( errors > 0 )); then log_error "Configuration has $errors error(s)" return 1 diff --git a/lib/constants.sh b/lib/constants.sh index 3ea5fa1..59d59fb 100644 --- a/lib/constants.sh +++ b/lib/constants.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # gniza/lib/constants.sh — Version, exit codes, colors +# shellcheck disable=SC2034 # constants are used by sourcing scripts [[ -n "${_GNIZA_CONSTANTS_LOADED:-}" ]] && return 0 _GNIZA_CONSTANTS_LOADED=1 @@ -50,4 +51,5 @@ readonly DEFAULT_REMOTE_TYPE="ssh" readonly DEFAULT_S3_REGION="us-east-1" readonly DEFAULT_SMTP_PORT=587 readonly DEFAULT_SMTP_SECURITY="tls" +readonly DEFAULT_USER_RESTORE_REMOTES="all" readonly DEFAULT_CONFIG_FILE="/etc/gniza/gniza.conf" diff --git a/lib/notify.sh b/lib/notify.sh index 9a18827..0c8787e 100644 --- a/lib/notify.sh +++ b/lib/notify.sh @@ -50,9 +50,8 @@ _send_via_smtp() { curl_args+=("--mail-from" "$from") # Recipients (split NOTIFY_EMAIL on commas) - local IFS=',' - local -a recipients=($NOTIFY_EMAIL) - unset IFS + local -a recipients + IFS=',' read -ra recipients <<< "$NOTIFY_EMAIL" local rcpt for rcpt in "${recipients[@]}"; do rcpt="${rcpt## }" # trim leading space @@ -81,11 +80,12 @@ _send_via_legacy() { local subject="$1" local body="$2" - # Convert comma-separated to space-separated for mail command - local recipients="${NOTIFY_EMAIL//,/ }" + # Split comma-separated emails for mail command + local -a recipients + IFS=',' read -ra recipients <<< "$NOTIFY_EMAIL" if command -v mail &>/dev/null; then - echo "$body" | mail -s "$subject" $recipients + echo "$body" | mail -s "$subject" "${recipients[@]}" elif command -v sendmail &>/dev/null; then { echo "To: $NOTIFY_EMAIL" diff --git a/lib/pkgacct.sh b/lib/pkgacct.sh index 0c39cea..ad436ca 100644 --- a/lib/pkgacct.sh +++ b/lib/pkgacct.sh @@ -68,7 +68,7 @@ cleanup_pkgacct() { cleanup_all_temp() { local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}" if [[ -d "$temp_dir" ]]; then - rm -rf "$temp_dir"/* + rm -rf "${temp_dir:?}"/* log_debug "Cleaned up all temp contents: $temp_dir" fi } diff --git a/lib/rclone.sh b/lib/rclone.sh index 41f1aff..b09651c 100644 --- a/lib/rclone.sh +++ b/lib/rclone.sh @@ -14,11 +14,15 @@ _is_rclone_mode() { _build_rclone_config() { local tmpfile + local old_umask + old_umask=$(umask) + umask 077 tmpfile=$(mktemp /tmp/gniza-rclone-XXXXXX.conf) || { + umask "$old_umask" log_error "Failed to create temp rclone config" return 1 } - chmod 600 "$tmpfile" + umask "$old_umask" case "${REMOTE_TYPE}" in s3) diff --git a/lib/remotes.sh b/lib/remotes.sh index e68b2d2..a648d29 100644 --- a/lib/remotes.sh +++ b/lib/remotes.sh @@ -106,8 +106,7 @@ load_remote() { return 1 fi - # shellcheck disable=SC1090 - source "$conf" || { + _safe_source_config "$conf" || { log_error "Failed to parse remote config: $conf" return 1 } @@ -133,6 +132,7 @@ load_remote() { GDRIVE_SERVICE_ACCOUNT_FILE="${GDRIVE_SERVICE_ACCOUNT_FILE:-}" GDRIVE_ROOT_FOLDER_ID="${GDRIVE_ROOT_FOLDER_ID:-}" + # shellcheck disable=SC2034 # used by bin/gniza CURRENT_REMOTE_NAME="$name" if [[ "$REMOTE_TYPE" == "ssh" ]]; then log_debug "Loaded remote '$name': ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT} -> ${REMOTE_BASE}" diff --git a/lib/restore.sh b/lib/restore.sh index c37bb10..765de5c 100644 --- a/lib/restore.sh +++ b/lib/restore.sh @@ -7,7 +7,8 @@ _rsync_download() { local local_path="$2" local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd) if _is_password_mode; then - sshpass -p "$REMOTE_PASSWORD" rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \ + export SSHPASS="$REMOTE_PASSWORD" + sshpass -e rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \ -e "$rsync_ssh" \ "${REMOTE_USER}@${REMOTE_HOST}:${remote_path}" \ "$local_path" diff --git a/lib/retention.sh b/lib/retention.sh index 99dec4d..2241843 100644 --- a/lib/retention.sh +++ b/lib/retention.sh @@ -38,15 +38,3 @@ enforce_retention() { log_info "Pruned $pruned old snapshot(s) for $user" fi } - -enforce_retention_all() { - local accounts; accounts=$(list_remote_accounts) - if [[ -z "$accounts" ]]; then - log_debug "No remote accounts found for retention" - return 0 - fi - - while IFS= read -r user; do - [[ -n "$user" ]] && enforce_retention "$user" - done <<< "$accounts" -} diff --git a/lib/schedule.sh b/lib/schedule.sh index 4339771..7461f4e 100644 --- a/lib/schedule.sh +++ b/lib/schedule.sh @@ -56,8 +56,7 @@ load_schedule() { SCHEDULE_SYSBACKUP="" SCHEDULE_SKIP_SUSPENDED="" - # shellcheck disable=SC1090 - source "$conf" || { + _safe_source_config "$conf" || { log_error "Failed to parse schedule config: $conf" return 1 } diff --git a/lib/ssh.sh b/lib/ssh.sh index 6098836..079e20a 100644 --- a/lib/ssh.sh +++ b/lib/ssh.sh @@ -24,7 +24,7 @@ build_ssh_opts() { build_ssh_cmd() { if _is_password_mode; then - echo "sshpass -p $(printf '%q' "$REMOTE_PASSWORD") ssh $(build_ssh_opts)" + echo "sshpass -e ssh $(build_ssh_opts)" else echo "ssh $(build_ssh_opts)" fi @@ -34,9 +34,10 @@ remote_exec() { local cmd="$1" local ssh_opts; ssh_opts=$(build_ssh_opts) if _is_password_mode; then - log_debug "CMD: sshpass ssh $ssh_opts ${REMOTE_USER}@${REMOTE_HOST} '$cmd'" + log_debug "CMD: sshpass -e ssh $ssh_opts ${REMOTE_USER}@${REMOTE_HOST} ''" + export SSHPASS="$REMOTE_PASSWORD" # shellcheck disable=SC2086 - sshpass -p "$REMOTE_PASSWORD" ssh $ssh_opts "${REMOTE_USER}@${REMOTE_HOST}" "$cmd" + sshpass -e ssh $ssh_opts "${REMOTE_USER}@${REMOTE_HOST}" "$cmd" else log_debug "CMD: ssh $ssh_opts ${REMOTE_USER}@${REMOTE_HOST} '$cmd'" # shellcheck disable=SC2086 diff --git a/lib/transfer.sh b/lib/transfer.sh index c658e5e..0233f39 100644 --- a/lib/transfer.sh +++ b/lib/transfer.sh @@ -36,14 +36,16 @@ rsync_to_remote() { log_debug "CMD: rsync ${rsync_opts[*]} $source_dir ${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}" local rsync_cmd=(rsync "${rsync_opts[@]}" "$source_dir" "${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}") if _is_password_mode; then - rsync_cmd=(sshpass -p "$REMOTE_PASSWORD" "${rsync_cmd[@]}") + export SSHPASS="$REMOTE_PASSWORD" + rsync_cmd=(sshpass -e "${rsync_cmd[@]}") fi - if "${rsync_cmd[@]}"; then + local rc=0 + "${rsync_cmd[@]}" || rc=$? + if (( rc == 0 )); then log_debug "rsync succeeded on attempt $attempt" return 0 fi - local rc=$? log_warn "rsync failed (exit $rc), attempt $attempt/$max_retries" if (( attempt < max_retries )); then @@ -142,31 +144,3 @@ finalize_snapshot() { update_latest_symlink "$user" "$timestamp" } - -rsync_dry_run() { - if _is_rclone_mode; then - log_info "[DRY RUN] rclone mode — dry run not supported for cloud remotes" - return 0 - fi - - local source_dir="$1" - local remote_dest="$2" - local link_dest="${3:-}" - local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd) - - local rsync_opts=(-aHAX --numeric-ids --delete --rsync-path="rsync --fake-super" --dry-run --stats) - - if [[ -n "$link_dest" ]]; then - rsync_opts+=(--link-dest="$link_dest") - fi - - rsync_opts+=(-e "$rsync_ssh") - - [[ "$source_dir" != */ ]] && source_dir="$source_dir/" - - if _is_password_mode; then - sshpass -p "$REMOTE_PASSWORD" rsync "${rsync_opts[@]}" "$source_dir" "${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}" 2>&1 - else - rsync "${rsync_opts[@]}" "$source_dir" "${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}" 2>&1 - fi -} diff --git a/lib/utils.sh b/lib/utils.sh index 51915ff..c09c715 100644 --- a/lib/utils.sh +++ b/lib/utils.sh @@ -49,3 +49,19 @@ human_duration() { require_cmd() { command -v "$1" &>/dev/null || die "Required command not found: $1" } + +validate_timestamp() { + local ts="$1" + if [[ ! "$ts" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{6}$ ]]; then + log_error "Invalid timestamp format: $ts (expected YYYY-MM-DDTHHMMSS)" + return 1 + fi +} + +validate_account_name() { + local name="$1" + if [[ ! "$name" =~ ^[a-z][a-z0-9_-]{0,15}$ ]]; then + log_error "Invalid account name: $name" + return 1 + fi +} diff --git a/scripts/install.sh b/scripts/install.sh index 682d14c..b14bd1f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -52,8 +52,9 @@ ln -sf "$INSTALL_DIR/bin/gniza" "$BIN_LINK" # Create working directory mkdir -p "$INSTALL_DIR/workdir" -# Create config directory structure -mkdir -p /etc/gniza/remotes.d /etc/gniza/schedules.d +# Create config directory structure with restrictive permissions +mkdir -p -m 700 /etc/gniza/remotes.d /etc/gniza/schedules.d +chmod 700 /etc/gniza # Copy example configs if no config exists if [[ ! -f /etc/gniza/gniza.conf ]]; then @@ -91,6 +92,32 @@ else echo "WHM not detected, skipping WHM plugin installation." fi +# ── cPanel User Plugin (if cPanel is present) ──────────────── +CPANEL_BASE="/usr/local/cpanel/base/frontend/jupiter" +ADMINBIN_DIR="/usr/local/cpanel/bin/admin/Gniza" +if [[ -d "$CPANEL_BASE" ]]; then + echo "Installing cPanel user plugin..." + # Copy CGI files + lib + assets + mkdir -p "$CPANEL_BASE/gniza/lib/GnizaCPanel" "$CPANEL_BASE/gniza/assets" + cp "$SOURCE_DIR/cpanel/gniza/index.live.cgi" "$CPANEL_BASE/gniza/" + cp "$SOURCE_DIR/cpanel/gniza/restore.live.cgi" "$CPANEL_BASE/gniza/" + chmod +x "$CPANEL_BASE/gniza/"*.cgi + cp "$SOURCE_DIR/cpanel/gniza/lib/GnizaCPanel/UI.pm" "$CPANEL_BASE/gniza/lib/GnizaCPanel/" + cp "$SOURCE_DIR/cpanel/gniza/assets/gniza-whm.css" "$CPANEL_BASE/gniza/assets/" + cp "$SOURCE_DIR/cpanel/gniza/assets/gniza-logo.svg" "$CPANEL_BASE/gniza/assets/" + # Install AdminBin module (runs as root) + mkdir -p "$ADMINBIN_DIR" + cp "$SOURCE_DIR/cpanel/admin/Gniza/Restore" "$ADMINBIN_DIR/" + cp "$SOURCE_DIR/cpanel/admin/Gniza/Restore.conf" "$ADMINBIN_DIR/" + chmod 0700 "$ADMINBIN_DIR/Restore" + chmod 0600 "$ADMINBIN_DIR/Restore.conf" + # Register plugin in cPanel interface + /usr/local/cpanel/scripts/install_plugin "$SOURCE_DIR/cpanel/gniza/install.json" 2>/dev/null || true + echo "cPanel user plugin installed — users will see gniza Restore in Files section" +else + echo "cPanel not detected, skipping cPanel user plugin installation." +fi + echo "" echo "Next steps:" echo " 1. Run 'gniza init' to create your configuration" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 524a7f1..5d53d36 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -25,6 +25,13 @@ if [[ -d "$INSTALL_DIR" ]]; then echo "Removed $INSTALL_DIR" fi +# ── Remove cron entries ─────────────────────────────────────── +if crontab -l 2>/dev/null | grep -q '# gniza:'; then + echo "Removing gniza cron entries..." + crontab -l 2>/dev/null | grep -v '# gniza:' | grep -v '/usr/local/bin/gniza' | crontab - + echo "Cron entries removed." +fi + # ── WHM Plugin ──────────────────────────────────────────────── WHM_CGI_DIR="/usr/local/cpanel/whostmgr/docroot/cgi" if [[ -d "$WHM_CGI_DIR/gniza-whm" ]]; then @@ -34,6 +41,20 @@ if [[ -d "$WHM_CGI_DIR/gniza-whm" ]]; then echo "WHM plugin removed." fi +# ── cPanel User Plugin ──────────────────────────────────────── +CPANEL_BASE="/usr/local/cpanel/base/frontend/jupiter" +ADMINBIN_DIR="/usr/local/cpanel/bin/admin/Gniza" +if [[ -d "$CPANEL_BASE/gniza" ]]; then + echo "Removing cPanel user plugin..." + /usr/local/cpanel/scripts/uninstall_plugin "$CPANEL_BASE/gniza/install.json" 2>/dev/null || true + rm -rf "$CPANEL_BASE/gniza" + echo "cPanel user plugin removed." +fi +if [[ -d "$ADMINBIN_DIR" ]]; then + rm -rf "$ADMINBIN_DIR" + echo "AdminBin module removed." +fi + echo "" echo "gniza uninstalled." echo "" @@ -42,4 +63,5 @@ echo " /etc/gniza/ (configuration + remotes.d/)" echo " /var/log/gniza/ (log files)" echo " /var/run/gniza.lock (lock file)" echo "" -echo "To remove gniza cron entries: crontab -l | grep -v '# gniza:' | grep -v '/usr/local/bin/gniza' | crontab -" +echo "To remove configs: rm -rf /etc/gniza/" +echo "To remove logs: rm -rf /var/log/gniza/" diff --git a/tests/test_utils.sh b/tests/test_utils.sh index 9922e30..6592602 100755 --- a/tests/test_utils.sh +++ b/tests/test_utils.sh @@ -146,4 +146,185 @@ else assert_ok "validate_config catches invalid NOTIFY_ON" fi +# ── Tests: validate_timestamp ──────────────────────────────── + +echo "" +echo "Testing validate_timestamp..." + +if validate_timestamp "2024-01-15T023000" 2>/dev/null; then + assert_ok "validate_timestamp accepts valid format" +else + assert_fail "validate_timestamp should accept 2024-01-15T023000" +fi + +if validate_timestamp "2024-12-31T235959" 2>/dev/null; then + assert_ok "validate_timestamp accepts end-of-year" +else + assert_fail "validate_timestamp should accept 2024-12-31T235959" +fi + +if validate_timestamp "not-a-timestamp" 2>/dev/null; then + assert_fail "validate_timestamp should reject garbage" +else + assert_ok "validate_timestamp rejects garbage input" +fi + +if validate_timestamp "2024-01-15 02:30:00" 2>/dev/null; then + assert_fail "validate_timestamp should reject spaces/colons" +else + assert_ok "validate_timestamp rejects spaces/colons" +fi + +if validate_timestamp "" 2>/dev/null; then + assert_fail "validate_timestamp should reject empty string" +else + assert_ok "validate_timestamp rejects empty string" +fi + +# ── Tests: validate_account_name ───────────────────────────── + +echo "" +echo "Testing validate_account_name..." + +if validate_account_name "alice" 2>/dev/null; then + assert_ok "validate_account_name accepts 'alice'" +else + assert_fail "validate_account_name should accept 'alice'" +fi + +if validate_account_name "user123" 2>/dev/null; then + assert_ok "validate_account_name accepts 'user123'" +else + assert_fail "validate_account_name should accept 'user123'" +fi + +if validate_account_name "my-site" 2>/dev/null; then + assert_ok "validate_account_name accepts 'my-site'" +else + assert_fail "validate_account_name should accept 'my-site'" +fi + +if validate_account_name "Root" 2>/dev/null; then + assert_fail "validate_account_name should reject uppercase" +else + assert_ok "validate_account_name rejects uppercase" +fi + +if validate_account_name "123user" 2>/dev/null; then + assert_fail "validate_account_name should reject leading digit" +else + assert_ok "validate_account_name rejects leading digit" +fi + +if validate_account_name "../etc/passwd" 2>/dev/null; then + assert_fail "validate_account_name should reject path traversal" +else + assert_ok "validate_account_name rejects path traversal" +fi + +if validate_account_name "" 2>/dev/null; then + assert_fail "validate_account_name should reject empty" +else + assert_ok "validate_account_name rejects empty string" +fi + +if validate_account_name "a]b" 2>/dev/null; then + assert_fail "validate_account_name should reject special chars" +else + assert_ok "validate_account_name rejects special chars" +fi + +# ── Tests: _safe_source_config ─────────────────────────────── + +echo "" +echo "Testing _safe_source_config..." + +_test_conf=$(mktemp) +cat > "$_test_conf" <<'CONF' +# Comment line +MYKEY="hello world" +ANOTHER='single quoted' +BARE=noquotes + +# blank lines above +NUMERIC=42 +CONF + +# Clear any previous values +unset MYKEY ANOTHER BARE NUMERIC 2>/dev/null || true + +_safe_source_config "$_test_conf" +assert_eq "hello world" "$MYKEY" "_safe_source_config reads double-quoted value" +assert_eq "single quoted" "$ANOTHER" "_safe_source_config reads single-quoted value" +assert_eq "noquotes" "$BARE" "_safe_source_config reads bare value" +assert_eq "42" "$NUMERIC" "_safe_source_config reads numeric value" + +# Test that malicious content is not executed +_test_conf2=$(mktemp) +cat > "$_test_conf2" <<'CONF' +SAFE_KEY="safe" +$(echo pwned) +`rm -rf /` +CONF + +unset SAFE_KEY 2>/dev/null || true +_safe_source_config "$_test_conf2" +assert_eq "safe" "$SAFE_KEY" "_safe_source_config reads safe key from malicious file" +rm -f "$_test_conf" "$_test_conf2" + +# ── Tests: config.sh validation (new fields) ──────────────── + +echo "" +echo "Testing config.sh validation (numeric + RSYNC_EXTRA_OPTS)..." + +# Valid config baseline +NOTIFY_ON="failure" +LOG_LEVEL="info" +SMTP_HOST="" +SSH_TIMEOUT="30" +SSH_RETRIES="3" +LOG_RETAIN="90" +RSYNC_EXTRA_OPTS="" + +if validate_config 2>/dev/null; then + assert_ok "validate_config passes with valid numeric fields" +else + assert_fail "validate_config should pass with valid config" +fi + +# Invalid SSH_TIMEOUT +SSH_TIMEOUT="abc" +if validate_config 2>/dev/null; then + assert_fail "validate_config should fail with non-numeric SSH_TIMEOUT" +else + assert_ok "validate_config catches non-numeric SSH_TIMEOUT" +fi +SSH_TIMEOUT="30" + +# Invalid SSH_RETRIES +SSH_RETRIES="abc" +if validate_config 2>/dev/null; then + assert_fail "validate_config should fail with non-numeric SSH_RETRIES" +else + assert_ok "validate_config catches non-numeric SSH_RETRIES" +fi +SSH_RETRIES="3" + +# Invalid RSYNC_EXTRA_OPTS (shell metacharacters) +RSYNC_EXTRA_OPTS='--rsh="evil command"' +if validate_config 2>/dev/null; then + assert_fail "validate_config should fail with dangerous RSYNC_EXTRA_OPTS" +else + assert_ok "validate_config catches dangerous RSYNC_EXTRA_OPTS" +fi + +# Valid RSYNC_EXTRA_OPTS +RSYNC_EXTRA_OPTS="--compress --bwlimit=1000" +if validate_config 2>/dev/null; then + assert_ok "validate_config accepts valid RSYNC_EXTRA_OPTS" +else + assert_fail "validate_config should accept --compress --bwlimit=1000" +fi +RSYNC_EXTRA_OPTS="" + print_summary diff --git a/whm/gniza-whm/lib/GnizaWHM/Config.pm b/whm/gniza-whm/lib/GnizaWHM/Config.pm index d5b56b2..a8a8c20 100644 --- a/whm/gniza-whm/lib/GnizaWHM/Config.pm +++ b/whm/gniza-whm/lib/GnizaWHM/Config.pm @@ -11,6 +11,7 @@ our @MAIN_KEYS = qw( RSYNC_EXTRA_OPTS LOG_DIR LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASSWORD SMTP_FROM SMTP_SECURITY LOCK_FILE SSH_TIMEOUT SSH_RETRIES + USER_RESTORE_REMOTES ); our @REMOTE_KEYS = qw( @@ -39,7 +40,7 @@ sub parse { : \%MAIN_KEY_SET; my %config; - open my $fh, '<', $filepath or return \%config; + open my $fh, '<', $filepath or return \%config; ## no critic (RequireBriefOpen) while (my $line = <$fh>) { chomp $line; # Skip blank lines and comments @@ -79,11 +80,11 @@ sub escape_password { return $val; } -# write($filepath, \%values, \@allowed_keys) +# save($filepath, \%values, \@allowed_keys) # Updates a config file preserving comments and structure. # Keys not in @allowed_keys are ignored. Values are escaped. # Uses flock for concurrency safety. -sub write { +sub save { my ($filepath, $values, $allowed_keys) = @_; my %allowed = map { $_ => 1 } @$allowed_keys; @@ -96,12 +97,17 @@ sub write { } } - # Read existing file + # Open file for read+write with exclusive lock to prevent TOCTOU + ## no critic (RequireBriefOpen) my @lines; + my $wfh; if (-f $filepath) { - open my $rfh, '<', $filepath or return (0, "Cannot read $filepath: $!"); - @lines = <$rfh>; - close $rfh; + open $wfh, '+<', $filepath or return (0, "Cannot open $filepath: $!"); + flock($wfh, LOCK_EX) or return (0, "Cannot lock $filepath: $!"); + @lines = <$wfh>; + } else { + open $wfh, '>', $filepath or return (0, "Cannot create $filepath: $!"); + flock($wfh, LOCK_EX) or return (0, "Cannot lock $filepath: $!"); } # Track which keys we've updated in-place @@ -130,11 +136,10 @@ sub write { push @output, "$key=$q$val$q\n"; } - # Write with flock - open my $wfh, '>', $filepath or return (0, "Cannot write $filepath: $!"); - flock($wfh, LOCK_EX) or return (0, "Cannot lock $filepath: $!"); + # Truncate and write under the same lock + seek($wfh, 0, 0) or return (0, "Cannot seek $filepath: $!"); + truncate($wfh, 0) or return (0, "Cannot truncate $filepath: $!"); print $wfh @output; - flock($wfh, Fcntl::LOCK_UN); close $wfh; return (1, undef); diff --git a/whm/gniza-whm/lib/GnizaWHM/Runner.pm b/whm/gniza-whm/lib/GnizaWHM/Runner.pm index 04e63cb..a3cffa2 100644 --- a/whm/gniza-whm/lib/GnizaWHM/Runner.pm +++ b/whm/gniza-whm/lib/GnizaWHM/Runner.pm @@ -45,9 +45,9 @@ my @ALLOWED = ( my %OPT_PATTERNS = ( remote => qr/^[a-zA-Z0-9_,-]+$/, timestamp => qr/^\d{4}-\d{2}-\d{2}T\d{6}$/, - path => qr/^[a-zA-Z0-9_.\/@ -]+$/, + path => qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/, account => qr/^[a-z][a-z0-9_-]*$/, - exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/, + exclude => qr/^(?!.*\.\.)[a-zA-Z0-9_.,\/@ *?\[\]-]+$/, terminate => qr/^1$/, ); diff --git a/whm/gniza-whm/lib/GnizaWHM/UI.pm b/whm/gniza-whm/lib/GnizaWHM/UI.pm index 4538411..349baf4 100644 --- a/whm/gniza-whm/lib/GnizaWHM/UI.pm +++ b/whm/gniza-whm/lib/GnizaWHM/UI.pm @@ -4,12 +4,12 @@ package GnizaWHM::UI; use strict; use warnings; -use Fcntl qw(:flock); +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 = '/tmp'; +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'; @@ -57,34 +57,56 @@ sub render_nav { 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-$$"; + 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; - } + _ensure_dir($FLASH_DIR); + _safe_write(_flash_file(), "$type\n$text\n"); + return; } 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; - } + my $file = _flash_file(); + my $content = _safe_read($file); + return unless defined $content; unlink $file; - return undef unless defined $type && defined $text; + my ($type, $text) = split /\n/, $content, 2; + return unless defined $type && defined $text; chomp $type; chomp $text; return ($type, $text); @@ -94,10 +116,21 @@ 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{
$escaped
\n}; } +sub _ensure_dir { + my ($dir) = @_; + unless (-d $dir) { + mkdir $dir, 0700; + } + return; +} + # ── CSRF Protection ────────────────────────────────────────── sub _csrf_file { @@ -112,14 +145,20 @@ sub generate_csrf_token { return $_current_csrf_token if defined $_current_csrf_token; my $token = ''; - for (1..32) { - $token .= sprintf('%02x', int(rand(256))); + 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))); + } } - if (open my $fh, '>', $CSRF_DIR) { - print $fh time() . "\n" . $token . "\n"; - close $fh; - } + _ensure_dir($CSRF_DIR); + _safe_write("$CSRF_DIR/token", time() . "\n" . $token . "\n"); $_current_csrf_token = $token; return $token; @@ -128,21 +167,19 @@ sub generate_csrf_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; - } + 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 $CSRF_DIR; + unlink $file; # Check expiry (1 hour) return 0 if (time() - $stored_time) > 3600; @@ -220,7 +257,7 @@ sub list_remotes { } closedir $dh; } - return sort @remotes; + return (sort @remotes); } sub remote_conf_path { @@ -258,7 +295,7 @@ sub list_schedules { } closedir $dh; } - return sort @schedules; + return (sort @schedules); } sub schedule_conf_path { @@ -335,11 +372,14 @@ sub render_ssh_guidance { # ── SSH Connection Test ────────────────────────────────────── sub test_ssh_connection { - my (%args) = @_; + my @params = @_; # Support legacy positional args: ($host, $port, $user, $key) - if (@_ == 4 && !ref $_[0]) { - %args = (host => $_[0], port => $_[1], user => $_[2], key => $_[3]); + 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}; diff --git a/whm/gniza-whm/lib/GnizaWHM/Validator.pm b/whm/gniza-whm/lib/GnizaWHM/Validator.pm index 4c8b67f..1c2ae97 100644 --- a/whm/gniza-whm/lib/GnizaWHM/Validator.pm +++ b/whm/gniza-whm/lib/GnizaWHM/Validator.pm @@ -107,6 +107,12 @@ sub validate_main_config { } } + if (defined $data->{USER_RESTORE_REMOTES} && $data->{USER_RESTORE_REMOTES} ne '') { + unless ($data->{USER_RESTORE_REMOTES} eq 'all' || $data->{USER_RESTORE_REMOTES} =~ /^[a-zA-Z0-9_,-]+$/) { + push @errors, 'USER_RESTORE_REMOTES must be "all" or comma-separated remote names'; + } + } + # Filter out empty strings from helper returns return [grep { $_ ne '' } @errors]; } diff --git a/whm/gniza-whm/remotes.cgi b/whm/gniza-whm/remotes.cgi index 79290fb..3de29d3 100644 --- a/whm/gniza-whm/remotes.cgi +++ b/whm/gniza-whm/remotes.cgi @@ -30,6 +30,14 @@ exit; sub handle_test_connection { print "Content-Type: application/json\r\n\r\n"; + unless ($method eq 'POST' && GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + print qq({"success":false,"message":"Invalid or expired token. Please reload and try again."}); + exit; + } + + # Generate fresh token after consuming the old one (CSRF is single-use) + my $new_csrf = GnizaWHM::UI::generate_csrf_token(); + my $type = $form->{'remote_type'} || 'ssh'; if ($type eq 'ssh') { @@ -41,18 +49,18 @@ sub handle_test_connection { my $password = $form->{'password'} // ''; if ($host eq '') { - print qq({"success":false,"message":"Host is required."}); + print qq({"success":false,"message":"Host is required.","csrf":"$new_csrf"}); exit; } if ($auth_method eq 'password') { if ($password eq '') { - print qq({"success":false,"message":"Password is required."}); + print qq({"success":false,"message":"Password is required.","csrf":"$new_csrf"}); exit; } } else { if ($key eq '') { - print qq({"success":false,"message":"SSH key path is required."}); + print qq({"success":false,"message":"SSH key path is required.","csrf":"$new_csrf"}); exit; } } @@ -66,7 +74,7 @@ sub handle_test_connection { password => $password, ); if ($ok) { - print qq({"success":true,"message":"SSH connection successful."}); + print qq({"success":true,"message":"SSH connection successful.","csrf":"$new_csrf"}); } else { $err //= 'Unknown error'; $err =~ s/\\/\\\\/g; @@ -75,7 +83,7 @@ sub handle_test_connection { $err =~ s/\r/\\r/g; $err =~ s/\t/\\t/g; $err =~ s/[\x00-\x1f]//g; - print qq({"success":false,"message":"SSH connection failed: $err"}); + print qq({"success":false,"message":"SSH connection failed: $err","csrf":"$new_csrf"}); } } elsif ($type eq 's3' || $type eq 'gdrive') { @@ -88,11 +96,11 @@ sub handle_test_connection { $rclone_args{s3_bucket} = $form->{'S3_BUCKET'} // ''; if ($rclone_args{s3_access_key_id} eq '' || $rclone_args{s3_secret_access_key} eq '') { - print qq({"success":false,"message":"S3 access key and secret are required."}); + print qq({"success":false,"message":"S3 access key and secret are required.","csrf":"$new_csrf"}); exit; } if ($rclone_args{s3_bucket} eq '') { - print qq({"success":false,"message":"S3 bucket is required."}); + print qq({"success":false,"message":"S3 bucket is required.","csrf":"$new_csrf"}); exit; } } else { @@ -100,7 +108,7 @@ sub handle_test_connection { $rclone_args{gdrive_root_folder_id} = $form->{'GDRIVE_ROOT_FOLDER_ID'} // ''; if ($rclone_args{gdrive_service_account_file} eq '') { - print qq({"success":false,"message":"Service account file path is required."}); + print qq({"success":false,"message":"Service account file path is required.","csrf":"$new_csrf"}); exit; } } @@ -108,7 +116,7 @@ sub handle_test_connection { my ($ok, $err) = GnizaWHM::UI::test_rclone_connection(%rclone_args); if ($ok) { my $label = $type eq 's3' ? 'S3' : 'Google Drive'; - print qq({"success":true,"message":"$label connection successful."}); + print qq({"success":true,"message":"$label connection successful.","csrf":"$new_csrf"}); } else { $err //= 'Unknown error'; $err =~ s/\\/\\\\/g; @@ -117,11 +125,11 @@ sub handle_test_connection { $err =~ s/\r/\\r/g; $err =~ s/\t/\\t/g; $err =~ s/[\x00-\x1f]//g; - print qq({"success":false,"message":"Connection failed: $err"}); + print qq({"success":false,"message":"Connection failed: $err","csrf":"$new_csrf"}); } } else { - print qq({"success":false,"message":"Unknown remote type."}); + print qq({"success":false,"message":"Unknown remote type.","csrf":"$new_csrf"}); } exit; } @@ -269,7 +277,7 @@ sub handle_add { File::Copy::copy($example, $dest) or do { push @errors, "Failed to create remote file: $!"; goto RENDER_ADD; }; } - my ($ok, $err) = GnizaWHM::Config::write($dest, \%data, \@GnizaWHM::Config::REMOTE_KEYS); + my ($ok, $err) = GnizaWHM::Config::save($dest, \%data, \@GnizaWHM::Config::REMOTE_KEYS); if ($ok) { # Init remote directory structure (like gniza init remote) my $type = $data{REMOTE_TYPE} || 'ssh'; @@ -410,7 +418,7 @@ sub handle_edit { } if (!@errors) { - my ($ok, $err) = GnizaWHM::Config::write($conf_path, \%data, \@GnizaWHM::Config::REMOTE_KEYS); + my ($ok, $err) = GnizaWHM::Config::save($conf_path, \%data, \@GnizaWHM::Config::REMOTE_KEYS); if ($ok) { GnizaWHM::UI::set_flash('success', "Remote '$name' updated successfully."); print "Status: 302 Found\r\n"; @@ -643,8 +651,9 @@ sub render_remote_form { print qq{\n}; + my $js_csrf = GnizaWHM::UI::esc(GnizaWHM::UI::generate_csrf_token()); + print qq{