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 => '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{
}
. qq{GNIZA Backup};
}
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{$label\n};
}
# Use inline styles to avoid cPanel Jupiter CSS conflicts with DaisyUI navbar
my $html = qq{
\n};
$html .= qq{
\n};
$html .= qq{ $logo\n} if $logo;
$html .= qq{
\n};
$html .= qq{
\n};
$html .= qq{ $menu_items};
$html .= qq{
\n};
$html .= qq{
\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{$escaped
\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{};
}
# ── 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{\n}
. qq{\n};
}
sub page_footer {
return qq{
\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{\n
\n};
for my $err (@$errors) {
$html .= ' - ' . esc($err) . "
\n";
}
$html .= "
\n
\n";
return $html;
}
1;