Diff -- /var/www/vhosts/webstls.com/httpdocs/admin.cgi
Diff
/var/www/vhosts/webstls.com/httpdocs/admin.cgi
added on WebSTLs (webstls.com) at 2026-07-01 22:26:15
Added
+251
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 68d095c3b013
to 68d095c3b013
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 | # WebSTLs - Admin Console (dashboard) | |
| 4 | # | |
| 5 | # Company-staff-only landing page. Lists platform-wide KPIs and offers | |
| 6 | # a user search that drills into /admin_user.cgi for per-account | |
| 7 | # inspection + control. Gated by MODS::WebSTLs::Admin->require_admin; | |
| 8 | # non-admins land on /dashboard.cgi. | |
| 9 | #====================================================================== | |
| 10 | use strict; | |
| 11 | use warnings; | |
| 12 | ||
| 13 | use lib '/var/www/vhosts/webstls.com/httpdocs'; | |
| 14 | use CGI; | |
| 15 | use MODS::Template; | |
| 16 | use MODS::DBConnect; | |
| 17 | use MODS::Login; | |
| 18 | use MODS::WebSTLs::Config; | |
| 19 | use MODS::WebSTLs::Wrapper; | |
| 20 | use MODS::WebSTLs::Admin; | |
| 21 | ||
| 22 | my $q = CGI->new; | |
| 23 | my $form = $q->Vars; | |
| 24 | my $auth = MODS::Login->new; | |
| 25 | my $wrap = MODS::WebSTLs::Wrapper->new; | |
| 26 | my $tfile = MODS::Template->new; | |
| 27 | my $db = MODS::DBConnect->new; | |
| 28 | my $cfg = MODS::WebSTLs::Config->new; | |
| 29 | my $admin = MODS::WebSTLs::Admin->new; | |
| 30 | my $DB = $cfg->settings('database_name'); | |
| 31 | ||
| 32 | $| = 1; | |
| 33 | ||
| 34 | my $userinfo = $auth->login_verify(); | |
| 35 | if (!$userinfo) { | |
| 36 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 37 | exit; | |
| 38 | } | |
| 39 | $admin->require_admin($userinfo); | |
| 40 | require MODS::WebSTLs::Permissions; | |
| 41 | MODS::WebSTLs::Permissions->new->require_feature($userinfo, 'admin_view_users'); | |
| 42 | ||
| 43 | # Tutorial mode: when ?tutorial=1, render an example-data walkthrough | |
| 44 | # instead of live KPIs / search. Same pattern as the dashboard + | |
| 45 | # analytics tutorials. Admins-only page, so the audience here is | |
| 46 | # company staff onboarding into the operations console -- the sample | |
| 47 | # data shows what the page reads like on a populated platform. | |
| 48 | my $tutorial_mode = ($form->{tutorial} && $form->{tutorial} eq '1') ? 1 : 0; | |
| 49 | ||
| 50 | # ---- Search input (optional). Free-text against email, display_name, | |
| 51 | # id, or storefront name. Sanitized before going into SQL. | |
| 52 | my $q_raw = defined $form->{q} ? $form->{q} : ''; | |
| 53 | my $q_clean = $q_raw; | |
| 54 | $q_clean =~ s/[^A-Za-z0-9._@\-\s]//g; | |
| 55 | $q_clean =~ s/^\s+|\s+$//g; | |
| 56 | my $q_safe = $q_clean; | |
| 57 | $q_safe =~ s/'/''/g; # SQL-escape any apostrophes | |
| 58 | my $like = '%' . $q_safe . '%'; | |
| 59 | ||
| 60 | my $dbh = $db->db_connect(); | |
| 61 | ||
| 62 | # ---- KPI tiles ------------------------------------------------------- | |
| 63 | my $u_total = _scalar($db, $dbh, "SELECT COUNT(*) AS n FROM `${DB}`.users", 'n'); | |
| 64 | my $u_active = _scalar($db, $dbh, "SELECT COUNT(*) AS n FROM `${DB}`.users WHERE account_status='active'", 'n'); | |
| 65 | my $u_suspended = _scalar($db, $dbh, "SELECT COUNT(*) AS n FROM `${DB}`.users WHERE account_status='suspended'", 'n'); | |
| 66 | my $u_admins = _scalar($db, $dbh, "SELECT COUNT(*) AS n FROM `${DB}`.users WHERE is_admin=1", 'n'); | |
| 67 | my $u_new_30d = _scalar($db, $dbh, | |
| 68 | "SELECT COUNT(*) AS n FROM `${DB}`.users WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)", 'n'); | |
| 69 | ||
| 70 | # Storefront + model counts (cheap, full-table scans on small tables). | |
| 71 | my $sf_total = _scalar($db, $dbh, "SELECT COUNT(*) AS n FROM `${DB}`.storefronts", 'n'); | |
| 72 | my $m_total = _scalar($db, $dbh, | |
| 73 | "SELECT COUNT(*) AS n FROM `${DB}`.models WHERE purged_at IS NULL", 'n'); | |
| 74 | ||
| 75 | # Platform revenue across every active storefront (orders.status='paid' | |
| 76 | # in the last 30 days). | |
| 77 | my $rev_30d_cents = _scalar($db, $dbh, qq~ | |
| 78 | SELECT COALESCE(SUM(total_amount_cents), 0) AS n | |
| 79 | FROM `${DB}`.orders | |
| 80 | WHERE status='paid' | |
| 81 | AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) | |
| 82 | ~, 'n'); | |
| 83 | ||
| 84 | # ---- User search results -------------------------------------------- | |
| 85 | # Default sort: most recently created. With a query, search across | |
| 86 | # email, display_name, numeric id, and the user's storefront name. | |
| 87 | my $where = ''; | |
| 88 | if ($q_clean ne '') { | |
| 89 | $where = qq~ | |
| 90 | WHERE u.email LIKE '$like' | |
| 91 | OR u.display_name LIKE '$like' | |
| 92 | OR u.id = '$q_safe' | |
| 93 | OR s.name LIKE '$like' | |
| 94 | OR s.subdomain LIKE '$like' | |
| 95 | ~; | |
| 96 | } | |
| 97 | ||
| 98 | my @users = $db->db_readwrite_multiple($dbh, qq~ | |
| 99 | SELECT u.id, | |
| 100 | u.email, | |
| 101 | u.display_name, | |
| 102 | u.plan_tier, | |
| 103 | u.account_status, | |
| 104 | u.is_admin, | |
| 105 | u.last_login_at, | |
| 106 | u.created_at, | |
| 107 | s.id AS storefront_id, | |
| 108 | s.name AS storefront_name, | |
| 109 | s.subdomain AS storefront_subdomain | |
| 110 | FROM `${DB}`.users u | |
| 111 | LEFT JOIN `${DB}`.storefronts s ON s.user_id = u.id | |
| 112 | $where | |
| 113 | GROUP BY u.id | |
| 114 | ORDER BY u.created_at DESC | |
| 115 | LIMIT 100 | |
| 116 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 117 | ||
| 118 | # Decorate for template. | |
| 119 | foreach my $u (@users) { | |
| 120 | $u->{plan_tier} = ucfirst($u->{plan_tier} || 'free'); | |
| 121 | $u->{is_admin_flag} = $u->{is_admin} ? 1 : 0; | |
| 122 | $u->{is_suspended} = (($u->{account_status} || '') eq 'suspended') ? 1 : 0; | |
| 123 | $u->{is_closed} = (($u->{account_status} || '') eq 'closed') ? 1 : 0; | |
| 124 | $u->{display_name} = $u->{display_name} || $u->{email} || ('User #' . $u->{id}); | |
| 125 | $u->{last_login_at} = $u->{last_login_at} || '-'; | |
| 126 | $u->{created_at} = $u->{created_at} || '-'; | |
| 127 | $u->{storefront_name} = $u->{storefront_name} || '-'; | |
| 128 | my $palette = ['#5aa9ff','#34d399','#fbbf24','#a78bfa','#f87171','#22c55e','#06b6d4','#ec4899','#f59e0b']; | |
| 129 | $u->{avatar_color} = $palette->[ ($u->{id} || 0) % scalar(@$palette) ]; | |
| 130 | my $dn = $u->{display_name} || $u->{email} || '?'; | |
| 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 | $db->db_disconnect($dbh); | |
| 141 | ||
| 142 | my $tvars = { | |
| 143 | user_id => $userinfo->{user_id}, | |
| 144 | ||
| 145 | # KPIs | |
| 146 | u_total => $u_total, | |
| 147 | u_active => $u_active, | |
| 148 | u_suspended => $u_suspended, | |
| 149 | u_admins => $u_admins, | |
| 150 | u_new_30d => $u_new_30d, | |
| 151 | sf_total => $sf_total, | |
| 152 | m_total => $m_total, | |
| 153 | rev_30d => '$' . sprintf('%.2f', ($rev_30d_cents || 0) / 100), | |
| 154 | ||
| 155 | # Search state | |
| 156 | q => _esc($q_clean), | |
| 157 | result_count => scalar(@users), | |
| 158 | has_results => scalar(@users) ? 1 : 0, | |
| 159 | has_query => ($q_clean ne '') ? 1 : 0, | |
| 160 | ||
| 161 | # Rows | |
| 162 | users => \@users, | |
| 163 | ||
| 164 | # Tutorial mode flag. When 1, the template renders the example-data | |
| 165 | # walkthrough; when 0, the KPIs + user table come from the live DB. | |
| 166 | tutorial_mode => $tutorial_mode, | |
| 167 | not_tutorial_mode => $tutorial_mode ? 0 : 1, | |
| 168 | }; | |
| 169 | ||
| 170 | # ---- Tutorial-mode overlay ------------------------------------------ | |
| 171 | # When ?tutorial=1 we paint sample platform-wide KPIs and a sample user | |
| 172 | # list. None of this touches the DB; numbers are illustrative. The | |
| 173 | # audience here is company staff being onboarded into the admin | |
| 174 | # console -- the sample shows what the page reads like on a populated | |
| 175 | # platform with thousands of users. | |
| 176 | if ($tutorial_mode) { | |
| 177 | $tvars->{u_total} = '1,247'; | |
| 178 | $tvars->{u_active} = '1,089'; | |
| 179 | $tvars->{u_suspended} = 12; | |
| 180 | $tvars->{u_admins} = 3; | |
| 181 | $tvars->{u_new_30d} = 86; | |
| 182 | $tvars->{sf_total} = '1,154'; | |
| 183 | $tvars->{m_total} = '8,372'; | |
| 184 | $tvars->{rev_30d} = '$42,810.00'; | |
| 185 | ||
| 186 | # Sample user list -- a mix of plans, statuses, and join dates so | |
| 187 | # every pill variant on the page (free / starter / pro / studio, | |
| 188 | # active / suspended / closed, admin flag) renders for the walker. | |
| 189 | $tvars->{users} = [ | |
| 190 | { id => 1247, email => 'rune.caster@example.com', display_name => 'Rune Caster', | |
| 191 | plan_tier => 'Studio', is_admin_flag => 0, is_suspended => 0, is_closed => 0, | |
| 192 | account_status => 'active', last_login_at => '2026-05-12 09:14', | |
| 193 | created_at => '2025-08-03', storefront_name => 'rune-demo.studio' }, | |
| 194 | { id => 1192, email => 'maya.r@example.com', display_name => 'Maya R.', | |
| 195 | plan_tier => 'Pro', is_admin_flag => 0, is_suspended => 0, is_closed => 0, | |
| 196 | account_status => 'active', last_login_at => '2026-05-12 07:42', | |
| 197 | created_at => '2025-11-19', storefront_name => 'shardworks.studio' }, | |
| 198 | { id => 1088, email => 'bench.hammer@example.com', display_name => 'Bench Hammer', | |
| 199 | plan_tier => 'Starter', is_admin_flag => 0, is_suspended => 0, is_closed => 0, | |
| 200 | account_status => 'active', last_login_at => '2026-05-11 22:08', | |
| 201 | created_at => '2026-01-14', storefront_name => 'benchhammer.print' }, | |
| 202 | { id => '987', email => 'priya.t@example.com', display_name => 'Priya T.', | |
| 203 | plan_tier => 'Pro', is_admin_flag => 0, is_suspended => 0, is_closed => 0, | |
| 204 | account_status => 'active', last_login_at => '2026-05-11 15:33', | |
| 205 | created_at => '2025-09-02', storefront_name => 'bridgeworks.co' }, | |
| 206 | { id => '641', email => 'support@webstls.com', display_name => 'Support Bot', | |
| 207 | plan_tier => 'Free', is_admin_flag => 1, is_suspended => 0, is_closed => 0, | |
| 208 | account_status => 'active', last_login_at => '2026-05-12 11:02', | |
| 209 | created_at => '2024-03-01', storefront_name => '-' }, | |
| 210 | { id => '312', email => 'kicked@example.com', display_name => 'Suspended Seller', | |
| 211 | plan_tier => 'Free', is_admin_flag => 0, is_suspended => 1, is_closed => 0, | |
| 212 | account_status => 'suspended', last_login_at => '2026-04-28 03:11', | |
| 213 | created_at => '2024-11-22', storefront_name => 'banned-store' }, | |
| 214 | { id => '204', email => 'old.account@example.com', display_name => 'Closed Account', | |
| 215 | plan_tier => 'Free', is_admin_flag => 0, is_suspended => 0, is_closed => 1, | |
| 216 | account_status => 'closed', last_login_at => '2025-12-30 12:00', | |
| 217 | created_at => '2024-06-15', storefront_name => '-' }, | |
| 218 | ]; | |
| 219 | $tvars->{has_results} = 1; | |
| 220 | $tvars->{result_count} = scalar @{ $tvars->{users} }; | |
| 221 | $tvars->{has_query} = 0; | |
| 222 | $tvars->{q} = ''; | |
| 223 | } | |
| 224 | ||
| 225 | 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"; | |
| 226 | ||
| 227 | my $body = join('', $tfile->template('webstls_admin.html', $tvars, $userinfo)); | |
| 228 | ||
| 229 | $wrap->render({ | |
| 230 | userinfo => $userinfo, | |
| 231 | page_key => 'admin', | |
| 232 | title => 'Admin Console', | |
| 233 | body => $body, | |
| 234 | extra_js => ['/assets/javascript/graphs.js'], | |
| 235 | }); | |
| 236 | ||
| 237 | sub _scalar { | |
| 238 | my ($db, $dbh, $sql, $key) = @_; | |
| 239 | my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 240 | return ($r && defined $r->{$key}) ? $r->{$key} : 0; | |
| 241 | } | |
| 242 | ||
| 243 | sub _esc { | |
| 244 | my $s = shift; | |
| 245 | return '' unless defined $s; | |
| 246 | $s =~ s/&/&/g; | |
| 247 | $s =~ s/</</g; | |
| 248 | $s =~ s/>/>/g; | |
| 249 | $s =~ s/"/"/g; | |
| 250 | return $s; | |
| 251 | } |