Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/admin_governance.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/admin_governance.cgi

added on local at 2026-07-11 18:33:15

Added
+364
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 36960a74d2ab
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# PTMatrix - Admin Governance
4#
5# One page, three tabs (param-dispatched via ?tab=):
6# users - ban / cancel / reinstate a user's account_state
7# blocklist - IP / email / email-domain / fingerprint blocklist
8# exclusions - tracking_exclusions (admin scope + per-user)
9#
10# Auto-generated 2026-07-02 from phase7_admin_governance.pl.tmpl
11#======================================================================
12use strict; use warnings;
13use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com';
14use CGI;
15use MODS::Template;
16use MODS::DBConnect;
17use MODS::Login;
18use MODS::PTMatrix::Config;
19use MODS::PTMatrix::Wrapper;
20
21my $q = CGI->new;
22my $form = $q->Vars;
23my $auth = MODS::Login->new;
24my $wrap = MODS::PTMatrix::Wrapper->new;
25my $db = MODS::DBConnect->new;
26my $cfg = MODS::PTMatrix::Config->new;
27my $DB = $cfg->settings('database_name');
28
29my $userinfo = $auth->login_verify();
30if (!$userinfo) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
31unless ($userinfo->{ is_super_admin } || $userinfo->{_admin_is_admin} || $userinfo->{_admin_is_super_admin}) {
32 print "Status: 403 Forbidden\nContent-Type: text/plain\n\nAdmin only.\n";
33 exit;
34}
35
36my $dbh = $db->db_connect();
37unless ($dbh) { print "Status: 500\nContent-Type: text/plain\n\nDB unavailable.\n"; exit; }
38
39# ---- Action dispatch (POST) -----------------------------------------
40if (($ENV{REQUEST_METHOD} || '') eq 'POST') {
41 my $action = $form->{action} || '';
42 if ($action eq 'ban') { _act_set_state($form->{user_id}, 'banned', $form->{reason}); }
43 elsif ($action eq 'cancel') { _act_set_state($form->{user_id}, 'cancelled', $form->{reason}); }
44 elsif ($action eq 'billing_lock') { _act_set_state($form->{user_id}, 'billing_locked', $form->{reason}); }
45 elsif ($action eq 'reinstate') { _act_set_state($form->{user_id}, 'active', ''); }
46 elsif ($action eq 'block_add') { _act_block_add($form->{kind}, $form->{value}, $form->{reason}); }
47 elsif ($action eq 'block_del') { _act_block_del($form->{id}); }
48 elsif ($action eq 'excl_add') { _act_excl_add($form->{scope}, $form->{ip}, $form->{label}); }
49 elsif ($action eq 'excl_del') { _act_excl_del($form->{id}); }
50 # Redirect back to the same tab so the change is visible
51 my $tab = $form->{tab} || 'users';
52 print "Status: 302 Found\nLocation: /admin_governance.cgi?tab=$tab\n\n";
53 exit;
54}
55
56# ---- Render tab -----------------------------------------------------
57my $tab = $form->{tab} || 'users';
58$tab = 'users' unless $tab =~ /^(users|blocklist|exclusions)$/;
59
60my $body_html = '';
61if ($tab eq 'users') { $body_html = _render_users(); }
62elsif ($tab eq 'blocklist') { $body_html = _render_blocklist(); }
63elsif ($tab eq 'exclusions') { $body_html = _render_exclusions(); }
64
65$db->db_disconnect($dbh);
66
67# Tab bar + brand-tinted styling
68my $accent = '#b94854';
69my $tabbar = qq~
70<style>
71.gov-shell { padding: 24px 40px 60px; max-width: 1400px; margin: 0 auto; }
72.gov-title { color: #fff; font-size: 26px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 6px; }
73.gov-sub { color: #7a8499; font-size: 14px; margin-bottom: 22px; }
74.gov-tabs { display: flex; gap: 6px; margin-bottom: 22px; border-bottom: 1px solid rgba(255,255,255,0.08); }
75.gov-tab { padding: 12px 20px; color: #b0b8c7; text-decoration: none; font-weight: 600; font-size: 14px; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: all 0.15s ease; }
76.gov-tab:hover { color: #fff; }
77.gov-tab.active { color: $accent; border-bottom-color: $accent; }
78.gov-panel { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); border-radius: 14px; padding: 22px; }
79.gov-h2 { color: #fff; font-size: 18px; font-weight: 700; margin: 0 0 14px; }
80.gov-tbl { width: 100%; border-collapse: collapse; }
81.gov-tbl th{ text-align: left; font-size: 11px; font-weight: 700; color: #7a8499; text-transform: uppercase; letter-spacing: 0.06em; padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); }
82.gov-tbl td{ padding: 12px; border-bottom: 1px solid rgba(255,255,255,0.05); color: #e8edf2; font-size: 13px; vertical-align: middle; }
83.gov-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; height: 34px; box-sizing: border-box; border-radius: 8px; font-size: 12px; font-weight: 600; text-decoration: none; border: none; cursor: pointer; }
84.gov-btn.primary { background: $accent; color: #fff; }
85.gov-btn.ghost { background: rgba(255,255,255,0.05); color: #cfd6e0; border: 1px solid rgba(255,255,255,0.08); }
86.gov-btn.danger { background: rgba(239,68,68,0.14); color: #fca5a5; border: 1px solid rgba(239,68,68,0.35); }
87.gov-input, .gov-select { background: #161c25; border: 1px solid rgba(255,255,255,0.08); color: #e8edf2; padding: 0 12px; height: 34px; border-radius: 8px; font-size: 13px; box-sizing: border-box; }
88.gov-input { width: 220px; }
89.gov-add { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; padding: 12px; background: rgba(255,255,255,0.02); border-radius: 10px; }
90.state-pill { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
91.state-active { background: rgba(34,197,94,0.14); color: #6ee7b7; }
92.state-cancelled { background: rgba(148,163,184,0.14); color: #cbd5e1; }
93.state-banned { background: rgba(239,68,68,0.14); color: #fca5a5; }
94.state-billing_locked { background: rgba(245,158,11,0.14); color: #fbbf24; }
95</style>
96<div class="gov-shell">
97 <div class="gov-title">Admin Governance</div>
98 <div class="gov-sub">Ban, cancel, or unlock accounts; manage the blocklist; manage tracking exclusions.</div>
99 <div class="gov-tabs">
100 <a class="gov-tab @{[$tab eq 'users' ? 'active' : '']}" href="?tab=users">Users</a>
101 <a class="gov-tab @{[$tab eq 'blocklist' ? 'active' : '']}" href="?tab=blocklist">Block list</a>
102 <a class="gov-tab @{[$tab eq 'exclusions' ? 'active' : '']}" href="?tab=exclusions">Tracking exclusions</a>
103 </div>
104 <div class="gov-panel">
105$body_html
106 </div>
107</div>
108~;
109
110print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
111$wrap->render({
112 userinfo => $userinfo,
113 title => 'Admin Governance',
114 body => $tabbar,
115});
116exit;
117
118
119#======================================================================
120# Action handlers
121#======================================================================
122sub _act_set_state {
123 my ($user_id, $state, $reason) = @_;
124 $user_id =~ s/[^0-9]//g if defined $user_id;
125 return unless $user_id;
126 my %ok = map { $_ => 1 } qw(active cancelled banned billing_locked);
127 return unless $ok{$state};
128 my $reason_q = defined $reason ? $reason : '';
129 $reason_q =~ s/[\\']/\\$&/g;
130 $reason_q = substr($reason_q, 0, 500);
131 my $admin_id = $userinfo->{_admin_user_id} || $userinfo->{user_id};
132 $admin_id =~ s/[^0-9]//g;
133 my $extras = '';
134 $extras .= ", cancelled_at = NOW()" if $state eq 'cancelled';
135 $extras .= ", banned_at = NOW()" if $state eq 'banned';
136 $extras .= ", banned_reason = '$reason_q'" if $state eq 'banned';
137 $extras .= ", banned_by_admin_id = $admin_id" if $state eq 'banned' && $admin_id;
138 # Clear ban fields when reinstating
139 if ($state eq 'active') {
140 $extras .= ", banned_at = NULL, banned_reason = NULL, banned_by_admin_id = NULL, cancelled_at = NULL";
141 }
142 $db->db_readwrite($dbh, qq~
143 UPDATE ${DB}.users
144 SET account_state = '$state' $extras
145 WHERE id = '$user_id'
146 ~, $ENV{SCRIPT_NAME}, __LINE__);
147 # Kill any active sessions for banned/cancelled users
148 if ($state ne 'active') {
149 $db->db_readwrite($dbh, qq~
150 DELETE FROM ${DB}.user_sessions WHERE user_id = '$user_id'
151 ~, $ENV{SCRIPT_NAME}, __LINE__);
152 }
153}
154
155sub _act_block_add {
156 my ($kind, $value, $reason) = @_;
157 my %ok = map { $_ => 1 } qw(ip email email_domain fingerprint);
158 return unless $ok{$kind};
159 $value =~ s/^\s+|\s+$//g;
160 return unless length $value;
161 $value = lc $value if $kind ne 'ip';
162 $value =~ s/[\\']/\\$&/g;
163 $value = substr($value, 0, 191);
164 my $reason_q = defined $reason ? $reason : '';
165 $reason_q =~ s/[\\']/\\$&/g;
166 $reason_q = substr($reason_q, 0, 500);
167 my $admin_id = $userinfo->{_admin_user_id} || $userinfo->{user_id};
168 $admin_id =~ s/[^0-9]//g;
169 my $admin_sql = $admin_id ? "'$admin_id'" : "NULL";
170 $db->db_readwrite($dbh, qq~
171 INSERT INTO ${DB}.blocklist (kind, value, reason, created_by, active)
172 VALUES ('$kind', '$value', '$reason_q', $admin_sql, 1)
173 ON DUPLICATE KEY UPDATE active=1, reason=VALUES(reason)
174 ~, $ENV{SCRIPT_NAME}, __LINE__);
175}
176
177sub _act_block_del {
178 my ($id) = @_;
179 $id =~ s/[^0-9]//g if defined $id;
180 return unless $id;
181 $db->db_readwrite($dbh, qq~
182 UPDATE ${DB}.blocklist SET active = 0 WHERE id = '$id'
183 ~, $ENV{SCRIPT_NAME}, __LINE__);
184}
185
186sub _act_excl_add {
187 my ($scope, $ip, $label) = @_;
188 my %ok = map { $_ => 1 } qw(admin user);
189 return unless $ok{$scope};
190 $ip =~ s/[^0-9a-fA-F:.]//g if defined $ip;
191 return unless length $ip;
192 my $label_q = defined $label ? $label : '';
193 $label_q =~ s/[\\']/\\$&/g;
194 $label_q = substr($label_q, 0, 100);
195 my $exists = $db->db_readwrite($dbh, qq~
196 SELECT id FROM ${DB}.tracking_exclusions
197 WHERE scope='$scope' AND ip='$ip' AND user_id IS NULL LIMIT 1
198 ~, $ENV{SCRIPT_NAME}, __LINE__);
199 return if $exists && $exists->{id};
200 $db->db_readwrite($dbh, qq~
201 INSERT INTO ${DB}.tracking_exclusions (scope, user_id, ip, label, source, active)
202 VALUES ('$scope', NULL, '$ip', '$label_q', 'manual', 1)
203 ~, $ENV{SCRIPT_NAME}, __LINE__);
204}
205
206sub _act_excl_del {
207 my ($id) = @_;
208 $id =~ s/[^0-9]//g if defined $id;
209 return unless $id;
210 $db->db_readwrite($dbh, qq~
211 DELETE FROM ${DB}.tracking_exclusions WHERE id = '$id'
212 ~, $ENV{SCRIPT_NAME}, __LINE__);
213}
214
215
216#======================================================================
217# Rendering
218#======================================================================
219sub _render_users {
220 my @rows = $db->db_readwrite_multiple($dbh, qq~
221 SELECT id, email, display_name, account_state, cancelled_at, banned_at, banned_reason, created_at
222 FROM ${DB}.users
223 ORDER BY (account_state != 'active') DESC, created_at DESC
224 LIMIT 200
225 ~, $ENV{SCRIPT_NAME}, __LINE__);
226 my $html = '<div class="gov-h2">Users (state overview)</div>';
227 $html .= '<table class="gov-tbl"><thead><tr><th>ID</th><th>Email</th><th>Display name</th><th>State</th><th>Actions</th></tr></thead><tbody>';
228 for my $u (@rows) {
229 my $st = $u->{account_state} || 'active';
230 my $email = _esc($u->{email});
231 my $name = _esc($u->{display_name});
232 my $reason = $st eq 'banned' && $u->{banned_reason} ? ' &middot; <em>'._esc($u->{banned_reason}).'</em>' : '';
233 my $actions = '';
234 if ($st eq 'active') {
235 $actions = qq~
236 <form method="POST" style="display:inline">
237 <input type="hidden" name="user_id" value="$u->{id}"/>
238 <input type="hidden" name="tab" value="users"/>
239 <input type="hidden" name="action" value="ban"/>
240 <input class="gov-input" style="width:180px" name="reason" placeholder="Reason (optional)"/>
241 <button class="gov-btn danger" type="submit">Ban</button>
242 </form>
243 <form method="POST" style="display:inline;margin-left:6px">
244 <input type="hidden" name="user_id" value="$u->{id}"/>
245 <input type="hidden" name="tab" value="users"/>
246 <input type="hidden" name="action" value="cancel"/>
247 <button class="gov-btn ghost" type="submit">Cancel</button>
248 </form>
249 ~;
250 } else {
251 $actions = qq~
252 <form method="POST" style="display:inline">
253 <input type="hidden" name="user_id" value="$u->{id}"/>
254 <input type="hidden" name="tab" value="users"/>
255 <input type="hidden" name="action" value="reinstate"/>
256 <button class="gov-btn primary" type="submit">Reinstate</button>
257 </form>
258 ~;
259 }
260 $html .= qq~<tr>
261 <td>$u->{id}</td>
262 <td>$email</td>
263 <td>$name</td>
264 <td><span class="state-pill state-$st">$st</span>$reason</td>
265 <td>$actions</td>
266 </tr>~;
267 }
268 $html .= '</tbody></table>';
269 return $html;
270}
271
272sub _render_blocklist {
273 my @rows = $db->db_readwrite_multiple($dbh, qq~
274 SELECT id, kind, value, reason, created_at, UNIX_TIMESTAMP(created_at) AS created_epoch, active
275 FROM ${DB}.blocklist
276 ORDER BY active DESC, created_at DESC
277 LIMIT 500
278 ~, $ENV{SCRIPT_NAME}, __LINE__);
279 my $html = '<div class="gov-h2">Block list</div>';
280 $html .= q~
281 <form method="POST" class="gov-add">
282 <input type="hidden" name="action" value="block_add"/>
283 <input type="hidden" name="tab" value="blocklist"/>
284 <select name="kind" class="gov-select">
285 <option value="ip">IP</option>
286 <option value="email">Email</option>
287 <option value="email_domain">Email domain</option>
288 <option value="fingerprint">Fingerprint hash</option>
289 </select>
290 <input class="gov-input" name="value" placeholder="Value" required/>
291 <input class="gov-input" name="reason" placeholder="Reason (optional)"/>
292 <button class="gov-btn primary" type="submit">Add to blocklist</button>
293 </form>
294 ~;
295 $html .= '<table class="gov-tbl"><thead><tr><th>ID</th><th>Kind</th><th>Value</th><th>Reason</th><th>Added</th><th>Status</th><th></th></tr></thead><tbody>';
296 for my $r (@rows) {
297 my $val = _esc($r->{value});
298 my $reason = _esc($r->{reason} // '');
299 my $active = $r->{active} ? '<span class="state-pill state-banned">BLOCKED</span>' : '<span class="state-pill state-cancelled">inactive</span>';
300 my $btn = $r->{active}
301 ? qq~<form method="POST" style="display:inline"><input type="hidden" name="action" value="block_del"/><input type="hidden" name="tab" value="blocklist"/><input type="hidden" name="id" value="$r->{id}"/><button class="gov-btn ghost" type="submit">Remove</button></form>~
302 : '';
303 $html .= qq~<tr>
304 <td>$r->{id}</td>
305 <td>$r->{kind}</td>
306 <td><code>$val</code></td>
307 <td>$reason</td>
308 <td><span class="ts" data-ts="$r->{created_epoch}" data-fmt="datetime">$r->{created_at}</span></td>
309 <td>$active</td>
310 <td>$btn</td>
311 </tr>~;
312 }
313 $html .= '</tbody></table>';
314 return $html;
315}
316
317sub _render_exclusions {
318 my @rows = $db->db_readwrite_multiple($dbh, qq~
319 SELECT id, scope, user_id, ip, label, source, created_at, UNIX_TIMESTAMP(created_at) AS created_epoch, active
320 FROM ${DB}.tracking_exclusions
321 WHERE active=1
322 ORDER BY scope ASC, created_at DESC
323 LIMIT 500
324 ~, $ENV{SCRIPT_NAME}, __LINE__);
325 my $html = '<div class="gov-h2">Tracking exclusions</div>';
326 $html .= q~
327 <form method="POST" class="gov-add">
328 <input type="hidden" name="action" value="excl_add"/>
329 <input type="hidden" name="tab" value="exclusions"/>
330 <select name="scope" class="gov-select">
331 <option value="admin">Admin (all sites)</option>
332 <option value="user">User (per-user)</option>
333 </select>
334 <input class="gov-input" name="ip" placeholder="IP address" required/>
335 <input class="gov-input" name="label" placeholder="Label (optional)"/>
336 <button class="gov-btn primary" type="submit">Add exclusion</button>
337 </form>
338 ~;
339 $html .= '<table class="gov-tbl"><thead><tr><th>ID</th><th>Scope</th><th>User</th><th>IP</th><th>Label</th><th>Source</th><th>Added</th><th></th></tr></thead><tbody>';
340 for my $r (@rows) {
341 my $ip = _esc($r->{ip});
342 my $label = _esc($r->{label} // '');
343 my $uid = defined $r->{user_id} ? $r->{user_id} : '&mdash;';
344 $html .= qq~<tr>
345 <td>$r->{id}</td>
346 <td><span class="state-pill state-active">$r->{scope}</span></td>
347 <td>$uid</td>
348 <td><code>$ip</code></td>
349 <td>$label</td>
350 <td>$r->{source}</td>
351 <td><span class="ts" data-ts="$r->{created_epoch}" data-fmt="datetime">$r->{created_at}</span></td>
352 <td><form method="POST" style="display:inline"><input type="hidden" name="action" value="excl_del"/><input type="hidden" name="tab" value="exclusions"/><input type="hidden" name="id" value="$r->{id}"/><button class="gov-btn ghost" type="submit">Remove</button></form></td>
353 </tr>~;
354 }
355 $html .= '</tbody></table>';
356 return $html;
357}
358
359sub _esc {
360 my $s = defined $_[0] ? $_[0] : '';
361 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
362 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
363 return $s;
364}