cPanel's live engine requires .live.cgi files to create a Cpanel::LiveAPI connection. Without it, the engine cannot establish communication with the CGI subprocess. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
853 lines
37 KiB
Perl
853 lines
37 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'} // '1';
|
|
|
|
my %TYPE_LABELS = (
|
|
account => 'Full Backup',
|
|
files => 'Home Directory',
|
|
database => 'Databases',
|
|
mailbox => 'Email Accounts',
|
|
cron => 'Cron Jobs',
|
|
dbusers => 'Database Users',
|
|
domains => 'Domains',
|
|
ssl => 'SSL Certificates',
|
|
);
|
|
|
|
my %SIMPLE_TYPES = map { $_ => 1 } qw(account cron);
|
|
|
|
# JSON endpoints
|
|
if ($step eq 'fetch_snapshots') { handle_fetch_snapshots() }
|
|
elsif ($step eq 'fetch_options') { handle_fetch_options() }
|
|
elsif ($step eq '2') { handle_step2() }
|
|
elsif ($step eq '3') { handle_step3() }
|
|
elsif ($step eq '4') { handle_step4() }
|
|
else { handle_step1() }
|
|
|
|
exit;
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────
|
|
|
|
sub _json_escape {
|
|
my ($str) = @_;
|
|
$str //= '';
|
|
$str =~ s/\\/\\\\/g;
|
|
$str =~ s/"/\\"/g;
|
|
$str =~ s/\n/\\n/g;
|
|
$str =~ s/\r/\\r/g;
|
|
$str =~ s/\t/\\t/g;
|
|
$str =~ s/[\x00-\x1f]//g;
|
|
return $str;
|
|
}
|
|
|
|
sub _adminbin_call {
|
|
my ($action, @args) = @_;
|
|
my $result = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', $action, @args) };
|
|
if ($@) {
|
|
return (0, '', "AdminBin call failed: $@");
|
|
}
|
|
$result //= '';
|
|
if ($result =~ /^ERROR:\s*(.*)/) {
|
|
return (0, '', $1);
|
|
}
|
|
return (1, $result, '');
|
|
}
|
|
|
|
# ── JSON: fetch snapshots ─────────────────────────────────────
|
|
|
|
sub handle_fetch_snapshots {
|
|
my $remote = $form->{'remote'} // '';
|
|
|
|
print "Content-Type: application/json\r\n\r\n";
|
|
|
|
if ($remote eq '') {
|
|
print qq({"error":"Remote is required"});
|
|
return;
|
|
}
|
|
|
|
my ($ok, $stdout, $err) = _adminbin_call('LIST_SNAPSHOTS', $remote);
|
|
|
|
unless ($ok) {
|
|
my $msg = _json_escape($err || 'Failed to list snapshots');
|
|
print qq({"error":"$msg"});
|
|
return;
|
|
}
|
|
|
|
my @snapshots;
|
|
for my $line (split /\n/, $stdout) {
|
|
if ($line =~ /^\s+(\d{4}-\d{2}-\d{2}T\d{6})/) {
|
|
push @snapshots, $1;
|
|
}
|
|
}
|
|
|
|
my $json_arr = join(',', map { qq("$_") } reverse sort @snapshots);
|
|
print qq({"snapshots":[$json_arr]});
|
|
}
|
|
|
|
# ── JSON: fetch item options ──────────────────────────────────
|
|
|
|
sub handle_fetch_options {
|
|
my $remote = $form->{'remote'} // '';
|
|
my $timestamp = $form->{'timestamp'} // '';
|
|
my $type = $form->{'type'} // '';
|
|
|
|
print "Content-Type: application/json\r\n\r\n";
|
|
|
|
if ($remote eq '' || $timestamp eq '' || $type eq '') {
|
|
print qq({"error":"Missing required parameters"});
|
|
return;
|
|
}
|
|
|
|
my %action_map = (
|
|
database => 'LIST_DATABASES',
|
|
mailbox => 'LIST_MAILBOXES',
|
|
files => 'LIST_FILES',
|
|
dbusers => 'LIST_DBUSERS',
|
|
cron => 'LIST_CRON',
|
|
domains => 'LIST_DNS',
|
|
ssl => 'LIST_SSL',
|
|
);
|
|
|
|
my $action = $action_map{$type};
|
|
unless ($action) {
|
|
print qq({"error":"Invalid type"});
|
|
return;
|
|
}
|
|
|
|
my @call_args = ($remote, $timestamp);
|
|
if ($type eq 'files') {
|
|
my $path = $form->{'path'} // '';
|
|
push @call_args, $path if $path ne '';
|
|
}
|
|
|
|
my ($ok, $stdout, $err) = _adminbin_call($action, @call_args);
|
|
|
|
unless ($ok) {
|
|
my $msg = _json_escape($err || 'Failed to list options');
|
|
print qq({"error":"$msg"});
|
|
return;
|
|
}
|
|
|
|
my @options;
|
|
for my $line (split /\n/, $stdout) {
|
|
$line =~ s/^\s+|\s+$//g;
|
|
next unless $line ne '';
|
|
push @options, $line;
|
|
}
|
|
|
|
my $json_arr = join(',', map { my $v = $_; $v =~ s/\\/\\\\/g; $v =~ s/"/\\"/g; qq("$v") } @options);
|
|
print qq({"options":[$json_arr]});
|
|
}
|
|
|
|
# ── Step 1: Select Remote + Snapshot ──────────────────────────
|
|
|
|
sub handle_step1 {
|
|
my $type = $form->{'type'} // 'account';
|
|
unless (exists $TYPE_LABELS{$type}) {
|
|
$type = 'account';
|
|
}
|
|
|
|
print "Content-Type: text/html\r\n\r\n";
|
|
print GnizaCPanel::UI::page_header('Restore: ' . ($TYPE_LABELS{$type} // $type));
|
|
print GnizaCPanel::UI::render_flash();
|
|
|
|
# Get allowed remotes
|
|
my $remotes_raw = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'LIST_ALLOWED_REMOTES') } // '';
|
|
my @remotes = grep { $_ ne '' } split /\n/, $remotes_raw;
|
|
|
|
unless (@remotes) {
|
|
print qq{<div class="alert alert-info mb-4">No backup remotes are available. Please contact your server administrator.</div>\n};
|
|
print qq{<a href="index.live.cgi" class="btn btn-info btn-sm">Back</a>\n};
|
|
print GnizaCPanel::UI::page_footer();
|
|
return;
|
|
}
|
|
|
|
my $esc_type = GnizaCPanel::UI::esc($type);
|
|
my $type_label = GnizaCPanel::UI::esc($TYPE_LABELS{$type} // $type);
|
|
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
|
|
print qq{<h2 class="card-title text-sm">Step 1: Select Source</h2>\n};
|
|
print qq{<p class="text-sm mb-3">Restore type: <strong>$type_label</strong></p>\n};
|
|
|
|
# Remote dropdown
|
|
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
|
|
print qq{ <label class="w-36 font-medium text-sm" for="remote">Remote</label>\n};
|
|
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="remote" name="remote" onchange="gnizaLoadSnapshots()">\n};
|
|
print qq{ <option value="">-- Select remote --</option>\n};
|
|
for my $r (@remotes) {
|
|
my $esc = GnizaCPanel::UI::esc($r);
|
|
print qq{ <option value="$esc">$esc</option>\n};
|
|
}
|
|
print qq{ </select>\n};
|
|
print qq{</div>\n};
|
|
|
|
# Snapshot dropdown (populated via AJAX)
|
|
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
|
|
print qq{ <label class="w-36 font-medium text-sm" for="timestamp">Snapshot</label>\n};
|
|
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="timestamp" name="timestamp" disabled>\n};
|
|
print qq{ <option value="">-- Select remote first --</option>\n};
|
|
print qq{ </select>\n};
|
|
print qq{</div>\n};
|
|
|
|
print qq{</div>\n</div>\n};
|
|
|
|
# For simple types, go directly to step 3 (confirm); others go to step 2
|
|
my $next_step = $SIMPLE_TYPES{$type} ? '3' : '2';
|
|
|
|
print qq{<div class="flex items-center gap-2">\n};
|
|
print qq{ <button type="button" class="btn btn-primary btn-sm" id="next-btn" disabled onclick="gnizaGoNext()">Next</button>\n};
|
|
print qq{ <a href="index.live.cgi" class="btn btn-info btn-sm">Back</a>\n};
|
|
print qq{</div>\n};
|
|
|
|
# JavaScript for snapshot loading and navigation
|
|
_print_step1_js($esc_type, $next_step);
|
|
|
|
print GnizaCPanel::UI::page_footer();
|
|
}
|
|
|
|
sub _print_step1_js {
|
|
my ($esc_type, $next_step) = @_;
|
|
print qq{<script>\n};
|
|
print qq{var gnizaType = '$esc_type';\n};
|
|
print qq{var gnizaNextStep = '$next_step';\n};
|
|
print qq{\n};
|
|
print qq{function gnizaLoadSnapshots() {\n};
|
|
print qq{ var remote = document.getElementById('remote').value;\n};
|
|
print qq{ var sel = document.getElementById('timestamp');\n};
|
|
print qq{ var btn = document.getElementById('next-btn');\n};
|
|
print qq{\n};
|
|
print qq{ if (!remote) {\n};
|
|
print qq{ _setSelectPlaceholder(sel, '-- Select remote first --');\n};
|
|
print qq{ sel.disabled = true;\n};
|
|
print qq{ btn.disabled = true;\n};
|
|
print qq{ return;\n};
|
|
print qq{ }\n};
|
|
print qq{\n};
|
|
print qq{ _setSelectPlaceholder(sel, 'Loading...');\n};
|
|
print qq{ sel.disabled = true;\n};
|
|
print qq{ btn.disabled = true;\n};
|
|
print qq{\n};
|
|
print qq{ var url = 'restore.live.cgi?step=fetch_snapshots&remote=' + encodeURIComponent(remote);\n};
|
|
print qq{ var xhr = new XMLHttpRequest();\n};
|
|
print qq{ xhr.open('GET', url, true);\n};
|
|
print qq{ xhr.onreadystatechange = function() {\n};
|
|
print qq{ if (xhr.readyState !== 4) return;\n};
|
|
print qq{ if (xhr.status === 200) {\n};
|
|
print qq{ try {\n};
|
|
print qq{ var data = JSON.parse(xhr.responseText);\n};
|
|
print qq{ if (data.error) {\n};
|
|
print qq{ _setSelectPlaceholder(sel, 'Error: ' + data.error);\n};
|
|
print qq{ } else if (data.snapshots && data.snapshots.length > 0) {\n};
|
|
print qq{ _populateSelect(sel, data.snapshots);\n};
|
|
print qq{ sel.disabled = false;\n};
|
|
print qq{ btn.disabled = false;\n};
|
|
print qq{ } else {\n};
|
|
print qq{ _setSelectPlaceholder(sel, 'No snapshots found');\n};
|
|
print qq{ }\n};
|
|
print qq{ } catch(e) {\n};
|
|
print qq{ _setSelectPlaceholder(sel, 'Failed to parse response');\n};
|
|
print qq{ }\n};
|
|
print qq{ } else {\n};
|
|
print qq{ _setSelectPlaceholder(sel, 'Request failed');\n};
|
|
print qq{ }\n};
|
|
print qq{ };\n};
|
|
print qq{ xhr.send();\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function _setSelectPlaceholder(sel, text) {\n};
|
|
print qq{ while (sel.options.length) sel.remove(0);\n};
|
|
print qq{ var opt = document.createElement('option');\n};
|
|
print qq{ opt.value = '';\n};
|
|
print qq{ opt.textContent = text;\n};
|
|
print qq{ sel.appendChild(opt);\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function _populateSelect(sel, values) {\n};
|
|
print qq{ while (sel.options.length) sel.remove(0);\n};
|
|
print qq{ for (var i = 0; i < values.length; i++) {\n};
|
|
print qq{ var opt = document.createElement('option');\n};
|
|
print qq{ opt.value = values[i];\n};
|
|
print qq{ opt.textContent = values[i];\n};
|
|
print qq{ sel.appendChild(opt);\n};
|
|
print qq{ }\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function gnizaGoNext() {\n};
|
|
print qq{ var remote = document.getElementById('remote').value;\n};
|
|
print qq{ var timestamp = document.getElementById('timestamp').value;\n};
|
|
print qq{ if (!remote || !timestamp) return;\n};
|
|
print qq{\n};
|
|
print qq{ var url = 'restore.live.cgi?step=' + gnizaNextStep\n};
|
|
print qq{ + '&type=' + encodeURIComponent(gnizaType)\n};
|
|
print qq{ + '&remote=' + encodeURIComponent(remote)\n};
|
|
print qq{ + '×tamp=' + encodeURIComponent(timestamp);\n};
|
|
print qq{ window.location.href = url;\n};
|
|
print qq{}\n};
|
|
print qq{</script>\n};
|
|
}
|
|
|
|
# ── Step 2: Select specific item ─────────────────────────────
|
|
|
|
sub handle_step2 {
|
|
my $type = $form->{'type'} // 'account';
|
|
my $remote = $form->{'remote'} // '';
|
|
my $timestamp = $form->{'timestamp'} // '';
|
|
|
|
$type = 'account' unless exists $TYPE_LABELS{$type};
|
|
|
|
if ($remote eq '' || $timestamp eq '') {
|
|
GnizaCPanel::UI::set_flash('error', 'Remote and snapshot are required.');
|
|
print "Status: 302 Found\r\n";
|
|
print "Location: restore.live.cgi?type=$type\r\n\r\n";
|
|
exit;
|
|
}
|
|
|
|
print "Content-Type: text/html\r\n\r\n";
|
|
print GnizaCPanel::UI::page_header('Restore: ' . ($TYPE_LABELS{$type} // $type));
|
|
print GnizaCPanel::UI::render_flash();
|
|
|
|
my $esc_type = GnizaCPanel::UI::esc($type);
|
|
my $esc_remote = GnizaCPanel::UI::esc($remote);
|
|
my $esc_timestamp = GnizaCPanel::UI::esc($timestamp);
|
|
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
|
|
print qq{<h2 class="card-title text-sm">Step 2: Select Items</h2>\n};
|
|
print qq{<p class="text-sm mb-3">Remote: <strong>$esc_remote</strong> · Snapshot: <strong>$esc_timestamp</strong></p>\n};
|
|
|
|
if ($type eq 'files') {
|
|
_render_file_picker();
|
|
}
|
|
elsif ($type eq 'database' || $type eq 'dbusers' || $type eq 'mailbox' || $type eq 'domains' || $type eq 'ssl') {
|
|
my $item_label = {
|
|
database => 'Databases',
|
|
dbusers => 'Database Users',
|
|
mailbox => 'Mailboxes',
|
|
domains => 'Domains',
|
|
ssl => 'SSL Certificates',
|
|
}->{$type};
|
|
|
|
print qq{<h3 class="font-medium text-sm mb-2">$item_label</h3>\n};
|
|
print qq{<input type="hidden" id="selected_items" name="selected_items" value="">\n};
|
|
print qq{<div id="item-list" class="flex flex-col gap-1 max-h-64 overflow-y-auto">\n};
|
|
print qq{ <span class="text-sm text-base-content/60"><span class="loading loading-spinner loading-xs"></span> Loading...</span>\n};
|
|
print qq{</div>\n};
|
|
}
|
|
elsif ($type eq 'cron') {
|
|
print qq{<h3 class="font-medium text-sm mb-2">Cron Jobs Preview</h3>\n};
|
|
print qq{<div id="item-list" class="max-h-64 overflow-y-auto">\n};
|
|
print qq{ <span class="text-sm text-base-content/60"><span class="loading loading-spinner loading-xs"></span> Loading...</span>\n};
|
|
print qq{</div>\n};
|
|
}
|
|
|
|
print qq{</div>\n</div>\n};
|
|
|
|
print qq{<div class="flex items-center gap-2">\n};
|
|
print qq{ <button type="button" class="btn btn-primary btn-sm" id="confirm-btn" onclick="gnizaGoConfirm()">Review & Confirm</button>\n};
|
|
print qq{ <a href="restore.live.cgi?type=$esc_type" class="btn btn-info btn-sm">Back</a>\n};
|
|
print qq{</div>\n};
|
|
|
|
_print_step2_js($esc_type, $esc_remote, $esc_timestamp);
|
|
|
|
print GnizaCPanel::UI::page_footer();
|
|
}
|
|
|
|
sub _render_file_picker {
|
|
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
|
|
print qq{ <label class="w-36 font-medium text-sm" for="path">Path</label>\n};
|
|
print qq{ <div class="flex items-center gap-2 w-full max-w-xs">\n};
|
|
print qq{ <input type="text" class="input input-bordered input-sm flex-1" id="path" name="path" placeholder="e.g. public_html/index.html">\n};
|
|
print qq{ <button type="button" class="btn btn-secondary btn-sm" onclick="gnizaOpenBrowser()">Browse</button>\n};
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
print qq{<p class="text-xs text-base-content/60">Leave empty to restore all files.</p>\n};
|
|
|
|
# File browser modal
|
|
print qq{<dialog id="fb-modal" class="modal">\n};
|
|
print qq{<div class="modal-box w-11/12 max-w-2xl">\n};
|
|
print qq{ <h3 class="text-lg font-bold mb-3">Browse Files</h3>\n};
|
|
print qq{ <div id="fb-breadcrumbs" class="breadcrumbs text-sm mb-3"><ul><li>homedir</li></ul></div>\n};
|
|
print qq{ <div id="fb-loading" class="text-center py-4" hidden><span class="loading loading-spinner"></span> Loading...</div>\n};
|
|
print qq{ <div id="fb-error" class="alert alert-error mb-3" hidden></div>\n};
|
|
print qq{ <div id="fb-list" class="overflow-y-auto max-h-96">\n};
|
|
print qq{ <table class="table table-zebra w-full"><tbody id="fb-tbody"></tbody></table>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ <div class="modal-action">\n};
|
|
print qq{ <button type="button" id="fb-select-btn" class="btn btn-primary btn-sm" disabled onclick="gnizaSelectPath()">Select</button>\n};
|
|
print qq{ <button type="button" class="btn btn-info btn-sm" onclick="document.getElementById('fb-modal').close()">Cancel</button>\n};
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
print qq{<div class="modal-backdrop" onclick="this.closest('dialog').close()"><button type="button">close</button></div>\n};
|
|
print qq{</dialog>\n};
|
|
}
|
|
|
|
sub _print_step2_js {
|
|
my ($esc_type, $esc_remote, $esc_timestamp) = @_;
|
|
print qq{<script>\n};
|
|
print qq{var gnizaType = '$esc_type';\n};
|
|
print qq{var gnizaRemote = '$esc_remote';\n};
|
|
print qq{var gnizaTimestamp = '$esc_timestamp';\n};
|
|
print qq{var fbCache = {};\n};
|
|
print qq{var fbSelected = '';\n};
|
|
print qq{\n};
|
|
print qq{(function() {\n};
|
|
print qq{ var listTypes = ['database','dbusers','mailbox','domains','ssl','cron'];\n};
|
|
print qq{ if (listTypes.indexOf(gnizaType) >= 0) {\n};
|
|
print qq{ loadOptions();\n};
|
|
print qq{ }\n};
|
|
print qq{})();\n};
|
|
print qq{\n};
|
|
print qq{function loadOptions() {\n};
|
|
print qq{ var url = 'restore.live.cgi?step=fetch_options'\n};
|
|
print qq{ + '&remote=' + encodeURIComponent(gnizaRemote)\n};
|
|
print qq{ + '×tamp=' + encodeURIComponent(gnizaTimestamp)\n};
|
|
print qq{ + '&type=' + encodeURIComponent(gnizaType);\n};
|
|
print qq{\n};
|
|
print qq{ var xhr = new XMLHttpRequest();\n};
|
|
print qq{ xhr.open('GET', url, true);\n};
|
|
print qq{ xhr.onreadystatechange = function() {\n};
|
|
print qq{ if (xhr.readyState !== 4) return;\n};
|
|
print qq{ var container = document.getElementById('item-list');\n};
|
|
print qq{ if (xhr.status === 200) {\n};
|
|
print qq{ try {\n};
|
|
print qq{ var data = JSON.parse(xhr.responseText);\n};
|
|
print qq{ if (data.error) {\n};
|
|
print qq{ container.textContent = 'Error: ' + data.error;\n};
|
|
print qq{ } else if (gnizaType === 'cron') {\n};
|
|
print qq{ populatePreview(container, data.options);\n};
|
|
print qq{ } else {\n};
|
|
print qq{ populateChecklist(container, data.options);\n};
|
|
print qq{ }\n};
|
|
print qq{ } catch(e) {\n};
|
|
print qq{ container.textContent = 'Failed to parse response';\n};
|
|
print qq{ }\n};
|
|
print qq{ } else {\n};
|
|
print qq{ container.textContent = 'Request failed';\n};
|
|
print qq{ }\n};
|
|
print qq{ };\n};
|
|
print qq{ xhr.send();\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function populateChecklist(container, options) {\n};
|
|
print qq{ var hidden = document.getElementById('selected_items');\n};
|
|
print qq{ container.textContent = '';\n};
|
|
print qq{ if (!options || options.length === 0) {\n};
|
|
print qq{ container.textContent = '(none found)';\n};
|
|
print qq{ return;\n};
|
|
print qq{ }\n};
|
|
print qq{\n};
|
|
print qq{ var allLabels = {database:'All Databases',dbusers:'All Database Users',mailbox:'All Mailboxes',domains:'All Domains',ssl:'All Certificates'};\n};
|
|
print qq{ var allLabel = allLabels[gnizaType] || 'All';\n};
|
|
print qq{\n};
|
|
print qq{ var allRow = _makeCheckRow(allLabel, '', true);\n};
|
|
print qq{ allRow.querySelector('input').setAttribute('data-all', '1');\n};
|
|
print qq{ allRow.querySelector('input').onchange = function() { toggleAll(this.checked); };\n};
|
|
print qq{ allRow.querySelector('span').className = 'text-sm font-semibold';\n};
|
|
print qq{ container.appendChild(allRow);\n};
|
|
print qq{\n};
|
|
print qq{ for (var i = 0; i < options.length; i++) {\n};
|
|
print qq{ var row = _makeCheckRow(options[i], options[i], false);\n};
|
|
print qq{ row.querySelector('input').setAttribute('data-item', '1');\n};
|
|
print qq{ row.querySelector('input').onchange = function() { syncHidden(); };\n};
|
|
print qq{ container.appendChild(row);\n};
|
|
print qq{ }\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function _makeCheckRow(labelText, value, isAll) {\n};
|
|
print qq{ var label = document.createElement('label');\n};
|
|
print qq{ label.className = 'flex items-center gap-2 cursor-pointer';\n};
|
|
print qq{ var cb = document.createElement('input');\n};
|
|
print qq{ cb.type = 'checkbox';\n};
|
|
print qq{ cb.className = 'checkbox checkbox-sm';\n};
|
|
print qq{ if (value) cb.value = value;\n};
|
|
print qq{ var span = document.createElement('span');\n};
|
|
print qq{ span.className = 'text-sm';\n};
|
|
print qq{ span.textContent = labelText;\n};
|
|
print qq{ label.appendChild(cb);\n};
|
|
print qq{ label.appendChild(span);\n};
|
|
print qq{ return label;\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function toggleAll(checked) {\n};
|
|
print qq{ var container = document.getElementById('item-list');\n};
|
|
print qq{ var hidden = document.getElementById('selected_items');\n};
|
|
print qq{ var items = container.querySelectorAll('input[data-item]');\n};
|
|
print qq{ for (var i = 0; i < items.length; i++) {\n};
|
|
print qq{ items[i].disabled = checked;\n};
|
|
print qq{ if (checked) items[i].checked = false;\n};
|
|
print qq{ }\n};
|
|
print qq{ hidden.value = checked ? '__ALL__' : '';\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function syncHidden() {\n};
|
|
print qq{ var container = document.getElementById('item-list');\n};
|
|
print qq{ var hidden = document.getElementById('selected_items');\n};
|
|
print qq{ var items = container.querySelectorAll('input[data-item]:checked');\n};
|
|
print qq{ var vals = [];\n};
|
|
print qq{ for (var i = 0; i < items.length; i++) {\n};
|
|
print qq{ vals.push(items[i].value);\n};
|
|
print qq{ }\n};
|
|
print qq{ hidden.value = vals.join(',');\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function populatePreview(container, options) {\n};
|
|
print qq{ container.textContent = '';\n};
|
|
print qq{ if (!options || options.length === 0) {\n};
|
|
print qq{ container.textContent = '(none found)';\n};
|
|
print qq{ return;\n};
|
|
print qq{ }\n};
|
|
print qq{ var pre = document.createElement('pre');\n};
|
|
print qq{ pre.className = 'text-xs font-mono bg-base-200 p-3 rounded-lg overflow-x-auto';\n};
|
|
print qq{ pre.textContent = options.join('\\n');\n};
|
|
print qq{ container.appendChild(pre);\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function gnizaGoConfirm() {\n};
|
|
print qq{ var url = 'restore.live.cgi?step=3'\n};
|
|
print qq{ + '&type=' + encodeURIComponent(gnizaType)\n};
|
|
print qq{ + '&remote=' + encodeURIComponent(gnizaRemote)\n};
|
|
print qq{ + '×tamp=' + encodeURIComponent(gnizaTimestamp);\n};
|
|
print qq{\n};
|
|
print qq{ if (gnizaType === 'files') {\n};
|
|
print qq{ var path = document.getElementById('path') ? document.getElementById('path').value : '';\n};
|
|
print qq{ url += '&path=' + encodeURIComponent(path);\n};
|
|
print qq{ } else if (document.getElementById('selected_items')) {\n};
|
|
print qq{ url += '&items=' + encodeURIComponent(document.getElementById('selected_items').value);\n};
|
|
print qq{ }\n};
|
|
print qq{\n};
|
|
print qq{ window.location.href = url;\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function gnizaOpenBrowser() {\n};
|
|
print qq{ fbSelected = '';\n};
|
|
print qq{ document.getElementById('fb-select-btn').disabled = true;\n};
|
|
print qq{ document.getElementById('fb-modal').showModal();\n};
|
|
print qq{ gnizaLoadDir('');\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function gnizaLoadDir(path) {\n};
|
|
print qq{ var cacheKey = path;\n};
|
|
print qq{ if (fbCache[cacheKey]) {\n};
|
|
print qq{ gnizaRenderFileList(path, fbCache[cacheKey]);\n};
|
|
print qq{ return;\n};
|
|
print qq{ }\n};
|
|
print qq{\n};
|
|
print qq{ document.getElementById('fb-loading').hidden = false;\n};
|
|
print qq{ document.getElementById('fb-error').hidden = true;\n};
|
|
print qq{ document.getElementById('fb-tbody').textContent = '';\n};
|
|
print qq{\n};
|
|
print qq{ var url = 'restore.live.cgi?step=fetch_options'\n};
|
|
print qq{ + '&remote=' + encodeURIComponent(gnizaRemote)\n};
|
|
print qq{ + '×tamp=' + encodeURIComponent(gnizaTimestamp)\n};
|
|
print qq{ + '&type=files'\n};
|
|
print qq{ + (path ? '&path=' + encodeURIComponent(path) : '');\n};
|
|
print qq{\n};
|
|
print qq{ var xhr = new XMLHttpRequest();\n};
|
|
print qq{ xhr.open('GET', url, true);\n};
|
|
print qq{ xhr.onreadystatechange = function() {\n};
|
|
print qq{ if (xhr.readyState !== 4) return;\n};
|
|
print qq{ document.getElementById('fb-loading').hidden = true;\n};
|
|
print qq{ if (xhr.status === 200) {\n};
|
|
print qq{ try {\n};
|
|
print qq{ var data = JSON.parse(xhr.responseText);\n};
|
|
print qq{ if (data.error) {\n};
|
|
print qq{ document.getElementById('fb-error').textContent = data.error;\n};
|
|
print qq{ document.getElementById('fb-error').hidden = false;\n};
|
|
print qq{ } else {\n};
|
|
print qq{ fbCache[cacheKey] = data.options;\n};
|
|
print qq{ gnizaRenderFileList(path, data.options);\n};
|
|
print qq{ }\n};
|
|
print qq{ } catch(e) {\n};
|
|
print qq{ document.getElementById('fb-error').textContent = 'Failed to parse response';\n};
|
|
print qq{ document.getElementById('fb-error').hidden = false;\n};
|
|
print qq{ }\n};
|
|
print qq{ }\n};
|
|
print qq{ };\n};
|
|
print qq{ xhr.send();\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function gnizaRenderBreadcrumbs(path) {\n};
|
|
print qq{ var ul = document.createElement('ul');\n};
|
|
print qq{ var li = document.createElement('li');\n};
|
|
print qq{ var a = document.createElement('a');\n};
|
|
print qq{ a.textContent = 'homedir';\n};
|
|
print qq{ a.href = '#';\n};
|
|
print qq{ a.onclick = function(e) { e.preventDefault(); gnizaLoadDir(''); };\n};
|
|
print qq{ li.appendChild(a);\n};
|
|
print qq{ ul.appendChild(li);\n};
|
|
print qq{\n};
|
|
print qq{ if (path) {\n};
|
|
print qq{ var parts = path.replace(/\\/\$/, '').split('/');\n};
|
|
print qq{ var built = '';\n};
|
|
print qq{ for (var i = 0; i < parts.length; i++) {\n};
|
|
print qq{ built += (i > 0 ? '/' : '') + parts[i];\n};
|
|
print qq{ li = document.createElement('li');\n};
|
|
print qq{ if (i < parts.length - 1) {\n};
|
|
print qq{ a = document.createElement('a');\n};
|
|
print qq{ a.textContent = parts[i];\n};
|
|
print qq{ a.href = '#';\n};
|
|
print qq{ (function(p) { a.onclick = function(e) { e.preventDefault(); gnizaLoadDir(p); }; })(built);\n};
|
|
print qq{ li.appendChild(a);\n};
|
|
print qq{ } else {\n};
|
|
print qq{ li.textContent = parts[i];\n};
|
|
print qq{ }\n};
|
|
print qq{ ul.appendChild(li);\n};
|
|
print qq{ }\n};
|
|
print qq{ }\n};
|
|
print qq{\n};
|
|
print qq{ var bc = document.getElementById('fb-breadcrumbs');\n};
|
|
print qq{ bc.textContent = '';\n};
|
|
print qq{ bc.appendChild(ul);\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function gnizaRenderFileList(currentPath, entries) {\n};
|
|
print qq{ gnizaRenderBreadcrumbs(currentPath);\n};
|
|
print qq{ fbSelected = '';\n};
|
|
print qq{ document.getElementById('fb-select-btn').disabled = true;\n};
|
|
print qq{\n};
|
|
print qq{ var tbody = document.getElementById('fb-tbody');\n};
|
|
print qq{ tbody.textContent = '';\n};
|
|
print qq{\n};
|
|
print qq{ if (!entries || entries.length === 0) {\n};
|
|
print qq{ var emptyTr = document.createElement('tr');\n};
|
|
print qq{ var emptyTd = document.createElement('td');\n};
|
|
print qq{ emptyTd.className = 'text-center text-base-content/60 py-4';\n};
|
|
print qq{ emptyTd.textContent = '(empty directory)';\n};
|
|
print qq{ emptyTr.appendChild(emptyTd);\n};
|
|
print qq{ tbody.appendChild(emptyTr);\n};
|
|
print qq{ return;\n};
|
|
print qq{ }\n};
|
|
print qq{\n};
|
|
print qq{ for (var i = 0; i < entries.length; i++) {\n};
|
|
print qq{ var entry = entries[i];\n};
|
|
print qq{ var isDir = entry.endsWith('/');\n};
|
|
print qq{ var fullPath = currentPath ? currentPath.replace(/\\/\$/, '') + '/' + entry : entry;\n};
|
|
print qq{\n};
|
|
print qq{ var tr = document.createElement('tr');\n};
|
|
print qq{ tr.className = 'cursor-pointer hover';\n};
|
|
print qq{ tr.setAttribute('data-path', fullPath);\n};
|
|
print qq{\n};
|
|
print qq{ var td = document.createElement('td');\n};
|
|
print qq{ td.className = 'py-1';\n};
|
|
print qq{ var icon = isDir ? '\\uD83D\\uDCC1 ' : '\\uD83D\\uDCC4 ';\n};
|
|
print qq{ td.textContent = icon + entry;\n};
|
|
print qq{ tr.appendChild(td);\n};
|
|
print qq{\n};
|
|
print qq{ (function(row, path, dir) {\n};
|
|
print qq{ row.onclick = function() {\n};
|
|
print qq{ var rows = document.getElementById('fb-tbody').querySelectorAll('tr');\n};
|
|
print qq{ for (var j = 0; j < rows.length; j++) rows[j].classList.remove('bg-primary/10');\n};
|
|
print qq{ row.classList.add('bg-primary/10');\n};
|
|
print qq{ fbSelected = path;\n};
|
|
print qq{ document.getElementById('fb-select-btn').disabled = false;\n};
|
|
print qq{ };\n};
|
|
print qq{ if (dir) {\n};
|
|
print qq{ row.ondblclick = function() { gnizaLoadDir(path.replace(/\\/\$/, '')); };\n};
|
|
print qq{ }\n};
|
|
print qq{ })(tr, fullPath, isDir);\n};
|
|
print qq{\n};
|
|
print qq{ tbody.appendChild(tr);\n};
|
|
print qq{ }\n};
|
|
print qq{}\n};
|
|
print qq{\n};
|
|
print qq{function gnizaSelectPath() {\n};
|
|
print qq{ if (fbSelected) {\n};
|
|
print qq{ document.getElementById('path').value = fbSelected;\n};
|
|
print qq{ }\n};
|
|
print qq{ document.getElementById('fb-modal').close();\n};
|
|
print qq{}\n};
|
|
print qq{</script>\n};
|
|
}
|
|
|
|
# ── Step 3: Confirmation ─────────────────────────────────────
|
|
|
|
sub handle_step3 {
|
|
my $type = $form->{'type'} // 'account';
|
|
my $remote = $form->{'remote'} // '';
|
|
my $timestamp = $form->{'timestamp'} // '';
|
|
my $path = $form->{'path'} // '';
|
|
my $items = $form->{'items'} // '';
|
|
|
|
$type = 'account' unless exists $TYPE_LABELS{$type};
|
|
|
|
if ($remote eq '' || $timestamp eq '') {
|
|
GnizaCPanel::UI::set_flash('error', 'Remote and snapshot are required.');
|
|
print "Status: 302 Found\r\n";
|
|
print "Location: restore.live.cgi?type=$type\r\n\r\n";
|
|
exit;
|
|
}
|
|
|
|
print "Content-Type: text/html\r\n\r\n";
|
|
print GnizaCPanel::UI::page_header('Restore: Confirm');
|
|
print GnizaCPanel::UI::render_flash();
|
|
|
|
my $esc_type = GnizaCPanel::UI::esc($type);
|
|
my $esc_remote = GnizaCPanel::UI::esc($remote);
|
|
my $esc_timestamp = GnizaCPanel::UI::esc($timestamp);
|
|
my $type_label = GnizaCPanel::UI::esc($TYPE_LABELS{$type} // $type);
|
|
my $user = GnizaCPanel::UI::esc(GnizaCPanel::UI::get_current_user());
|
|
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
|
|
print qq{<h2 class="card-title text-sm">Step 3: Confirm Restore</h2>\n};
|
|
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
|
|
print qq{<tr><td class="font-medium w-36">Account</td><td>$user</td></tr>\n};
|
|
print qq{<tr><td class="font-medium">Remote</td><td>$esc_remote</td></tr>\n};
|
|
print qq{<tr><td class="font-medium">Snapshot</td><td>$esc_timestamp</td></tr>\n};
|
|
print qq{<tr><td class="font-medium">Restore Type</td><td>$type_label</td></tr>\n};
|
|
|
|
if ($type eq 'files') {
|
|
my $path_display = $path ne '' ? GnizaCPanel::UI::esc($path) : 'All files';
|
|
print qq{<tr><td class="font-medium">Path</td><td>$path_display</td></tr>\n};
|
|
} elsif ($type ne 'account' && $type ne 'cron' && $items ne '') {
|
|
my $items_display = $items eq '__ALL__' ? 'All' : GnizaCPanel::UI::esc($items);
|
|
$items_display =~ s/,/, /g;
|
|
print qq{<tr><td class="font-medium">Items</td><td>$items_display</td></tr>\n};
|
|
}
|
|
|
|
print qq{</table></div>\n};
|
|
print qq{</div>\n</div>\n};
|
|
|
|
print qq{<form method="POST" action="restore.live.cgi">\n};
|
|
print qq{<input type="hidden" name="step" value="4">\n};
|
|
print qq{<input type="hidden" name="type" value="$esc_type">\n};
|
|
print qq{<input type="hidden" name="remote" value="$esc_remote">\n};
|
|
print qq{<input type="hidden" name="timestamp" value="$esc_timestamp">\n};
|
|
print qq{<input type="hidden" name="path" value="} . GnizaCPanel::UI::esc($path) . qq{">\n};
|
|
print qq{<input type="hidden" name="items" value="} . GnizaCPanel::UI::esc($items) . qq{">\n};
|
|
print GnizaCPanel::UI::csrf_hidden_field();
|
|
|
|
print qq{<div class="flex items-center gap-2">\n};
|
|
print qq{ <button type="submit" class="btn btn-error btn-sm" onclick="return confirm('Are you sure? This may overwrite existing data.')">Execute Restore</button>\n};
|
|
print qq{ <a href="index.live.cgi" class="btn btn-info btn-sm">Cancel</a>\n};
|
|
print qq{</div>\n};
|
|
print qq{</form>\n};
|
|
|
|
print GnizaCPanel::UI::page_footer();
|
|
}
|
|
|
|
# ── Step 4: Execute ───────────────────────────────────────────
|
|
|
|
sub handle_step4 {
|
|
unless ($method eq 'POST' && GnizaCPanel::UI::verify_csrf_token($form->{'gniza_csrf'})) {
|
|
GnizaCPanel::UI::set_flash('error', 'Invalid or expired form token.');
|
|
print "Status: 302 Found\r\n";
|
|
print "Location: index.live.cgi\r\n\r\n";
|
|
exit;
|
|
}
|
|
|
|
my $type = $form->{'type'} // 'account';
|
|
my $remote = $form->{'remote'} // '';
|
|
my $timestamp = $form->{'timestamp'} // '';
|
|
my $path = $form->{'path'} // '';
|
|
my $items = $form->{'items'} // '';
|
|
|
|
$type = 'account' unless exists $TYPE_LABELS{$type};
|
|
|
|
print "Content-Type: text/html\r\n\r\n";
|
|
print GnizaCPanel::UI::page_header('Restore: Results');
|
|
|
|
my $type_label = GnizaCPanel::UI::esc($TYPE_LABELS{$type});
|
|
|
|
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
|
|
print qq{<h2 class="card-title text-sm">Restore Results: $type_label</h2>\n};
|
|
|
|
my @results;
|
|
|
|
my %action_map = (
|
|
account => 'RESTORE_ACCOUNT',
|
|
files => 'RESTORE_FILES',
|
|
database => 'RESTORE_DATABASE',
|
|
mailbox => 'RESTORE_MAILBOX',
|
|
cron => 'RESTORE_CRON',
|
|
dbusers => 'RESTORE_DBUSERS',
|
|
domains => 'RESTORE_DOMAINS',
|
|
ssl => 'RESTORE_SSL',
|
|
);
|
|
|
|
my $action = $action_map{$type};
|
|
unless ($action) {
|
|
push @results, { ok => 0, label => $type, msg => 'Unknown restore type' };
|
|
_render_results(\@results);
|
|
print qq{</div>\n</div>\n};
|
|
print qq{<a href="index.live.cgi" class="btn btn-info btn-sm">Back to Categories</a>\n};
|
|
print GnizaCPanel::UI::page_footer();
|
|
return;
|
|
}
|
|
|
|
if ($type eq 'account') {
|
|
my ($ok, $stdout, $err) = _adminbin_call($action, $remote, $timestamp, '');
|
|
push @results, { ok => $ok, label => 'Full Account', msg => $ok ? $stdout : $err };
|
|
}
|
|
elsif ($type eq 'cron') {
|
|
my ($ok, $stdout, $err) = _adminbin_call($action, $remote, $timestamp);
|
|
push @results, { ok => $ok, label => 'Cron Jobs', msg => $ok ? $stdout : $err };
|
|
}
|
|
elsif ($type eq 'files') {
|
|
my ($ok, $stdout, $err) = _adminbin_call($action, $remote, $timestamp, $path, '');
|
|
push @results, { ok => $ok, label => 'Files', msg => $ok ? $stdout : $err };
|
|
}
|
|
elsif ($type eq 'database' || $type eq 'dbusers' || $type eq 'mailbox' || $type eq 'domains' || $type eq 'ssl') {
|
|
if ($items eq '' || $items eq '__ALL__') {
|
|
my ($ok, $stdout, $err) = _adminbin_call($action, $remote, $timestamp, '');
|
|
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
|
|
} else {
|
|
for my $item (split /,/, $items) {
|
|
next if $item eq '';
|
|
my ($ok, $stdout, $err) = _adminbin_call($action, $remote, $timestamp, $item);
|
|
push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err };
|
|
}
|
|
}
|
|
}
|
|
|
|
_render_results(\@results);
|
|
|
|
print qq{</div>\n</div>\n};
|
|
|
|
print qq{<a href="index.live.cgi" class="btn btn-info btn-sm">Back to Categories</a>\n};
|
|
|
|
print GnizaCPanel::UI::page_footer();
|
|
}
|
|
|
|
sub _render_results {
|
|
my ($results) = @_;
|
|
for my $r (@$results) {
|
|
my $icon_class = $r->{ok} ? 'text-success' : 'text-error';
|
|
my $icon = $r->{ok} ? '✓' : '✗';
|
|
my $label = GnizaCPanel::UI::esc($r->{label});
|
|
my $msg = GnizaCPanel::UI::esc($r->{msg} // '');
|
|
# Clean up the "OK\n" prefix from successful results
|
|
$msg =~ s/^OK\s*//;
|
|
|
|
print qq{<div class="flex items-start gap-2 mb-3 p-3 rounded-lg bg-base-200">\n};
|
|
print qq{ <span class="$icon_class font-bold text-lg">$icon</span>\n};
|
|
print qq{ <div>\n};
|
|
print qq{ <div class="font-medium text-sm">$label</div>\n};
|
|
if ($msg ne '') {
|
|
print qq{ <pre class="text-xs mt-1 whitespace-pre-wrap max-h-32 overflow-y-auto">$msg</pre>\n};
|
|
}
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
}
|
|
}
|