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

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

added on local at 2026-07-01 22:09:17

Added
+261
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to af803bd254e3
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 - Software Configuration (super-admin only)
4#
5# Edit DB-backed platform settings: Stripe keys, branding, email,
6# storage, default currency/timezone. Anything not in
7# MODS::ShopCart::Config::%EDITABLE stays in code (database_name,
8# session/cookie, asset paths, feature-flag fallbacks) and is shown
9# read-only here so the admin can see the full configuration surface.
10#
11# Gated by Permissions::require_super_admin -- regular admins do NOT
12# see this page. The /admin_config_action.cgi handler runs the same
13# gate so a stale-tab POST from a downgraded admin still bounces.
14#======================================================================
15use strict;
16use warnings;
17
18use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com';
19use CGI;
20use MODS::Template;
21use MODS::DBConnect;
22use MODS::Login;
23use MODS::ShopCart::Config;
24use MODS::ShopCart::Wrapper;
25use MODS::ShopCart::Permissions;
26
27my $q = CGI->new;
28my $form = $q->Vars;
29my $auth = MODS::Login->new;
30my $wrap = MODS::ShopCart::Wrapper->new;
31my $tfile = MODS::Template->new;
32my $cfg = MODS::ShopCart::Config->new;
33my $perm = MODS::ShopCart::Permissions->new;
34
35$|=1;
36my $userinfo = $auth->login_verify();
37
38my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_config';
39
40if ($entry eq 'admin_config_action') {
41 if (!$userinfo) {
42 print "Status: 302 Found\nLocation: /login.cgi\n\n";
43 exit;
44 }
45 $perm->require_super_admin($userinfo);
46 _handle_action($q, $form, $userinfo);
47 exit;
48}
49
50if (!$userinfo) {
51 print "Status: 302 Found\nLocation: /login.cgi\n\n";
52 exit;
53}
54$perm->require_super_admin($userinfo);
55
56# Group editable settings for the template. Order is fixed (Stripe
57# first -- most-asked-for, then Branding / Email / Storage / Defaults).
58# The template uses a nested loop: outer over group_blocks, inner over
59# loop1.rows.
60my @groups_order = qw(Stripe Branding Email Storage Defaults);
61my %by_group;
62foreach my $row ($cfg->editable_settings_meta) {
63 push @{ $by_group{ $row->{group} } }, $row;
64}
65my %group_help = (
66 'Stripe' => {
67 summary => 'Card-processing credentials. Without them, paid plans cannot collect money.',
68 detail => q[<p><strong>Publishable key</strong> (starts <code>pk_</code>) is safe to expose in client-side code &mdash; it goes into the checkout JavaScript.</p>
69<p><strong>Secret key</strong> (starts <code>sk_</code>) <em>never</em> leaves the server. Anyone with this key can charge cards on your account &mdash; rotate it if exposed.</p>
70<p><strong>Webhook signing secret</strong> (starts <code>whsec_</code>) verifies that incoming webhook events actually came from Stripe (and not an attacker spoofing payment events). Generate it from the Stripe Dashboard.</p>
71<p><strong>Live vs. test:</strong> test keys (<code>pk_test_...</code>, <code>sk_test_...</code>) only process test card numbers. Live keys (<code>pk_live_...</code>) process real money. Always start in test mode.</p>],
72 },
73 'Branding' => {
74 summary => 'How your product appears to customers.',
75 detail => q[<p><strong>Brand name and tagline</strong> show in the sidebar, marketing pages, and email headers.</p>
76<p><strong>Site title</strong> is the &lt;title&gt; tag of marketing pages &mdash; this is what shows in browser tabs and Google search results, so include a clear value-prop.</p>
77<p>Changes take effect on next page load (template cache).</p>],
78 },
79 'Email' => {
80 summary => 'Outbound email delivery for receipts, invites, and notifications.',
81 detail => q[<p><strong>SMTP host / port / user / password:</strong> credentials for the SMTP server. Common choices: SendGrid, Mailgun, Amazon SES, or your own mail server.</p>
82<p><strong>From address:</strong> the &ldquo;From:&rdquo; header on outbound mail. Use a real address on a domain whose DNS you control (SPF / DKIM passing) or deliverability tanks.</p>
83<p><strong>Support inbox:</strong> where customer replies are routed if not auto-parsed back into a ticket.</p>],
84 },
85 'Storage' => {
86 summary => 'Where uploaded files, thumbnails, and assets are stored.',
87 detail => q[<p><strong>R2 bucket configuration</strong> for Cloudflare R2 object storage. Used for hosting customer uploads, generated thumbnails, and signed-URL paid downloads.</p>
88<p><strong>Public CDN base</strong> is for hot-cacheable public assets (images, thumbnails). <strong>Account ID + signed URL settings</strong> are for serving paid / private files that need access control.</p>],
89 },
90 'Defaults' => {
91 summary => 'Sensible defaults applied to new accounts.',
92 detail => q[<p><strong>Default timezone &amp; currency</strong> are applied when a new account signs up. Each customer can override their own settings later.</p>],
93 },
94 'Amazon' => {
95 summary => 'Amazon SP-API credentials for Amazon repricing.',
96 detail => q[<p>Required for the repricer to read your offers and submit price updates via Amazon Selling Partner API.</p>
97<p><strong>Client ID + secret</strong> from the Amazon Developer Console, <strong>refresh token</strong> from authorizing your seller account against your app.</p>
98<p>Marketplace selection determines which storefront the repricer monitors (US, UK, DE, JP, etc.).</p>],
99 },
100 'eBay' => {
101 summary => 'eBay Trading / Sell API credentials for eBay repricing.',
102 detail => q[<p>Required for the repricer to read your active listings and submit revisions via the eBay Sell API.</p>
103<p><strong>App ID, Cert ID, Dev ID</strong> from your eBay Developer account; <strong>user token</strong> generated by authorizing your eBay account against your app.</p>],
104 },
105);
106
107my @group_blocks;
108foreach my $g (@groups_order) {
109 next unless $by_group{$g};
110 my $h = $group_help{$g} || { summary => '', detail => '' };
111 my $slug = lc $g; $slug =~ s/[^a-z0-9]/_/g;
112 my $has_help = (length($h->{summary}) || length($h->{detail})) ? 1 : 0;
113 push @group_blocks, {
114 name => $g,
115 rows => $by_group{$g},
116 help_summary => $h->{summary},
117 help_detail => $h->{detail},
118 has_help => $has_help,
119 slug => $slug,
120 };
121}
122
123
124# Read-only "in code" reference. We surface the deploy-coupled keys
125# so the admin understands the full surface without thinking "where
126# did session_minutes go?".
127my @code_only = (
128 { key => 'database_name', note => 'MODS/ShopCart/Config.pm. Chicken-and-egg with the lookup itself.' },
129 { key => 'cookie_name', note => 'MODS/ShopCart/Config.pm. Changing logs everyone out.' },
130 { key => 'secure_cookies', note => 'MODS/ShopCart/Config.pm. Set to 0 only for local HTTP dev.' },
131 { key => 'assets', note => 'MODS/ShopCart/Config.pm. Deploy-coupled.' },
132 { key => 'flag_default__*', note => 'MODS/ShopCart/Config.pm. Used only when the DB is unreachable, so DB-backed values would never apply anyway.' },
133);
134
135# Flash from the action handler.
136my $flash_kind = '';
137my $flash_msg = '';
138if (defined $form->{ok}) {
139 my $n = $form->{ok}; $n =~ s/[^0-9]//g; $n = 0 + ($n || 0);
140 $flash_kind = 'ok';
141 $flash_msg = "Saved. $n setting" . ($n == 1 ? '' : 's') . ' updated.';
142}
143if (defined $form->{err}) {
144 my $k = $form->{err}; $k =~ s/[^a-z_]//g;
145 $flash_kind = 'danger';
146 $flash_msg = 'bad_key' eq $k ? 'One of the submitted keys is not editable here.'
147 : 'db' eq $k ? 'Database error while saving. No changes were applied.'
148 : 'Something went wrong.';
149}
150
151my $tvars = {
152 user_id => $userinfo->{user_id},
153 group_blocks => \@group_blocks,
154 code_only => \@code_only,
155 has_flash => length $flash_msg ? 1 : 0,
156 flash_kind => $flash_kind,
157 flash_msg => $flash_msg,
158};
159
160print "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";
161
162my $body = join('', $tfile->template('shopcart_admin_config.html', $tvars, $userinfo));
163
164$wrap->render({
165 userinfo => $userinfo,
166 page_key => 'admin_config',
167 title => 'Software Configuration',
168 body => $body,
169});
170
171#======================================================================
172# admin_config_action entry: POST handler
173#
174# POST-only. Iterates the editable-settings whitelist, takes whatever
175# the admin submitted, and UPSERTs into platform_settings. Skips
176# secret fields when the form value is blank (browsers can't repost
177# the masked value; an empty submit means "no change").
178#
179# Empty non-secret values are saved as empty rows so the lookup falls
180# through to the file/code default. This lets the admin "blank" an
181# override.
182#======================================================================
183sub _handle_action {
184 my ($q, $form, $userinfo) = @_;
185 my $db = MODS::DBConnect->new;
186 my $DB = $cfg->settings('database_name');
187
188 my $uid = $userinfo->{user_id};
189 $uid =~ s/[^0-9]//g;
190
191 my $dbh = $db->db_connect();
192
193 # Probe table exists -- if not, send the admin back with a clear error
194 # rather than crashing. The Software Config page also shows the same
195 # state so they know they need to run the migration first.
196 my $tab = $db->db_readwrite($dbh, qq~
197 SELECT COUNT(*) AS n FROM information_schema.tables
198 WHERE table_schema='$DB' AND table_name='platform_settings'
199 ~, $ENV{SCRIPT_NAME}, __LINE__);
200 unless ($tab && $tab->{n}) {
201 $db->db_disconnect($dbh);
202 _act_redirect('/admin_config.cgi?err=db');
203 return;
204 }
205
206 my $updated = 0;
207 foreach my $field (keys %$form) {
208 next unless $field =~ /^cfg_(.+)$/;
209 my $key = $1;
210 next unless $cfg->is_editable($key);
211
212 my $meta = $cfg->editable_meta_for($key);
213 my $value = defined $form->{$field} ? $form->{$field} : '';
214
215 # Secret fields: blank submission = "no change", don't overwrite
216 # the existing row. Non-secret blanks DO clear the override so the
217 # admin can reset to default by emptying the field.
218 if ($meta->{secret} && $value eq '') {
219 next;
220 }
221
222 # Quote-escape for the SQL string. Use single-quoted string + ''
223 # escape per project convention. Numeric fields are coerced
224 # implicitly by MySQL on read.
225 my $sv = $value;
226 $sv =~ s/'/''/g;
227 my $sk = $key;
228 $sk =~ s/[^a-zA-Z0-9_]//g;
229 my $sec = $meta->{secret} ? 1 : 0;
230
231 # Upsert: ON DUPLICATE KEY UPDATE on the unique setting_key.
232 eval {
233 $db->db_readwrite($dbh, qq~
234 INSERT INTO `${DB}`.platform_settings
235 SET setting_key='$sk',
236 setting_value='$sv',
237 is_secret='$sec',
238 updated_by_user_id='$uid'
239 ON DUPLICATE KEY UPDATE
240 setting_value=VALUES(setting_value),
241 is_secret=VALUES(is_secret),
242 updated_by_user_id=VALUES(updated_by_user_id)
243 ~, $ENV{SCRIPT_NAME}, __LINE__);
244 $updated++;
245 1;
246 } or do {
247 $db->db_disconnect($dbh);
248 _act_redirect('/admin_config.cgi?err=db');
249 return;
250 };
251 }
252
253 $db->db_disconnect($dbh);
254 _act_redirect('/admin_config.cgi?ok=' . $updated);
255 return;
256}
257
258sub _act_redirect {
259 my $url = shift;
260 print "Status: 302 Found\nLocation: $url\n\n";
261}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help