Replace the 8-card category grid with a unified restore workflow: - index.live.cgi: now serves as Step 1 (remote + snapshot selection) - restore.live.cgi: Step 2 (Full Account/Selective mode toggle with type checkboxes, exclude paths, file browser), Step 3 (multi-type confirmation), Step 4 (multi-type execution via AdminBin) Also update cPanel plugin icon from gniza.svg source. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1139 lines
45 KiB
Perl
1139 lines
45 KiB
Perl
#!/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::LiveAPI ();
|
|
use Cpanel::AdminBin::Call ();
|
|
use Cpanel::Form ();
|
|
use GnizaCPanel::UI;
|
|
|
|
my $cpanel = Cpanel::LiveAPI->new();
|
|
END { $cpanel->end() if $cpanel }
|
|
my $form = Cpanel::Form::parseform();
|
|
my $method = $ENV{'REQUEST_METHOD'} // 'GET';
|
|
my $step = $form->{'step'} // '';
|
|
|
|
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',
|
|
);
|
|
|
|
# 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 {
|
|
# Default: redirect to index
|
|
print "Status: 302 Found\r\n";
|
|
print "Location: index.live.cgi\r\n\r\n";
|
|
}
|
|
|
|
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, '');
|
|
}
|
|
|
|
sub _uri_escape {
|
|
my $str = shift // '';
|
|
$str =~ s/([^A-Za-z0-9\-._~])/sprintf("%%%02X", ord($1))/ge;
|
|
return $str;
|
|
}
|
|
|
|
# ── 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 2: Choose Restore Options ───────────────────────────
|
|
|
|
sub handle_step2 {
|
|
my $remote = $form->{'remote'} // '';
|
|
my $timestamp = $form->{'timestamp'} // '';
|
|
|
|
if ($remote eq '' || $timestamp eq '') {
|
|
GnizaCPanel::UI::set_flash('error', 'Remote and snapshot are required.');
|
|
print "Status: 302 Found\r\n";
|
|
print "Location: index.live.cgi\r\n\r\n";
|
|
exit;
|
|
}
|
|
|
|
# Fetch snapshots server-side via AdminBin
|
|
my ($ok, $stdout, $err) = _adminbin_call('LIST_SNAPSHOTS', $remote);
|
|
|
|
my @snapshots;
|
|
if ($ok) {
|
|
for my $line (split /\n/, $stdout) {
|
|
if ($line =~ /^\s+(\d{4}-\d{2}-\d{2}T\d{6})/) {
|
|
push @snapshots, $1;
|
|
}
|
|
}
|
|
}
|
|
|
|
print "Content-Type: text/html\r\n\r\n";
|
|
print $cpanel->header('GNIZA Backups');
|
|
print GnizaCPanel::UI::page_header('Restore Options');
|
|
print GnizaCPanel::UI::render_flash();
|
|
|
|
my $esc_remote = GnizaCPanel::UI::esc($remote);
|
|
my $esc_timestamp = GnizaCPanel::UI::esc($timestamp);
|
|
|
|
print qq{<form method="GET" action="restore.live.cgi">\n};
|
|
print qq{<input type="hidden" name="step" value="3">\n};
|
|
print qq{<input type="hidden" name="remote" value="$esc_remote">\n};
|
|
|
|
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: Choose Restore Options</h2>\n};
|
|
print qq{<p class="text-sm mb-3">Remote: <strong>$esc_remote</strong></p>\n};
|
|
|
|
# Snapshot 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="timestamp">Snapshot</label>\n};
|
|
if (@snapshots) {
|
|
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="timestamp" name="timestamp" required onchange="gnizaSnapshotChanged()">\n};
|
|
for my $snap (sort { $b cmp $a } @snapshots) {
|
|
my $esc = GnizaCPanel::UI::esc($snap);
|
|
my $sel = ($snap eq $timestamp) ? ' selected' : '';
|
|
print qq{ <option value="$esc"$sel>$esc</option>\n};
|
|
}
|
|
print qq{ </select>\n};
|
|
} else {
|
|
print qq{ <span class="text-sm text-base-content/60">No snapshots found</span>\n};
|
|
}
|
|
print qq{</div>\n};
|
|
|
|
# Restore mode toggle
|
|
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
|
|
print qq{ <label class="w-36 font-medium text-sm whitespace-nowrap">Restore Mode</label>\n};
|
|
print qq{ <div class="join inline-flex items-stretch">\n};
|
|
print qq{ <input type="radio" name="restore_mode" class="join-item btn btn-sm m-0" aria-label="Full Account" value="full" checked onchange="gnizaModeChanged()">\n};
|
|
print qq{ <input type="radio" name="restore_mode" class="join-item btn btn-sm m-0" aria-label="Selective" value="selective" onchange="gnizaModeChanged()">\n};
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
|
|
# Exclude paths section
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3 mt-3">\n};
|
|
print qq{<div class="card-body py-3 px-4">\n};
|
|
print qq{ <h3 class="card-title text-sm">Directories and Files to Exclude</h3>\n};
|
|
print qq{ <div class="flex items-center gap-2">\n};
|
|
print qq{ <input type="text" class="input input-bordered input-sm flex-1 max-w-xs" id="exclude-input" placeholder="e.g. public_html/cache">\n};
|
|
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaAddExclude()">Add Path</button>\n};
|
|
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaOpenExcludeModal()">Insert Multiple</button>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ <p class="text-xs text-base-content/60">Exclude files and directories from restoration</p>\n};
|
|
print qq{ <div id="exclude-tags" class="flex flex-wrap gap-1 mt-1"></div>\n};
|
|
print qq{ <input type="hidden" id="exclude_paths" name="exclude_paths" value="">\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
|
|
# Exclude modal
|
|
print qq{<dialog id="exclude-modal" class="modal">\n};
|
|
print qq{<div class="modal-box">\n};
|
|
print qq{ <h3 class="text-lg font-bold">Directories and Files to Exclude</h3>\n};
|
|
print qq{ <textarea id="exclude-textarea" class="textarea textarea-bordered w-full h-40 mt-3" placeholder="One path per line"></textarea>\n};
|
|
print qq{ <p class="text-xs text-base-content/60 mt-1">* Separated by new line</p>\n};
|
|
print qq{ <div class="modal-action">\n};
|
|
print qq{ <button type="button" class="btn btn-sm" onclick="document.getElementById('exclude-modal').close()">Cancel</button>\n};
|
|
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaExcludeModalOk()">OK</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};
|
|
|
|
# Hidden field for account type in full mode
|
|
print qq{<input type="hidden" id="type_account_hidden" name="type_account" value="1">\n};
|
|
|
|
# Selective type buttons (hidden by default)
|
|
my @selective_types = (
|
|
['files', 'Files'],
|
|
['database', 'Database'],
|
|
['dbusers', 'Database Users'],
|
|
['mailbox', 'Mailbox'],
|
|
['cron', 'Cron'],
|
|
['domains', 'Domains'],
|
|
['ssl', 'SSL'],
|
|
);
|
|
|
|
print qq{<div id="selective-panel" hidden>\n};
|
|
|
|
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
|
|
print qq{ <label class="w-36 font-medium text-sm">Restore Types</label>\n};
|
|
print qq{ <div class="flex flex-wrap gap-1">\n};
|
|
for my $t (@selective_types) {
|
|
print qq{ <input type="checkbox" class="btn btn-sm" aria-label="$t->[1]" name="type_$t->[0]" value="1" onchange="gnizaTypesChanged()">\n};
|
|
}
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
|
|
# Sub-field cards per type
|
|
print qq{<div id="field-path" hidden>\n};
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3">\n};
|
|
print qq{<div class="card-body py-3 px-4">\n};
|
|
print qq{ <h3 class="card-title text-sm">Files</h3>\n};
|
|
print qq{ <div class="flex items-center gap-3">\n};
|
|
print qq{ <label class="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="gnizaOpenFileBrowser()">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};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
|
|
print qq{<div id="field-dbname" hidden>\n};
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3">\n};
|
|
print qq{<div class="card-body py-3 px-4">\n};
|
|
print qq{ <h3 class="card-title text-sm">Databases</h3>\n};
|
|
print qq{ <input type="hidden" id="dbnames" name="dbnames">\n};
|
|
print qq{ <div id="dbname-list" class="flex flex-col gap-1 max-h-48 overflow-y-auto">\n};
|
|
print qq{ <span class="text-sm text-base-content/60">Loading...</span>\n};
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
|
|
print qq{<div id="field-email" hidden>\n};
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3">\n};
|
|
print qq{<div class="card-body py-3 px-4">\n};
|
|
print qq{ <h3 class="card-title text-sm">Mailboxes</h3>\n};
|
|
print qq{ <input type="hidden" id="emails" name="emails">\n};
|
|
print qq{ <div id="email-list" class="flex flex-col gap-1 max-h-48 overflow-y-auto">\n};
|
|
print qq{ <span class="text-sm text-base-content/60">Loading...</span>\n};
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
|
|
print qq{<div id="field-dbusers" hidden>\n};
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3">\n};
|
|
print qq{<div class="card-body py-3 px-4">\n};
|
|
print qq{ <h3 class="card-title text-sm">Database Users</h3>\n};
|
|
print qq{ <input type="hidden" id="dbuser_names" name="dbuser_names">\n};
|
|
print qq{ <div id="dbusers-list" class="flex flex-col gap-1 max-h-48 overflow-y-auto">\n};
|
|
print qq{ <span class="text-sm text-base-content/60">Loading...</span>\n};
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
|
|
print qq{<div id="field-cron" hidden>\n};
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3">\n};
|
|
print qq{<div class="card-body py-3 px-4">\n};
|
|
print qq{ <h3 class="card-title text-sm">Cron Jobs</h3>\n};
|
|
print qq{ <div id="cron-list" class="max-h-48 overflow-y-auto">\n};
|
|
print qq{ <span class="text-sm text-base-content/60">Loading...</span>\n};
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
|
|
print qq{<div id="field-domains" hidden>\n};
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3">\n};
|
|
print qq{<div class="card-body py-3 px-4">\n};
|
|
print qq{ <h3 class="card-title text-sm">Domains</h3>\n};
|
|
print qq{ <input type="hidden" id="domain_names" name="domain_names">\n};
|
|
print qq{ <div id="domains-list" class="flex flex-col gap-1 max-h-48 overflow-y-auto">\n};
|
|
print qq{ <span class="text-sm text-base-content/60">Loading...</span>\n};
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
|
|
print qq{<div id="field-ssl" hidden>\n};
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3">\n};
|
|
print qq{<div class="card-body py-3 px-4">\n};
|
|
print qq{ <h3 class="card-title text-sm">SSL Certificates</h3>\n};
|
|
print qq{ <input type="hidden" id="ssl_names" name="ssl_names">\n};
|
|
print qq{ <div id="ssl-list" class="flex flex-col gap-1 max-h-48 overflow-y-auto">\n};
|
|
print qq{ <span class="text-sm text-base-content/60">Loading...</span>\n};
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n};
|
|
|
|
print qq{</div>\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="document.getElementById('fb-modal').close()"><button type="button">close</button></div>\n};
|
|
print qq{</dialog>\n};
|
|
|
|
print qq{</div>\n</div>\n};
|
|
|
|
if (@snapshots) {
|
|
print qq{<div class="flex items-center gap-2">\n};
|
|
print qq{ <button type="submit" class="btn btn-primary btn-sm">Review & Confirm</button>\n};
|
|
print qq{ <a href="index.live.cgi" class="btn btn-info btn-sm">Back</a>\n};
|
|
print qq{</div>\n};
|
|
} else {
|
|
print qq{<a href="index.live.cgi" class="btn btn-info btn-sm">Back</a>\n};
|
|
}
|
|
|
|
print qq{</form>\n};
|
|
|
|
# JavaScript for dynamic dropdowns and interactive elements
|
|
_print_step2_js($esc_remote);
|
|
|
|
print GnizaCPanel::UI::page_footer();
|
|
print $cpanel->footer();
|
|
}
|
|
|
|
sub _print_step2_js {
|
|
my ($esc_remote) = @_;
|
|
print <<"END_JS";
|
|
<script>
|
|
var gnizaCache = {};
|
|
var gnizaRemote = '$esc_remote';
|
|
var fbCache = {};
|
|
var fbSelected = '';
|
|
|
|
function gnizaSnapshotChanged() {
|
|
gnizaCache = {};
|
|
fbCache = {};
|
|
gnizaModeChanged();
|
|
}
|
|
|
|
function gnizaModeChanged() {
|
|
var mode = document.querySelector('input[name="restore_mode"]:checked').value;
|
|
var selective = mode === 'selective';
|
|
document.getElementById('selective-panel').hidden = !selective;
|
|
document.getElementById('type_account_hidden').disabled = selective;
|
|
if (selective) {
|
|
gnizaTypesChanged();
|
|
} else {
|
|
var panels = ['field-path','field-dbname','field-email','field-dbusers','field-cron','field-domains','field-ssl'];
|
|
for (var i = 0; i < panels.length; i++) {
|
|
document.getElementById(panels[i]).hidden = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function gnizaTypesChanged() {
|
|
var types = {
|
|
files: 'field-path',
|
|
database: 'field-dbname',
|
|
mailbox: 'field-email',
|
|
dbusers: 'field-dbusers',
|
|
cron: 'field-cron',
|
|
domains: 'field-domains',
|
|
ssl: 'field-ssl'
|
|
};
|
|
for (var t in types) {
|
|
var el = document.querySelector('input[name="type_' + t + '"]');
|
|
document.getElementById(types[t]).hidden = !(el && el.checked);
|
|
}
|
|
|
|
if (document.querySelector('input[name="type_database"]').checked) { gnizaLoadOptions('database', 'dbname-list', 'dbnames'); }
|
|
if (document.querySelector('input[name="type_mailbox"]').checked) { gnizaLoadOptions('mailbox', 'email-list', 'emails'); }
|
|
if (document.querySelector('input[name="type_dbusers"]').checked) { gnizaLoadOptions('dbusers', 'dbusers-list', 'dbuser_names'); }
|
|
if (document.querySelector('input[name="type_cron"]').checked) { gnizaLoadPreview('cron', 'cron-list'); }
|
|
if (document.querySelector('input[name="type_domains"]').checked) { gnizaLoadOptions('domains', 'domains-list', 'domain_names'); }
|
|
if (document.querySelector('input[name="type_ssl"]').checked) { gnizaLoadOptions('ssl', 'ssl-list', 'ssl_names'); }
|
|
}
|
|
|
|
function gnizaLoadOptions(type, containerId, hiddenId) {
|
|
var ts = document.getElementById('timestamp').value;
|
|
var cacheKey = type + ':' + ts;
|
|
|
|
if (gnizaCache[cacheKey]) {
|
|
gnizaPopulateChecklist(containerId, hiddenId, gnizaCache[cacheKey]);
|
|
return;
|
|
}
|
|
|
|
var container = document.getElementById(containerId);
|
|
container.textContent = '';
|
|
var loadSpan = document.createElement('span');
|
|
loadSpan.className = 'text-sm text-base-content/60';
|
|
var spinner = document.createElement('span');
|
|
spinner.className = 'loading loading-spinner loading-xs';
|
|
loadSpan.appendChild(spinner);
|
|
loadSpan.appendChild(document.createTextNode(' Loading...'));
|
|
container.appendChild(loadSpan);
|
|
document.getElementById(hiddenId).value = '';
|
|
|
|
var url = 'restore.live.cgi?step=fetch_options'
|
|
+ '&remote=' + encodeURIComponent(gnizaRemote)
|
|
+ '×tamp=' + encodeURIComponent(ts)
|
|
+ '&type=' + encodeURIComponent(type);
|
|
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('GET', url, true);
|
|
xhr.onreadystatechange = function() {
|
|
if (xhr.readyState !== 4) return;
|
|
if (xhr.status === 200) {
|
|
try {
|
|
var data = JSON.parse(xhr.responseText);
|
|
if (data.error) {
|
|
container.textContent = 'Error: ' + data.error;
|
|
} else {
|
|
gnizaCache[cacheKey] = data.options;
|
|
gnizaPopulateChecklist(containerId, hiddenId, data.options);
|
|
}
|
|
} catch(e) {
|
|
container.textContent = 'Failed to parse response';
|
|
}
|
|
} else {
|
|
container.textContent = 'Request failed';
|
|
}
|
|
};
|
|
xhr.send();
|
|
}
|
|
|
|
function gnizaPopulateChecklist(containerId, hiddenId, options) {
|
|
var container = document.getElementById(containerId);
|
|
var hidden = document.getElementById(hiddenId);
|
|
hidden.value = '';
|
|
container.textContent = '';
|
|
|
|
if (!options || options.length === 0) {
|
|
container.textContent = '(none found)';
|
|
return;
|
|
}
|
|
|
|
var allLabels = {'dbname-list':'All Databases','dbusers-list':'All Database Users','email-list':'All Mailboxes','domains-list':'All Domains','ssl-list':'All Certificates'};
|
|
var allLabel = allLabels[containerId] || 'All';
|
|
|
|
// "All" checkbox
|
|
var allRow = document.createElement('label');
|
|
allRow.className = 'flex items-center gap-2 cursor-pointer';
|
|
var allCb = document.createElement('input');
|
|
allCb.type = 'checkbox';
|
|
allCb.className = 'checkbox checkbox-sm';
|
|
allCb.setAttribute('data-all', '1');
|
|
allCb.onchange = function() { gnizaToggleAll(containerId, hiddenId, this.checked); };
|
|
allRow.appendChild(allCb);
|
|
var allSpan = document.createElement('span');
|
|
allSpan.className = 'text-sm font-semibold';
|
|
allSpan.textContent = allLabel;
|
|
allRow.appendChild(allSpan);
|
|
container.appendChild(allRow);
|
|
|
|
// Individual items
|
|
for (var i = 0; i < options.length; i++) {
|
|
var row = document.createElement('label');
|
|
row.className = 'flex items-center gap-2 cursor-pointer';
|
|
var cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.className = 'checkbox checkbox-sm';
|
|
cb.value = options[i];
|
|
cb.setAttribute('data-item', '1');
|
|
cb.onchange = (function(cId, hId) { return function() { gnizaSyncHidden(cId, hId); }; })(containerId, hiddenId);
|
|
row.appendChild(cb);
|
|
var span = document.createElement('span');
|
|
span.className = 'text-sm';
|
|
span.textContent = options[i];
|
|
row.appendChild(span);
|
|
container.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function gnizaToggleAll(containerId, hiddenId, checked) {
|
|
var container = document.getElementById(containerId);
|
|
var hidden = document.getElementById(hiddenId);
|
|
var items = container.querySelectorAll('input[data-item]');
|
|
for (var i = 0; i < items.length; i++) {
|
|
items[i].disabled = checked;
|
|
if (checked) items[i].checked = false;
|
|
}
|
|
hidden.value = checked ? '__ALL__' : '';
|
|
}
|
|
|
|
function gnizaSyncHidden(containerId, hiddenId) {
|
|
var container = document.getElementById(containerId);
|
|
var hidden = document.getElementById(hiddenId);
|
|
var items = container.querySelectorAll('input[data-item]:checked');
|
|
var vals = [];
|
|
for (var i = 0; i < items.length; i++) {
|
|
vals.push(items[i].value);
|
|
}
|
|
hidden.value = vals.join(',');
|
|
}
|
|
|
|
function gnizaLoadPreview(type, containerId) {
|
|
var ts = document.getElementById('timestamp').value;
|
|
var cacheKey = type + ':' + ts;
|
|
|
|
if (gnizaCache[cacheKey]) {
|
|
gnizaPopulatePreview(containerId, gnizaCache[cacheKey], type);
|
|
return;
|
|
}
|
|
|
|
var container = document.getElementById(containerId);
|
|
container.textContent = '';
|
|
var loadSpan = document.createElement('span');
|
|
loadSpan.className = 'text-sm text-base-content/60';
|
|
var spinner = document.createElement('span');
|
|
spinner.className = 'loading loading-spinner loading-xs';
|
|
loadSpan.appendChild(spinner);
|
|
loadSpan.appendChild(document.createTextNode(' Loading...'));
|
|
container.appendChild(loadSpan);
|
|
|
|
var url = 'restore.live.cgi?step=fetch_options'
|
|
+ '&remote=' + encodeURIComponent(gnizaRemote)
|
|
+ '×tamp=' + encodeURIComponent(ts)
|
|
+ '&type=' + encodeURIComponent(type);
|
|
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('GET', url, true);
|
|
xhr.onreadystatechange = function() {
|
|
if (xhr.readyState !== 4) return;
|
|
if (xhr.status === 200) {
|
|
try {
|
|
var data = JSON.parse(xhr.responseText);
|
|
if (data.error) {
|
|
container.textContent = 'Error: ' + data.error;
|
|
} else {
|
|
gnizaCache[cacheKey] = data.options;
|
|
gnizaPopulatePreview(containerId, data.options, type);
|
|
}
|
|
} catch(e) {
|
|
container.textContent = 'Failed to parse response';
|
|
}
|
|
} else {
|
|
container.textContent = 'Request failed';
|
|
}
|
|
};
|
|
xhr.send();
|
|
}
|
|
|
|
function gnizaPopulatePreview(containerId, options, type) {
|
|
var container = document.getElementById(containerId);
|
|
container.textContent = '';
|
|
if (!options || options.length === 0) {
|
|
container.textContent = '(none found)';
|
|
return;
|
|
}
|
|
if (type === 'cron') {
|
|
var pre = document.createElement('pre');
|
|
pre.className = 'text-xs font-mono bg-base-200 p-3 rounded-lg overflow-x-auto';
|
|
pre.textContent = options.join('\\n');
|
|
container.appendChild(pre);
|
|
} else {
|
|
var ul = document.createElement('ul');
|
|
ul.className = 'list-disc pl-5 text-sm';
|
|
for (var i = 0; i < options.length; i++) {
|
|
var li = document.createElement('li');
|
|
li.textContent = options[i];
|
|
ul.appendChild(li);
|
|
}
|
|
container.appendChild(ul);
|
|
}
|
|
}
|
|
|
|
function gnizaAddExclude() {
|
|
var input = document.getElementById('exclude-input');
|
|
var val = input.value.trim();
|
|
if (!val) return;
|
|
gnizaAddExcludeTag(val);
|
|
input.value = '';
|
|
gnizaUpdateExcludeField();
|
|
}
|
|
|
|
function gnizaAddExcludeTag(text) {
|
|
var container = document.getElementById('exclude-tags');
|
|
var existing = container.querySelectorAll('.badge span');
|
|
for (var i = 0; i < existing.length; i++) {
|
|
if (existing[i].textContent === text) return;
|
|
}
|
|
var badge = document.createElement('span');
|
|
badge.className = 'badge badge-warning gap-1';
|
|
var span = document.createElement('span');
|
|
span.textContent = text;
|
|
badge.appendChild(span);
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'btn btn-xs btn-ghost btn-circle';
|
|
btn.textContent = '\\u2715';
|
|
btn.onclick = function() { badge.remove(); gnizaUpdateExcludeField(); };
|
|
badge.appendChild(btn);
|
|
container.appendChild(badge);
|
|
}
|
|
|
|
function gnizaUpdateExcludeField() {
|
|
var tags = document.getElementById('exclude-tags').querySelectorAll('.badge span');
|
|
var vals = [];
|
|
for (var i = 0; i < tags.length; i++) {
|
|
vals.push(tags[i].textContent);
|
|
}
|
|
document.getElementById('exclude_paths').value = vals.join(',');
|
|
}
|
|
|
|
function gnizaOpenExcludeModal() {
|
|
document.getElementById('exclude-textarea').value = '';
|
|
document.getElementById('exclude-modal').showModal();
|
|
}
|
|
|
|
function gnizaExcludeModalOk() {
|
|
var text = document.getElementById('exclude-textarea').value;
|
|
var lines = text.split('\\n');
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i].trim();
|
|
if (line) gnizaAddExcludeTag(line);
|
|
}
|
|
gnizaUpdateExcludeField();
|
|
document.getElementById('exclude-modal').close();
|
|
}
|
|
|
|
function gnizaOpenFileBrowser() {
|
|
fbSelected = '';
|
|
document.getElementById('fb-select-btn').disabled = true;
|
|
document.getElementById('fb-modal').showModal();
|
|
gnizaLoadDir('');
|
|
}
|
|
|
|
function gnizaLoadDir(path) {
|
|
var ts = document.getElementById('timestamp').value;
|
|
var cacheKey = 'fb:' + ts + ':' + path;
|
|
|
|
if (fbCache[cacheKey]) {
|
|
gnizaRenderFileList(path, fbCache[cacheKey]);
|
|
return;
|
|
}
|
|
|
|
document.getElementById('fb-loading').hidden = false;
|
|
document.getElementById('fb-error').hidden = true;
|
|
document.getElementById('fb-tbody').textContent = '';
|
|
|
|
var url = 'restore.live.cgi?step=fetch_options'
|
|
+ '&remote=' + encodeURIComponent(gnizaRemote)
|
|
+ '×tamp=' + encodeURIComponent(ts)
|
|
+ '&type=files'
|
|
+ (path ? '&path=' + encodeURIComponent(path) : '');
|
|
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('GET', url, true);
|
|
xhr.onreadystatechange = function() {
|
|
if (xhr.readyState !== 4) return;
|
|
document.getElementById('fb-loading').hidden = true;
|
|
if (xhr.status === 200) {
|
|
try {
|
|
var data = JSON.parse(xhr.responseText);
|
|
if (data.error) {
|
|
document.getElementById('fb-error').textContent = data.error;
|
|
document.getElementById('fb-error').hidden = false;
|
|
} else {
|
|
fbCache[cacheKey] = data.options;
|
|
gnizaRenderFileList(path, data.options);
|
|
}
|
|
} catch(e) {
|
|
document.getElementById('fb-error').textContent = 'Failed to parse response';
|
|
document.getElementById('fb-error').hidden = false;
|
|
}
|
|
} else {
|
|
document.getElementById('fb-error').textContent = 'Request failed';
|
|
document.getElementById('fb-error').hidden = false;
|
|
}
|
|
};
|
|
xhr.send();
|
|
}
|
|
|
|
function gnizaRenderBreadcrumbs(path) {
|
|
var ul = document.createElement('ul');
|
|
var li = document.createElement('li');
|
|
var a = document.createElement('a');
|
|
a.textContent = 'homedir';
|
|
a.href = '#';
|
|
a.onclick = function(e) { e.preventDefault(); gnizaLoadDir(''); };
|
|
li.appendChild(a);
|
|
ul.appendChild(li);
|
|
|
|
if (path) {
|
|
var parts = path.replace(/\\/\$/, '').split('/');
|
|
var built = '';
|
|
for (var i = 0; i < parts.length; i++) {
|
|
built += (i > 0 ? '/' : '') + parts[i];
|
|
li = document.createElement('li');
|
|
if (i < parts.length - 1) {
|
|
a = document.createElement('a');
|
|
a.textContent = parts[i];
|
|
a.href = '#';
|
|
(function(p) { a.onclick = function(e) { e.preventDefault(); gnizaLoadDir(p); }; })(built);
|
|
li.appendChild(a);
|
|
} else {
|
|
li.textContent = parts[i];
|
|
}
|
|
ul.appendChild(li);
|
|
}
|
|
}
|
|
|
|
var bc = document.getElementById('fb-breadcrumbs');
|
|
bc.textContent = '';
|
|
bc.appendChild(ul);
|
|
}
|
|
|
|
function gnizaRenderFileList(currentPath, entries) {
|
|
gnizaRenderBreadcrumbs(currentPath);
|
|
fbSelected = '';
|
|
document.getElementById('fb-select-btn').disabled = true;
|
|
|
|
var tbody = document.getElementById('fb-tbody');
|
|
tbody.textContent = '';
|
|
|
|
if (!entries || entries.length === 0) {
|
|
var emptyTr = document.createElement('tr');
|
|
var emptyTd = document.createElement('td');
|
|
emptyTd.className = 'text-center text-base-content/60 py-4';
|
|
emptyTd.textContent = '(empty directory)';
|
|
emptyTr.appendChild(emptyTd);
|
|
tbody.appendChild(emptyTr);
|
|
return;
|
|
}
|
|
|
|
for (var i = 0; i < entries.length; i++) {
|
|
var entry = entries[i];
|
|
var isDir = entry.endsWith('/');
|
|
var fullPath = currentPath ? currentPath.replace(/\\/\$/, '') + '/' + entry : entry;
|
|
|
|
var tr = document.createElement('tr');
|
|
tr.className = 'cursor-pointer hover';
|
|
tr.setAttribute('data-path', fullPath);
|
|
|
|
var td = document.createElement('td');
|
|
td.className = 'py-1';
|
|
var icon = isDir ? '\\uD83D\\uDCC1 ' : '\\uD83D\\uDCC4 ';
|
|
td.textContent = icon + entry;
|
|
tr.appendChild(td);
|
|
|
|
(function(row, path, dir) {
|
|
row.onclick = function() { gnizaHighlight(row, path); };
|
|
if (dir) {
|
|
row.ondblclick = function() { gnizaLoadDir(path.replace(/\\/\$/, '')); };
|
|
}
|
|
})(tr, fullPath, isDir);
|
|
|
|
tbody.appendChild(tr);
|
|
}
|
|
}
|
|
|
|
function gnizaHighlight(row, path) {
|
|
var rows = document.getElementById('fb-tbody').querySelectorAll('tr');
|
|
for (var i = 0; i < rows.length; i++) {
|
|
rows[i].classList.remove('bg-primary/10');
|
|
}
|
|
row.classList.add('bg-primary/10');
|
|
fbSelected = path;
|
|
document.getElementById('fb-select-btn').disabled = false;
|
|
}
|
|
|
|
function gnizaSelectPath() {
|
|
if (fbSelected) {
|
|
document.getElementById('path').value = fbSelected;
|
|
}
|
|
document.getElementById('fb-modal').close();
|
|
}
|
|
|
|
gnizaModeChanged();
|
|
</script>
|
|
END_JS
|
|
}
|
|
|
|
# ── Step 3: Confirmation ─────────────────────────────────────
|
|
|
|
sub handle_step3 {
|
|
my $remote = $form->{'remote'} // '';
|
|
my $timestamp = $form->{'timestamp'} // '';
|
|
my $path = $form->{'path'} // '';
|
|
my $dbnames = $form->{'dbnames'} // '';
|
|
my $dbuser_names = $form->{'dbuser_names'} // '';
|
|
my $emails = $form->{'emails'} // '';
|
|
my $domain_names = $form->{'domain_names'} // '';
|
|
my $ssl_names = $form->{'ssl_names'} // '';
|
|
my $exclude_paths = $form->{'exclude_paths'} // '';
|
|
|
|
# Collect selected types from type_* checkboxes
|
|
my @all_type_keys = qw(account files database mailbox cron dbusers domains ssl);
|
|
my @selected_types;
|
|
for my $t (@all_type_keys) {
|
|
push @selected_types, $t if ($form->{"type_$t"} // '') eq '1';
|
|
}
|
|
|
|
if ($remote eq '' || $timestamp eq '') {
|
|
GnizaCPanel::UI::set_flash('error', 'Remote and snapshot are required.');
|
|
print "Status: 302 Found\r\n";
|
|
print "Location: index.live.cgi\r\n\r\n";
|
|
exit;
|
|
}
|
|
|
|
unless (@selected_types) {
|
|
GnizaCPanel::UI::set_flash('error', 'Please select at least one restore type.');
|
|
print "Status: 302 Found\r\n";
|
|
print "Location: restore.live.cgi?step=2&remote=" . _uri_escape($remote) . "×tamp=" . _uri_escape($timestamp) . "\r\n\r\n";
|
|
exit;
|
|
}
|
|
|
|
print "Content-Type: text/html\r\n\r\n";
|
|
print $cpanel->header('GNIZA Backups');
|
|
print GnizaCPanel::UI::page_header('Restore: Confirm');
|
|
print GnizaCPanel::UI::render_flash();
|
|
|
|
my $esc_remote = GnizaCPanel::UI::esc($remote);
|
|
my $esc_timestamp = GnizaCPanel::UI::esc($timestamp);
|
|
my $user = GnizaCPanel::UI::esc(GnizaCPanel::UI::get_current_user());
|
|
|
|
my $types_display = join(', ', map { GnizaCPanel::UI::esc($TYPE_LABELS{$_} // $_) } @selected_types);
|
|
|
|
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 Types</td><td>$types_display</td></tr>\n};
|
|
|
|
# Show sub-field details for applicable types
|
|
if (grep { $_ eq 'files' } @selected_types) {
|
|
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};
|
|
}
|
|
if (grep { $_ eq 'database' } @selected_types) {
|
|
my $db_display = ($dbnames eq '' || $dbnames eq '__ALL__') ? 'All databases' : GnizaCPanel::UI::esc($dbnames);
|
|
$db_display =~ s/,/, /g;
|
|
print qq{<tr><td class="font-medium">Database</td><td>$db_display</td></tr>\n};
|
|
}
|
|
if (grep { $_ eq 'dbusers' } @selected_types) {
|
|
my $dbu_display = ($dbuser_names eq '' || $dbuser_names eq '__ALL__') ? 'All database users' : GnizaCPanel::UI::esc($dbuser_names);
|
|
$dbu_display =~ s/,/, /g;
|
|
print qq{<tr><td class="font-medium">Database Users</td><td>$dbu_display</td></tr>\n};
|
|
}
|
|
if (grep { $_ eq 'mailbox' } @selected_types) {
|
|
my $mb_display = ($emails eq '' || $emails eq '__ALL__') ? 'All mailboxes' : GnizaCPanel::UI::esc($emails);
|
|
$mb_display =~ s/,/, /g;
|
|
print qq{<tr><td class="font-medium">Mailbox</td><td>$mb_display</td></tr>\n};
|
|
}
|
|
if (grep { $_ eq 'domains' } @selected_types) {
|
|
my $dom_display = ($domain_names eq '' || $domain_names eq '__ALL__') ? 'All domains' : GnizaCPanel::UI::esc($domain_names);
|
|
$dom_display =~ s/,/, /g;
|
|
print qq{<tr><td class="font-medium">Domains</td><td>$dom_display</td></tr>\n};
|
|
}
|
|
if (grep { $_ eq 'ssl' } @selected_types) {
|
|
my $ssl_display = ($ssl_names eq '' || $ssl_names eq '__ALL__') ? 'All certificates' : GnizaCPanel::UI::esc($ssl_names);
|
|
$ssl_display =~ s/,/, /g;
|
|
print qq{<tr><td class="font-medium">SSL</td><td>$ssl_display</td></tr>\n};
|
|
}
|
|
|
|
if ($exclude_paths ne '') {
|
|
my $exclude_display = GnizaCPanel::UI::esc($exclude_paths);
|
|
$exclude_display =~ s/,/, /g;
|
|
print qq{<tr><td class="font-medium">Exclude</td><td>$exclude_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="remote" value="$esc_remote">\n};
|
|
print qq{<input type="hidden" name="timestamp" value="$esc_timestamp">\n};
|
|
for my $t (@selected_types) {
|
|
print qq{<input type="hidden" name="type_$t" value="1">\n};
|
|
}
|
|
print qq{<input type="hidden" name="path" value="} . GnizaCPanel::UI::esc($path) . qq{">\n};
|
|
print qq{<input type="hidden" name="dbnames" value="} . GnizaCPanel::UI::esc($dbnames) . qq{">\n};
|
|
print qq{<input type="hidden" name="dbuser_names" value="} . GnizaCPanel::UI::esc($dbuser_names) . qq{">\n};
|
|
print qq{<input type="hidden" name="emails" value="} . GnizaCPanel::UI::esc($emails) . qq{">\n};
|
|
print qq{<input type="hidden" name="domain_names" value="} . GnizaCPanel::UI::esc($domain_names) . qq{">\n};
|
|
print qq{<input type="hidden" name="ssl_names" value="} . GnizaCPanel::UI::esc($ssl_names) . qq{">\n};
|
|
print qq{<input type="hidden" name="exclude_paths" value="} . GnizaCPanel::UI::esc($exclude_paths) . 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();
|
|
print $cpanel->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 $remote = $form->{'remote'} // '';
|
|
my $timestamp = $form->{'timestamp'} // '';
|
|
my $path = $form->{'path'} // '';
|
|
my $dbnames = $form->{'dbnames'} // '';
|
|
my $dbuser_names = $form->{'dbuser_names'} // '';
|
|
my $emails = $form->{'emails'} // '';
|
|
my $domain_names = $form->{'domain_names'} // '';
|
|
my $ssl_names = $form->{'ssl_names'} // '';
|
|
my $exclude_paths = $form->{'exclude_paths'} // '';
|
|
|
|
# Collect selected types
|
|
my @all_type_keys = qw(account files database mailbox cron dbusers domains ssl);
|
|
my @selected_types;
|
|
for my $t (@all_type_keys) {
|
|
push @selected_types, $t if ($form->{"type_$t"} // '') eq '1';
|
|
}
|
|
|
|
unless (@selected_types) {
|
|
GnizaCPanel::UI::set_flash('error', 'No restore types selected.');
|
|
print "Status: 302 Found\r\n";
|
|
print "Location: index.live.cgi\r\n\r\n";
|
|
exit;
|
|
}
|
|
|
|
print "Content-Type: text/html\r\n\r\n";
|
|
print $cpanel->header('GNIZA Backups');
|
|
print GnizaCPanel::UI::page_header('Restore Results');
|
|
|
|
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</h2>\n};
|
|
|
|
my @results;
|
|
|
|
for my $type (@selected_types) {
|
|
my $type_label = $TYPE_LABELS{$type} // $type;
|
|
|
|
if ($type eq 'account') {
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_ACCOUNT', $remote, $timestamp, $exclude_paths);
|
|
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
|
|
}
|
|
elsif ($type eq 'files') {
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_FILES', $remote, $timestamp, $path, $exclude_paths);
|
|
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
|
|
}
|
|
elsif ($type eq 'cron') {
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_CRON', $remote, $timestamp);
|
|
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
|
|
}
|
|
elsif ($type eq 'database') {
|
|
if ($dbnames eq '' || $dbnames eq '__ALL__') {
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DATABASE', $remote, $timestamp, '');
|
|
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
|
|
} else {
|
|
for my $item (split /,/, $dbnames) {
|
|
next if $item eq '';
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DATABASE', $remote, $timestamp, $item);
|
|
push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err };
|
|
}
|
|
}
|
|
}
|
|
elsif ($type eq 'dbusers') {
|
|
if ($dbuser_names eq '' || $dbuser_names eq '__ALL__') {
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DBUSERS', $remote, $timestamp, '');
|
|
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
|
|
} else {
|
|
for my $item (split /,/, $dbuser_names) {
|
|
next if $item eq '';
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DBUSERS', $remote, $timestamp, $item);
|
|
push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err };
|
|
}
|
|
}
|
|
}
|
|
elsif ($type eq 'mailbox') {
|
|
if ($emails eq '' || $emails eq '__ALL__') {
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_MAILBOX', $remote, $timestamp, '');
|
|
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
|
|
} else {
|
|
for my $item (split /,/, $emails) {
|
|
next if $item eq '';
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_MAILBOX', $remote, $timestamp, $item);
|
|
push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err };
|
|
}
|
|
}
|
|
}
|
|
elsif ($type eq 'domains') {
|
|
if ($domain_names eq '' || $domain_names eq '__ALL__') {
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DOMAINS', $remote, $timestamp, '');
|
|
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
|
|
} else {
|
|
for my $item (split /,/, $domain_names) {
|
|
next if $item eq '';
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DOMAINS', $remote, $timestamp, $item);
|
|
push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err };
|
|
}
|
|
}
|
|
}
|
|
elsif ($type eq 'ssl') {
|
|
if ($ssl_names eq '' || $ssl_names eq '__ALL__') {
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_SSL', $remote, $timestamp, '');
|
|
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
|
|
} else {
|
|
for my $item (split /,/, $ssl_names) {
|
|
next if $item eq '';
|
|
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_SSL', $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 Home</a>\n};
|
|
|
|
print GnizaCPanel::UI::page_footer();
|
|
print $cpanel->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};
|
|
}
|
|
}
|