Diff -- /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/admin_audit_log.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/admin_audit_log.cgi

added on local at 2026-07-11 18:38:26

Added
+157
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 429feba3957d
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# AffSoft -- admin: audit log viewer.
4#
5# Searchable / filterable view of admin_audit_log.
6# Filters: ?actor=<uid> ?action=<prefix> ?target_type=<t> ?days=<n>
7#======================================================================
8use strict; use warnings;
9
10use lib '/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com';
11use CGI;
12use MODS::DBConnect;
13use MODS::Login;
14use MODS::AffSoft::Config;
15use MODS::AffSoft::Wrapper;
16
17my $q = CGI->new;
18my $form = $q->Vars;
19my $auth = MODS::Login->new;
20my $wrap = MODS::AffSoft::Wrapper->new;
21my $db = MODS::DBConnect->new;
22my $cfg = MODS::AffSoft::Config->new;
23my $DB = $cfg->settings('database_name');
24
25my $userinfo = $auth->login_verify;
26unless ($userinfo) { print "Status: 302 Found\r\nLocation: /login.cgi\r\n\r\n"; exit; }
27unless ($userinfo->{is_admin} || $userinfo->{is_super_admin}) {
28 print "Status: 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nAdmin only.\n"; exit;
29}
30
31my $actor_filter = ($form->{actor} || 0) + 0;
32my $action_filter = $form->{action} || '';
33$action_filter =~ s/[^a-z0-9_\.\-]//gi; $action_filter = substr($action_filter, 0, 80);
34my $target_filter = $form->{target_type} || '';
35$target_filter =~ s/[^a-z0-9_\-]//gi; $target_filter = substr($target_filter, 0, 60);
36my $days = ($form->{days} || 30) + 0; $days = 30 if $days <= 0; $days = 365 if $days > 365;
37
38my $dbh = $db->db_connect;
39my @where = ("occurred_at >= DATE_SUB(NOW(), INTERVAL $days DAY)");
40push @where, "actor_user_id = $actor_filter" if $actor_filter;
41push @where, "action LIKE '" . _esc($action_filter) . "%'" if length $action_filter;
42push @where, "target_type = '" . _esc($target_filter) . "'" if length $target_filter;
43my $where_sql = join(' AND ', @where);
44
45my @rows = $db->db_readwrite_multiple($dbh, qq~
46 SELECT a.id, a.actor_user_id, a.action, a.target_type, a.target_id,
47 a.before_json, a.after_json, a.note,
48 a.occurred_at, UNIX_TIMESTAMP(a.occurred_at) AS occurred_at_epoch,
49 HEX(a.ip_address) AS ip_hex,
50 u.email AS actor_email, u.display_name AS actor_name
51 FROM ${DB}.admin_audit_log a
52 LEFT JOIN ${DB}.users u ON u.id = a.actor_user_id
53 WHERE $where_sql
54 ORDER BY a.id DESC LIMIT 300
55~, $0, __LINE__);
56
57# Distinct action prefixes for the filter dropdown
58my @actions = $db->db_readwrite_multiple($dbh, qq~
59 SELECT DISTINCT action FROM ${DB}.admin_audit_log
60 WHERE occurred_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
61 ORDER BY action LIMIT 50
62~, $0, __LINE__);
63
64$db->db_disconnect($dbh);
65
66my $body = qq~
67<div class="page-pad" style="padding:0">
68 <h1 style="margin:8px 0 4px;font-size:24px;color:#fff">Audit log</h1>
69 <p style="color:#9ca3af;margin:0 0 24px;font-size:14px">Every administrative action that changed state. Filter + drill in.</p>
70
71 <form method="GET" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px;align-items:center">
72 <select name="action" style="padding:8px 12px;background:#0f1014;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:13px">
73 <option value="">All actions</option>
74~;
75for my $a (@actions) {
76 my $sel = ($a->{action} eq $action_filter) ? ' selected' : '';
77 my $lbl = _h($a->{action});
78 $body .= qq~ <option value="$lbl"$sel>$lbl</option>\n~;
79}
80$body .= qq~
81 </select>
82 <input type="number" name="actor" value="${\ ($actor_filter || '') }" placeholder="Actor user id" style="padding:8px 12px;background:#0f1014;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:13px;width:120px">
83 <input type="text" name="target_type" value="${\ _h($target_filter) }" placeholder="Target type" style="padding:8px 12px;background:#0f1014;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:13px;width:140px">
84 <select name="days" style="padding:8px 12px;background:#0f1014;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:13px">
85~;
86for my $d ([1,'1 day'], [7,'7 days'], [30,'30 days'], [90,'90 days'], [365,'1 year']) {
87 my $sel = $d->[0] == $days ? ' selected' : '';
88 $body .= qq~ <option value="$d->[0]"$sel>$d->[1]</option>\n~;
89}
90$body .= qq~
91 </select>
92 <button type="submit" style="padding:8px 16px;background:#5aa9ff;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:600;cursor:pointer">Filter</button>
93 </form>
94
95 <div style="background:rgba(20,20,30,.4);border:1px solid rgba(255,255,255,.06);border-radius:14px;overflow:hidden">
96 <div style="padding:12px 18px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#a78bfa;font-weight:600;background:rgba(102,99,255,.05)">${\ scalar(@rows) } event(s)</div>
97~;
98
99if (@rows) {
100 for my $r (@rows) {
101 my $actor = _h($r->{actor_name} || $r->{actor_email} || ('uid=' . ($r->{actor_user_id} || '?')));
102 my $action = _h($r->{action});
103 my $ttype = _h($r->{target_type} || '');
104 my $tid = $r->{target_id} || '-';
105 my $note = _h($r->{note} || '');
106 my $when = $r->{occurred_at};
107 my $when_ep = ($r->{occurred_at_epoch} || 0) + 0;
108 my $before = _h($r->{before_json} || '');
109 my $after = _h($r->{after_json} || '');
110 my $diff = '';
111 if (length($before) || length($after)) {
112 $diff = qq~<div style="margin-top:6px;font-family:ui-monospace,Consolas,Menlo,monospace;font-size:11px;color:#9ca3af">~;
113 $diff .= qq~before: $before<br>~ if length $before;
114 $diff .= qq~after:&nbsp; $after~ if length $after;
115 $diff .= qq~</div>~;
116 }
117 my $note_block = length($note) ? qq~<div style="font-size:12px;color:#fcd34d;margin-top:4px">"$note"</div>~ : '';
118 $body .= qq~
119<div style="padding:14px 18px;border-top:1px solid rgba(255,255,255,.06)">
120 <div style="display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;align-items:baseline">
121 <div>
122 <span style="font-family:ui-monospace;font-size:12px;color:#a78bfa;font-weight:600">$action</span>
123 <span style="color:#9ca3af;font-size:12px">&middot; by</span>
124 <span style="color:#e5e7eb;font-size:13px">$actor</span>
125 ${\ ($ttype ? qq~ <span style="color:#9ca3af;font-size:12px">on</span> <span style="color:#e5e7eb;font-size:13px">$ttype #$tid</span>~ : '') }
126 </div>
127 <span style="color:#6b7280;font-size:11px;font-family:ui-monospace"><span class="ts" data-ts="$when_ep" data-fmt="datetime">$when</span></span>
128 </div>
129 $note_block
130 $diff
131</div>
132~;
133 }
134} else {
135 $body .= '<div style="padding:24px;text-align:center;color:#9ca3af;font-size:13px">No audit events match.</div>';
136}
137$body .= '</div></div>';
138
139print "Content-Type: text/html; charset=utf-8\r\nCache-Control: no-cache\r\n\r\n";
140$wrap->render({
141 userinfo => $userinfo,
142 page_key => 'admin_audit',
143 title => 'Audit log',
144 body => $body,
145});
146
147sub _h {
148 my $s = shift // '';
149 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
150 return $s;
151}
152
153sub _esc {
154 my $s = shift // '';
155 $s =~ s/\\/\\\\/g; $s =~ s/'/\\'/g;
156 return $s;
157}