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

O Operator
Diff

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

added on local at 2026-07-11 18:37:02

Added
+322
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to b7eee93bcd1f
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# ABForge - Admin: Users list + per-user detail
4#
5# Dispatches on SCRIPT_NAME:
6# /admin_users.cgi -> list all users
7# /admin_user.cgi?u=<int> -> per-user detail (drilldown)
8#
9# admin_user.cgi is a thin wrapper that `do`s this file.
10#======================================================================
11use strict;
12use warnings;
13
14use lib '/var/www/vhosts/3dshawn.com/abforge.3dshawn.com';
15use CGI;
16use MODS::Template;
17use MODS::DBConnect;
18use MODS::Login;
19use MODS::ABForge::Config;
20use MODS::ABForge::Wrapper;
21
22my $q = CGI->new;
23my $form = $q->Vars;
24my $auth = MODS::Login->new;
25my $wrap = MODS::ABForge::Wrapper->new;
26my $tfile = MODS::Template->new;
27my $cfg = MODS::ABForge::Config->new;
28my $db = MODS::DBConnect->new;
29my $DB = $cfg->settings('database_name');
30
31$| = 1;
32
33my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_users';
34
35my $userinfo = $auth->login_verify();
36unless ($userinfo) {
37 print "Status: 302 Found\nLocation: /login.cgi\n\n";
38 exit;
39}
40
41if ($entry eq 'admin_user') {
42 _usr_render_detail();
43 exit;
44}
45
46# ---- Users list (default) -------------------------------------------
47unless ($userinfo->{is_admin}) {
48 print "Status: 403\nContent-Type: text/plain\n\nForbidden";
49 exit;
50}
51
52my $dbh = $db->db_connect();
53my @users = $db->db_readwrite_multiple($dbh,
54 "SELECT u.id, u.email, u.display_name, u.plan_tier, u.account_status, u.created_at, u.last_login_at,"
55 . " UNIX_TIMESTAMP(u.created_at) AS created_at_epoch,"
56 . " UNIX_TIMESTAMP(u.last_login_at) AS last_login_at_epoch,"
57 . " (SELECT COUNT(*) FROM ${DB}.sites WHERE user_id=u.id) AS site_count"
58 . " FROM ${DB}.users u ORDER BY u.id DESC LIMIT 200",
59 $ENV{SCRIPT_NAME}, __LINE__);
60$db->db_disconnect($dbh);
61
62foreach my $u (@users) {
63 $u->{last_login_at} = _usr_ts_wrap($u->{last_login_at}, $u->{last_login_at_epoch}, 'datetime');
64 $u->{created_at} = _usr_ts_wrap($u->{created_at}, $u->{created_at_epoch}, 'datetime');
65}
66
67print "Content-Type: text/html\n\n";
68my $body = join('', $tfile->template('abforge_admin_users.html', { users => \@users }, $userinfo));
69$wrap->render({ userinfo => $userinfo, page_key => 'admin_users', title => 'Admin: Users', body => $body });
70exit;
71
72#======================================================================
73# admin_user.cgi ?u=<id> -- drill into a single seller's account.
74#======================================================================
75sub _usr_render_detail {
76 require MODS::ABForge::Admin;
77 require MODS::ABForge::Billing;
78 my $admin = MODS::ABForge::Admin->new;
79 my $bill = MODS::ABForge::Billing->new;
80 my $mp = undef;
81
82 $admin->require_admin($userinfo);
83 require MODS::ABForge::Permissions;
84 MODS::ABForge::Permissions->new->require_feature($userinfo, 'admin_view_users');
85
86 my $uid = $form->{u} || 0;
87 $uid =~ s/[^0-9]//g;
88 if (!$uid) {
89 print "Status: 302 Found\nLocation: /admin.cgi\n\n";
90 return;
91 }
92
93 my $dbh = $db->db_connect();
94
95 # ---- The target user -------------------------------------------------
96 my $u = $db->db_readwrite($dbh, qq~
97 SELECT id, email, display_name, plan_tier, account_status, is_admin,
98 trust_level, two_factor_enabled, email_verified_at,
99 last_login_at, default_currency, timezone, created_at, updated_at,
100 UNIX_TIMESTAMP(created_at) AS created_at_epoch,
101 UNIX_TIMESTAMP(last_login_at) AS last_login_at_epoch
102 FROM `${DB}`.users
103 WHERE id='$uid'
104 LIMIT 1
105 ~, $ENV{SCRIPT_NAME}, __LINE__);
106
107 if (!$u || !$u->{id}) {
108 $db->db_disconnect($dbh);
109 print "Status: 302 Found\nLocation: /admin.cgi\n\n";
110 return;
111 }
112
113 # ---- Storefront(s) ---------------------------------------------------
114 my @storefronts = $db->db_readwrite_multiple($dbh, qq~
115 SELECT id, name, subdomain, created_at,
116 UNIX_TIMESTAMP(created_at) AS created_at_epoch
117 FROM `${DB}`.storefronts
118 WHERE user_id='$uid'
119 ORDER BY id ASC
120 ~, $ENV{SCRIPT_NAME}, __LINE__);
121 foreach my $sf (@storefronts) {
122 $sf->{created_at} = _usr_ts_wrap($sf->{created_at}, $sf->{created_at_epoch}, 'datetime');
123 }
124
125 # ---- Model counts ----------------------------------------------------
126 my $m_total = _usr_scalar($db, $dbh,
127 "SELECT COUNT(*) AS n FROM `${DB}`.models WHERE user_id='$uid' AND purged_at IS NULL", 'n');
128 my $m_purged = _usr_scalar($db, $dbh,
129 "SELECT COUNT(*) AS n FROM `${DB}`.models WHERE user_id='$uid' AND purged_at IS NOT NULL", 'n');
130 my $m_published = _usr_scalar($db, $dbh,
131 "SELECT COUNT(*) AS n FROM `${DB}`.models WHERE user_id='$uid' AND status='published' AND purged_at IS NULL", 'n');
132
133 # Last 10 models so the admin can see what they're working on.
134 my @models = $db->db_readwrite_multiple($dbh, qq~
135 SELECT id, title, status, visibility, currency, base_price_cents,
136 created_at, updated_at,
137 UNIX_TIMESTAMP(created_at) AS created_at_epoch,
138 UNIX_TIMESTAMP(updated_at) AS updated_at_epoch
139 FROM `${DB}`.models
140 WHERE user_id='$uid' AND purged_at IS NULL
141 ORDER BY updated_at DESC
142 LIMIT 10
143 ~, $ENV{SCRIPT_NAME}, __LINE__);
144
145 foreach my $m (@models) {
146 $m->{price} = '$' . sprintf('%.2f', ($m->{base_price_cents} || 0) / 100);
147 $m->{title} = $m->{title} || ('Model #' . $m->{id});
148 $m->{created_at} = _usr_ts_wrap($m->{created_at}, $m->{created_at_epoch}, 'datetime');
149 $m->{updated_at} = _usr_ts_wrap($m->{updated_at}, $m->{updated_at_epoch}, 'datetime');
150 }
151
152 # ---- Orders / revenue -----------------------------------------------
153 my $orders_30d_count = 0;
154 my $orders_30d_cents = 0;
155 my $orders_all_cents = 0;
156 my @recent_orders;
157
158 if (@storefronts) {
159 my $sids = join(',', map { "'$_->{id}'" } @storefronts);
160
161 my $rec30 = $db->db_readwrite($dbh, qq~
162 SELECT COUNT(*) AS n, COALESCE(SUM(total_amount_cents),0) AS cents
163 FROM `${DB}`.orders
164 WHERE storefront_id IN ($sids)
165 AND status='paid'
166 AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
167 ~, $ENV{SCRIPT_NAME}, __LINE__);
168 if ($rec30) {
169 $orders_30d_count = $rec30->{n} || 0;
170 $orders_30d_cents = $rec30->{cents} || 0;
171 }
172
173 my $allr = $db->db_readwrite($dbh, qq~
174 SELECT COALESCE(SUM(total_amount_cents),0) AS cents
175 FROM `${DB}`.orders
176 WHERE storefront_id IN ($sids)
177 AND status='paid'
178 ~, $ENV{SCRIPT_NAME}, __LINE__);
179 $orders_all_cents = ($allr && $allr->{cents}) ? $allr->{cents} : 0;
180
181 @recent_orders = $db->db_readwrite_multiple($dbh, qq~
182 SELECT id, storefront_id, buyer_email, total_amount_cents,
183 currency, status, created_at,
184 UNIX_TIMESTAMP(created_at) AS created_at_epoch
185 FROM `${DB}`.orders
186 WHERE storefront_id IN ($sids)
187 ORDER BY created_at DESC
188 LIMIT 10
189 ~, $ENV{SCRIPT_NAME}, __LINE__);
190
191 foreach my $o (@recent_orders) {
192 $o->{total} = '$' . sprintf('%.2f', ($o->{total_amount_cents} || 0) / 100);
193 $o->{created_at} = _usr_ts_wrap($o->{created_at}, $o->{created_at_epoch}, 'datetime');
194 }
195 }
196
197 # ---- Marketplace connections ----------------------------------------
198 my @mp_rows;
199 my $have_mp = _usr_scalar($db, $dbh, qq~
200 SELECT COUNT(*) AS n FROM information_schema.tables
201 WHERE table_schema='$DB' AND table_name='marketplace_accounts'
202 ~, 'n');
203 if ($have_mp) {
204 @mp_rows = $db->db_readwrite_multiple($dbh, qq~
205 SELECT platform, status, account_handle, connected_at, last_used_at,
206 UNIX_TIMESTAMP(connected_at) AS connected_at_epoch,
207 UNIX_TIMESTAMP(last_used_at) AS last_used_at_epoch
208 FROM `${DB}`.marketplace_accounts
209 WHERE user_id='$uid'
210 ORDER BY platform ASC
211 ~, $ENV{SCRIPT_NAME}, __LINE__);
212 foreach my $r (@mp_rows) {
213 my $p = $mp ? $mp->by_slug($r->{platform}) : undef;
214 $r->{name} = $p ? $p->{name} : ucfirst($r->{platform});
215 $r->{connected_at} = _usr_ts_wrap($r->{connected_at}, $r->{connected_at_epoch}, 'datetime');
216 $r->{last_used_at} = _usr_ts_wrap($r->{last_used_at}, $r->{last_used_at_epoch}, 'datetime');
217 }
218 }
219
220 # ---- Credit balance --------------------------------------------------
221 my $credit_cents = $bill->credit_balance_cents($db, $dbh, $DB, $uid);
222
223 $db->db_disconnect($dbh);
224
225 # ---- Format / tvars -------------------------------------------------
226 my $is_self = ($uid eq $userinfo->{_admin_user_id} || $uid eq $userinfo->{user_id}) ? 1 : 0;
227
228 my $tvars = {
229 target_id => $u->{id},
230 target_email => _usr_esc($u->{email}),
231 target_display_name => _usr_esc($u->{display_name} || $u->{email}),
232 target_plan_tier => ucfirst($u->{plan_tier} || 'free'),
233 target_plan_raw => $u->{plan_tier} || 'free',
234 target_account_status => $u->{account_status} || 'active',
235 target_is_admin => $u->{is_admin} ? 1 : 0,
236 target_is_super_admin => $u->{is_super_admin} ? 1 : 0,
237 target_not_super_admin => $u->{is_super_admin} ? 0 : 1,
238 viewer_is_super_admin => do {
239 require MODS::ABForge::Permissions;
240 MODS::ABForge::Permissions->new->is_super_admin($userinfo) ? 1 : 0;
241 },
242 target_2fa => $u->{two_factor_enabled} ? 'On' : 'Off',
243 target_trust_level => $u->{trust_level} || 0,
244 target_currency => $u->{default_currency} || 'USD',
245 target_timezone => $u->{timezone} || 'UTC',
246 target_created_at => _usr_ts_wrap($u->{created_at}, $u->{created_at_epoch}, 'datetime'),
247 target_last_login_at => _usr_ts_wrap($u->{last_login_at}, $u->{last_login_at_epoch}, 'datetime'),
248 target_email_verified => $u->{email_verified_at} ? 'Verified' : 'Unverified',
249
250 is_self => $is_self,
251 is_active => (($u->{account_status} || '') eq 'active') ? 1 : 0,
252 is_suspended => (($u->{account_status} || '') eq 'suspended') ? 1 : 0,
253 is_closed => (($u->{account_status} || '') eq 'closed') ? 1 : 0,
254
255 storefronts => \@storefronts,
256 storefront_count => scalar(@storefronts),
257 has_storefront => scalar(@storefronts) ? 1 : 0,
258
259 models => \@models,
260 model_count => scalar(@models),
261 m_total => $m_total,
262 m_published => $m_published,
263 m_purged => $m_purged,
264 has_models => scalar(@models) ? 1 : 0,
265
266 recent_orders => \@recent_orders,
267 has_orders => scalar(@recent_orders) ? 1 : 0,
268 orders_30d_count => $orders_30d_count,
269 orders_30d_revenue => '$' . sprintf('%.2f', $orders_30d_cents / 100),
270 orders_all_revenue => '$' . sprintf('%.2f', $orders_all_cents / 100),
271
272 marketplaces => \@mp_rows,
273 has_marketplaces => scalar(@mp_rows) ? 1 : 0,
274
275 target_credit_cents => $credit_cents,
276 target_credit_dollars => ($credit_cents < 0 ? '-' : '') . '$' . sprintf('%.2f', abs($credit_cents) / 100),
277 target_has_credit => $credit_cents > 0 ? 1 : 0,
278 target_has_debit => $credit_cents < 0 ? 1 : 0,
279 target_no_credit => $credit_cents == 0 ? 1 : 0,
280 };
281
282 print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
283
284 my $body = join('', $tfile->template('abforge_admin_user.html', $tvars, $userinfo));
285
286 $wrap->render({
287 userinfo => $userinfo,
288 page_key => 'admin',
289 title => 'Admin: ' . ($u->{display_name} || $u->{email}),
290 body => $body,
291 extra_js => ['/assets/javascript/graphs.js'],
292 });
293}
294
295sub _usr_scalar {
296 my ($db, $dbh, $sql, $key) = @_;
297 my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
298 return ($r && defined $r->{$key}) ? $r->{$key} : 0;
299}
300
301sub _usr_esc {
302 my $s = shift;
303 return '' unless defined $s;
304 $s =~ s/&/&amp;/g;
305 $s =~ s/</&lt;/g;
306 $s =~ s/>/&gt;/g;
307 $s =~ s/"/&quot;/g;
308 return $s;
309}
310
311# ---- Viewer-local timestamp wrapper ---------------------------------
312# Wraps a server-rendered DATETIME string in the portfolio's ts-span.
313# window.tzLocalize() (loaded on every page) rewrites this to the
314# viewer's local zone at DOMContentLoaded. Falls back to the original
315# server string if JS is off or the epoch is NULL.
316sub _usr_ts_wrap {
317 my ($human, $epoch, $fmt) = @_;
318 $fmt = 'datetime' unless defined $fmt && length $fmt;
319 return '-' unless defined($human) && length($human);
320 return _usr_esc($human) unless $epoch && $epoch =~ /^\d+$/;
321 return qq~<span class="ts" data-ts="$epoch" data-fmt="$fmt">~ . _usr_esc($human) . qq~</span>~;
322}