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

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

added on local at 2026-07-01 21:46:52

Added
+261
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 2c117a5f4e57
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# RePricer - 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::RePricer::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/repricer.3dshawn.com';
19use CGI;
20use MODS::Template;
21use MODS::DBConnect;
22use MODS::Login;
23use MODS::RePricer::Config;
24use MODS::RePricer::Wrapper;
25use MODS::RePricer::Permissions;
26
27my $q = CGI->new;
28my $form = $q->Vars;
29my $auth = MODS::Login->new;
30my $wrap = MODS::RePricer::Wrapper->new;
31my $tfile = MODS::Template->new;
32my $cfg = MODS::RePricer::Config->new;
33my $perm = MODS::RePricer::Permissions->new;
34
35$|=1;
36
37my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_config';
38
39if ($entry eq 'admin_config_action') {
40 _handle_action($q, $form);
41 exit;
42}
43
44my $userinfo = $auth->login_verify();
45if (!$userinfo) {
46 print "Status: 302 Found\nLocation: /login.cgi\n\n";
47 exit;
48}
49$perm->require_super_admin($userinfo);
50
51# Group editable settings for the template. Order is fixed (Stripe
52# first -- most-asked-for, then Branding / Email / Storage / Defaults).
53# The template uses a nested loop: outer over group_blocks, inner over
54# loop1.rows.
55my @groups_order = qw(Stripe Branding Email Storage Defaults);
56my %by_group;
57foreach my $row ($cfg->editable_settings_meta) {
58 push @{ $by_group{ $row->{group} } }, $row;
59}
60my %group_help = (
61 'Stripe' => {
62 summary => 'Card-processing credentials. Without them, paid plans cannot collect money.',
63 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>
64<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>
65<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>
66<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>],
67 },
68 'Branding' => {
69 summary => 'How your product appears to customers.',
70 detail => q[<p><strong>Brand name and tagline</strong> show in the sidebar, marketing pages, and email headers.</p>
71<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>
72<p>Changes take effect on next page load (template cache).</p>],
73 },
74 'Email' => {
75 summary => 'Outbound email delivery for receipts, invites, and notifications.',
76 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>
77<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>
78<p><strong>Support inbox:</strong> where customer replies are routed if not auto-parsed back into a ticket.</p>],
79 },
80 'Storage' => {
81 summary => 'Where uploaded files, thumbnails, and assets are stored.',
82 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>
83<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>],
84 },
85 'Defaults' => {
86 summary => 'Sensible defaults applied to new accounts.',
87 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>],
88 },
89 'Amazon' => {
90 summary => 'Amazon SP-API credentials for Amazon repricing.',
91 detail => q[<p>Required for the repricer to read your offers and submit price updates via Amazon Selling Partner API.</p>
92<p><strong>Client ID + secret</strong> from the Amazon Developer Console, <strong>refresh token</strong> from authorizing your seller account against your app.</p>
93<p>Marketplace selection determines which storefront the repricer monitors (US, UK, DE, JP, etc.).</p>],
94 },
95 'eBay' => {
96 summary => 'eBay Trading / Sell API credentials for eBay repricing.',
97 detail => q[<p>Required for the repricer to read your active listings and submit revisions via the eBay Sell API.</p>
98<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>],
99 },
100);
101
102my @group_blocks;
103foreach my $g (@groups_order) {
104 next unless $by_group{$g};
105 my $h = $group_help{$g} || { summary => '', detail => '' };
106 my $slug = lc $g; $slug =~ s/[^a-z0-9]/_/g;
107 my $has_help = (length($h->{summary}) || length($h->{detail})) ? 1 : 0;
108 push @group_blocks, {
109 name => $g,
110 rows => $by_group{$g},
111 help_summary => $h->{summary},
112 help_detail => $h->{detail},
113 has_help => $has_help,
114 slug => $slug,
115 };
116}
117
118
119# Read-only "in code" reference. We surface the deploy-coupled keys
120# so the admin understands the full surface without thinking "where
121# did session_minutes go?".
122my @code_only = (
123 { key => 'database_name', note => 'MODS/RePricer/Config.pm. Chicken-and-egg with the lookup itself.' },
124 { key => 'cookie_name', note => 'MODS/RePricer/Config.pm. Changing logs everyone out.' },
125 { key => 'secure_cookies', note => 'MODS/RePricer/Config.pm. Set to 0 only for local HTTP dev.' },
126 { key => 'assets', note => 'MODS/RePricer/Config.pm. Deploy-coupled.' },
127 { key => 'flag_default__*', note => 'MODS/RePricer/Config.pm. Used only when the DB is unreachable, so DB-backed values would never apply anyway.' },
128);
129
130# Flash from the action handler.
131my $flash_kind = '';
132my $flash_msg = '';
133if (defined $form->{ok}) {
134 my $n = $form->{ok}; $n =~ s/[^0-9]//g; $n = 0 + ($n || 0);
135 $flash_kind = 'ok';
136 $flash_msg = "Saved. $n setting" . ($n == 1 ? '' : 's') . ' updated.';
137}
138if (defined $form->{err}) {
139 my $k = $form->{err}; $k =~ s/[^a-z_]//g;
140 $flash_kind = 'danger';
141 $flash_msg = 'bad_key' eq $k ? 'One of the submitted keys is not editable here.'
142 : 'db' eq $k ? 'Database error while saving. No changes were applied.'
143 : 'Something went wrong.';
144}
145
146my $tvars = {
147 user_id => $userinfo->{user_id},
148 group_blocks => \@group_blocks,
149 code_only => \@code_only,
150 has_flash => length $flash_msg ? 1 : 0,
151 flash_kind => $flash_kind,
152 flash_msg => $flash_msg,
153};
154
155print "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";
156
157my $body = join('', $tfile->template('repricer_admin_config.html', $tvars, $userinfo));
158
159$wrap->render({
160 userinfo => $userinfo,
161 page_key => 'admin_config',
162 title => 'Software Configuration',
163 body => $body,
164});
165
166#======================================================================
167# admin_config_action entry: POST handler
168# Iterates the editable-settings whitelist, takes whatever the admin
169# submitted, and UPSERTs into platform_settings. Skips secret fields
170# when the form value is blank (browsers can't repost the masked
171# value; empty submit means "no change"). Empty non-secret values are
172# saved as empty rows so the lookup falls through to the file/code
173# default.
174#======================================================================
175sub _handle_action {
176 my ($q, $form) = @_;
177
178 my $userinfo = $auth->login_verify();
179 if (!$userinfo) {
180 print "Status: 302 Found\nLocation: /login.cgi\n\n";
181 return;
182 }
183 $perm->require_super_admin($userinfo);
184
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
194 # error rather than crashing. The Software Config page also shows
195 # the same 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 _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
217 # the 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 _redirect('/admin_config.cgi?err=db');
249 return;
250 };
251 }
252
253 $db->db_disconnect($dbh);
254 _redirect('/admin_config.cgi?ok=' . $updated);
255 return;
256}
257
258sub _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