Files
gniza4cp/cpanel/gniza/lib/GnizaCPanel/UI.pm
shuki 5ffd365c43 Use inline styles for cPanel navbar to avoid Jupiter CSS conflicts
cPanel's Jupiter theme overrides DaisyUI's .navbar component class.
Replace with plain flex layout using inline styles.

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

328 lines
10 KiB
Perl

package GnizaCPanel::UI;
# Shared UI helpers for the gniza cPanel user restore plugin.
# Adapted from GnizaWHM::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/gniza/assets/gniza-whm.css';
my $LOGO_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza/assets/gniza-logo.svg';
my $_logo_data_uri = '';
my @NAV_ITEMS = (
{ url => 'index.live.cgi', label => 'Select Source' },
{ url => 'restore.live.cgi', label => 'Restore' },
{ url => 'logs.live.cgi', label => 'Logs' },
);
# ── HTML Escaping ─────────────────────────────────────────────
sub esc {
my ($str) = @_;
$str //= '';
$str =~ s/&/&amp;/g;
$str =~ s/</&lt;/g;
$str =~ s/>/&gt;/g;
$str =~ s/"/&quot;/g;
$str =~ s/'/&#39;/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">GNIZA <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/.gniza-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/.gniza-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="gniza_csrf" value="} . esc($token) . qq{">};
}
# ── Page Wrappers ────────────────────────────────────────────
sub page_header {
my ($title) = @_;
$title = esc($title // 'gniza 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="gniza" 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=gniza\]/\&/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="gniza"]{' . $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;