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

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

added on local at 2026-07-01 13:46:57

Added
+252
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 77a00a2b3149
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# AffSoft - 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::AffSoft::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/affiliate.3dshawn.com';
19use CGI;
20use MODS::Template;
21use MODS::DBConnect;
22use MODS::Login;
23use MODS::AffSoft::Config;
24use MODS::AffSoft::Wrapper;
25use MODS::AffSoft::Permissions;
26
27my $q = CGI->new;
28my $form = $q->Vars;
29my $auth = MODS::Login->new;
30my $wrap = MODS::AffSoft::Wrapper->new;
31my $tfile = MODS::Template->new;
32my $cfg = MODS::AffSoft::Config->new;
33my $perm = MODS::AffSoft::Permissions->new;
34my $db = MODS::DBConnect->new;
35my $DB = $cfg->settings('database_name');
36
37$|=1;
38my $userinfo = $auth->login_verify();
39if (!$userinfo) {
40 print "Status: 302 Found\nLocation: /login.cgi\n\n";
41 exit;
42}
43$perm->require_super_admin($userinfo);
44
45my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_config';
46
47if ($entry eq 'admin_config_action') {
48 _handle_action();
49 exit;
50}
51
52# Group editable settings for the template. Order is fixed (Stripe
53# first -- most-asked-for, then Branding / Email / Storage / Defaults).
54# The template uses a nested loop: outer over group_blocks, inner over
55# loop1.rows.
56my @groups_order = qw(Stripe Branding Email Storage Defaults);
57my %by_group;
58foreach my $row ($cfg->editable_settings_meta) {
59 push @{ $by_group{ $row->{group} } }, $row;
60}
61my %group_help = (
62 'Stripe' => {
63 summary => 'Card-processing credentials. Without them, paid plans cannot collect money.',
64 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>
65<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>
66<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>
67<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>],
68 },
69 'Branding' => {
70 summary => 'How your product appears to customers.',
71 detail => q[<p><strong>Brand name and tagline</strong> show in the sidebar, marketing pages, and email headers.</p>
72<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>
73<p>Changes take effect on next page load (template cache).</p>],
74 },
75 'Email' => {
76 summary => 'Outbound email delivery for receipts, invites, and notifications.',
77 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>
78<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>
79<p><strong>Support inbox:</strong> where customer replies are routed if not auto-parsed back into a ticket.</p>],
80 },
81 'Storage' => {
82 summary => 'Where uploaded files, thumbnails, and assets are stored.',
83 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>
84<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>],
85 },
86 'Defaults' => {
87 summary => 'Sensible defaults applied to new accounts.',
88 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>],
89 },
90 'Amazon' => {
91 summary => 'Amazon SP-API credentials for Amazon repricing.',
92 detail => q[<p>Required for the repricer to read your offers and submit price updates via Amazon Selling Partner API.</p>
93<p><strong>Client ID + secret</strong> from the Amazon Developer Console, <strong>refresh token</strong> from authorizing your seller account against your app.</p>
94<p>Marketplace selection determines which storefront the repricer monitors (US, UK, DE, JP, etc.).</p>],
95 },
96 'eBay' => {
97 summary => 'eBay Trading / Sell API credentials for eBay repricing.',
98 detail => q[<p>Required for the repricer to read your active listings and submit revisions via the eBay Sell API.</p>
99<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>],
100 },
101);
102
103my @group_blocks;
104foreach my $g (@groups_order) {
105 next unless $by_group{$g};
106 my $h = $group_help{$g} || { summary => '', detail => '' };
107 my $slug = lc $g; $slug =~ s/[^a-z0-9]/_/g;
108 my $has_help = (length($h->{summary}) || length($h->{detail})) ? 1 : 0;
109 push @group_blocks, {
110 name => $g,
111 rows => $by_group{$g},
112 help_summary => $h->{summary},
113 help_detail => $h->{detail},
114 has_help => $has_help,
115 slug => $slug,
116 };
117}
118
119
120# Read-only "in code" reference. We surface the deploy-coupled keys
121# so the admin understands the full surface without thinking "where
122# did session_minutes go?".
123my @code_only = (
124 { key => 'database_name', note => 'MODS/AffSoft/Config.pm. Chicken-and-egg with the lookup itself.' },
125 { key => 'cookie_name', note => 'MODS/AffSoft/Config.pm. Changing logs everyone out.' },
126 { key => 'secure_cookies', note => 'MODS/AffSoft/Config.pm. Set to 0 only for local HTTP dev.' },
127 { key => 'assets', note => 'MODS/AffSoft/Config.pm. Deploy-coupled.' },
128 { key => 'flag_default__*', note => 'MODS/AffSoft/Config.pm. Used only when the DB is unreachable, so DB-backed values would never apply anyway.' },
129);
130
131# Flash from the action handler.
132my $flash_kind = '';
133my $flash_msg = '';
134if (defined $form->{ok}) {
135 my $n = $form->{ok}; $n =~ s/[^0-9]//g; $n = 0 + ($n || 0);
136 $flash_kind = 'ok';
137 $flash_msg = "Saved. $n setting" . ($n == 1 ? '' : 's') . ' updated.';
138}
139if (defined $form->{err}) {
140 my $k = $form->{err}; $k =~ s/[^a-z_]//g;
141 $flash_kind = 'danger';
142 $flash_msg = 'bad_key' eq $k ? 'One of the submitted keys is not editable here.'
143 : 'db' eq $k ? 'Database error while saving. No changes were applied.'
144 : 'Something went wrong.';
145}
146
147my $tvars = {
148 user_id => $userinfo->{user_id},
149 group_blocks => \@group_blocks,
150 code_only => \@code_only,
151 has_flash => length $flash_msg ? 1 : 0,
152 flash_kind => $flash_kind,
153 flash_msg => $flash_msg,
154};
155
156print "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";
157
158my $body = join('', $tfile->template('webstls_admin_config.html', $tvars, $userinfo));
159
160$wrap->render({
161 userinfo => $userinfo,
162 page_key => 'admin_config',
163 title => 'Software Configuration',
164 body => $body,
165});
166
167#======================================================================
168# admin_config_action entry: POST handler (super-admin only)
169#
170# Iterates the editable-settings whitelist, takes whatever the admin
171# submitted, and UPSERTs into platform_settings. Skips secret fields
172# when the form value is blank (browsers can't repost the masked value;
173# an empty submit means "no change").
174#
175# Empty non-secret values are saved as empty rows so the lookup falls
176# through to the file/code default. This lets the admin "blank" an
177# override.
178#======================================================================
179sub _handle_action {
180 my $uid = $userinfo->{user_id};
181 $uid =~ s/[^0-9]//g;
182
183 my $dbh = $db->db_connect();
184
185 # Probe table exists -- if not, send the admin back with a clear error
186 # rather than crashing. The Software Config page also shows the same
187 # state so they know they need to run the migration first.
188 my $tab = $db->db_readwrite($dbh, qq~
189 SELECT COUNT(*) AS n FROM information_schema.tables
190 WHERE table_schema='$DB' AND table_name='platform_settings'
191 ~, $ENV{SCRIPT_NAME}, __LINE__);
192 unless ($tab && $tab->{n}) {
193 $db->db_disconnect($dbh);
194 _act_redirect('/admin_config.cgi?err=db');
195 return;
196 }
197
198 my $updated = 0;
199 foreach my $field (keys %$form) {
200 next unless $field =~ /^cfg_(.+)$/;
201 my $key = $1;
202 next unless $cfg->is_editable($key);
203
204 my $meta = $cfg->editable_meta_for($key);
205 my $value = defined $form->{$field} ? $form->{$field} : '';
206
207 # Secret fields: blank submission = "no change", don't overwrite
208 # the existing row. Non-secret blanks DO clear the override so the
209 # admin can reset to default by emptying the field.
210 if ($meta->{secret} && $value eq '') {
211 next;
212 }
213
214 # Quote-escape for the SQL string. Use single-quoted string + ''
215 # escape per project convention. Numeric fields are coerced
216 # implicitly by MySQL on read.
217 my $sv = $value;
218 $sv =~ s/'/''/g;
219 my $sk = $key;
220 $sk =~ s/[^a-zA-Z0-9_]//g;
221 my $sec = $meta->{secret} ? 1 : 0;
222
223 # Upsert: ON DUPLICATE KEY UPDATE on the unique setting_key.
224 eval {
225 $db->db_readwrite($dbh, qq~
226 INSERT INTO `${DB}`.platform_settings
227 SET setting_key='$sk',
228 setting_value='$sv',
229 is_secret='$sec',
230 updated_by_user_id='$uid'
231 ON DUPLICATE KEY UPDATE
232 setting_value=VALUES(setting_value),
233 is_secret=VALUES(is_secret),
234 updated_by_user_id=VALUES(updated_by_user_id)
235 ~, $ENV{SCRIPT_NAME}, __LINE__);
236 $updated++;
237 1;
238 } or do {
239 $db->db_disconnect($dbh);
240 _act_redirect('/admin_config.cgi?err=db');
241 return;
242 };
243 }
244
245 $db->db_disconnect($dbh);
246 _act_redirect('/admin_config.cgi?ok=' . $updated);
247}
248
249sub _act_redirect {
250 my $url = shift;
251 print "Status: 302 Found\nLocation: $url\n\n";
252}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help