- CLI binary: bin/gniza -> bin/gniza4cp - Install path: /usr/local/gniza4cp/ - Config path: /etc/gniza4cp/ - Log path: /var/log/gniza4cp/ - WHM plugin: gniza4cp-whm/ - cPanel plugin: cpanel/gniza4cp/ - AdminBin: Gniza4cp::Restore - Perl modules: Gniza4cpWHM::*, Gniza4cpCPanel::* - DaisyUI theme: gniza4cp - All internal references, branding, paths updated - Git remote updated to gniza4cp repo
327 lines
10 KiB
Perl
327 lines
10 KiB
Perl
package Gniza4cpCPanel::UI;
|
|
# Shared UI helpers for the gniza4cp cPanel user restore plugin.
|
|
# Adapted from Gniza4cpWHM::UI for cPanel context (runs as user, not root).
|
|
|
|
use strict;
|
|
use warnings;
|
|
use Fcntl qw(:DEFAULT);
|
|
|
|
my $CSS_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza4cp/assets/gniza4cp-whm.css';
|
|
my $LOGO_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza4cp/assets/gniza4cp-logo.svg';
|
|
my $_logo_data_uri = '';
|
|
|
|
my @NAV_ITEMS = (
|
|
{ url => 'index.live.cgi', label => 'Restore' },
|
|
{ url => 'logs.live.cgi', label => 'Logs' },
|
|
);
|
|
|
|
# ── HTML Escaping ─────────────────────────────────────────────
|
|
|
|
sub esc {
|
|
my ($str) = @_;
|
|
$str //= '';
|
|
$str =~ s/&/&/g;
|
|
$str =~ s/</</g;
|
|
$str =~ s/>/>/g;
|
|
$str =~ s/"/"/g;
|
|
$str =~ s/'/'/g;
|
|
return $str;
|
|
}
|
|
|
|
# ── Navigation ────────────────────────────────────────────────
|
|
|
|
sub render_nav {
|
|
my ($current_page) = @_;
|
|
my $logo = '';
|
|
if ($_logo_data_uri) {
|
|
$logo = qq{<img src="$_logo_data_uri" alt="" style="height:3rem;width:auto">}
|
|
. qq{<span class="text-3xl font-bold leading-none">GNIZA4CP <span class="text-secondary">Backup</span></span>};
|
|
}
|
|
my $menu_items = '';
|
|
for my $item (@NAV_ITEMS) {
|
|
my $is_active = ($item->{url} eq $current_page);
|
|
my $label = esc($item->{label});
|
|
my $style = $is_active
|
|
? 'style="font-weight:600;color:inherit"'
|
|
: 'style="color:inherit;opacity:0.7"';
|
|
$menu_items .= qq{<a class="no-underline" href="$item->{url}" $style>$label</a>\n};
|
|
}
|
|
|
|
# Use inline styles to avoid cPanel Jupiter CSS conflicts with DaisyUI navbar
|
|
my $html = qq{<div class="bg-base-200 rounded-box mb-5" style="display:flex;align-items:center;justify-content:space-between;padding:0.5rem 1rem;min-height:4rem">\n};
|
|
$html .= qq{ <div style="display:flex;align-items:center;gap:0.5rem">\n};
|
|
$html .= qq{ $logo\n} if $logo;
|
|
$html .= qq{ </div>\n};
|
|
$html .= qq{ <div style="display:flex;align-items:center;gap:1.5rem;font-size:0.95rem">\n};
|
|
$html .= qq{ $menu_items};
|
|
$html .= qq{ </div>\n};
|
|
$html .= qq{</div>\n};
|
|
return $html;
|
|
}
|
|
|
|
# ── Current User ─────────────────────────────────────────────
|
|
|
|
sub get_current_user {
|
|
return $ENV{'REMOTE_USER'} // '';
|
|
}
|
|
|
|
# ── Safe File I/O (symlink-safe) ──────────────────────────────
|
|
|
|
sub _safe_write {
|
|
my ($file, $content) = @_;
|
|
# Remove existing file/symlink before writing (prevents symlink attacks)
|
|
unlink $file if -e $file || -l $file;
|
|
# Create new file with O_CREAT|O_EXCL (fails if file already exists/race)
|
|
if (sysopen my $fh, $file, O_WRONLY | O_CREAT | O_EXCL, 0600) {
|
|
print $fh $content;
|
|
close $fh;
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
sub _safe_read {
|
|
my ($file) = @_;
|
|
# Refuse to read symlinks
|
|
return if -l $file;
|
|
return unless -f $file;
|
|
if (open my $fh, '<', $file) {
|
|
local $/;
|
|
my $content = <$fh>;
|
|
close $fh;
|
|
return $content;
|
|
}
|
|
return;
|
|
}
|
|
|
|
# ── Flash Messages ────────────────────────────────────────────
|
|
|
|
sub _flash_file {
|
|
my $user = get_current_user();
|
|
return "/tmp/.gniza4cp-cpanel-flash-$user";
|
|
}
|
|
|
|
sub set_flash {
|
|
my ($type, $text) = @_;
|
|
_safe_write(_flash_file(), "$type\n$text\n");
|
|
return;
|
|
}
|
|
|
|
sub get_flash {
|
|
my $file = _flash_file();
|
|
my $content = _safe_read($file);
|
|
return unless defined $content;
|
|
unlink $file;
|
|
|
|
my ($type, $text) = split /\n/, $content, 2;
|
|
|
|
return unless defined $type && defined $text;
|
|
chomp $type;
|
|
chomp $text;
|
|
return ($type, $text);
|
|
}
|
|
|
|
sub render_flash {
|
|
my @flash = get_flash();
|
|
return '' unless defined $flash[0];
|
|
my ($type, $text) = @flash;
|
|
# Validate flash type against allowlist
|
|
my %valid_types = map { $_ => 1 } qw(success error info warning);
|
|
$type = 'info' unless $valid_types{$type // ''};
|
|
my $escaped = esc($text);
|
|
return qq{<div class="alert alert-$type mb-4">$escaped</div>\n};
|
|
}
|
|
|
|
# ── CSRF Protection ──────────────────────────────────────────
|
|
|
|
my $_current_csrf_token;
|
|
|
|
sub _csrf_file {
|
|
my $user = get_current_user();
|
|
return "/tmp/.gniza4cp-cpanel-csrf-$user";
|
|
}
|
|
|
|
sub generate_csrf_token {
|
|
return $_current_csrf_token if defined $_current_csrf_token;
|
|
|
|
my $token = '';
|
|
if (open my $ufh, '<:raw', '/dev/urandom') {
|
|
my $bytes;
|
|
read $ufh, $bytes, 32;
|
|
close $ufh;
|
|
$token = unpack('H*', $bytes);
|
|
} else {
|
|
# Fallback (should not happen on Linux)
|
|
for (1..32) {
|
|
$token .= sprintf('%02x', int(rand(256)));
|
|
}
|
|
}
|
|
|
|
my $csrf_file = _csrf_file();
|
|
unless (_safe_write($csrf_file, time() . "\n" . $token . "\n")) {
|
|
if (open my $fh, '>', $csrf_file) {
|
|
print $fh time() . "\n" . $token . "\n";
|
|
close $fh;
|
|
}
|
|
}
|
|
|
|
$_current_csrf_token = $token;
|
|
return $token;
|
|
}
|
|
|
|
sub verify_csrf_token {
|
|
my ($submitted) = @_;
|
|
return 0 unless defined $submitted && $submitted ne '';
|
|
|
|
my $file = _csrf_file();
|
|
my $content = _safe_read($file);
|
|
return 0 unless defined $content;
|
|
|
|
my ($stored_time, $stored_token) = split /\n/, $content, 2;
|
|
return 0 unless defined $stored_time && defined $stored_token;
|
|
|
|
chomp $stored_time;
|
|
chomp $stored_token;
|
|
|
|
# Delete after use (single-use)
|
|
unlink $file;
|
|
|
|
# Check expiry (1 hour)
|
|
return 0 if (time() - $stored_time) > 3600;
|
|
|
|
# Constant-time comparison
|
|
return 0 if length($submitted) != length($stored_token);
|
|
my $result = 0;
|
|
for my $i (0 .. length($submitted) - 1) {
|
|
$result |= ord(substr($submitted, $i, 1)) ^ ord(substr($stored_token, $i, 1));
|
|
}
|
|
return $result == 0;
|
|
}
|
|
|
|
sub csrf_hidden_field {
|
|
my $token = generate_csrf_token();
|
|
return qq{<input type="hidden" name="gniza4cp_csrf" value="} . esc($token) . qq{">};
|
|
}
|
|
|
|
# ── Page Wrappers ────────────────────────────────────────────
|
|
|
|
sub page_header {
|
|
my ($title) = @_;
|
|
$title = esc($title // 'gniza4cp Restore');
|
|
my $css = '';
|
|
if (open my $fh, '<', $CSS_FILE) {
|
|
local $/;
|
|
$css = <$fh>;
|
|
close $fh;
|
|
}
|
|
$css = _unwrap_layers($css);
|
|
$css = _scope_to_container($css);
|
|
|
|
# Pre-compute logo data URI for render_nav()
|
|
if (!$_logo_data_uri && open my $lfh, '<', $LOGO_FILE) {
|
|
local $/;
|
|
my $svg_data = <$lfh>;
|
|
close $lfh;
|
|
require MIME::Base64;
|
|
$_logo_data_uri = 'data:image/svg+xml;base64,' . MIME::Base64::encode_base64($svg_data, '');
|
|
}
|
|
|
|
return qq{<style>$css</style>\n}
|
|
. qq{<div data-theme="gniza4cp" class="font-sans text-base" style="padding:20px 10px 10px 10px">\n};
|
|
}
|
|
|
|
sub page_footer {
|
|
return qq{</div>\n};
|
|
}
|
|
|
|
sub _unwrap_layers {
|
|
my ($css) = @_;
|
|
while ($css =~ /\@layer\s/) {
|
|
$css =~ s/\@layer\s+[\w.,\s]+\s*;//g;
|
|
my $out = '';
|
|
my $i = 0;
|
|
my $len = length($css);
|
|
while ($i < $len) {
|
|
if (substr($css, $i, 6) eq '@layer') {
|
|
my $brace = index($css, '{', $i);
|
|
if ($brace == -1) { $out .= substr($css, $i); last; }
|
|
my $semi = index($css, ';', $i);
|
|
if ($semi != -1 && $semi < $brace) {
|
|
$i = $semi + 1;
|
|
next;
|
|
}
|
|
my $depth = 1;
|
|
my $j = $brace + 1;
|
|
while ($j < $len && $depth > 0) {
|
|
my $c = substr($css, $j, 1);
|
|
$depth++ if $c eq '{';
|
|
$depth-- if $c eq '}';
|
|
$j++;
|
|
}
|
|
$out .= substr($css, $brace + 1, $j - $brace - 2);
|
|
$i = $j;
|
|
} else {
|
|
$out .= substr($css, $i, 1);
|
|
$i++;
|
|
}
|
|
}
|
|
$css = $out;
|
|
}
|
|
return $css;
|
|
}
|
|
|
|
sub _scope_to_container {
|
|
my ($css) = @_;
|
|
$css =~ s/:root,\s*:host/\&/g;
|
|
$css =~ s/:where\(:root,\s*\[data-theme[^\]]*\]\)/\&/g;
|
|
$css =~ s/:where\(:root\)/\&/g;
|
|
$css =~ s/:root,\s*\[data-theme[^\]]*\]/\&/g;
|
|
$css =~ s/\[data-theme=light\]/\&/g;
|
|
$css =~ s/\[data-theme=gniza4cp\]/\&/g;
|
|
$css =~ s/:root:not\(span\)/\&/g;
|
|
$css =~ s/:root:has\(/\&:has(/g;
|
|
$css =~ s/:root\b/\&/g;
|
|
|
|
my @top_level;
|
|
my $scoped = '';
|
|
my $i = 0;
|
|
my $len = length($css);
|
|
while ($i < $len) {
|
|
if (substr($css, $i, 1) eq '@') {
|
|
if (substr($css, $i, 11) eq '@keyframes '
|
|
|| substr($css, $i, 10) eq '@property ') {
|
|
my $brace = index($css, '{', $i);
|
|
if ($brace == -1) { $scoped .= substr($css, $i); last; }
|
|
my $depth = 1;
|
|
my $j = $brace + 1;
|
|
while ($j < $len && $depth > 0) {
|
|
my $c = substr($css, $j, 1);
|
|
$depth++ if $c eq '{';
|
|
$depth-- if $c eq '}';
|
|
$j++;
|
|
}
|
|
push @top_level, substr($css, $i, $j - $i);
|
|
$i = $j;
|
|
next;
|
|
}
|
|
}
|
|
$scoped .= substr($css, $i, 1);
|
|
$i++;
|
|
}
|
|
|
|
return join('', @top_level) . '[data-theme="gniza4cp"]{' . $scoped . '}';
|
|
}
|
|
|
|
sub render_errors {
|
|
my ($errors) = @_;
|
|
return '' unless $errors && @$errors;
|
|
my $html = qq{<div class="alert alert-error mb-4">\n<ul class="list-disc pl-5">\n};
|
|
for my $err (@$errors) {
|
|
$html .= ' <li>' . esc($err) . "</li>\n";
|
|
}
|
|
$html .= "</ul>\n</div>\n";
|
|
return $html;
|
|
}
|
|
|
|
1;
|