added on local at 2026-07-01 15:02:16
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # ContactForge - 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::ContactForge::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 | # Consolidates the former /admin_config_action.cgi (POST handler) via | |
| 16 | # SCRIPT_NAME dispatch. The _action.cgi file is a wrapper that | |
| 17 | # `do`s this file. | |
| 18 | #====================================================================== | |
| 19 | use strict; | |
| 20 | use warnings; | |
| 21 | ||
| 22 | use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com'; | |
| 23 | use CGI; | |
| 24 | use MODS::Template; | |
| 25 | use MODS::DBConnect; | |
| 26 | use MODS::Login; | |
| 27 | use MODS::ContactForge::Config; | |
| 28 | use MODS::ContactForge::Wrapper; | |
| 29 | use MODS::ContactForge::Permissions; | |
| 30 | ||
| 31 | my $q = CGI->new; | |
| 32 | my $form = $q->Vars; | |
| 33 | my $auth = MODS::Login->new; | |
| 34 | my $wrap = MODS::ContactForge::Wrapper->new; | |
| 35 | my $tfile = MODS::Template->new; | |
| 36 | my $db = MODS::DBConnect->new; | |
| 37 | my $cfg = MODS::ContactForge::Config->new; | |
| 38 | my $perm = MODS::ContactForge::Permissions->new; | |
| 39 | my $DB = $cfg->settings('database_name'); | |
| 40 | ||
| 41 | $|=1; | |
| 42 | my $userinfo = $auth->login_verify(); | |
| 43 | if (!$userinfo) { | |
| 44 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 45 | exit; | |
| 46 | } | |
| 47 | $perm->require_super_admin($userinfo); | |
| 48 | ||
| 49 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_config'; | |
| 50 | ||
| 51 | if ($entry eq 'admin_config_action') { | |
| 52 | _handle_action($q, $form); | |
| 53 | exit; | |
| 54 | } | |
| 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. | |
| 60 | my @groups_order = qw(Stripe Branding Email Storage Defaults); | |
| 61 | my %by_group; | |
| 62 | foreach my $row ($cfg->editable_settings_meta) { | |
| 63 | push @{ $by_group{ $row->{group} } }, $row; | |
| 64 | } | |
| 65 | my %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 — 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 — 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 <title> tag of marketing pages — 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 “From:” 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 & 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 | ||
| 107 | my @group_blocks; | |
| 108 | foreach 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?". | |
| 127 | my @code_only = ( | |
| 128 | { key => 'database_name', note => 'MODS/ContactForge/Config.pm. Chicken-and-egg with the lookup itself.' }, | |
| 129 | { key => 'cookie_name', note => 'MODS/ContactForge/Config.pm. Changing logs everyone out.' }, | |
| 130 | { key => 'secure_cookies', note => 'MODS/ContactForge/Config.pm. Set to 0 only for local HTTP dev.' }, | |
| 131 | { key => 'assets', note => 'MODS/ContactForge/Config.pm. Deploy-coupled.' }, | |
| 132 | { key => 'flag_default__*', note => 'MODS/ContactForge/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. | |
| 136 | my $flash_kind = ''; | |
| 137 | my $flash_msg = ''; | |
| 138 | if (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 | } | |
| 143 | if (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 | ||
| 151 | my $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 | ||
| 160 | 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"; | |
| 161 | ||
| 162 | my $body = join('', $tfile->template('cf_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-only settings upsert handler. | |
| 173 | # | |
| 174 | # Iterates the editable-settings whitelist, takes whatever the admin | |
| 175 | # submitted, and UPSERTs into platform_settings. Skips secret fields | |
| 176 | # when the form value is blank (browsers can't repost the masked value; | |
| 177 | # 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 | #====================================================================== | |
| 183 | sub _act_redirect { | |
| 184 | my $url = shift; | |
| 185 | print "Status: 302 Found\nLocation: $url\n\n"; | |
| 186 | } | |
| 187 | ||
| 188 | sub _handle_action { | |
| 189 | my ($q, $form) = @_; | |
| 190 | ||
| 191 | my $uid = $userinfo->{user_id}; | |
| 192 | $uid =~ s/[^0-9]//g; | |
| 193 | ||
| 194 | my $dbh = $db->db_connect(); | |
| 195 | ||
| 196 | # Probe table exists -- if not, send the admin back with a clear error | |
| 197 | # rather than crashing. The Software Config page also shows the same | |
| 198 | # state so they know they need to run the migration first. | |
| 199 | my $tab = $db->db_readwrite($dbh, qq~ | |
| 200 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 201 | WHERE table_schema='$DB' AND table_name='platform_settings' | |
| 202 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 203 | unless ($tab && $tab->{n}) { | |
| 204 | $db->db_disconnect($dbh); | |
| 205 | _act_redirect('/admin_config.cgi?err=db'); | |
| 206 | return; | |
| 207 | } | |
| 208 | ||
| 209 | my $updated = 0; | |
| 210 | foreach my $field (keys %$form) { | |
| 211 | next unless $field =~ /^cfg_(.+)$/; | |
| 212 | my $key = $1; | |
| 213 | next unless $cfg->is_editable($key); | |
| 214 | ||
| 215 | my $meta = $cfg->editable_meta_for($key); | |
| 216 | my $value = defined $form->{$field} ? $form->{$field} : ''; | |
| 217 | ||
| 218 | # Secret fields: blank submission = "no change", don't overwrite | |
| 219 | # the existing row. Non-secret blanks DO clear the override so the | |
| 220 | # admin can reset to default by emptying the field. | |
| 221 | if ($meta->{secret} && $value eq '') { | |
| 222 | next; | |
| 223 | } | |
| 224 | ||
| 225 | # Quote-escape for the SQL string. Use single-quoted string + '' | |
| 226 | # escape per project convention. Numeric fields are coerced | |
| 227 | # implicitly by MySQL on read. | |
| 228 | my $sv = $value; | |
| 229 | $sv =~ s/'/''/g; | |
| 230 | my $sk = $key; | |
| 231 | $sk =~ s/[^a-zA-Z0-9_]//g; | |
| 232 | my $sec = $meta->{secret} ? 1 : 0; | |
| 233 | ||
| 234 | # Upsert: ON DUPLICATE KEY UPDATE on the unique setting_key. | |
| 235 | eval { | |
| 236 | $db->db_readwrite($dbh, qq~ | |
| 237 | INSERT INTO `${DB}`.platform_settings | |
| 238 | SET setting_key='$sk', | |
| 239 | setting_value='$sv', | |
| 240 | is_secret='$sec', | |
| 241 | updated_by_user_id='$uid' | |
| 242 | ON DUPLICATE KEY UPDATE | |
| 243 | setting_value=VALUES(setting_value), | |
| 244 | is_secret=VALUES(is_secret), | |
| 245 | updated_by_user_id=VALUES(updated_by_user_id) | |
| 246 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 247 | $updated++; | |
| 248 | 1; | |
| 249 | } or do { | |
| 250 | $db->db_disconnect($dbh); | |
| 251 | _act_redirect('/admin_config.cgi?err=db'); | |
| 252 | return; | |
| 253 | }; | |
| 254 | } | |
| 255 | ||
| 256 | $db->db_disconnect($dbh); | |
| 257 | _act_redirect('/admin_config.cgi?ok=' . $updated); | |
| 258 | return; | |
| 259 | } |