Diff -- /var/www/vhosts/3dshawn.com/shop.3dshawn.com/admin_users.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/shop.3dshawn.com/admin_users.cgi

added on local at 2026-07-11 18:36:29

Added
+465
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 2256c6fc83f1
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# ShopCart - SUPER-ADMIN: All Users (TF-mirror layout)
4#
5# Consolidates the former /admin_user.cgi (per-user detail page) via
6# param dispatch on ?u=<int>. When ?u=<int> is present, this file
7# renders the drill-down; otherwise it renders the list.
8#======================================================================
9use strict; use warnings;
10use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com';
11use CGI;
12use MODS::Template;
13use MODS::DBConnect;
14use MODS::Login;
15use MODS::ShopCart::Config;
16use MODS::ShopCart::Wrapper;
17use MODS::ShopCart::Admin;
18use MODS::ShopCart::Billing;
19use MODS::ShopCart::Storage;
20
21my $q = CGI->new;
22my $form = $q->Vars;
23my $auth = MODS::Login->new;
24my $wrap = MODS::ShopCart::Wrapper->new;
25my $tfile = MODS::Template->new;
26my $db = MODS::DBConnect->new;
27my $cfg = MODS::ShopCart::Config->new;
28my $admin = MODS::ShopCart::Admin->new;
29my $DB = $cfg->settings('database_name');
30
31$| = 1;
32
33my $userinfo = $auth->login_verify();
34if (!$userinfo) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
35$admin->require_admin($userinfo);
36require MODS::ShopCart::Permissions;
37MODS::ShopCart::Permissions->new->require_feature($userinfo, 'admin_view_users');
38
39# ---- Param dispatch: ?u=<int> triggers detail view ------------------
40my $uid_param = $form->{u} || '';
41$uid_param =~ s/[^0-9]//g;
42if ($uid_param) {
43 _usr_render_detail($uid_param);
44 exit;
45}
46
47# ---- Default: list view ---------------------------------------------
48_usr_render_list();
49exit;
50
51#======================================================================
52# List view (formerly admin_users.cgi)
53#======================================================================
54sub _usr_render_list {
55 # Sort: default created_at DESC; ?sort=storage_pct&dir=desc sorts by
56 # computed storage % so admins can spot heavy users instantly.
57 my $sort = lc($form->{sort} || '');
58 $sort =~ s/[^a-z_]//g;
59 $sort = '' unless $sort =~ /^(storage_pct)$/;
60 my $dir = lc($form->{dir} || 'desc');
61 $dir = 'desc' unless $dir =~ /^(asc|desc)$/;
62
63 my $sq = $form->{q} || '';
64 $sq =~ s/[^A-Za-z0-9_\-\.\s\@]//g;
65
66 my $dbh = $db->db_connect();
67
68 my $where = '1=1';
69 $where .= " AND (u.email LIKE '%$sq%' OR u.display_name LIKE '%$sq%' OR s.name LIKE '%$sq%' OR s.subdomain LIKE '%$sq%')" if $sq;
70
71 my @users = $db->db_readwrite_multiple($dbh, qq~
72 SELECT u.id, u.email, u.display_name, u.plan_tier, u.account_status,
73 u.is_admin, u.last_login_at, u.created_at,
74 UNIX_TIMESTAMP(u.last_login_at) AS last_login_at_epoch,
75 UNIX_TIMESTAMP(u.created_at) AS created_at_epoch,
76 u.upload_max_mb_override, u.storage_hard_cap_gb_override,
77 u.storage_used_bytes,
78 s.id AS storefront_id, s.name AS storefront_name, s.subdomain
79 FROM `${DB}`.users u
80 LEFT JOIN `${DB}`.storefronts s ON s.user_id = u.id
81 WHERE $where
82 GROUP BY u.id
83 ORDER BY u.created_at DESC
84 LIMIT 200
85 ~, $ENV{SCRIPT_NAME}, __LINE__);
86
87 # Tier-default GB caps for storage % computation. Pull once outside the
88 # loop so we don't hit the settings cache 200x.
89 my %tier_hard_gb = (
90 free => $cfg->settings('storage_hard_cap_gb_free') || 5,
91 pro => $cfg->settings('storage_hard_cap_gb_pro') || 200,
92 business => $cfg->settings('storage_hard_cap_gb_business') || 2048,
93 );
94
95 my @palette = ('#5aa9ff','#34d399','#fbbf24','#a78bfa','#f87171','#22c55e','#06b6d4','#ec4899','#f59e0b');
96 foreach my $u (@users) {
97 my $dn = $u->{display_name} || $u->{email} || ('User #' . $u->{id});
98
99 # ---- Storage % (computed; column is virtual in the SELECT result) ----
100 my $tier = lc($u->{plan_tier} || 'free');
101 $tier = 'free' unless $tier =~ /^(free|pro|business)$/;
102 my $hard_gb = (defined $u->{storage_hard_cap_gb_override} && length $u->{storage_hard_cap_gb_override})
103 ? $u->{storage_hard_cap_gb_override} + 0
104 : $tier_hard_gb{$tier} + 0;
105 my $hard_bytes = $hard_gb * 1073741824;
106 my $used = ($u->{storage_used_bytes} || 0) + 0;
107 my $pct = $hard_bytes > 0 ? int($used * 100 / $hard_bytes) : 0;
108 $pct = 999 if $pct > 999;
109 $u->{storage_pct_raw} = $pct;
110 $u->{storage_pct} = $pct . '%';
111 $u->{storage_used_h} = MODS::ShopCart::Storage::human_size($used);
112 $u->{storage_cap_h} = MODS::ShopCart::Storage::human_size($hard_bytes);
113 # Color buckets: green < 50, yellow 50-79, red >= 80, bold-red >= 100.
114 $u->{storage_color} = '#4ade80';
115 $u->{storage_color} = '#fbbf24' if $pct >= 50 && $pct < 80;
116 $u->{storage_color} = '#f87171' if $pct >= 80;
117 $u->{storage_over} = $pct >= 100 ? 1 : 0;
118
119 $u->{display_name} = _usr_h($dn);
120 $u->{email} = _usr_h($u->{email});
121 $u->{plan_tier} = ucfirst($u->{plan_tier} || 'free');
122 $u->{account_status} = _usr_h($u->{account_status} || 'active');
123 $u->{is_admin_flag} = $u->{is_admin} ? 1 : 0;
124 my $_ll_raw = $u->{last_login_at};
125 my $_ll_ep = $u->{last_login_at_epoch};
126 $u->{last_login_at} = ($_ll_raw && $_ll_ep)
127 ? qq~<span class="ts" data-ts="$_ll_ep" data-fmt="datetime">$_ll_raw</span>~
128 : '-';
129 $u->{storefront_name} = _usr_h($u->{storefront_name} || '-');
130 $u->{avatar_color} = $palette[ ($u->{id} || 0) % scalar(@palette) ];
131 my @parts = split /\s+/, $dn, 2;
132 my $ini = '';
133 $ini .= uc(substr($parts[0], 0, 1)) if defined $parts[0];
134 $ini .= uc(substr($parts[1], 0, 1)) if defined $parts[1] && length $parts[1];
135 $ini = uc(substr($dn, 0, 2)) unless length $ini;
136 $u->{initials} = $ini;
137 $u->{is_self} = ($u->{id} == $userinfo->{user_id}) ? 1 : 0;
138 }
139
140 # Sort by storage %, requested via ?sort=storage_pct&dir=desc|asc.
141 # Done after the main query because storage_pct is computed in Perl.
142 if ($sort eq 'storage_pct') {
143 @users = sort { $b->{storage_pct_raw} <=> $a->{storage_pct_raw} } @users;
144 @users = reverse @users if $dir eq 'asc';
145 }
146
147 $db->db_disconnect($dbh);
148
149 print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
150 my $tvars = {
151 users => \@users,
152 has_users => scalar(@users) ? 1 : 0,
153 q => _usr_h($sq),
154 sort => _usr_h($sort),
155 dir => _usr_h($dir),
156 sort_by_storage => ($sort eq 'storage_pct') ? 1 : 0,
157 };
158 my $body = join('', $tfile->template('shopcart_admin_users.html', $tvars, $userinfo));
159 $wrap->render({
160 userinfo => $userinfo,
161 page_key => 'admin_users',
162 title => 'All Users',
163 body => $body,
164 });
165 return;
166}
167
168#======================================================================
169# Detail view (formerly admin_user.cgi) - ?u=<id> per-user drill-down
170#======================================================================
171sub _usr_render_detail {
172 my ($uid) = @_;
173 my $bill = MODS::ShopCart::Billing->new;
174 my $mp = eval { require MODS::ShopCart::Marketplaces; MODS::ShopCart::Marketplaces->new } || undef;
175
176 my $dbh = $db->db_connect();
177
178 # ---- The target user -------------------------------------------------
179 my $u = $db->db_readwrite($dbh, qq~
180 SELECT id, email, display_name, plan_tier, account_status, is_admin,
181 trust_level, two_factor_enabled, email_verified_at,
182 last_login_at, default_currency, timezone, created_at, updated_at,
183 UNIX_TIMESTAMP(last_login_at) AS last_login_at_epoch,
184 UNIX_TIMESTAMP(created_at) AS created_at_epoch,
185 UNIX_TIMESTAMP(updated_at) AS updated_at_epoch,
186 UNIX_TIMESTAMP(email_verified_at) AS email_verified_at_epoch,
187 upload_max_mb_override, storage_hard_cap_gb_override,
188 storage_used_bytes
189 FROM `${DB}`.users
190 WHERE id='$uid'
191 LIMIT 1
192 ~, $ENV{SCRIPT_NAME}, __LINE__);
193
194 if (!$u || !$u->{id}) {
195 $db->db_disconnect($dbh);
196 print "Status: 302 Found\nLocation: /admin.cgi\n\n";
197 return;
198 }
199
200 # ---- Storefront(s) ---------------------------------------------------
201 my @storefronts = $db->db_readwrite_multiple($dbh, qq~
202 SELECT id, name, subdomain, created_at,
203 UNIX_TIMESTAMP(created_at) AS created_at_epoch
204 FROM `${DB}`.storefronts
205 WHERE user_id='$uid'
206 ORDER BY id ASC
207 ~, $ENV{SCRIPT_NAME}, __LINE__);
208 foreach my $sf (@storefronts) {
209 my $_r = $sf->{created_at}; my $_e = $sf->{created_at_epoch};
210 $sf->{created_at} = ($_r && $_e)
211 ? qq~<span class="ts" data-ts="$_e" data-fmt="datetime">$_r</span>~
212 : ($_r || '-');
213 }
214
215 # ---- Model counts ----------------------------------------------------
216 my $m_total = _usr_scalar($db, $dbh,
217 "SELECT COUNT(*) AS n FROM `${DB}`.models WHERE user_id='$uid' AND purged_at IS NULL", 'n');
218 my $m_purged = _usr_scalar($db, $dbh,
219 "SELECT COUNT(*) AS n FROM `${DB}`.models WHERE user_id='$uid' AND purged_at IS NOT NULL", 'n');
220 my $m_published = _usr_scalar($db, $dbh,
221 "SELECT COUNT(*) AS n FROM `${DB}`.models WHERE user_id='$uid' AND status='published' AND purged_at IS NULL", 'n');
222
223 # Last 10 models so the admin can see what they're working on.
224 my @models = $db->db_readwrite_multiple($dbh, qq~
225 SELECT id, title, status, visibility, currency, base_price_cents,
226 created_at, updated_at,
227 UNIX_TIMESTAMP(created_at) AS created_at_epoch,
228 UNIX_TIMESTAMP(updated_at) AS updated_at_epoch
229 FROM `${DB}`.models
230 WHERE user_id='$uid' AND purged_at IS NULL
231 ORDER BY updated_at DESC
232 LIMIT 10
233 ~, $ENV{SCRIPT_NAME}, __LINE__);
234
235 foreach my $m (@models) {
236 $m->{price} = '$' . sprintf('%.2f', ($m->{base_price_cents} || 0) / 100);
237 $m->{title} = $m->{title} || ('Model #' . $m->{id});
238 my $_cr = $m->{created_at}; my $_ce = $m->{created_at_epoch};
239 $m->{created_at} = ($_cr && $_ce)
240 ? qq~<span class="ts" data-ts="$_ce" data-fmt="datetime">$_cr</span>~
241 : ($_cr || '-');
242 my $_ur = $m->{updated_at}; my $_ue = $m->{updated_at_epoch};
243 $m->{updated_at} = ($_ur && $_ue)
244 ? qq~<span class="ts" data-ts="$_ue" data-fmt="datetime">$_ur</span>~
245 : ($_ur || '-');
246 }
247
248 # ---- Orders / revenue -----------------------------------------------
249 my $orders_30d_count = 0;
250 my $orders_30d_cents = 0;
251 my $orders_all_cents = 0;
252 my @recent_orders;
253
254 if (@storefronts) {
255 my $sids = join(',', map { "'$_->{id}'" } @storefronts);
256
257 my $rec30 = $db->db_readwrite($dbh, qq~
258 SELECT COUNT(*) AS n, COALESCE(SUM(total_amount_cents),0) AS cents
259 FROM `${DB}`.orders
260 WHERE storefront_id IN ($sids)
261 AND status='paid'
262 AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
263 ~, $ENV{SCRIPT_NAME}, __LINE__);
264 if ($rec30) {
265 $orders_30d_count = $rec30->{n} || 0;
266 $orders_30d_cents = $rec30->{cents} || 0;
267 }
268
269 my $allr = $db->db_readwrite($dbh, qq~
270 SELECT COALESCE(SUM(total_amount_cents),0) AS cents
271 FROM `${DB}`.orders
272 WHERE storefront_id IN ($sids)
273 AND status='paid'
274 ~, $ENV{SCRIPT_NAME}, __LINE__);
275 $orders_all_cents = ($allr && $allr->{cents}) ? $allr->{cents} : 0;
276
277 @recent_orders = $db->db_readwrite_multiple($dbh, qq~
278 SELECT id, storefront_id, buyer_email, total_amount_cents,
279 currency, status, created_at,
280 UNIX_TIMESTAMP(created_at) AS created_at_epoch
281 FROM `${DB}`.orders
282 WHERE storefront_id IN ($sids)
283 ORDER BY created_at DESC
284 LIMIT 10
285 ~, $ENV{SCRIPT_NAME}, __LINE__);
286
287 foreach my $o (@recent_orders) {
288 $o->{total} = '$' . sprintf('%.2f', ($o->{total_amount_cents} || 0) / 100);
289 my $_or = $o->{created_at}; my $_oe = $o->{created_at_epoch};
290 $o->{created_at} = ($_or && $_oe)
291 ? qq~<span class="ts" data-ts="$_oe" data-fmt="datetime">$_or</span>~
292 : ($_or || '-');
293 }
294 }
295
296 # ---- Marketplace connections ----------------------------------------
297 my @mp_rows;
298 my $have_mp = _usr_scalar($db, $dbh, qq~
299 SELECT COUNT(*) AS n FROM information_schema.tables
300 WHERE table_schema='$DB' AND table_name='marketplace_accounts'
301 ~, 'n');
302 if ($have_mp) {
303 @mp_rows = $db->db_readwrite_multiple($dbh, qq~
304 SELECT platform, status, account_handle, connected_at, last_used_at,
305 UNIX_TIMESTAMP(connected_at) AS connected_at_epoch,
306 UNIX_TIMESTAMP(last_used_at) AS last_used_at_epoch
307 FROM `${DB}`.marketplace_accounts
308 WHERE user_id='$uid'
309 ORDER BY platform ASC
310 ~, $ENV{SCRIPT_NAME}, __LINE__);
311 foreach my $r (@mp_rows) {
312 my $p = $mp ? $mp->by_slug($r->{platform}) : undef;
313 $r->{name} = $p ? $p->{name} : ucfirst($r->{platform});
314 my $_cr = $r->{connected_at}; my $_ce = $r->{connected_at_epoch};
315 $r->{connected_at} = ($_cr && $_ce)
316 ? qq~<span class="ts" data-ts="$_ce" data-fmt="datetime">$_cr</span>~
317 : '-';
318 my $_lr = $r->{last_used_at}; my $_le = $r->{last_used_at_epoch};
319 $r->{last_used_at} = ($_lr && $_le)
320 ? qq~<span class="ts" data-ts="$_le" data-fmt="datetime">$_lr</span>~
321 : '-';
322 }
323 }
324
325 # ---- Credit balance --------------------------------------------------
326 my $credit_cents = $bill->credit_balance_cents($db, $dbh, $DB, $uid);
327
328 # ---- Storage limits --------------------------------------------------
329 my $storage = MODS::ShopCart::Storage->new;
330 my $st_limits = $storage->limits_for_user($db, $dbh, $DB, $uid) || {};
331 my $st_tier = $st_limits->{tier} || (lc($u->{plan_tier} || 'free'));
332 $st_tier = 'free' unless $st_tier =~ /^(free|pro|business)$/;
333 my $st_used_b = $st_limits->{used_bytes} || 0;
334 my $st_hard_b = $st_limits->{hard_cap_bytes} || 0;
335 my $st_upload_b = $st_limits->{upload_max_bytes} || 0;
336 my $st_pct = ($st_hard_b > 0) ? int($st_used_b * 100 / $st_hard_b) : 0;
337 $st_pct = 999 if $st_pct > 999;
338
339 my $tier_upload_mb_default = $cfg->settings("storage_upload_max_mb_$st_tier") || 25;
340 my $tier_hard_gb_default = $cfg->settings("storage_hard_cap_gb_$st_tier") || 5;
341
342 my $st_color = '#4ade80';
343 $st_color = '#fbbf24' if $st_pct >= 50 && $st_pct < 80;
344 $st_color = '#f87171' if $st_pct >= 80;
345
346 $db->db_disconnect($dbh);
347
348 # ---- Format / tvars -------------------------------------------------
349 my $is_self = ($uid eq $userinfo->{_admin_user_id} || $uid eq $userinfo->{user_id}) ? 1 : 0;
350
351 my $tvars = {
352 target_id => $u->{id},
353 target_email => _usr_esc($u->{email}),
354 target_display_name => _usr_esc($u->{display_name} || $u->{email}),
355 target_plan_tier => ucfirst($u->{plan_tier} || 'free'),
356 target_plan_raw => $u->{plan_tier} || 'free',
357 target_account_status => $u->{account_status} || 'active',
358 target_is_admin => $u->{is_admin} ? 1 : 0,
359 target_is_super_admin => $u->{is_super_admin} ? 1 : 0,
360 target_not_super_admin => $u->{is_super_admin} ? 0 : 1,
361 viewer_is_super_admin => do {
362 require MODS::ShopCart::Permissions;
363 MODS::ShopCart::Permissions->new->is_super_admin($userinfo) ? 1 : 0;
364 },
365 target_2fa => $u->{two_factor_enabled} ? 'On' : 'Off',
366 target_trust_level => $u->{trust_level} || 0,
367 target_currency => $u->{default_currency} || 'USD',
368 target_timezone => $u->{timezone} || 'UTC',
369 target_created_at => ($u->{created_at} && $u->{created_at_epoch})
370 ? qq~<span class="ts" data-ts="$u->{created_at_epoch}" data-fmt="datetime">$u->{created_at}</span>~
371 : '-',
372 target_last_login_at => ($u->{last_login_at} && $u->{last_login_at_epoch})
373 ? qq~<span class="ts" data-ts="$u->{last_login_at_epoch}" data-fmt="datetime">$u->{last_login_at}</span>~
374 : '-',
375 target_email_verified => $u->{email_verified_at} ? 'Verified' : 'Unverified',
376
377 is_self => $is_self,
378 is_active => (($u->{account_status} || '') eq 'active') ? 1 : 0,
379 is_suspended => (($u->{account_status} || '') eq 'suspended') ? 1 : 0,
380 is_closed => (($u->{account_status} || '') eq 'closed') ? 1 : 0,
381
382 storefronts => \@storefronts,
383 storefront_count => scalar(@storefronts),
384 has_storefront => scalar(@storefronts) ? 1 : 0,
385
386 models => \@models,
387 model_count => scalar(@models),
388 m_total => $m_total,
389 m_published => $m_published,
390 m_purged => $m_purged,
391 has_models => scalar(@models) ? 1 : 0,
392
393 recent_orders => \@recent_orders,
394 has_orders => scalar(@recent_orders) ? 1 : 0,
395 orders_30d_count => $orders_30d_count,
396 orders_30d_revenue => '$' . sprintf('%.2f', $orders_30d_cents / 100),
397 orders_all_revenue => '$' . sprintf('%.2f', $orders_all_cents / 100),
398
399 marketplaces => \@mp_rows,
400 has_marketplaces => scalar(@mp_rows) ? 1 : 0,
401
402 target_credit_cents => $credit_cents,
403 target_credit_dollars => ($credit_cents < 0 ? '-' : '') . '$' . sprintf('%.2f', abs($credit_cents) / 100),
404 target_has_credit => $credit_cents > 0 ? 1 : 0,
405 target_has_debit => $credit_cents < 0 ? 1 : 0,
406 target_no_credit => $credit_cents == 0 ? 1 : 0,
407
408 storage_tier_label => ucfirst($st_tier),
409 storage_used_human => MODS::ShopCart::Storage::human_size($st_used_b),
410 storage_hard_human => MODS::ShopCart::Storage::human_size($st_hard_b),
411 storage_upload_human => MODS::ShopCart::Storage::human_size($st_upload_b),
412 storage_pct => $st_pct,
413 storage_pct_capped => ($st_pct > 100 ? 100 : $st_pct),
414 storage_bar_color => $st_color,
415 storage_over_cap => $st_pct >= 100 ? 1 : 0,
416 storage_warn => ($st_pct >= 80 && $st_pct < 100) ? 1 : 0,
417 storage_tier_upload_mb_def => $tier_upload_mb_default,
418 storage_tier_hard_gb_def => $tier_hard_gb_default,
419 storage_upload_override => (defined $u->{upload_max_mb_override} && length $u->{upload_max_mb_override})
420 ? ($u->{upload_max_mb_override} + 0) : '',
421 storage_hard_override => (defined $u->{storage_hard_cap_gb_override} && length $u->{storage_hard_cap_gb_override})
422 ? ($u->{storage_hard_cap_gb_override} + 0) : '',
423 storage_has_upload_override => (defined $u->{upload_max_mb_override} && length $u->{upload_max_mb_override}) ? 1 : 0,
424 storage_has_hard_override => (defined $u->{storage_hard_cap_gb_override} && length $u->{storage_hard_cap_gb_override}) ? 1 : 0,
425 };
426
427 print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
428
429 my $body = join('', $tfile->template('shopcart_admin_user.html', $tvars, $userinfo));
430
431 $wrap->render({
432 userinfo => $userinfo,
433 page_key => 'admin',
434 title => 'Admin: ' . ($u->{display_name} || $u->{email}),
435 body => $body,
436 extra_js => ['/assets/javascript/graphs.js'],
437 });
438 return;
439}
440
441sub _usr_scalar {
442 my ($db, $dbh, $sql, $key) = @_;
443 my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
444 return ($r && defined $r->{$key}) ? $r->{$key} : 0;
445}
446
447sub _usr_esc {
448 my $s = shift;
449 return '' unless defined $s;
450 $s =~ s/&/&amp;/g;
451 $s =~ s/</&lt;/g;
452 $s =~ s/>/&gt;/g;
453 $s =~ s/"/&quot;/g;
454 return $s;
455}
456
457sub _usr_h {
458 my $s = shift;
459 return '' unless defined $s;
460 $s =~ s/&/&amp;/g;
461 $s =~ s/</&lt;/g;
462 $s =~ s/>/&gt;/g;
463 $s =~ s/"/&quot;/g;
464 return $s;
465}