Security hardening, static analysis fixes, and expanded test coverage

- Fix CRITICAL: safe config parser replacing shell source, sshpass -e,
  CSRF with /dev/urandom, symlink-safe file I/O
- Fix HIGH: input validation for timestamps/accounts, path traversal
  prevention in Runner.pm, AJAX CSRF on all endpoints
- Fix MEDIUM: umask 077, chmod 700 on config dirs, Config.pm TOCTOU lock,
  rsync exit code capture bug, RSYNC_EXTRA_OPTS character validation
- ShellCheck: fix word-splitting in notify.sh, safe rm in pkgacct.sh,
  suppress cross-file SC2034 false positives
- Perl::Critic: return undef→bare return, return (sort), unpack @_,
  explicit return on void subs, rename Config::write→save
- Remove dead code: enforce_retention_all(), rsync_dry_run()
- Add require_cmd checks for rsync/ssh/hostname/gzip at startup
- Escape $hint/$tip in CGI helper functions for defense-in-depth
- Expand tests from 17→40: validate_timestamp, validate_account_name,
  _safe_source_config (including malicious input), numeric validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-04 23:57:26 +02:00
parent b16893086d
commit 1f68ea1058
34 changed files with 2257 additions and 152 deletions

View File

@@ -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:<name>` 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/<name>.conf`)

View File

@@ -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

View File

@@ -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 <name> [--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 <name> [--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 <name> [<dbname>] [--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 <name> [<email@domain>] [--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:-}"

409
cpanel/admin/Gniza/Restore Normal file
View File

@@ -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;

View File

@@ -0,0 +1 @@
mode=full

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 369 KiB

File diff suppressed because one or more lines are too long

105
cpanel/gniza/index.live.cgi Normal file
View File

@@ -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{<div class="alert alert-info mb-4">No backup remotes are available for restore. Please contact your server administrator.</div>\n};
print GnizaCPanel::UI::page_footer();
exit;
}
# Category cards
my @categories = (
{
type => 'account',
label => 'Full Backup',
desc => 'Restore entire account from backup',
icon => '<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>',
},
{
type => 'files',
label => 'Home Directory',
desc => 'Restore files and folders',
icon => '<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>',
},
{
type => 'database',
label => 'Databases',
desc => 'Restore MySQL databases',
icon => '<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4.03 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/></svg>',
},
{
type => 'dbusers',
label => 'Database Users',
desc => 'Restore database users and grants',
icon => '<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/></svg>',
},
{
type => 'cron',
label => 'Cron Jobs',
desc => 'Restore scheduled tasks',
icon => '<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
},
{
type => 'domains',
label => 'Domains',
desc => 'Restore domain and DNS configuration',
icon => '<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"/></svg>',
},
{
type => 'ssl',
label => 'SSL Certificates',
desc => 'Restore SSL certificates',
icon => '<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>',
},
{
type => 'mailbox',
label => 'Email Accounts',
desc => 'Restore mailboxes and email',
icon => '<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/></svg>',
},
);
print qq{<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">\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{<a href="restore.live.cgi?type=$type" class="card bg-white shadow-sm border border-base-300 hover:shadow-md transition-shadow no-underline">\n};
print qq{<div class="card-body items-center text-center">\n};
print qq{ <div class="text-primary mb-2">$icon</div>\n};
print qq{ <h2 class="card-title text-sm">$label</h2>\n};
print qq{ <p class="text-xs text-base-content/60">$desc</p>\n};
print qq{</div>\n};
print qq{</a>\n};
}
print qq{</div>\n};
print GnizaCPanel::UI::page_footer();

12
cpanel/gniza/install.json Normal file
View File

@@ -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"
}
]

View File

@@ -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/&/&amp;/g;
$str =~ s/</&lt;/g;
$str =~ s/>/&gt;/g;
$str =~ s/"/&quot;/g;
$str =~ s/'/&#39;/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{<div class="alert alert-$type mb-4">$escaped</div>\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{<input type="hidden" name="gniza_csrf" value="} . esc($token) . 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{<div class="flex items-center justify-center gap-3 mb-4"><img src="data:image/svg+xml;base64,$b64" alt="gniza" style="height:40px;width:auto"></div>\n};
}
return qq{<style>$css</style>\n}
. qq{<div data-theme="gniza" class="font-sans text-base" style="padding:20px 10px 10px 10px">\n}
. $logo_html;
}
sub page_footer {
return qq{</div>\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{<div class="alert alert-error mb-4">\n<ul class="list-disc pl-5">\n};
for my $err (@$errors) {
$html .= ' <li>' . esc($err) . "</li>\n";
}
$html .= "</ul>\n</div>\n";
return $html;
}
1;

View File

@@ -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{<div class="alert alert-info mb-4">No backup remotes are available. Please contact your server administrator.</div>\n};
print qq{<a href="index.live.cgi" class="btn btn-info btn-sm">Back</a>\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{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Step 1: Select Source</h2>\n};
print qq{<p class="text-sm mb-3">Restore type: <strong>$type_label</strong></p>\n};
# Remote dropdown
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-36 font-medium text-sm" for="remote">Remote</label>\n};
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="remote" name="remote" onchange="gnizaLoadSnapshots()">\n};
print qq{ <option value="">-- Select remote --</option>\n};
for my $r (@remotes) {
my $esc = GnizaCPanel::UI::esc($r);
print qq{ <option value="$esc">$esc</option>\n};
}
print qq{ </select>\n};
print qq{</div>\n};
# Snapshot dropdown (populated via AJAX)
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-36 font-medium text-sm" for="timestamp">Snapshot</label>\n};
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="timestamp" name="timestamp" disabled>\n};
print qq{ <option value="">-- Select remote first --</option>\n};
print qq{ </select>\n};
print qq{</div>\n};
print qq{</div>\n</div>\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{<div class="flex items-center gap-2">\n};
print qq{ <button type="button" class="btn btn-primary btn-sm" id="next-btn" disabled onclick="gnizaGoNext()">Next</button>\n};
print qq{ <a href="index.live.cgi" class="btn btn-info btn-sm">Back</a>\n};
print qq{</div>\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{<script>\n};
print qq{var gnizaType = '$esc_type';\n};
print qq{var gnizaNextStep = '$next_step';\n};
print qq{\n};
print qq{function gnizaLoadSnapshots() {\n};
print qq{ var remote = document.getElementById('remote').value;\n};
print qq{ var sel = document.getElementById('timestamp');\n};
print qq{ var btn = document.getElementById('next-btn');\n};
print qq{\n};
print qq{ if (!remote) {\n};
print qq{ _setSelectPlaceholder(sel, '-- Select remote first --');\n};
print qq{ sel.disabled = true;\n};
print qq{ btn.disabled = true;\n};
print qq{ return;\n};
print qq{ }\n};
print qq{\n};
print qq{ _setSelectPlaceholder(sel, 'Loading...');\n};
print qq{ sel.disabled = true;\n};
print qq{ btn.disabled = true;\n};
print qq{\n};
print qq{ var url = 'restore.live.cgi?step=fetch_snapshots&remote=' + encodeURIComponent(remote);\n};
print qq{ var xhr = new XMLHttpRequest();\n};
print qq{ xhr.open('GET', url, true);\n};
print qq{ xhr.onreadystatechange = function() {\n};
print qq{ if (xhr.readyState !== 4) return;\n};
print qq{ if (xhr.status === 200) {\n};
print qq{ try {\n};
print qq{ var data = JSON.parse(xhr.responseText);\n};
print qq{ if (data.error) {\n};
print qq{ _setSelectPlaceholder(sel, 'Error: ' + data.error);\n};
print qq{ } else if (data.snapshots && data.snapshots.length > 0) {\n};
print qq{ _populateSelect(sel, data.snapshots);\n};
print qq{ sel.disabled = false;\n};
print qq{ btn.disabled = false;\n};
print qq{ } else {\n};
print qq{ _setSelectPlaceholder(sel, 'No snapshots found');\n};
print qq{ }\n};
print qq{ } catch(e) {\n};
print qq{ _setSelectPlaceholder(sel, 'Failed to parse response');\n};
print qq{ }\n};
print qq{ } else {\n};
print qq{ _setSelectPlaceholder(sel, 'Request failed');\n};
print qq{ }\n};
print qq{ };\n};
print qq{ xhr.send();\n};
print qq{}\n};
print qq{\n};
print qq{function _setSelectPlaceholder(sel, text) {\n};
print qq{ while (sel.options.length) sel.remove(0);\n};
print qq{ var opt = document.createElement('option');\n};
print qq{ opt.value = '';\n};
print qq{ opt.textContent = text;\n};
print qq{ sel.appendChild(opt);\n};
print qq{}\n};
print qq{\n};
print qq{function _populateSelect(sel, values) {\n};
print qq{ while (sel.options.length) sel.remove(0);\n};
print qq{ for (var i = 0; i < values.length; i++) {\n};
print qq{ var opt = document.createElement('option');\n};
print qq{ opt.value = values[i];\n};
print qq{ opt.textContent = values[i];\n};
print qq{ sel.appendChild(opt);\n};
print qq{ }\n};
print qq{}\n};
print qq{\n};
print qq{function gnizaGoNext() {\n};
print qq{ var remote = document.getElementById('remote').value;\n};
print qq{ var timestamp = document.getElementById('timestamp').value;\n};
print qq{ if (!remote || !timestamp) return;\n};
print qq{\n};
print qq{ var url = 'restore.live.cgi?step=' + gnizaNextStep\n};
print qq{ + '&type=' + encodeURIComponent(gnizaType)\n};
print qq{ + '&remote=' + encodeURIComponent(remote)\n};
print qq{ + '&timestamp=' + encodeURIComponent(timestamp);\n};
print qq{ window.location.href = url;\n};
print qq{}\n};
print qq{</script>\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{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Step 2: Select Items</h2>\n};
print qq{<p class="text-sm mb-3">Remote: <strong>$esc_remote</strong> &middot; Snapshot: <strong>$esc_timestamp</strong></p>\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{<h3 class="font-medium text-sm mb-2">$item_label</h3>\n};
print qq{<input type="hidden" id="selected_items" name="selected_items" value="">\n};
print qq{<div id="item-list" class="flex flex-col gap-1 max-h-64 overflow-y-auto">\n};
print qq{ <span class="text-sm text-base-content/60"><span class="loading loading-spinner loading-xs"></span> Loading...</span>\n};
print qq{</div>\n};
}
elsif ($type eq 'cron') {
print qq{<h3 class="font-medium text-sm mb-2">Cron Jobs Preview</h3>\n};
print qq{<div id="item-list" class="max-h-64 overflow-y-auto">\n};
print qq{ <span class="text-sm text-base-content/60"><span class="loading loading-spinner loading-xs"></span> Loading...</span>\n};
print qq{</div>\n};
}
print qq{</div>\n</div>\n};
print qq{<div class="flex items-center gap-2">\n};
print qq{ <button type="button" class="btn btn-primary btn-sm" id="confirm-btn" onclick="gnizaGoConfirm()">Review &amp; Confirm</button>\n};
print qq{ <a href="restore.live.cgi?type=$esc_type" class="btn btn-info btn-sm">Back</a>\n};
print qq{</div>\n};
_print_step2_js($esc_type, $esc_remote, $esc_timestamp);
print GnizaCPanel::UI::page_footer();
}
sub _render_file_picker {
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-36 font-medium text-sm" for="path">Path</label>\n};
print qq{ <div class="flex items-center gap-2 w-full max-w-xs">\n};
print qq{ <input type="text" class="input input-bordered input-sm flex-1" id="path" name="path" placeholder="e.g. public_html/index.html">\n};
print qq{ <button type="button" class="btn btn-secondary btn-sm" onclick="gnizaOpenBrowser()">Browse</button>\n};
print qq{ </div>\n};
print qq{</div>\n};
print qq{<p class="text-xs text-base-content/60">Leave empty to restore all files.</p>\n};
# File browser modal
print qq{<dialog id="fb-modal" class="modal">\n};
print qq{<div class="modal-box w-11/12 max-w-2xl">\n};
print qq{ <h3 class="text-lg font-bold mb-3">Browse Files</h3>\n};
print qq{ <div id="fb-breadcrumbs" class="breadcrumbs text-sm mb-3"><ul><li>homedir</li></ul></div>\n};
print qq{ <div id="fb-loading" class="text-center py-4" hidden><span class="loading loading-spinner"></span> Loading...</div>\n};
print qq{ <div id="fb-error" class="alert alert-error mb-3" hidden></div>\n};
print qq{ <div id="fb-list" class="overflow-y-auto max-h-96">\n};
print qq{ <table class="table table-zebra w-full"><tbody id="fb-tbody"></tbody></table>\n};
print qq{ </div>\n};
print qq{ <div class="modal-action">\n};
print qq{ <button type="button" id="fb-select-btn" class="btn btn-primary btn-sm" disabled onclick="gnizaSelectPath()">Select</button>\n};
print qq{ <button type="button" class="btn btn-info btn-sm" onclick="document.getElementById('fb-modal').close()">Cancel</button>\n};
print qq{ </div>\n};
print qq{</div>\n};
print qq{<div class="modal-backdrop" onclick="this.closest('dialog').close()"><button type="button">close</button></div>\n};
print qq{</dialog>\n};
}
sub _print_step2_js {
my ($esc_type, $esc_remote, $esc_timestamp) = @_;
print qq{<script>\n};
print qq{var gnizaType = '$esc_type';\n};
print qq{var gnizaRemote = '$esc_remote';\n};
print qq{var gnizaTimestamp = '$esc_timestamp';\n};
print qq{var fbCache = {};\n};
print qq{var fbSelected = '';\n};
print qq{\n};
print qq{(function() {\n};
print qq{ var listTypes = ['database','dbusers','mailbox','domains','ssl','cron'];\n};
print qq{ if (listTypes.indexOf(gnizaType) >= 0) {\n};
print qq{ loadOptions();\n};
print qq{ }\n};
print qq{})();\n};
print qq{\n};
print qq{function loadOptions() {\n};
print qq{ var url = 'restore.live.cgi?step=fetch_options'\n};
print qq{ + '&remote=' + encodeURIComponent(gnizaRemote)\n};
print qq{ + '&timestamp=' + encodeURIComponent(gnizaTimestamp)\n};
print qq{ + '&type=' + encodeURIComponent(gnizaType);\n};
print qq{\n};
print qq{ var xhr = new XMLHttpRequest();\n};
print qq{ xhr.open('GET', url, true);\n};
print qq{ xhr.onreadystatechange = function() {\n};
print qq{ if (xhr.readyState !== 4) return;\n};
print qq{ var container = document.getElementById('item-list');\n};
print qq{ if (xhr.status === 200) {\n};
print qq{ try {\n};
print qq{ var data = JSON.parse(xhr.responseText);\n};
print qq{ if (data.error) {\n};
print qq{ container.textContent = 'Error: ' + data.error;\n};
print qq{ } else if (gnizaType === 'cron') {\n};
print qq{ populatePreview(container, data.options);\n};
print qq{ } else {\n};
print qq{ populateChecklist(container, data.options);\n};
print qq{ }\n};
print qq{ } catch(e) {\n};
print qq{ container.textContent = 'Failed to parse response';\n};
print qq{ }\n};
print qq{ } else {\n};
print qq{ container.textContent = 'Request failed';\n};
print qq{ }\n};
print qq{ };\n};
print qq{ xhr.send();\n};
print qq{}\n};
print qq{\n};
print qq{function populateChecklist(container, options) {\n};
print qq{ var hidden = document.getElementById('selected_items');\n};
print qq{ container.textContent = '';\n};
print qq{ if (!options || options.length === 0) {\n};
print qq{ container.textContent = '(none found)';\n};
print qq{ return;\n};
print qq{ }\n};
print qq{\n};
print qq{ var allLabels = {database:'All Databases',dbusers:'All Database Users',mailbox:'All Mailboxes',domains:'All Domains',ssl:'All Certificates'};\n};
print qq{ var allLabel = allLabels[gnizaType] || 'All';\n};
print qq{\n};
print qq{ var allRow = _makeCheckRow(allLabel, '', true);\n};
print qq{ allRow.querySelector('input').setAttribute('data-all', '1');\n};
print qq{ allRow.querySelector('input').onchange = function() { toggleAll(this.checked); };\n};
print qq{ allRow.querySelector('span').className = 'text-sm font-semibold';\n};
print qq{ container.appendChild(allRow);\n};
print qq{\n};
print qq{ for (var i = 0; i < options.length; i++) {\n};
print qq{ var row = _makeCheckRow(options[i], options[i], false);\n};
print qq{ row.querySelector('input').setAttribute('data-item', '1');\n};
print qq{ row.querySelector('input').onchange = function() { syncHidden(); };\n};
print qq{ container.appendChild(row);\n};
print qq{ }\n};
print qq{}\n};
print qq{\n};
print qq{function _makeCheckRow(labelText, value, isAll) {\n};
print qq{ var label = document.createElement('label');\n};
print qq{ label.className = 'flex items-center gap-2 cursor-pointer';\n};
print qq{ var cb = document.createElement('input');\n};
print qq{ cb.type = 'checkbox';\n};
print qq{ cb.className = 'checkbox checkbox-sm';\n};
print qq{ if (value) cb.value = value;\n};
print qq{ var span = document.createElement('span');\n};
print qq{ span.className = 'text-sm';\n};
print qq{ span.textContent = labelText;\n};
print qq{ label.appendChild(cb);\n};
print qq{ label.appendChild(span);\n};
print qq{ return label;\n};
print qq{}\n};
print qq{\n};
print qq{function toggleAll(checked) {\n};
print qq{ var container = document.getElementById('item-list');\n};
print qq{ var hidden = document.getElementById('selected_items');\n};
print qq{ var items = container.querySelectorAll('input[data-item]');\n};
print qq{ for (var i = 0; i < items.length; i++) {\n};
print qq{ items[i].disabled = checked;\n};
print qq{ if (checked) items[i].checked = false;\n};
print qq{ }\n};
print qq{ hidden.value = checked ? '__ALL__' : '';\n};
print qq{}\n};
print qq{\n};
print qq{function syncHidden() {\n};
print qq{ var container = document.getElementById('item-list');\n};
print qq{ var hidden = document.getElementById('selected_items');\n};
print qq{ var items = container.querySelectorAll('input[data-item]:checked');\n};
print qq{ var vals = [];\n};
print qq{ for (var i = 0; i < items.length; i++) {\n};
print qq{ vals.push(items[i].value);\n};
print qq{ }\n};
print qq{ hidden.value = vals.join(',');\n};
print qq{}\n};
print qq{\n};
print qq{function populatePreview(container, options) {\n};
print qq{ container.textContent = '';\n};
print qq{ if (!options || options.length === 0) {\n};
print qq{ container.textContent = '(none found)';\n};
print qq{ return;\n};
print qq{ }\n};
print qq{ var pre = document.createElement('pre');\n};
print qq{ pre.className = 'text-xs font-mono bg-base-200 p-3 rounded-lg overflow-x-auto';\n};
print qq{ pre.textContent = options.join('\\n');\n};
print qq{ container.appendChild(pre);\n};
print qq{}\n};
print qq{\n};
print qq{function gnizaGoConfirm() {\n};
print qq{ var url = 'restore.live.cgi?step=3'\n};
print qq{ + '&type=' + encodeURIComponent(gnizaType)\n};
print qq{ + '&remote=' + encodeURIComponent(gnizaRemote)\n};
print qq{ + '&timestamp=' + encodeURIComponent(gnizaTimestamp);\n};
print qq{\n};
print qq{ if (gnizaType === 'files') {\n};
print qq{ var path = document.getElementById('path') ? document.getElementById('path').value : '';\n};
print qq{ url += '&path=' + encodeURIComponent(path);\n};
print qq{ } else if (document.getElementById('selected_items')) {\n};
print qq{ url += '&items=' + encodeURIComponent(document.getElementById('selected_items').value);\n};
print qq{ }\n};
print qq{\n};
print qq{ window.location.href = url;\n};
print qq{}\n};
print qq{\n};
print qq{function gnizaOpenBrowser() {\n};
print qq{ fbSelected = '';\n};
print qq{ document.getElementById('fb-select-btn').disabled = true;\n};
print qq{ document.getElementById('fb-modal').showModal();\n};
print qq{ gnizaLoadDir('');\n};
print qq{}\n};
print qq{\n};
print qq{function gnizaLoadDir(path) {\n};
print qq{ var cacheKey = path;\n};
print qq{ if (fbCache[cacheKey]) {\n};
print qq{ gnizaRenderFileList(path, fbCache[cacheKey]);\n};
print qq{ return;\n};
print qq{ }\n};
print qq{\n};
print qq{ document.getElementById('fb-loading').hidden = false;\n};
print qq{ document.getElementById('fb-error').hidden = true;\n};
print qq{ document.getElementById('fb-tbody').textContent = '';\n};
print qq{\n};
print qq{ var url = 'restore.live.cgi?step=fetch_options'\n};
print qq{ + '&remote=' + encodeURIComponent(gnizaRemote)\n};
print qq{ + '&timestamp=' + encodeURIComponent(gnizaTimestamp)\n};
print qq{ + '&type=files'\n};
print qq{ + (path ? '&path=' + encodeURIComponent(path) : '');\n};
print qq{\n};
print qq{ var xhr = new XMLHttpRequest();\n};
print qq{ xhr.open('GET', url, true);\n};
print qq{ xhr.onreadystatechange = function() {\n};
print qq{ if (xhr.readyState !== 4) return;\n};
print qq{ document.getElementById('fb-loading').hidden = true;\n};
print qq{ if (xhr.status === 200) {\n};
print qq{ try {\n};
print qq{ var data = JSON.parse(xhr.responseText);\n};
print qq{ if (data.error) {\n};
print qq{ document.getElementById('fb-error').textContent = data.error;\n};
print qq{ document.getElementById('fb-error').hidden = false;\n};
print qq{ } else {\n};
print qq{ fbCache[cacheKey] = data.options;\n};
print qq{ gnizaRenderFileList(path, data.options);\n};
print qq{ }\n};
print qq{ } catch(e) {\n};
print qq{ document.getElementById('fb-error').textContent = 'Failed to parse response';\n};
print qq{ document.getElementById('fb-error').hidden = false;\n};
print qq{ }\n};
print qq{ }\n};
print qq{ };\n};
print qq{ xhr.send();\n};
print qq{}\n};
print qq{\n};
print qq{function gnizaRenderBreadcrumbs(path) {\n};
print qq{ var ul = document.createElement('ul');\n};
print qq{ var li = document.createElement('li');\n};
print qq{ var a = document.createElement('a');\n};
print qq{ a.textContent = 'homedir';\n};
print qq{ a.href = '#';\n};
print qq{ a.onclick = function(e) { e.preventDefault(); gnizaLoadDir(''); };\n};
print qq{ li.appendChild(a);\n};
print qq{ ul.appendChild(li);\n};
print qq{\n};
print qq{ if (path) {\n};
print qq{ var parts = path.replace(/\\/\$/, '').split('/');\n};
print qq{ var built = '';\n};
print qq{ for (var i = 0; i < parts.length; i++) {\n};
print qq{ built += (i > 0 ? '/' : '') + parts[i];\n};
print qq{ li = document.createElement('li');\n};
print qq{ if (i < parts.length - 1) {\n};
print qq{ a = document.createElement('a');\n};
print qq{ a.textContent = parts[i];\n};
print qq{ a.href = '#';\n};
print qq{ (function(p) { a.onclick = function(e) { e.preventDefault(); gnizaLoadDir(p); }; })(built);\n};
print qq{ li.appendChild(a);\n};
print qq{ } else {\n};
print qq{ li.textContent = parts[i];\n};
print qq{ }\n};
print qq{ ul.appendChild(li);\n};
print qq{ }\n};
print qq{ }\n};
print qq{\n};
print qq{ var bc = document.getElementById('fb-breadcrumbs');\n};
print qq{ bc.textContent = '';\n};
print qq{ bc.appendChild(ul);\n};
print qq{}\n};
print qq{\n};
print qq{function gnizaRenderFileList(currentPath, entries) {\n};
print qq{ gnizaRenderBreadcrumbs(currentPath);\n};
print qq{ fbSelected = '';\n};
print qq{ document.getElementById('fb-select-btn').disabled = true;\n};
print qq{\n};
print qq{ var tbody = document.getElementById('fb-tbody');\n};
print qq{ tbody.textContent = '';\n};
print qq{\n};
print qq{ if (!entries || entries.length === 0) {\n};
print qq{ var emptyTr = document.createElement('tr');\n};
print qq{ var emptyTd = document.createElement('td');\n};
print qq{ emptyTd.className = 'text-center text-base-content/60 py-4';\n};
print qq{ emptyTd.textContent = '(empty directory)';\n};
print qq{ emptyTr.appendChild(emptyTd);\n};
print qq{ tbody.appendChild(emptyTr);\n};
print qq{ return;\n};
print qq{ }\n};
print qq{\n};
print qq{ for (var i = 0; i < entries.length; i++) {\n};
print qq{ var entry = entries[i];\n};
print qq{ var isDir = entry.endsWith('/');\n};
print qq{ var fullPath = currentPath ? currentPath.replace(/\\/\$/, '') + '/' + entry : entry;\n};
print qq{\n};
print qq{ var tr = document.createElement('tr');\n};
print qq{ tr.className = 'cursor-pointer hover';\n};
print qq{ tr.setAttribute('data-path', fullPath);\n};
print qq{\n};
print qq{ var td = document.createElement('td');\n};
print qq{ td.className = 'py-1';\n};
print qq{ var icon = isDir ? '\\uD83D\\uDCC1 ' : '\\uD83D\\uDCC4 ';\n};
print qq{ td.textContent = icon + entry;\n};
print qq{ tr.appendChild(td);\n};
print qq{\n};
print qq{ (function(row, path, dir) {\n};
print qq{ row.onclick = function() {\n};
print qq{ var rows = document.getElementById('fb-tbody').querySelectorAll('tr');\n};
print qq{ for (var j = 0; j < rows.length; j++) rows[j].classList.remove('bg-primary/10');\n};
print qq{ row.classList.add('bg-primary/10');\n};
print qq{ fbSelected = path;\n};
print qq{ document.getElementById('fb-select-btn').disabled = false;\n};
print qq{ };\n};
print qq{ if (dir) {\n};
print qq{ row.ondblclick = function() { gnizaLoadDir(path.replace(/\\/\$/, '')); };\n};
print qq{ }\n};
print qq{ })(tr, fullPath, isDir);\n};
print qq{\n};
print qq{ tbody.appendChild(tr);\n};
print qq{ }\n};
print qq{}\n};
print qq{\n};
print qq{function gnizaSelectPath() {\n};
print qq{ if (fbSelected) {\n};
print qq{ document.getElementById('path').value = fbSelected;\n};
print qq{ }\n};
print qq{ document.getElementById('fb-modal').close();\n};
print qq{}\n};
print qq{</script>\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{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Step 3: Confirm Restore</h2>\n};
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
print qq{<tr><td class="font-medium w-36">Account</td><td>$user</td></tr>\n};
print qq{<tr><td class="font-medium">Remote</td><td>$esc_remote</td></tr>\n};
print qq{<tr><td class="font-medium">Snapshot</td><td>$esc_timestamp</td></tr>\n};
print qq{<tr><td class="font-medium">Restore Type</td><td>$type_label</td></tr>\n};
if ($type eq 'files') {
my $path_display = $path ne '' ? GnizaCPanel::UI::esc($path) : 'All files';
print qq{<tr><td class="font-medium">Path</td><td>$path_display</td></tr>\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{<tr><td class="font-medium">Items</td><td>$items_display</td></tr>\n};
}
print qq{</table></div>\n};
print qq{</div>\n</div>\n};
print qq{<form method="POST" action="restore.live.cgi">\n};
print qq{<input type="hidden" name="step" value="4">\n};
print qq{<input type="hidden" name="type" value="$esc_type">\n};
print qq{<input type="hidden" name="remote" value="$esc_remote">\n};
print qq{<input type="hidden" name="timestamp" value="$esc_timestamp">\n};
print qq{<input type="hidden" name="path" value="} . GnizaCPanel::UI::esc($path) . qq{">\n};
print qq{<input type="hidden" name="items" value="} . GnizaCPanel::UI::esc($items) . qq{">\n};
print GnizaCPanel::UI::csrf_hidden_field();
print qq{<div class="flex items-center gap-2">\n};
print qq{ <button type="submit" class="btn btn-error btn-sm" onclick="return confirm('Are you sure? This may overwrite existing data.')">Execute Restore</button>\n};
print qq{ <a href="index.live.cgi" class="btn btn-info btn-sm">Cancel</a>\n};
print qq{</div>\n};
print qq{</form>\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{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Restore Results: $type_label</h2>\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{</div>\n</div>\n};
print qq{<a href="index.live.cgi" class="btn btn-info btn-sm">Back to Categories</a>\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{</div>\n</div>\n};
print qq{<a href="index.live.cgi" class="btn btn-info btn-sm">Back to Categories</a>\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} ? '&#10003;' : '&#10007;';
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{<div class="flex items-start gap-2 mb-3 p-3 rounded-lg bg-base-200">\n};
print qq{ <span class="$icon_class font-bold text-lg">$icon</span>\n};
print qq{ <div>\n};
print qq{ <div class="font-medium text-sm">$label</div>\n};
if ($msg ne '') {
print qq{ <pre class="text-xs mt-1 whitespace-pre-wrap max-h-32 overflow-y-auto">$msg</pre>\n};
}
print qq{ </div>\n};
print qq{</div>\n};
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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}"

View File

@@ -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"

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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} '<cmd>'"
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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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/"

View File

@@ -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

View File

@@ -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);

View File

@@ -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$/,
);

View File

@@ -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{<div class="alert alert-$type mb-4">$escaped</div>\n};
}
sub _ensure_dir {
my ($dir) = @_;
unless (-d $dir) {
mkdir $dir, 0700;
}
return;
}
# ── CSRF Protection ──────────────────────────────────────────
sub _csrf_file {
@@ -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};

View File

@@ -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];
}

View File

@@ -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{</form>\n};
my $js_csrf = GnizaWHM::UI::esc(GnizaWHM::UI::generate_csrf_token());
print qq{<script>var gnizaCsrf = '$js_csrf';\n};
print <<'JS';
<script>
function gnizaGetType() {
var radios = document.querySelectorAll('input[name="REMOTE_TYPE"]');
for (var i = 0; i < radios.length; i++) {
@@ -694,6 +703,7 @@ function gnizaTestConnection() {
var btn = document.getElementById('test-conn-btn');
var fd = new FormData();
fd.append('action', 'test');
fd.append('gniza_csrf', gnizaCsrf);
fd.append('remote_type', type);
if (type === 'ssh') {
@@ -741,6 +751,7 @@ function gnizaTestConnection() {
.then(function(r) { return r.json(); })
.then(function(data) {
gnizaToast(data.success ? 'success' : 'error', data.message);
if (data.csrf) gnizaCsrf = data.csrf;
})
.catch(function(err) {
gnizaToast('error', 'Request failed: ' + err.toString());
@@ -770,8 +781,8 @@ JS
sub _field {
my ($conf, $key, $label, $hint, $tip) = @_;
my $val = GnizaWHM::UI::esc($conf->{$key} // '');
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
my $tip_html = $tip ? qq{ <span class="tooltip tooltip-top" data-tip="$tip">&#9432;</span>} : '';
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">} . GnizaWHM::UI::esc($hint) . qq{</span>} : '';
my $tip_html = $tip ? qq{ <span class="tooltip tooltip-top" data-tip="} . GnizaWHM::UI::esc($tip) . qq{">&#9432;</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm whitespace-nowrap" for="$key">$label$tip_html</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="$key" name="$key" value="$val">\n};
@@ -782,7 +793,7 @@ sub _field {
sub _password_field {
my ($conf, $key, $label, $hint) = @_;
my $val = GnizaWHM::UI::esc($conf->{$key} // '');
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">} . GnizaWHM::UI::esc($hint) . qq{</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="$key">$label</label>\n};
print qq{ <input type="password" class="input input-bordered input-sm w-full max-w-xs" id="$key" name="$key" value="$val">\n};

View File

@@ -213,7 +213,7 @@ sub handle_add {
File::Copy::copy($example, $dest)
or do { push @errors, "Failed to create schedule file: $!"; goto RENDER_ADD; };
}
my ($ok, $err) = GnizaWHM::Config::write($dest, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS);
my ($ok, $err) = GnizaWHM::Config::save($dest, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS);
if ($ok) {
my ($cron_ok, $cron_err) = GnizaWHM::Cron::install_schedule($name);
if ($form->{'wizard'}) {
@@ -302,7 +302,7 @@ sub handle_edit {
}
if (!@errors) {
my ($ok, $err) = GnizaWHM::Config::write($conf_path, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS);
my ($ok, $err) = GnizaWHM::Config::save($conf_path, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS);
if ($ok) {
# Always reinstall cron so changes take effect immediately
GnizaWHM::Cron::install_schedule($name);
@@ -540,7 +540,7 @@ sub handle_toggle_sysbackup {
# Toggle the value
$conf->{SYSBACKUP} = $is_on ? '' : 'yes';
my ($ok, $err) = GnizaWHM::Config::write($conf_path, $conf, \@GnizaWHM::Config::SCHEDULE_KEYS);
my ($ok, $err) = GnizaWHM::Config::save($conf_path, $conf, \@GnizaWHM::Config::SCHEDULE_KEYS);
if ($ok) {
# Reinstall cron so --sysbackup flag is updated
@@ -585,7 +585,7 @@ sub handle_toggle_skip_suspended {
# Toggle the value
$conf->{SKIP_SUSPENDED} = $is_on ? '' : 'yes';
my ($ok, $err) = GnizaWHM::Config::write($conf_path, $conf, \@GnizaWHM::Config::SCHEDULE_KEYS);
my ($ok, $err) = GnizaWHM::Config::save($conf_path, $conf, \@GnizaWHM::Config::SCHEDULE_KEYS);
if ($ok) {
# Reinstall cron so --skip-suspended flag is updated

View File

@@ -21,6 +21,14 @@ my $action = $form->{'action'} // '';
if ($action eq 'test_smtp') {
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
my $new_csrf = GnizaWHM::UI::generate_csrf_token();
my $host = $form->{'SMTP_HOST'} // '';
my $port = $form->{'SMTP_PORT'} || '587';
my $user = $form->{'SMTP_USER'} // '';
@@ -30,11 +38,11 @@ if ($action eq 'test_smtp') {
my $to = $form->{'NOTIFY_EMAIL'} // '';
if ($host eq '') {
print qq({"success":false,"message":"SMTP Host is required."});
print qq({"success":false,"message":"SMTP Host is required.","csrf":"$new_csrf"});
exit;
}
if ($to eq '') {
print qq({"success":false,"message":"Notification email is required for test."});
print qq({"success":false,"message":"Notification email is required for test.","csrf":"$new_csrf"});
exit;
}
@@ -48,7 +56,7 @@ if ($action eq 'test_smtp') {
to => $to,
);
if ($ok) {
print qq({"success":true,"message":"Test email sent successfully. Check your inbox."});
print qq({"success":true,"message":"Test email sent successfully. Check your inbox.","csrf":"$new_csrf"});
} else {
$err //= 'Unknown error';
$err =~ s/\\/\\\\/g;
@@ -57,7 +65,7 @@ if ($action eq 'test_smtp') {
$err =~ s/\r/\\r/g;
$err =~ s/\t/\\t/g;
$err =~ s/[\x00-\x1f]//g;
print qq({"success":false,"message":"SMTP test failed: $err"});
print qq({"success":false,"message":"SMTP test failed: $err","csrf":"$new_csrf"});
}
exit;
}
@@ -83,7 +91,7 @@ if ($method eq 'POST') {
if (@$validation_errors) {
@errors = @$validation_errors;
} else {
my ($ok, $err) = GnizaWHM::Config::write($CONFIG_FILE, \%data, \@GnizaWHM::Config::MAIN_KEYS);
my ($ok, $err) = GnizaWHM::Config::save($CONFIG_FILE, \%data, \@GnizaWHM::Config::MAIN_KEYS);
if ($ok) {
GnizaWHM::UI::set_flash('success', 'Configuration saved successfully.');
print "Status: 302 Found\r\n";
@@ -126,8 +134,8 @@ sub field_text {
my ($key, $label, $hint, $extra, $tip) = @_;
$extra //= '';
my $val = GnizaWHM::UI::esc($conf->{$key} // '');
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
my $tip_html = $tip ? qq{ <span class="tooltip tooltip-top" data-tip="$tip">&#9432;</span>} : '';
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">} . GnizaWHM::UI::esc($hint) . qq{</span>} : '';
my $tip_html = $tip ? qq{ <span class="tooltip tooltip-top" data-tip="} . GnizaWHM::UI::esc($tip) . qq{">&#9432;</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm whitespace-nowrap" for="$key">$label$tip_html</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="$key" name="$key" value="$val" $extra>\n};
@@ -223,6 +231,13 @@ print qq{</div>\n};
print qq{<div id="gniza-smtp-alert" class="mt-3"></div>\n};
print qq{</div>\n</div>\n};
# Section: User Restore (cPanel Plugin)
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">User Restore (cPanel Plugin)</h2>\n};
print qq{<p class="text-xs text-base-content/60 mb-3">Controls which remotes are available for cPanel user self-service restore.</p>\n};
field_text('USER_RESTORE_REMOTES', 'Allowed Remotes', '"all" = all remotes, comma-separated names, empty = disabled', '', 'Which backup remotes cPanel users can restore from. Set to "all" for all remotes, specific names like "nas,offsite", or leave empty to disable user restore.');
print qq{</div>\n</div>\n};
# Section: Advanced
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Advanced</h2>\n};
@@ -239,12 +254,15 @@ print qq{</div>\n};
print qq{</form>\n};
my $smtp_csrf = GnizaWHM::UI::generate_csrf_token();
print qq{<script>\n};
print qq{var gnizaCsrf = '} . GnizaWHM::UI::esc($smtp_csrf) . qq{';\n};
print <<'JS';
<script>
function gnizaTestSmtp() {
var btn = document.getElementById('test-smtp-btn');
var fd = new FormData();
fd.append('action', 'test_smtp');
fd.append('gniza_csrf', gnizaCsrf);
fd.append('SMTP_HOST', document.getElementById('SMTP_HOST').value);
fd.append('SMTP_PORT', document.getElementById('SMTP_PORT').value);
fd.append('SMTP_USER', document.getElementById('SMTP_USER').value);
@@ -259,11 +277,12 @@ function gnizaTestSmtp() {
if (!email) { gnizaSmtpToast('error', 'Notification email is required for test.'); return; }
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Sending\u2026';
btn.textContent = 'Sending\u2026';
fetch('settings.cgi', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.csrf) { gnizaCsrf = data.csrf; }
gnizaSmtpToast(data.success ? 'success' : 'error', data.message);
})
.catch(function(err) {
@@ -271,7 +290,7 @@ function gnizaTestSmtp() {
})
.finally(function() {
btn.disabled = false;
btn.innerHTML = 'Send Test Email';
btn.textContent = 'Send Test Email';
});
}