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:
56
CLAUDE.md
56
CLAUDE.md
@@ -38,7 +38,7 @@ etc/
|
||||
└── schedule.conf.example # Schedule config template
|
||||
scripts/
|
||||
├── install.sh # Install to /usr/local/gniza, create dirs/symlinks
|
||||
└── uninstall.sh # Remove install dir and symlink
|
||||
└── uninstall.sh # Remove install dir, symlink, cron entries, WHM plugin
|
||||
tests/
|
||||
└── test_utils.sh # Unit tests for utils.sh, accounts.sh, config.sh
|
||||
whm/
|
||||
@@ -63,6 +63,19 @@ whm/
|
||||
├── Cron.pm # Cron read + allowlisted gniza schedule commands
|
||||
├── Runner.pm # Pattern-based safe CLI command runner for WHM
|
||||
└── UI.pm # Nav, flash, CSRF, HTML escaping, CSS delivery
|
||||
cpanel/
|
||||
├── gniza/
|
||||
│ ├── index.live.cgi # Category grid — 8 restore type cards
|
||||
│ ├── restore.live.cgi # Multi-step restore workflow (4 steps)
|
||||
│ ├── install.json # cPanel plugin registration (Files section)
|
||||
│ ├── assets/
|
||||
│ │ ├── gniza-whm.css # Built CSS (copy of WHM CSS)
|
||||
│ │ └── gniza-logo.svg # Logo (copy of WHM logo)
|
||||
│ └── lib/GnizaCPanel/
|
||||
│ └── UI.pm # Page wrapper, CSRF, flash, CSS delivery
|
||||
└── admin/Gniza/
|
||||
├── Restore # AdminBin module (runs as root, privilege escalation)
|
||||
└── Restore.conf # AdminBin config (mode=full)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -170,6 +183,46 @@ Cron entries are tagged with `# gniza:<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`)
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
19
bin/gniza
19
bin/gniza
@@ -3,6 +3,7 @@
|
||||
# CLI entrypoint and command routing
|
||||
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
# Resolve install directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -94,6 +95,10 @@ _backup_to_current_remote() {
|
||||
|
||||
cmd_backup() {
|
||||
require_root
|
||||
require_cmd rsync
|
||||
require_cmd ssh
|
||||
require_cmd hostname
|
||||
require_cmd gzip
|
||||
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
|
||||
load_config "$config_file"
|
||||
validate_config || die "Invalid configuration"
|
||||
@@ -286,6 +291,9 @@ _test_connection() {
|
||||
|
||||
cmd_restore() {
|
||||
require_root
|
||||
require_cmd rsync
|
||||
require_cmd ssh
|
||||
require_cmd hostname
|
||||
local subcommand="${1:-}"
|
||||
shift 2>/dev/null || true
|
||||
|
||||
@@ -294,6 +302,7 @@ cmd_restore() {
|
||||
local name="${1:-}"
|
||||
shift 2>/dev/null || true
|
||||
[[ -z "$name" ]] && die "Usage: gniza restore account <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
409
cpanel/admin/Gniza/Restore
Normal 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;
|
||||
1
cpanel/admin/Gniza/Restore.conf
Normal file
1
cpanel/admin/Gniza/Restore.conf
Normal file
@@ -0,0 +1 @@
|
||||
mode=full
|
||||
25
cpanel/gniza/assets/gniza-logo.svg
Normal file
25
cpanel/gniza/assets/gniza-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 369 KiB |
2
cpanel/gniza/assets/gniza-whm.css
Normal file
2
cpanel/gniza/assets/gniza-whm.css
Normal file
File diff suppressed because one or more lines are too long
105
cpanel/gniza/index.live.cgi
Normal file
105
cpanel/gniza/index.live.cgi
Normal 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
12
cpanel/gniza/install.json
Normal 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"
|
||||
}
|
||||
]
|
||||
286
cpanel/gniza/lib/GnizaCPanel/UI.pm
Normal file
286
cpanel/gniza/lib/GnizaCPanel/UI.pm
Normal 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/&/&/g;
|
||||
$str =~ s/</</g;
|
||||
$str =~ s/>/>/g;
|
||||
$str =~ s/"/"/g;
|
||||
$str =~ s/'/'/g;
|
||||
return $str;
|
||||
}
|
||||
|
||||
# ── Current User ─────────────────────────────────────────────
|
||||
|
||||
sub get_current_user {
|
||||
return $ENV{'REMOTE_USER'} // '';
|
||||
}
|
||||
|
||||
# ── Safe File I/O (symlink-safe) ──────────────────────────────
|
||||
|
||||
sub _safe_write {
|
||||
my ($file, $content) = @_;
|
||||
# Remove existing file/symlink before writing (prevents symlink attacks)
|
||||
unlink $file if -e $file || -l $file;
|
||||
# Create new file with O_CREAT|O_EXCL (fails if file already exists/race)
|
||||
if (sysopen my $fh, $file, O_WRONLY | O_CREAT | O_EXCL, 0600) {
|
||||
print $fh $content;
|
||||
close $fh;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
sub _safe_read {
|
||||
my ($file) = @_;
|
||||
# Refuse to read symlinks
|
||||
return if -l $file;
|
||||
return unless -f $file;
|
||||
if (open my $fh, '<', $file) {
|
||||
local $/;
|
||||
my $content = <$fh>;
|
||||
close $fh;
|
||||
return $content;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
# ── Flash Messages ────────────────────────────────────────────
|
||||
|
||||
sub _flash_file {
|
||||
my $user = get_current_user();
|
||||
return "/tmp/.gniza-cpanel-flash-$user";
|
||||
}
|
||||
|
||||
sub set_flash {
|
||||
my ($type, $text) = @_;
|
||||
_safe_write(_flash_file(), "$type\n$text\n");
|
||||
return;
|
||||
}
|
||||
|
||||
sub get_flash {
|
||||
my $file = _flash_file();
|
||||
my $content = _safe_read($file);
|
||||
return unless defined $content;
|
||||
unlink $file;
|
||||
|
||||
my ($type, $text) = split /\n/, $content, 2;
|
||||
|
||||
return unless defined $type && defined $text;
|
||||
chomp $type;
|
||||
chomp $text;
|
||||
return ($type, $text);
|
||||
}
|
||||
|
||||
sub render_flash {
|
||||
my @flash = get_flash();
|
||||
return '' unless defined $flash[0];
|
||||
my ($type, $text) = @flash;
|
||||
# Validate flash type against allowlist
|
||||
my %valid_types = map { $_ => 1 } qw(success error info warning);
|
||||
$type = 'info' unless $valid_types{$type // ''};
|
||||
my $escaped = esc($text);
|
||||
return qq{<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;
|
||||
849
cpanel/gniza/restore.live.cgi
Normal file
849
cpanel/gniza/restore.live.cgi
Normal 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{ + '×tamp=' + 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> · 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 & 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{ + '×tamp=' + 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{ + '×tamp=' + 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{ + '×tamp=' + 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} ? '✓' : '✗';
|
||||
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};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
16
lib/utils.sh
16
lib/utils.sh
@@ -49,3 +49,19 @@ human_duration() {
|
||||
require_cmd() {
|
||||
command -v "$1" &>/dev/null || die "Required command not found: $1"
|
||||
}
|
||||
|
||||
validate_timestamp() {
|
||||
local ts="$1"
|
||||
if [[ ! "$ts" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{6}$ ]]; then
|
||||
log_error "Invalid timestamp format: $ts (expected YYYY-MM-DDTHHMMSS)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_account_name() {
|
||||
local name="$1"
|
||||
if [[ ! "$name" =~ ^[a-z][a-z0-9_-]{0,15}$ ]]; then
|
||||
log_error "Invalid account name: $name"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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$/,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,15 +145,21 @@ 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)));
|
||||
}
|
||||
|
||||
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};
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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">ⓘ</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{">ⓘ</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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">ⓘ</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{">ⓘ</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';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user