added on local at 2026-07-01 21:46:52
| 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 | #====================================================================== | |
| 15 | use strict; | |
| 16 | use warnings; | |
| 17 | ||
| 18 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 19 | use CGI; | |
| 20 | use MODS::Template; | |
| 21 | use MODS::DBConnect; | |
| 22 | use MODS::Login; | |
| 23 | use MODS::RePricer::Config; | |
| 24 | use MODS::RePricer::Wrapper; | |
| 25 | use MODS::RePricer::Permissions; | |
| 26 | ||
| 27 | my $q = CGI->new; | |
| 28 | my $form = $q->Vars; | |
| 29 | my $auth = MODS::Login->new; | |
| 30 | my $wrap = MODS::RePricer::Wrapper->new; | |
| 31 | my $tfile = MODS::Template->new; | |
| 32 | my $cfg = MODS::RePricer::Config->new; | |
| 33 | my $perm = MODS::RePricer::Permissions->new; | |
| 34 | ||
| 35 | $|=1; | |
| 36 | ||
| 37 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_config'; | |
| 38 | ||
| 39 | if ($entry eq 'admin_config_action') { | |
| 40 | _handle_action($q, $form); | |
| 41 | exit; | |
| 42 | } | |
| 43 | ||
| 44 | my $userinfo = $auth->login_verify(); | |
| 45 | if (!$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. | |
| 55 | my @groups_order = qw(Stripe Branding Email Storage Defaults); | |
| 56 | my %by_group; | |
| 57 | foreach my $row ($cfg->editable_settings_meta) { | |
| 58 | push @{ $by_group{ $row->{group} } }, $row; | |
| 59 | } | |
| 60 | my %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 — 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 — 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 <title> tag of marketing pages — 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 “From:” 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 & 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 | ||
| 102 | my @group_blocks; | |
| 103 | foreach 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?". | |
| 122 | my @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. | |
| 131 | my $flash_kind = ''; | |
| 132 | my $flash_msg = ''; | |
| 133 | if (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 | } | |
| 138 | if (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 | ||
| 146 | my $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 | ||
| 155 | 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"; | |
| 156 | ||
| 157 | my $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 | #====================================================================== | |
| 175 | sub _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 | ||
| 258 | sub _redirect { | |
| 259 | my $url = shift; | |
| 260 | print "Status: 302 Found\nLocation: $url\n\n"; | |
| 261 | } |