added on local at 2026-07-01 13:46:57
| 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 | #====================================================================== | |
| 15 | use strict; | |
| 16 | use warnings; | |
| 17 | ||
| 18 | use lib '/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com'; | |
| 19 | use CGI; | |
| 20 | use MODS::Template; | |
| 21 | use MODS::DBConnect; | |
| 22 | use MODS::Login; | |
| 23 | use MODS::AffSoft::Config; | |
| 24 | use MODS::AffSoft::Wrapper; | |
| 25 | use MODS::AffSoft::Permissions; | |
| 26 | ||
| 27 | my $q = CGI->new; | |
| 28 | my $form = $q->Vars; | |
| 29 | my $auth = MODS::Login->new; | |
| 30 | my $wrap = MODS::AffSoft::Wrapper->new; | |
| 31 | my $tfile = MODS::Template->new; | |
| 32 | my $cfg = MODS::AffSoft::Config->new; | |
| 33 | my $perm = MODS::AffSoft::Permissions->new; | |
| 34 | my $db = MODS::DBConnect->new; | |
| 35 | my $DB = $cfg->settings('database_name'); | |
| 36 | ||
| 37 | $|=1; | |
| 38 | my $userinfo = $auth->login_verify(); | |
| 39 | if (!$userinfo) { | |
| 40 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 41 | exit; | |
| 42 | } | |
| 43 | $perm->require_super_admin($userinfo); | |
| 44 | ||
| 45 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_config'; | |
| 46 | ||
| 47 | if ($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. | |
| 56 | my @groups_order = qw(Stripe Branding Email Storage Defaults); | |
| 57 | my %by_group; | |
| 58 | foreach my $row ($cfg->editable_settings_meta) { | |
| 59 | push @{ $by_group{ $row->{group} } }, $row; | |
| 60 | } | |
| 61 | my %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 — 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 — 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 <title> tag of marketing pages — 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 “From:” 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 & 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 | ||
| 103 | my @group_blocks; | |
| 104 | foreach 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?". | |
| 123 | my @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. | |
| 132 | my $flash_kind = ''; | |
| 133 | my $flash_msg = ''; | |
| 134 | if (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 | } | |
| 139 | if (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 | ||
| 147 | my $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 | ||
| 156 | 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"; | |
| 157 | ||
| 158 | my $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 | #====================================================================== | |
| 179 | sub _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 | ||
| 249 | sub _act_redirect { | |
| 250 | my $url = shift; | |
| 251 | print "Status: 302 Found\nLocation: $url\n\n"; | |
| 252 | } |