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'; # ── HTML Escaping ───────────────────────────────────────────── sub esc { my ($str) = @_; $str //= ''; $str =~ s/&/&/g; $str =~ s//>/g; $str =~ s/"/"/g; $str =~ s/'/'/g; return $str; } # ── 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))); } } _safe_write(_csrf_file(), time() . "\n" . $token . "\n"); $_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); # Inline logo as base64 data URI my $logo_html = ''; if (open my $lfh, '<', $LOGO_FILE) { local $/; my $svg_data = <$lfh>; close $lfh; require MIME::Base64; my $b64 = MIME::Base64::encode_base64($svg_data, ''); $logo_html = qq{
gniza
\n}; } return qq{\n} . qq{
\n} . $logo_html; } 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
\n"; return $html; } 1;