Files
gniza4cp/whm/gniza-whm/restore.cgi
shuki 4759bb84b2 Fix join button alignment with m-0 on radio inputs
WHM CSS adds margin to checked radio inputs, causing the active
button in join groups to shift down. Added m-0 class to all
join-item radio buttons to override.

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

1018 lines
43 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 '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]});
}
# ── 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">\n};
print qq{<input type="hidden" name="restore_step" value="2">\n};
print qq{<div class="card bg-base-100 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>\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 input
my @accounts = GnizaWHM::UI::get_cpanel_accounts();
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};
if (@accounts) {
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="account" name="account" required>\n};
print qq{ <option value="">-- Select account --</option>\n};
for my $a (@accounts) {
my $esc = GnizaWHM::UI::esc($a);
print qq{ <option value="$esc">$esc</option>\n};
}
print qq{ </select>\n};
} else {
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="account" name="account" required placeholder="Username">\n};
}
print qq{</div>\n};
print qq{</div>\n</div>\n};
print qq{<div class="flex gap-2">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm">Load Snapshots</button>\n};
print qq{</div>\n};
print qq{</form>\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{<a href="restore.cgi" class="btn btn-ghost btn-sm">Back</a>\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-base-100 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 (reverse @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">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};
# 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-base-100 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 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-base-100 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-base-100 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-base-100 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-base-100 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-base-100 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-base-100 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-ghost 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 gap-2">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm">Review &amp; Confirm</button>\n};
print qq{ <a href="restore.cgi" class="btn btn-ghost btn-sm">Back</a>\n};
print qq{</div>\n};
} else {
print qq{<a href="restore.cgi" class="btn btn-ghost btn-sm">Back</a>\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('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;
}
}
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'} // '';
# 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-base-100 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};
}
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 GnizaWHM::UI::csrf_hidden_field();
print qq{<div class="flex 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{ <a href="restore.cgi" class="btn btn-ghost btn-sm">Cancel</a>\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'} // '';
# 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;
}
# Execute each type sequentially
my @results;
for my $type (@selected_types) {
my %opts = (remote => $remote);
$opts{timestamp} = $timestamp if $timestamp ne '';
if ($SIMPLE_TYPES{$type}) {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', $type, [$account], \%opts);
push @results, { type => $type, label => $TYPE_LABELS{$type} // $type, ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
} elsif ($type eq 'files') {
$opts{path} = $path if $path ne '';
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'files', [$account], \%opts);
push @results, { type => $type, label => $TYPE_LABELS{$type} // $type, ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
} elsif ($type eq 'database') {
my @dbs;
if ($dbnames ne '' && $dbnames ne '__ALL__') {
@dbs = split /,/, $dbnames;
}
if (@dbs) {
for my $db (@dbs) {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'database', [$account, $db], \%opts);
push @results, { type => $type, label => "Database: $db", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
}
} else {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'database', [$account], \%opts);
push @results, { type => $type, label => 'Database: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
}
} elsif ($type eq 'dbusers') {
my @dbus;
if ($dbuser_names ne '' && $dbuser_names ne '__ALL__') {
@dbus = split /,/, $dbuser_names;
}
if (@dbus) {
for my $dbu (@dbus) {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'dbusers', [$account, $dbu], \%opts);
push @results, { type => $type, label => "DB User: $dbu", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
}
} else {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'dbusers', [$account], \%opts);
push @results, { type => $type, label => 'Database Users: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
}
} elsif ($type eq 'mailbox') {
my @mbs;
if ($emails ne '' && $emails ne '__ALL__') {
@mbs = split /,/, $emails;
}
if (@mbs) {
for my $mb (@mbs) {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'mailbox', [$account, $mb], \%opts);
push @results, { type => $type, label => "Mailbox: $mb", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
}
} else {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'mailbox', [$account], \%opts);
push @results, { type => $type, label => 'Mailbox: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
}
} elsif ($type eq 'domains') {
my @doms;
if ($domain_names ne '' && $domain_names ne '__ALL__') {
@doms = split /,/, $domain_names;
}
if (@doms) {
for my $dom (@doms) {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'domains', [$account, $dom], \%opts);
push @results, { type => $type, label => "Domain: $dom", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
}
} else {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'domains', [$account], \%opts);
push @results, { type => $type, label => 'Domains: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
}
} elsif ($type eq 'ssl') {
my @certs;
if ($ssl_names ne '' && $ssl_names ne '__ALL__') {
@certs = split /,/, $ssl_names;
}
if (@certs) {
for my $cert (@certs) {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'ssl', [$account, $cert], \%opts);
push @results, { type => $type, label => "SSL: $cert", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
}
} else {
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'ssl', [$account], \%opts);
push @results, { type => $type, label => 'SSL: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' };
}
} else {
push @results, { type => $type, label => $TYPE_LABELS{$type} // $type, ok => 0, stdout => '', stderr => 'Invalid restore type' };
}
}
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');
# Overall status
my $all_ok = !grep { !$_->{ok} } @results;
my $any_ok = grep { $_->{ok} } @results;
if ($all_ok) {
print qq{<div class="alert alert-success mb-4">All restore operations completed successfully.</div>\n};
} elsif ($any_ok) {
print qq{<div class="alert alert-warning mb-4">Some restore operations failed. See details below.</div>\n};
} else {
print qq{<div class="alert alert-error mb-4">All restore operations failed.</div>\n};
}
# Per-type output blocks
for my $r (@results) {
my $esc_label = GnizaWHM::UI::esc($r->{label});
my $badge = $r->{ok}
? '<span class="badge badge-success badge-sm">OK</span>'
: '<span class="badge badge-error badge-sm">Failed</span>';
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-4">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">$esc_label $badge</h2>\n};
my $output = $r->{stdout} . ($r->{stderr} =~ /\S/ ? "\n$r->{stderr}" : '');
$output = '(no output)' unless $output =~ /\S/;
print qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto whitespace-pre-wrap">} . GnizaWHM::UI::esc($output) . qq{</pre>\n};
print qq{</div>\n</div>\n};
}
print qq{<a href="restore.cgi" class="btn btn-ghost btn-sm">Start New Restore</a>\n};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}