Files
gniza4cp/whm/gniza-whm/remotes.cgi
shuki bde1fe0822 Fix JSON control character escaping in connection test responses
SSH stderr can contain \r and other control characters that break
JSON parsing. Strip all control chars after escaping known ones.

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

755 lines
32 KiB
Perl

#!/usr/local/cpanel/3rdparty/bin/perl
# gniza WHM Plugin — Remote Destination CRUD
use strict;
use warnings;
use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib';
use Whostmgr::HTMLInterface ();
use Cpanel::Form ();
use File::Copy ();
use GnizaWHM::Config;
use GnizaWHM::Validator;
use GnizaWHM::UI;
my $form = Cpanel::Form::parseform();
my $method = $ENV{'REQUEST_METHOD'} // 'GET';
my $action = $form->{'action'} // 'list';
# Route to handler
if ($action eq 'test') { handle_test_connection() }
elsif ($action eq 'add') { handle_add() }
elsif ($action eq 'edit') { handle_edit() }
elsif ($action eq 'delete') { handle_delete() }
else { handle_list() }
exit;
# ── Test Connection (JSON) ────────────────────────────────────
sub handle_test_connection {
print "Content-Type: application/json\r\n\r\n";
my $type = $form->{'remote_type'} || 'ssh';
if ($type eq 'ssh') {
my $host = $form->{'host'} // '';
my $port = $form->{'port'} || '22';
my $user = $form->{'user'} || 'root';
my $auth_method = $form->{'auth_method'} || 'key';
my $key = $form->{'key'} // '';
my $password = $form->{'password'} // '';
if ($host eq '') {
print qq({"success":false,"message":"Host is required."});
exit;
}
if ($auth_method eq 'password') {
if ($password eq '') {
print qq({"success":false,"message":"Password is required."});
exit;
}
} else {
if ($key eq '') {
print qq({"success":false,"message":"SSH key path is required."});
exit;
}
}
my ($ok, $err) = GnizaWHM::UI::test_ssh_connection(
host => $host,
port => $port,
user => $user,
auth_method => $auth_method,
key => $key,
password => $password,
);
if ($ok) {
print qq({"success":true,"message":"SSH connection successful."});
} else {
$err //= 'Unknown error';
$err =~ s/\\/\\\\/g;
$err =~ s/"/\\"/g;
$err =~ s/\n/\\n/g;
$err =~ s/\r/\\r/g;
$err =~ s/\t/\\t/g;
$err =~ s/[\x00-\x1f]//g;
print qq({"success":false,"message":"SSH connection failed: $err"});
}
}
elsif ($type eq 's3' || $type eq 'gdrive') {
my %rclone_args = (type => $type);
if ($type eq 's3') {
$rclone_args{s3_access_key_id} = $form->{'S3_ACCESS_KEY_ID'} // '';
$rclone_args{s3_secret_access_key} = $form->{'S3_SECRET_ACCESS_KEY'} // '';
$rclone_args{s3_region} = $form->{'S3_REGION'} || 'us-east-1';
$rclone_args{s3_endpoint} = $form->{'S3_ENDPOINT'} // '';
$rclone_args{s3_bucket} = $form->{'S3_BUCKET'} // '';
if ($rclone_args{s3_access_key_id} eq '' || $rclone_args{s3_secret_access_key} eq '') {
print qq({"success":false,"message":"S3 access key and secret are required."});
exit;
}
if ($rclone_args{s3_bucket} eq '') {
print qq({"success":false,"message":"S3 bucket is required."});
exit;
}
} else {
$rclone_args{gdrive_service_account_file} = $form->{'GDRIVE_SERVICE_ACCOUNT_FILE'} // '';
$rclone_args{gdrive_root_folder_id} = $form->{'GDRIVE_ROOT_FOLDER_ID'} // '';
if ($rclone_args{gdrive_service_account_file} eq '') {
print qq({"success":false,"message":"Service account file path is required."});
exit;
}
}
my ($ok, $err) = GnizaWHM::UI::test_rclone_connection(%rclone_args);
if ($ok) {
my $label = $type eq 's3' ? 'S3' : 'Google Drive';
print qq({"success":true,"message":"$label connection successful."});
} else {
$err //= 'Unknown error';
$err =~ s/\\/\\\\/g;
$err =~ s/"/\\"/g;
$err =~ s/\n/\\n/g;
$err =~ s/\r/\\r/g;
$err =~ s/\t/\\t/g;
$err =~ s/[\x00-\x1f]//g;
print qq({"success":false,"message":"Connection failed: $err"});
}
}
else {
print qq({"success":false,"message":"Unknown remote type."});
}
exit;
}
# ── List ─────────────────────────────────────────────────────
sub handle_list {
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Remotes', '', '/cgi/gniza-whm/remotes.cgi');
print GnizaWHM::UI::page_header('Remote Destinations');
print GnizaWHM::UI::render_nav('remotes.cgi');
print GnizaWHM::UI::render_flash();
my @remotes = GnizaWHM::UI::list_remotes();
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
if (@remotes) {
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
print qq{<thead><tr><th>Name</th><th>Type</th><th>Destination</th><th>Retention</th><th>Actions</th></tr></thead>\n};
print qq{<tbody>\n};
for my $name (@remotes) {
my $conf = GnizaWHM::Config::parse(GnizaWHM::UI::remote_conf_path($name), 'remote');
my $esc_name = GnizaWHM::UI::esc($name);
my $type = $conf->{REMOTE_TYPE} // 'ssh';
my $retention = GnizaWHM::UI::esc($conf->{RETENTION_COUNT} // '30');
my ($type_label, $dest);
if ($type eq 's3') {
$type_label = 'S3';
$dest = 's3://' . GnizaWHM::UI::esc($conf->{S3_BUCKET} // '');
} elsif ($type eq 'gdrive') {
$type_label = 'GDrive';
my $sa = $conf->{GDRIVE_SERVICE_ACCOUNT_FILE} // '';
$sa =~ s{.*/}{};
$dest = 'gdrive:' . GnizaWHM::UI::esc($sa);
} else {
$type_label = 'SSH';
my $host = GnizaWHM::UI::esc($conf->{REMOTE_HOST} // '');
my $port = GnizaWHM::UI::esc($conf->{REMOTE_PORT} // '22');
$dest = "$host:$port";
}
print qq{<tr class="hover">};
print qq{<td><strong>$esc_name</strong></td>};
print qq{<td><span class="badge badge-sm">$type_label</span></td>};
print qq{<td>$dest</td><td>$retention</td>};
print qq{<td>};
print qq{<div class="flex items-center gap-2">};
print qq{<a href="remotes.cgi?action=edit&amp;name=$esc_name" class="btn btn-primary btn-sm">Edit</a>};
print qq{<form method="POST" action="remotes.cgi" class="inline">};
print qq{<input type="hidden" name="action" value="delete">};
print qq{<input type="hidden" name="name" value="$esc_name">};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<button type="submit" class="btn btn-error btn-sm" onclick="return confirm('Delete remote $esc_name?')">Delete</button>};
print qq{</form>};
print qq{</div>};
print qq{</td>};
print qq{</tr>\n};
}
print qq{</tbody>\n</table></div>\n};
} else {
print qq{<p>No remote destinations configured. Add a remote to enable multi-remote backups.</p>\n};
print qq{<p class="text-xs text-base-content/60 mt-2">Remote configs are stored in <code>/etc/gniza/remotes.d/</code>.</p>\n};
}
print qq{</div>\n</div>\n};
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <a href="remotes.cgi?action=add" class="btn btn-primary btn-sm">Add Remote</a>\n};
print qq{</div>\n};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Add ──────────────────────────────────────────────────────
sub handle_add {
my @errors;
if ($method eq 'POST') {
unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) {
push @errors, 'Invalid or expired form token. Please try again.';
}
my $name = $form->{'remote_name'} // '';
my $name_err = GnizaWHM::Validator::validate_remote_name($name);
push @errors, $name_err if $name_err;
if (!@errors && -f GnizaWHM::UI::remote_conf_path($name)) {
push @errors, "A remote named '$name' already exists.";
}
my %data;
for my $key (@GnizaWHM::Config::REMOTE_KEYS) {
$data{$key} = $form->{$key} // '';
}
if (!@errors) {
my $validation_errors = GnizaWHM::Validator::validate_remote_config(\%data);
push @errors, @$validation_errors;
}
if (!@errors) {
my $type = $data{REMOTE_TYPE} || 'ssh';
my ($conn_ok, $conn_err);
if ($type eq 'ssh') {
($conn_ok, $conn_err) = GnizaWHM::UI::test_ssh_connection(
host => $data{REMOTE_HOST},
port => $data{REMOTE_PORT} || '22',
user => $data{REMOTE_USER} || 'root',
auth_method => $data{REMOTE_AUTH_METHOD} || 'key',
key => $data{REMOTE_KEY},
password => $data{REMOTE_PASSWORD},
);
} else {
my %rclone_args = (type => $type);
if ($type eq 's3') {
$rclone_args{s3_access_key_id} = $data{S3_ACCESS_KEY_ID};
$rclone_args{s3_secret_access_key} = $data{S3_SECRET_ACCESS_KEY};
$rclone_args{s3_region} = $data{S3_REGION} || 'us-east-1';
$rclone_args{s3_endpoint} = $data{S3_ENDPOINT};
$rclone_args{s3_bucket} = $data{S3_BUCKET};
} else {
$rclone_args{gdrive_service_account_file} = $data{GDRIVE_SERVICE_ACCOUNT_FILE};
$rclone_args{gdrive_root_folder_id} = $data{GDRIVE_ROOT_FOLDER_ID};
}
($conn_ok, $conn_err) = GnizaWHM::UI::test_rclone_connection(%rclone_args);
}
push @errors, "Connection test failed: $conn_err" unless $conn_ok;
}
if (!@errors) {
# Copy example template then write values
my $dest = GnizaWHM::UI::remote_conf_path($name);
my $example = GnizaWHM::UI::remote_example_path();
if (-f $example) {
File::Copy::copy($example, $dest)
or do { push @errors, "Failed to create remote file: $!"; goto RENDER_ADD; };
}
my ($ok, $err) = GnizaWHM::Config::write($dest, \%data, \@GnizaWHM::Config::REMOTE_KEYS);
if ($ok) {
GnizaWHM::UI::set_flash('success', "Remote '$name' created successfully.");
print "Status: 302 Found\r\n";
print "Location: remotes.cgi\r\n\r\n";
exit;
} else {
push @errors, "Failed to save remote config: $err";
}
}
}
RENDER_ADD:
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Add Remote', '', '/cgi/gniza-whm/remotes.cgi');
print GnizaWHM::UI::page_header('Add Remote Destination');
print GnizaWHM::UI::render_nav('remotes.cgi');
if (@errors) {
print GnizaWHM::UI::render_errors(\@errors);
}
# Pre-populate from POST if validation failed, else empty
my $conf = {};
if ($method eq 'POST') {
for my $key (@GnizaWHM::Config::REMOTE_KEYS) {
$conf->{$key} = $form->{$key} // '';
}
}
my $name_val = GnizaWHM::UI::esc($form->{'remote_name'} // '');
render_remote_form($conf, $name_val, 0);
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Edit ─────────────────────────────────────────────────────
sub handle_edit {
my $name = $form->{'name'} // '';
my @errors;
# Validate name
my $name_err = GnizaWHM::Validator::validate_remote_name($name);
if ($name_err) {
GnizaWHM::UI::set_flash('error', "Invalid remote name.");
print "Status: 302 Found\r\n";
print "Location: remotes.cgi\r\n\r\n";
exit;
}
my $conf_path = GnizaWHM::UI::remote_conf_path($name);
unless (-f $conf_path) {
GnizaWHM::UI::set_flash('error', "Remote '$name' not found.");
print "Status: 302 Found\r\n";
print "Location: remotes.cgi\r\n\r\n";
exit;
}
if ($method eq 'POST') {
unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) {
push @errors, 'Invalid or expired form token. Please try again.';
}
my %data;
for my $key (@GnizaWHM::Config::REMOTE_KEYS) {
$data{$key} = $form->{$key} // '';
}
if (!@errors) {
my $validation_errors = GnizaWHM::Validator::validate_remote_config(\%data);
push @errors, @$validation_errors;
}
if (!@errors) {
my $type = $data{REMOTE_TYPE} || 'ssh';
my ($conn_ok, $conn_err);
if ($type eq 'ssh') {
($conn_ok, $conn_err) = GnizaWHM::UI::test_ssh_connection(
host => $data{REMOTE_HOST},
port => $data{REMOTE_PORT} || '22',
user => $data{REMOTE_USER} || 'root',
auth_method => $data{REMOTE_AUTH_METHOD} || 'key',
key => $data{REMOTE_KEY},
password => $data{REMOTE_PASSWORD},
);
} else {
my %rclone_args = (type => $type);
if ($type eq 's3') {
$rclone_args{s3_access_key_id} = $data{S3_ACCESS_KEY_ID};
$rclone_args{s3_secret_access_key} = $data{S3_SECRET_ACCESS_KEY};
$rclone_args{s3_region} = $data{S3_REGION} || 'us-east-1';
$rclone_args{s3_endpoint} = $data{S3_ENDPOINT};
$rclone_args{s3_bucket} = $data{S3_BUCKET};
} else {
$rclone_args{gdrive_service_account_file} = $data{GDRIVE_SERVICE_ACCOUNT_FILE};
$rclone_args{gdrive_root_folder_id} = $data{GDRIVE_ROOT_FOLDER_ID};
}
($conn_ok, $conn_err) = GnizaWHM::UI::test_rclone_connection(%rclone_args);
}
push @errors, "Connection test failed: $conn_err" unless $conn_ok;
}
if (!@errors) {
my ($ok, $err) = GnizaWHM::Config::write($conf_path, \%data, \@GnizaWHM::Config::REMOTE_KEYS);
if ($ok) {
GnizaWHM::UI::set_flash('success', "Remote '$name' updated successfully.");
print "Status: 302 Found\r\n";
print "Location: remotes.cgi\r\n\r\n";
exit;
} else {
push @errors, "Failed to save remote config: $err";
}
}
}
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Edit Remote', '', '/cgi/gniza-whm/remotes.cgi');
print GnizaWHM::UI::page_header("Edit Remote: " . GnizaWHM::UI::esc($name));
print GnizaWHM::UI::render_nav('remotes.cgi');
if (@errors) {
print GnizaWHM::UI::render_errors(\@errors);
}
# Load config (or re-use POST data on error)
my $conf;
if (@errors && $method eq 'POST') {
$conf = {};
for my $key (@GnizaWHM::Config::REMOTE_KEYS) {
$conf->{$key} = $form->{$key} // '';
}
} else {
$conf = GnizaWHM::Config::parse($conf_path, 'remote');
}
render_remote_form($conf, GnizaWHM::UI::esc($name), 1);
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Delete ───────────────────────────────────────────────────
sub handle_delete {
if ($method ne 'POST') {
print "Status: 302 Found\r\n";
print "Location: remotes.cgi\r\n\r\n";
exit;
}
unless (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: remotes.cgi\r\n\r\n";
exit;
}
my $name = $form->{'name'} // '';
my $name_err = GnizaWHM::Validator::validate_remote_name($name);
if ($name_err) {
GnizaWHM::UI::set_flash('error', 'Invalid remote name.');
print "Status: 302 Found\r\n";
print "Location: remotes.cgi\r\n\r\n";
exit;
}
my $conf_path = GnizaWHM::UI::remote_conf_path($name);
if (-f $conf_path) {
unlink $conf_path;
GnizaWHM::UI::set_flash('success', "Remote '$name' deleted.");
} else {
GnizaWHM::UI::set_flash('error', "Remote '$name' not found.");
}
print "Status: 302 Found\r\n";
print "Location: remotes.cgi\r\n\r\n";
exit;
}
# ── Shared Form Renderer ────────────────────────────────────
sub render_remote_form {
my ($conf, $name_val, $is_edit) = @_;
my $action_val = $is_edit ? 'edit' : 'add';
my $remote_type = $conf->{REMOTE_TYPE} // 'ssh';
print qq{<form method="POST" action="remotes.cgi">\n};
print qq{<input type="hidden" name="action" value="$action_val">\n};
print GnizaWHM::UI::csrf_hidden_field();
if ($is_edit) {
print qq{<input type="hidden" name="name" value="$name_val">\n};
}
# Remote name
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">Remote Identity</h2>\n};
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_name">Remote Name</label>\n};
if ($is_edit) {
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" value="$name_val" disabled>\n};
} else {
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="remote_name" name="remote_name" value="$name_val" required>\n};
print qq{ <span class="text-xs text-base-content/60 ml-2">Letters, digits, hyphens, underscores</span>\n};
}
print qq{</div>\n};
# Remote type selector
my $ssh_checked = ($remote_type eq 'ssh') ? ' checked' : '';
my $s3_checked = ($remote_type eq 's3') ? ' checked' : '';
my $gdrive_checked = ($remote_type eq 'gdrive') ? ' checked' : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm">Remote Type</label>\n};
print qq{ <div class="join">\n};
print qq{ <input type="radio" name="REMOTE_TYPE" class="join-item btn btn-sm" aria-label="SSH" value="ssh" onchange="gnizaTypeChanged()"$ssh_checked>\n};
print qq{ <input type="radio" name="REMOTE_TYPE" class="join-item btn btn-sm" aria-label="Amazon S3" value="s3" onchange="gnizaTypeChanged()"$s3_checked>\n};
print qq{ <input type="radio" name="REMOTE_TYPE" class="join-item btn btn-sm" aria-label="Google Drive" value="gdrive" onchange="gnizaTypeChanged()"$gdrive_checked>\n};
print qq{ </div>\n};
print qq{</div>\n};
print qq{</div>\n</div>\n};
# ── SSH fields ────────────────────────────────────────────
my $ssh_hidden = ($remote_type ne 'ssh') ? ' hidden' : '';
# SSH key guidance (add mode only)
print qq{<div id="type-ssh-guidance"$ssh_hidden>\n};
unless ($is_edit) {
print GnizaWHM::UI::render_ssh_guidance();
}
print qq{</div>\n};
my $auth_method = $conf->{REMOTE_AUTH_METHOD} // 'key';
my $key_checked = ($auth_method ne 'password') ? ' checked' : '';
my $pw_checked = ($auth_method eq 'password') ? ' checked' : '';
my $key_hidden = ($auth_method eq 'password') ? ' hidden' : '';
my $pw_hidden = ($auth_method ne 'password') ? ' hidden' : '';
print qq{<div id="type-ssh-fields"$ssh_hidden>\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">SSH Connection</h2>\n};
_field($conf, 'REMOTE_HOST', 'Hostname / IP', 'Required');
_field($conf, 'REMOTE_PORT', 'SSH Port', 'Default: 22');
_field($conf, 'REMOTE_USER', 'SSH User', 'Default: root');
# Auth method toggle
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm">Auth Method</label>\n};
print qq{ <div class="join">\n};
print qq{ <input type="radio" name="REMOTE_AUTH_METHOD" class="join-item btn btn-sm" aria-label="SSH Key" value="key" onchange="gnizaAuthChanged()"$key_checked>\n};
print qq{ <input type="radio" name="REMOTE_AUTH_METHOD" class="join-item btn btn-sm" aria-label="Password" value="password" onchange="gnizaAuthChanged()"$pw_checked>\n};
print qq{ </div>\n};
print qq{</div>\n};
# Key field
print qq{<div id="auth-key-field"$key_hidden>\n};
_field($conf, 'REMOTE_KEY', 'SSH Private Key', 'Absolute path');
print qq{</div>\n};
# Password field
my $pw_val = GnizaWHM::UI::esc($conf->{REMOTE_PASSWORD} // '');
print qq{<div id="auth-password-field"$pw_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" for="REMOTE_PASSWORD">SSH Password</label>\n};
print qq{ <input type="password" class="input input-bordered input-sm w-full max-w-xs" id="REMOTE_PASSWORD" name="REMOTE_PASSWORD" value="$pw_val">\n};
print qq{ <span class="text-xs text-base-content/60 ml-2">Requires sshpass on server</span>\n};
print qq{</div>\n};
print qq{</div>\n};
print qq{</div>\n</div>\n};
print qq{</div>\n};
# ── S3 fields ─────────────────────────────────────────────
my $s3_hidden = ($remote_type ne 's3') ? ' hidden' : '';
print qq{<div id="type-s3-fields"$s3_hidden>\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">Amazon S3 / S3-Compatible</h2>\n};
_field($conf, 'S3_ACCESS_KEY_ID', 'Access Key ID', 'Required');
_password_field($conf, 'S3_SECRET_ACCESS_KEY', 'Secret Access Key', 'Required');
_field($conf, 'S3_REGION', 'Region', 'Default: us-east-1');
_field($conf, 'S3_ENDPOINT', 'Custom Endpoint', 'For MinIO, Wasabi, etc.');
_field($conf, 'S3_BUCKET', 'Bucket Name', 'Required');
print qq{<p class="text-xs text-base-content/60 mt-2">Requires <code>rclone</code> installed on this server.</p>\n};
print qq{</div>\n</div>\n};
print qq{</div>\n};
# ── Google Drive fields ───────────────────────────────────
my $gdrive_hidden = ($remote_type ne 'gdrive') ? ' hidden' : '';
print qq{<div id="type-gdrive-fields"$gdrive_hidden>\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">Google Drive</h2>\n};
_field($conf, 'GDRIVE_SERVICE_ACCOUNT_FILE', 'Service Account JSON', 'Absolute path, required');
_field($conf, 'GDRIVE_ROOT_FOLDER_ID', 'Root Folder ID', 'Optional');
print qq{<p class="text-xs text-base-content/60 mt-2">Requires <code>rclone</code> installed on this server.</p>\n};
print qq{</div>\n</div>\n};
print qq{</div>\n};
# ── Common fields ─────────────────────────────────────────
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">Storage Path</h2>\n};
_field($conf, 'REMOTE_BASE', 'Remote Base Dir', 'Default: /backups');
print qq{</div>\n</div>\n};
# Transfer
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">Transfer Settings</h2>\n};
_field($conf, 'BWLIMIT', 'Bandwidth Limit', 'KB/s, 0 = unlimited');
print qq{<div id="rsync-opts-field"$ssh_hidden>\n};
_field($conf, 'RSYNC_EXTRA_OPTS', 'Extra rsync Options', 'SSH only');
print qq{</div>\n};
print qq{</div>\n</div>\n};
# Retention
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">Retention</h2>\n};
_field($conf, 'RETENTION_COUNT', 'Snapshots to Keep', 'Default: 30');
print qq{</div>\n</div>\n};
# Submit
print qq{<div class="flex gap-2 mt-4">\n};
my $btn_label = $is_edit ? 'Save Changes' : 'Create Remote';
print qq{ <button type="submit" class="btn btn-primary btn-sm">$btn_label</button>\n};
print qq{ <button type="button" class="btn btn-secondary btn-sm" id="test-conn-btn" onclick="gnizaTestConnection()">Test Connection</button>\n};
print qq{ <a href="remotes.cgi" class="btn btn-ghost btn-sm">Cancel</a>\n};
print qq{</div>\n};
print qq{</form>\n};
print <<'JS';
<script>
function gnizaGetType() {
var radios = document.querySelectorAll('input[name="REMOTE_TYPE"]');
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) return radios[i].value;
}
return 'ssh';
}
function gnizaGetAuthMethod() {
var radios = document.querySelectorAll('input[name="REMOTE_AUTH_METHOD"]');
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) return radios[i].value;
}
return 'key';
}
function gnizaTypeChanged() {
var type = gnizaGetType();
var sshFields = document.getElementById('type-ssh-fields');
var sshGuidance = document.getElementById('type-ssh-guidance');
var s3Fields = document.getElementById('type-s3-fields');
var gdriveFields = document.getElementById('type-gdrive-fields');
var rsyncOpts = document.getElementById('rsync-opts-field');
sshFields.hidden = (type !== 'ssh');
sshGuidance.hidden = (type !== 'ssh');
s3Fields.hidden = (type !== 's3');
gdriveFields.hidden = (type !== 'gdrive');
rsyncOpts.hidden = (type !== 'ssh');
}
function gnizaAuthChanged() {
var method = gnizaGetAuthMethod();
var keyField = document.getElementById('auth-key-field');
var pwField = document.getElementById('auth-password-field');
if (method === 'password') {
keyField.hidden = true;
pwField.hidden = false;
} else {
keyField.hidden = false;
pwField.hidden = true;
}
}
function gnizaTestConnection() {
var type = gnizaGetType();
var btn = document.getElementById('test-conn-btn');
var fd = new FormData();
fd.append('action', 'test');
fd.append('remote_type', type);
if (type === 'ssh') {
var host = document.getElementById('REMOTE_HOST').value;
var port = document.getElementById('REMOTE_PORT').value;
var user = document.getElementById('REMOTE_USER').value;
var authMethod = gnizaGetAuthMethod();
var key = document.getElementById('REMOTE_KEY').value;
var pw = document.getElementById('REMOTE_PASSWORD').value;
if (!host) { gnizaToast('error', 'Host is required.'); return; }
if (authMethod === 'password' && !pw) { gnizaToast('error', 'Password is required.'); return; }
if (authMethod === 'key' && !key) { gnizaToast('error', 'SSH key path is required.'); return; }
fd.append('host', host);
fd.append('port', port);
fd.append('user', user);
fd.append('auth_method', authMethod);
fd.append('key', key);
fd.append('password', pw);
}
else if (type === 's3') {
var keyId = document.getElementById('S3_ACCESS_KEY_ID').value;
var secret = document.getElementById('S3_SECRET_ACCESS_KEY').value;
var bucket = document.getElementById('S3_BUCKET').value;
if (!keyId || !secret) { gnizaToast('error', 'S3 access key and secret are required.'); return; }
if (!bucket) { gnizaToast('error', 'S3 bucket is required.'); return; }
fd.append('S3_ACCESS_KEY_ID', keyId);
fd.append('S3_SECRET_ACCESS_KEY', secret);
fd.append('S3_REGION', document.getElementById('S3_REGION').value);
fd.append('S3_ENDPOINT', document.getElementById('S3_ENDPOINT').value);
fd.append('S3_BUCKET', bucket);
}
else if (type === 'gdrive') {
var saFile = document.getElementById('GDRIVE_SERVICE_ACCOUNT_FILE').value;
if (!saFile) { gnizaToast('error', 'Service account file path is required.'); return; }
fd.append('GDRIVE_SERVICE_ACCOUNT_FILE', saFile);
fd.append('GDRIVE_ROOT_FOLDER_ID', document.getElementById('GDRIVE_ROOT_FOLDER_ID').value);
}
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Testing\u2026';
fetch('remotes.cgi', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
gnizaToast(data.success ? 'success' : 'error', data.message);
})
.catch(function(err) {
gnizaToast('error', 'Request failed: ' + err.toString());
})
.finally(function() {
btn.disabled = false;
btn.innerHTML = 'Test Connection';
});
}
function gnizaToast(type, msg) {
var toast = document.getElementById('gniza-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'gniza-toast';
toast.style.cssText = 'position:fixed;top:12px;right:12px;z-index:9999;display:flex;flex-direction:column;gap:8px;max-width:400px';
document.body.appendChild(toast);
}
var el = document.createElement('div');
el.className = 'alert alert-' + type;
el.style.cssText = 'transition:opacity .3s;box-shadow:0 4px 12px rgba(0,0,0,.15)';
el.textContent = msg;
toast.appendChild(el);
setTimeout(function() { el.style.opacity = '0'; }, type === 'error' ? 6000 : 3000);
setTimeout(function() { el.remove(); }, type === 'error' ? 6500 : 3500);
}
</script>
JS
}
sub _field {
my ($conf, $key, $label, $hint) = @_;
my $val = GnizaWHM::UI::esc($conf->{$key} // '');
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="$key">$label</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="$key" name="$key" value="$val">\n};
print qq{ $hint_html\n} if $hint;
print qq{</div>\n};
}
sub _password_field {
my ($conf, $key, $label, $hint) = @_;
my $val = GnizaWHM::UI::esc($conf->{$key} // '');
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="$key">$label</label>\n};
print qq{ <input type="password" class="input input-bordered input-sm w-full max-w-xs" id="$key" name="$key" value="$val">\n};
print qq{ $hint_html\n} if $hint;
print qq{</div>\n};
}