Files
gniza4cp/whm/gniza-whm/restore.cgi
shuki 1e6097eb9e Fix restore spinner not hiding due to !important CSS override
Use hidden class toggle instead of inline style.display, since
Tailwind CSS is built with the important flag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:50:05 +02:00

1207 lines
50 KiB
Perl

#!/usr/local/cpanel/3rdparty/bin/perl
# gniza WHM Plugin — Restore
# Step-by-step restore workflow with dynamic dropdowns
use strict;
use warnings;
use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib';
use Whostmgr::HTMLInterface ();
use Cpanel::Form ();
use GnizaWHM::Config;
use GnizaWHM::Runner;
use GnizaWHM::UI;
my $form = Cpanel::Form::parseform();
my $method = $ENV{'REQUEST_METHOD'} // 'GET';
my $step = $form->{'restore_step'} // '1';
my %TYPE_LABELS = (
account => 'Full Account',
files => 'Files',
database => 'Database',
mailbox => 'Mailbox',
cron => 'Cron Jobs',
dbusers => 'Database Users & Grants',
cpconfig => 'Panel Config',
domains => 'Domains',
ssl => 'SSL Certificates',
);
my %SIMPLE_TYPES = map { $_ => 1 } qw(account cron cpconfig);
if ($step eq 'fetch_options') { handle_fetch_options() }
elsif ($step eq 'fetch_accounts') { handle_fetch_accounts() }
elsif ($step eq '2') { handle_step2() }
elsif ($step eq '3') { handle_step3() }
elsif ($step eq '4') { handle_step4() }
else { handle_step1() }
exit;
# ── Helpers ───────────────────────────────────────────────────
sub _uri_escape {
my $str = shift // '';
$str =~ s/([^A-Za-z0-9\-._~])/sprintf("%%%02X", ord($1))/ge;
return $str;
}
# ── JSON endpoint: fetch database/mailbox options ─────────────
sub handle_fetch_options {
my $remote = $form->{'remote'} // '';
my $account = $form->{'account'} // '';
my $timestamp = $form->{'timestamp'} // '';
my $type = $form->{'type'} // '';
print "Content-Type: application/json\r\n\r\n";
if ($remote eq '' || $account eq '' || $timestamp eq '' || $type eq '') {
print qq({"error":"Missing required parameters"});
return;
}
my $subcmd;
my %extra_opts;
if ($type eq 'database') { $subcmd = 'list-databases' }
elsif ($type eq 'mailbox') { $subcmd = 'list-mailboxes' }
elsif ($type eq 'dbusers') { $subcmd = 'list-dbusers' }
elsif ($type eq 'cron') { $subcmd = 'list-cron' }
elsif ($type eq 'domains') { $subcmd = 'list-dns' }
elsif ($type eq 'ssl') { $subcmd = 'list-ssl' }
elsif ($type eq 'files') {
$subcmd = 'list-files';
my $path = $form->{'path'} // '';
$extra_opts{path} = $path if $path ne '';
}
else {
print qq({"error":"Invalid type"});
return;
}
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run(
'restore', $subcmd, [$account],
{ remote => $remote, timestamp => $timestamp, %extra_opts }
);
unless ($ok) {
my $msg = $stderr // 'Failed to list options';
$msg =~ s/\\/\\\\/g;
$msg =~ s/"/\\"/g;
$msg =~ s/\n/\\n/g;
$msg =~ s/\r/\\r/g;
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;
}
# Build JSON array manually (no JSON module dependency)
my $json_arr = join(',', map { my $v = $_; $v =~ s/\\/\\\\/g; $v =~ s/"/\\"/g; qq("$v") } @options);
print qq({"options":[$json_arr]});
}
# ── JSON endpoint: fetch accounts from remote ──────────────────
sub handle_fetch_accounts {
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, $stderr) = GnizaWHM::Runner::run(
'list', 'accounts', [], { remote => $remote }
);
unless ($ok) {
my $msg = $stderr // 'Failed to list accounts';
$msg =~ s/\\/\\\\/g;
$msg =~ s/"/\\"/g;
$msg =~ s/\n/\\n/g;
$msg =~ s/\r/\\r/g;
print qq({"error":"$msg"});
return;
}
# Get local cPanel accounts for terminated detection
my %local_accounts = map { $_ => 1 } GnizaWHM::UI::get_cpanel_accounts();
my @accounts;
for my $line (split /\n/, $stdout) {
$line =~ s/^\s+|\s+$//g;
next unless $line =~ /^[a-z][a-z0-9_-]*$/;
my $terminated = $local_accounts{$line} ? 0 : 1;
$line =~ s/\\/\\\\/g;
$line =~ s/"/\\"/g;
push @accounts, qq({"name":"$line","terminated":$terminated});
}
print '{"accounts":[' . join(',', @accounts) . ']}';
}
# ── Step 1: Select Account + Remote ─────────────────────────
sub handle_step1 {
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('GNIZA Backup Manager — Restore', '', '/cgi/gniza-whm/restore.cgi');
print GnizaWHM::UI::page_header('Restore from Backup');
print GnizaWHM::UI::render_nav('restore.cgi');
print GnizaWHM::UI::render_flash();
my @remotes = GnizaWHM::UI::list_remotes();
unless (@remotes) {
print qq{<div class="alert alert-info mb-4">No remotes configured. <a href="remotes.cgi?action=add" class="link">Add a remote</a> first.</div>\n};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
return;
}
print qq{<form method="GET" action="restore.cgi" id="gnizaStep1Form">\n};
print qq{<input type="hidden" name="restore_step" value="2">\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 1: Select Source</h2>\n};
# Remote dropdown
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 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" required onchange="gnizaLoadAccounts()">\n};
if (@remotes > 1) {
print qq{ <option value="">-- Select remote --</option>\n};
}
for my $r (@remotes) {
my $esc = GnizaWHM::UI::esc($r);
print qq{ <option value="$esc">$esc</option>\n};
}
print qq{ </select>\n};
print qq{</div>\n};
# Account dropdown (populated via AJAX)
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="account">Account</label>\n};
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="account" name="account" required disabled>\n};
print qq{ <option value="">-- Select remote first --</option>\n};
print qq{ </select>\n};
print qq{ <span class="loading loading-spinner loading-sm hidden" id="gnizaAccountSpinner"></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="submit" class="btn btn-primary btn-sm" id="gnizaSubmitBtn" disabled>Load Snapshots</button>\n};
print qq{</div>\n};
print qq{</form>\n};
# JavaScript for dynamic account loading
my $single_remote = (@remotes == 1) ? 'true' : 'false';
print <<'JSEOF';
<script>
function gnizaLoadAccounts() {
var sel = document.getElementById('remote');
var remote = sel.value;
var acctSel = document.getElementById('account');
var spinner = document.getElementById('gnizaAccountSpinner');
var submitBtn = document.getElementById('gnizaSubmitBtn');
if (!remote) {
acctSel.innerHTML = '<option value="">-- Select remote first --</option>';
acctSel.disabled = true;
submitBtn.disabled = true;
return;
}
acctSel.innerHTML = '<option value="">Loading...</option>';
acctSel.disabled = true;
submitBtn.disabled = true;
spinner.classList.remove('hidden');
function gnizaSetAcctMsg(msg) {
acctSel.innerHTML = '';
var opt = document.createElement('option');
opt.value = '';
opt.textContent = msg;
acctSel.appendChild(opt);
}
function gnizaDone() { spinner.classList.add('hidden'); }
var xhr = new XMLHttpRequest();
xhr.timeout = 30000;
xhr.open('GET', 'restore.cgi?restore_step=fetch_accounts&remote=' + encodeURIComponent(remote));
xhr.onload = function() {
gnizaDone();
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
if (data.error) {
gnizaSetAcctMsg('Error: ' + data.error);
return;
}
acctSel.innerHTML = '';
var placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = '-- Select account --';
acctSel.appendChild(placeholder);
for (var i = 0; i < data.accounts.length; i++) {
var a = data.accounts[i];
var opt = document.createElement('option');
opt.value = a.name;
opt.textContent = a.name + (a.terminated ? ' (terminated)' : '');
acctSel.appendChild(opt);
}
if (data.accounts.length === 0) {
gnizaSetAcctMsg('No accounts found');
} else {
acctSel.disabled = false;
submitBtn.disabled = false;
}
} catch(e) {
gnizaSetAcctMsg('Failed to parse response');
}
} else {
gnizaSetAcctMsg('Request failed');
}
};
xhr.onerror = function() { gnizaDone(); gnizaSetAcctMsg('Connection error'); };
xhr.ontimeout = function() { gnizaDone(); gnizaSetAcctMsg('Request timed out'); };
xhr.send();
}
JSEOF
print "if ($single_remote) { gnizaLoadAccounts(); }\n";
print "</script>\n";
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Step 2: Select Snapshot + Restore Type ───────────────────
sub handle_step2 {
my $remote = $form->{'remote'} // '';
my $account = $form->{'account'} // '';
if ($remote eq '' || $account eq '') {
GnizaWHM::UI::set_flash('error', 'Remote and account are required.');
print "Status: 302 Found\r\n";
print "Location: restore.cgi\r\n\r\n";
exit;
}
# Fetch snapshots via Runner
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('list', undef, [], { remote => $remote, account => $account });
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('GNIZA Backup Manager — Restore', '', '/cgi/gniza-whm/restore.cgi');
print GnizaWHM::UI::page_header('Restore from Backup');
print GnizaWHM::UI::render_nav('restore.cgi');
print GnizaWHM::UI::render_flash();
my $esc_remote = GnizaWHM::UI::esc($remote);
my $esc_account = GnizaWHM::UI::esc($account);
unless ($ok) {
my $msg = GnizaWHM::UI::esc($stderr || 'Failed to list snapshots');
print qq{<div class="alert alert-error mb-4">$msg</div>\n};
print qq{<button type="button" class="btn btn-info btn-sm" onclick="location.href='restore.cgi'">Back</button>\n};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
return;
}
# Parse snapshot timestamps from output
my @snapshots;
for my $line (split /\n/, $stdout) {
if ($line =~ /^\s+(\d{4}-\d{2}-\d{2}T\d{6})/) {
push @snapshots, $1;
}
}
print qq{<form method="GET" action="restore.cgi">\n};
print qq{<input type="hidden" name="restore_step" value="3">\n};
print qq{<input type="hidden" name="remote" value="$esc_remote">\n};
print qq{<input type="hidden" name="account" value="$esc_account">\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">Account: <strong>$esc_account</strong> on 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-44 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="gnizaSnapshotChange()">\n};
for my $snap (sort { $b cmp $a } @snapshots) {
my $esc = GnizaWHM::UI::esc($snap);
print qq{ <option value="$esc">$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: Full Account vs Selective
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">Restore Mode <span class="tooltip tooltip-top" data-tip="Full Account restores everything; Selective lets you pick specific items like files, databases, or mailboxes">&#9432;</span></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};
# Terminate toggle (only visible in Full Account mode)
print qq{<div id="terminate-panel" class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-52 font-medium text-sm whitespace-nowrap">Terminate First <span class="tooltip tooltip-top" data-tip="Remove the existing cPanel account before restoring. Results in a clean restore but causes brief downtime.">&#9432;</span></label>\n};
print qq{ <input type="checkbox" class="toggle toggle-sm toggle-error" id="terminate" name="terminate" value="1">\n};
print qq{</div>\n};
# Exclude paths (visible in both Full Account and Selective modes)
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 that always carries account type when Full is selected
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'],
['cpconfig', 'Config'],
['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-44 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" class="max-h-[360px]">\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 &amp; Confirm</button>\n};
print qq{ <button type="button" class="btn btn-info btn-sm" onclick="location.href='restore.cgi'">Back</button>\n};
print qq{</div>\n};
} else {
print qq{<button type="button" class="btn btn-info btn-sm" onclick="location.href='restore.cgi'">Back</button>\n};
}
print qq{</form>\n};
# JavaScript for dynamic dropdowns
print <<JS;
<script>
var gnizaCache = {};
var gnizaRemote = '$esc_remote';
var gnizaAccount = '$esc_account';
var fbCache = {};
var fbSelected = '';
function gnizaSnapshotChange() {
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('terminate-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.innerHTML = '<span class="text-sm text-base-content/60"><span class="loading loading-spinner loading-xs"></span> Loading...</span>';
document.getElementById(hiddenId).value = '';
var url = 'restore.cgi?restore_step=fetch_options'
+ '&remote=' + encodeURIComponent(gnizaRemote)
+ '&account=' + encodeURIComponent(gnizaAccount)
+ '&timestamp=' + 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.innerHTML = '<span class="text-sm text-error">Error: ' + data.error + '</span>';
} else {
gnizaCache[cacheKey] = data.options;
gnizaPopulateChecklist(containerId, hiddenId, data.options);
}
} catch(e) {
container.innerHTML = '<span class="text-sm text-error">Failed to parse response</span>';
}
} else {
container.innerHTML = '<span class="text-sm text-error">Request failed</span>';
}
};
xhr.send();
}
function gnizaPopulateChecklist(containerId, hiddenId, options) {
var container = document.getElementById(containerId);
var hidden = document.getElementById(hiddenId);
hidden.value = '';
if (!options || options.length === 0) {
container.innerHTML = '<span class="text-sm text-base-content/60">(none found)</span>';
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';
var html = '<label class="flex items-center gap-2 cursor-pointer">'
+ '<input type="checkbox" class="checkbox checkbox-sm" onchange="gnizaToggleAll(\\'' + containerId + '\\',\\'' + hiddenId + '\\',this.checked)" data-all="1">'
+ '<span class="text-sm font-semibold">' + allLabel + '</span></label>';
for (var i = 0; i < options.length; i++) {
var v = options[i].replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
html += '<label class="flex items-center gap-2 cursor-pointer">'
+ '<input type="checkbox" class="checkbox checkbox-sm" value="' + v + '" onchange="gnizaSyncHidden(\\'' + containerId + '\\',\\'' + hiddenId + '\\')" data-item="1">'
+ '<span class="text-sm">' + v + '</span></label>';
}
container.innerHTML = html;
}
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.innerHTML = '<span class="text-sm text-base-content/60"><span class="loading loading-spinner loading-xs"></span> Loading...</span>';
var url = 'restore.cgi?restore_step=fetch_options'
+ '&remote=' + encodeURIComponent(gnizaRemote)
+ '&account=' + encodeURIComponent(gnizaAccount)
+ '&timestamp=' + 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.innerHTML = '<span class="text-sm text-error">Error: ' + data.error + '</span>';
} else {
gnizaCache[cacheKey] = data.options;
gnizaPopulatePreview(containerId, data.options, type);
}
} catch(e) {
container.innerHTML = '<span class="text-sm text-error">Failed to parse response</span>';
}
} else {
container.innerHTML = '<span class="text-sm text-error">Request failed</span>';
}
};
xhr.send();
}
function gnizaPopulatePreview(containerId, options, type) {
var container = document.getElementById(containerId);
if (!options || options.length === 0) {
container.innerHTML = '<span class="text-sm text-base-content/60">(none found)</span>';
return;
}
if (type === 'cron') {
var html = '<pre class="text-xs font-mono bg-base-200 p-3 rounded-lg overflow-x-auto">';
for (var i = 0; i < options.length; i++) {
html += options[i].replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '\\n';
}
html += '</pre>';
container.innerHTML = html;
} else {
var html = '<ul class="list-disc pl-5 text-sm">';
for (var i = 0; i < options.length; i++) {
html += '<li>' + options[i].replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</li>';
}
html += '</ul>';
container.innerHTML = html;
}
}
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');
// Skip duplicates
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.innerHTML = '\\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();
}
gnizaModeChanged();
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').innerHTML = '';
var url = 'restore.cgi?restore_step=fetch_options'
+ '&remote=' + encodeURIComponent(gnizaRemote)
+ '&account=' + encodeURIComponent(gnizaAccount)
+ '&timestamp=' + 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.innerHTML = '';
bc.appendChild(ul);
}
function gnizaRenderFileList(currentPath, entries) {
gnizaRenderBreadcrumbs(currentPath);
fbSelected = '';
document.getElementById('fb-select-btn').disabled = true;
var tbody = document.getElementById('fb-tbody');
tbody.innerHTML = '';
if (!entries || entries.length === 0) {
tbody.innerHTML = '<tr><td class="text-center text-base-content/60 py-4">(empty directory)</td></tr>';
return;
}
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
var isDir = entry.endsWith('/');
var displayName = entry;
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 + displayName;
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();
}
</script>
JS
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Step 3: Summary + Confirm ────────────────────────────────
sub handle_step3 {
my $remote = $form->{'remote'} // '';
my $account = $form->{'account'} // '';
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'} // '';
my $terminate = $form->{'terminate'} // '';
# Collect selected types from type_* checkboxes
my @all_type_keys = qw(account files database mailbox cron dbusers cpconfig domains ssl);
my @selected_types;
for my $t (@all_type_keys) {
push @selected_types, $t if ($form->{"type_$t"} // '') eq '1';
}
unless (@selected_types) {
GnizaWHM::UI::set_flash('error', 'Please select at least one restore type.');
print "Status: 302 Found\r\n";
print "Location: restore.cgi?restore_step=2&remote=" . _uri_escape($remote) . "&account=" . _uri_escape($account) . "\r\n\r\n";
exit;
}
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('GNIZA Backup Manager — Restore', '', '/cgi/gniza-whm/restore.cgi');
print GnizaWHM::UI::page_header('Restore from Backup');
print GnizaWHM::UI::render_nav('restore.cgi');
my $esc_remote = GnizaWHM::UI::esc($remote);
my $esc_account = GnizaWHM::UI::esc($account);
my $esc_timestamp = GnizaWHM::UI::esc($timestamp);
my $types_display = join(', ', map { GnizaWHM::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-44">Remote</td><td>$esc_remote</td></tr>\n};
print qq{<tr><td class="font-medium">Account</td><td>$esc_account</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 '' ? GnizaWHM::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' : GnizaWHM::UI::esc($dbnames);
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' : GnizaWHM::UI::esc($dbuser_names);
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' : GnizaWHM::UI::esc($emails);
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' : GnizaWHM::UI::esc($domain_names);
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' : GnizaWHM::UI::esc($ssl_names);
print qq{<tr><td class="font-medium">SSL</td><td>$ssl_display</td></tr>\n};
}
if ($terminate eq '1' && grep { $_ eq 'account' } @selected_types) {
print qq{<tr><td class="font-medium">Terminate First</td><td class="text-error font-medium">Yes — account will be removed before restore</td></tr>\n};
}
if ($exclude_paths ne '') {
my $exclude_display = GnizaWHM::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.cgi">\n};
print qq{<input type="hidden" name="restore_step" value="4">\n};
print qq{<input type="hidden" name="remote" value="$esc_remote">\n};
print qq{<input type="hidden" name="account" value="$esc_account">\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="} . GnizaWHM::UI::esc($path) . qq{">\n};
print qq{<input type="hidden" name="dbnames" value="} . GnizaWHM::UI::esc($dbnames) . qq{">\n};
print qq{<input type="hidden" name="dbuser_names" value="} . GnizaWHM::UI::esc($dbuser_names) . qq{">\n};
print qq{<input type="hidden" name="emails" value="} . GnizaWHM::UI::esc($emails) . qq{">\n};
print qq{<input type="hidden" name="domain_names" value="} . GnizaWHM::UI::esc($domain_names) . qq{">\n};
print qq{<input type="hidden" name="ssl_names" value="} . GnizaWHM::UI::esc($ssl_names) . qq{">\n};
print qq{<input type="hidden" name="exclude_paths" value="} . GnizaWHM::UI::esc($exclude_paths) . qq{">\n};
print qq{<input type="hidden" name="terminate" value="} . GnizaWHM::UI::esc($terminate) . qq{">\n};
print GnizaWHM::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 you want to restore? This may overwrite existing data.')">Execute Restore</button>\n};
print qq{ <button type="button" class="btn btn-info btn-sm" onclick="location.href='restore.cgi'">Cancel</button>\n};
print qq{</div>\n};
print qq{</form>\n};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Step 4: Execute + Show Output ────────────────────────────
sub handle_step4 {
unless ($method eq 'POST' && GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) {
GnizaWHM::UI::set_flash('error', 'Invalid or expired form token.');
print "Status: 302 Found\r\n";
print "Location: restore.cgi\r\n\r\n";
exit;
}
my $remote = $form->{'remote'} // '';
my $account = $form->{'account'} // '';
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'} // '';
my $terminate = $form->{'terminate'} // '';
# Collect selected types
my @all_type_keys = qw(account files database mailbox cron dbusers cpconfig domains ssl);
my @selected_types;
for my $t (@all_type_keys) {
push @selected_types, $t if ($form->{"type_$t"} // '') eq '1';
}
unless (@selected_types) {
GnizaWHM::UI::set_flash('error', 'No restore types selected.');
print "Status: 302 Found\r\n";
print "Location: restore.cgi\r\n\r\n";
exit;
}
# Build commands for async execution
my @commands;
for my $type (@selected_types) {
my %opts = (remote => $remote);
$opts{timestamp} = $timestamp if $timestamp ne '';
if ($SIMPLE_TYPES{$type}) {
if ($type eq 'account') {
$opts{exclude} = $exclude_paths if $exclude_paths ne '';
$opts{terminate} = '1' if $terminate eq '1';
}
push @commands, ['restore', $type, [$account], {%opts}];
} elsif ($type eq 'files') {
$opts{path} = $path if $path ne '';
$opts{exclude} = $exclude_paths if $exclude_paths ne '';
push @commands, ['restore', 'files', [$account], {%opts}];
} elsif ($type eq 'database') {
my @dbs;
if ($dbnames ne '' && $dbnames ne '__ALL__') {
@dbs = split /,/, $dbnames;
}
if (@dbs) {
for my $db (@dbs) {
push @commands, ['restore', 'database', [$account, $db], {%opts}];
}
} else {
push @commands, ['restore', 'database', [$account], {%opts}];
}
} elsif ($type eq 'dbusers') {
my @dbus;
if ($dbuser_names ne '' && $dbuser_names ne '__ALL__') {
@dbus = split /,/, $dbuser_names;
}
if (@dbus) {
for my $dbu (@dbus) {
push @commands, ['restore', 'dbusers', [$account, $dbu], {%opts}];
}
} else {
push @commands, ['restore', 'dbusers', [$account], {%opts}];
}
} elsif ($type eq 'mailbox') {
my @mbs;
if ($emails ne '' && $emails ne '__ALL__') {
@mbs = split /,/, $emails;
}
if (@mbs) {
for my $mb (@mbs) {
push @commands, ['restore', 'mailbox', [$account, $mb], {%opts}];
}
} else {
push @commands, ['restore', 'mailbox', [$account], {%opts}];
}
} elsif ($type eq 'domains') {
my @doms;
if ($domain_names ne '' && $domain_names ne '__ALL__') {
@doms = split /,/, $domain_names;
}
if (@doms) {
for my $dom (@doms) {
push @commands, ['restore', 'domains', [$account, $dom], {%opts}];
}
} else {
push @commands, ['restore', 'domains', [$account], {%opts}];
}
} elsif ($type eq 'ssl') {
my @certs;
if ($ssl_names ne '' && $ssl_names ne '__ALL__') {
@certs = split /,/, $ssl_names;
}
if (@certs) {
for my $cert (@certs) {
push @commands, ['restore', 'ssl', [$account, $cert], {%opts}];
}
} else {
push @commands, ['restore', 'ssl', [$account], {%opts}];
}
}
}
my ($ok, $err) = GnizaWHM::Runner::run_async(\@commands);
if ($ok) {
GnizaWHM::UI::set_flash('success', 'Restore started in background. Watch progress below.');
print "Status: 302 Found\r\n";
print "Location: logs.cgi\r\n\r\n";
} else {
GnizaWHM::UI::set_flash('error', "Failed to start restore: $err");
print "Status: 302 Found\r\n";
print "Location: restore.cgi\r\n\r\n";
}
}