Diff -- /var/www/vhosts/3dshawn.com/shop.3dshawn.com/MODS/ShopCart/Config.pm
Diff

/var/www/vhosts/3dshawn.com/shop.3dshawn.com/MODS/ShopCart/Config.pm

added on local at 2026-07-01 22:09:37

Added
+471
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to aa47dc100c5e
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package MODS::ShopCart::Config;
2#======================================================================
3# ShopCart — central configuration.
4#
5# Lookup order for settings():
6# 1. platform_settings table (DB-backed) -- editable via
7# /admin_config.cgi by a super-admin. Highest precedence so an
8# admin can override anything without a code deploy.
9# 2. /var/www/vhosts/shop.3dshawn.com/private/stripe.conf -- legacy
10# bootstrap path for the Stripe secret + webhook secret on a
11# brand-new install (before the admin has opened the config page).
12# 3. The hardcoded %DEFAULTS hash below -- final fallback. These are
13# also the deploy-coupled keys that must stay in code so they're
14# readable before the DB is reachable (database_name, asset paths,
15# cookie/session, feature-flag fallbacks).
16#
17# Lookups are cached per request inside %_DB_CACHE so a page that
18# reads ten settings only hits the DB once. The DB probe is column-
19# existence-guarded so pre-migration installs degrade cleanly to the
20# file + code fallbacks.
21#======================================================================
22
23use strict;
24use warnings;
25
26# Whitelist of keys the admin can override via the Software
27# Configuration page. Anything not in this list is read straight from
28# %DEFAULTS and is_secret marking controls UI masking.
29#
30# Note: this also defines what /admin_config.cgi can WRITE. Adding a
31# key here is the one-line "expose this to the admin" switch.
32my %EDITABLE = (
33 session_minutes => {
34 label => 'Session length (minutes)',
35 group => 'Defaults', secret => 0,
36 description => 'How long a user stays signed in after login. Default 720 = 12 hours. Affects both server-side user_sessions.expires_at AND the browser cookie Max-Age. Per-account override at users.session_minutes_override takes precedence.',
37 },
38 # Stripe
39 stripe_publishable => {
40 label => 'Stripe publishable key',
41 group => 'Stripe', secret => 0,
42 description => 'From Stripe Dashboard > Developers > API keys. Starts with pk_live_ for production or pk_test_ for test mode. Safe to expose to browsers -- it is embedded in the card-entry form on /billing_payment.cgi so Stripe Elements can tokenize card numbers without the PAN ever touching our server.',
43 },
44 stripe_secret => {
45 label => 'Stripe secret key',
46 group => 'Stripe', secret => 1,
47 description => 'From Stripe Dashboard > Developers > API keys. Starts with sk_live_ or sk_test_. NEVER paste this anywhere outside this field -- anyone with this key can charge cards on your Stripe account. Server-side code uses it to create customers, SetupIntents, and PaymentIntents.',
48 },
49 stripe_connect_client => {
50 label => 'Stripe Connect client id',
51 group => 'Stripe', secret => 0,
52 description => 'From Stripe Dashboard > Settings > Connect > "Client ID". Starts with ca_. Identifies your platform when sellers onboard their connected accounts via Stripe Express. Required for buyer-to-seller destination charges; not used for subscription billing.',
53 },
54 stripe_webhook_secret => {
55 label => 'Stripe webhook signing secret',
56 group => 'Stripe', secret => 1,
57 description => 'From Stripe Dashboard > Developers > Webhooks > your endpoint > "Signing secret". Starts with whsec_. Used to verify incoming webhook calls really came from Stripe. Set this AFTER pointing the webhook at https://shop.3dshawn.com/stripe_webhook.cgi -- Stripe generates the secret when you create the endpoint.',
58 },
59 stripe_platform_fee_bps => {
60 label => 'Platform fee (basis points)',
61 group => 'Stripe', secret => 0,
62 description => 'ShopCart cut of each buyer-to-seller transaction, in basis points: 100 = 1%, 1000 = 10%, 250 = 2.5%. Stripe takes this as application_fee_amount from the seller payout. Does NOT apply to subscription invoices (those are direct platform charges, not destination charges).',
63 },
64 stripe_currency_default => {
65 label => 'Default currency code',
66 group => 'Stripe', secret => 0,
67 description => 'Three-letter ISO 4217 code, lowercase: usd, eur, gbp, cad. Used when a transaction does not specify its own currency. Only affects Stripe API calls -- the storefront display currency is set per-order.',
68 },
69
70 # Branding
71 website_title => {
72 label => 'Site title',
73 group => 'Branding', secret => 0,
74 description => 'The HTML <title> tag value -- shown in browser tabs, search results, and link previews. HTML entities are allowed (e.g. —).',
75 },
76 brand_name => {
77 label => 'Brand name',
78 group => 'Branding', secret => 0,
79 description => 'Short product name shown in the wrapper header, profile dropdown, marketing pages, and the From-line on system emails.',
80 },
81 brand_tagline => {
82 label => 'Tagline',
83 group => 'Branding', secret => 0,
84 description => 'One-line tagline shown under the brand name in the sidebar header and a few marketing pages.',
85 },
86
87 # Email
88 feedback_emails => {
89 label => 'Support / feedback inbox',
90 group => 'Email', secret => 0,
91 description => 'Inbox that receives "Contact us" form submissions. Comma-separate for multiple recipients (e.g. "support@example.com, ops@example.com").',
92 },
93 from_email => {
94 label => 'Outbound "From" address',
95 group => 'Email', secret => 0,
96 description => 'Sender address on system-generated emails (password resets, receipts, invoices). Must be on a domain whose SPF and DKIM records you control -- otherwise messages land in spam.',
97 },
98 smtp_host => {
99 label => 'SMTP host',
100 group => 'Email', secret => 0,
101 description => 'SMTP server hostname for outbound mail (e.g. smtp.gmail.com, smtp.sendgrid.net, smtp.postmarkapp.com). Leave blank to use the server\'s local sendmail / Plesk MTA.',
102 },
103 smtp_port => {
104 label => 'SMTP port',
105 group => 'Email', secret => 0,
106 description => 'Usually 587 for STARTTLS or 465 for implicit TLS. Only consulted when SMTP host is set.',
107 },
108 smtp_user => {
109 label => 'SMTP username',
110 group => 'Email', secret => 0,
111 description => 'Authentication username for the SMTP server. Often your full email address. Leave blank if your SMTP server allows unauthenticated relay (uncommon).',
112 },
113 smtp_pass => {
114 label => 'SMTP password',
115 group => 'Email', secret => 1,
116 description => 'Authentication password (or app-specific password / API token) for the SMTP server. Stored encrypted at rest; never echoed back to the browser after save.',
117 },
118
119 # File storage (R2)
120 r2_bucket => {
121 label => 'R2 bucket name',
122 group => 'Storage', secret => 0,
123 description => 'Cloudflare R2 bucket where uploaded model files and storefront images are stored. The CGIs use this when generating signed upload and download URLs.',
124 },
125 r2_public_base => {
126 label => 'R2 public base URL',
127 group => 'Storage', secret => 0,
128 description => 'Public-facing base URL for the R2 bucket (typically a custom CNAME like https://files.shop.3dshawn.com). Prepended to object keys when building <img> and <a href> URLs visitors see. No trailing slash. Use only for images / public assets -- paid model files must stay in the bucket and be served via the signed-URL path below.',
129 },
130 r2_account_id => {
131 label => 'R2 account ID',
132 group => 'Storage', secret => 0,
133 description => 'Cloudflare account ID for R2. Find it in Cloudflare Dashboard > R2 > "Account ID" on the right sidebar. Used to construct the signed-URL endpoint as <account_id>.r2.cloudflarestorage.com. Required for serving paid model files directly from R2.',
134 },
135 r2_access_key_id => {
136 label => 'R2 access key ID',
137 group => 'Storage', secret => 0,
138 description => 'Cloudflare R2 API token "Access Key ID". Generate at Cloudflare Dashboard > R2 > Manage R2 API tokens > "Create API token" (Object Read access is sufficient for the download path).',
139 },
140 r2_secret_access_key => {
141 label => 'R2 secret access key',
142 group => 'Storage', secret => 1,
143 description => 'Secret half of the R2 API token (shown only once when the token is created in Cloudflare). Used to HMAC-SHA256-sign download URLs server-side. NEVER share or commit -- the signing key is your bucket\'s access control.',
144 },
145 r2_region => {
146 label => 'R2 region',
147 group => 'Storage', secret => 0,
148 description => 'R2 is single-region for signing purposes -- leave as "auto" unless Cloudflare instructs otherwise. Used as the region segment in the AWS sigv4 credential scope.',
149 },
150
151 # Storage Caps
152 storage_upload_max_mb_free => {
153 label => 'Free plan: per-file upload limit (MB)',
154 group => 'Storage Caps', secret => 0,
155 description => 'Maximum single-file upload size (MB) for Free-tier accounts. Enforced server-side before the body is read. Applies to model files, gallery images, page-editor images, and support attachments. A per-user override on the user record (upload_max_mb_override) wins over this value.',
156 },
157 storage_upload_max_mb_pro => {
158 label => 'Pro plan: per-file upload limit (MB)',
159 group => 'Storage Caps', secret => 0,
160 description => 'Maximum single-file upload size (MB) for Pro-tier accounts. Enforced server-side before the body is read. A per-user override on the user record (upload_max_mb_override) wins over this value.',
161 },
162 storage_upload_max_mb_business => {
163 label => 'Business plan: per-file upload limit (MB)',
164 group => 'Storage Caps', secret => 0,
165 description => 'Maximum single-file upload size (MB) for Business-tier accounts. Default 5120 = 5 GB. Enforced server-side before the body is read. A per-user override on the user record (upload_max_mb_override) wins over this value.',
166 },
167 storage_hard_cap_gb_free => {
168 label => 'Free plan: total storage cap (GB)',
169 group => 'Storage Caps', secret => 0,
170 description => 'Total storage ceiling (GB) for Free-tier accounts. Hidden from marketing copy by default. When a user reaches this cap, new uploads are rejected with a clear message. A per-user override (storage_hard_cap_gb_override) wins over this value.',
171 },
172 storage_hard_cap_gb_pro => {
173 label => 'Pro plan: total storage cap (GB)',
174 group => 'Storage Caps', secret => 0,
175 description => 'Total storage ceiling (GB) for Pro-tier accounts. A per-user override (storage_hard_cap_gb_override) wins over this value.',
176 },
177 storage_hard_cap_gb_business => {
178 label => 'Business plan: total storage cap (GB)',
179 group => 'Storage Caps', secret => 0,
180 description => 'Total storage ceiling (GB) for Business-tier accounts. Default 2048 = 2 TB. A per-user override (storage_hard_cap_gb_override) wins over this value.',
181 },
182 storage_soft_alert_pct => {
183 label => 'Soft-alert threshold (% of hard cap)',
184 group => 'Storage Caps', secret => 0,
185 description => 'Percent of the hard cap that triggers the storage-usage warning email and admin notification. Default 80 = email the user when they cross 80% of their cap. Set to 0 to disable soft alerts (uploads still hard-stop at the cap).',
186 },
187
188 # Defaults
189 timezone_default => {
190 label => 'Default timezone',
191 group => 'Defaults', secret => 0,
192 description => 'IANA timezone name -- UTC, America/New_York, Europe/Berlin, Asia/Tokyo. New users inherit this until they pick their own in Preferences. Affects display only; the database stores timestamps in server time.',
193 },
194 currency_default => {
195 label => 'Default currency',
196 group => 'Defaults', secret => 0,
197 description => 'Three-letter ISO 4217 code (USD, EUR, GBP, CAD). New users inherit this for their storefront display currency. Affects display only -- order amounts are stored in the currency they were transacted in.',
198 },
199);
200
201my %DEFAULTS = (
202 # ----- Database ----------------------------------------------
203 # NOT editable via UI -- the lookup itself depends on this.
204 database_name => 'shop3dshawn',
205
206 # ----- Branding ----------------------------------------------
207 website_title => 'ShopCart — Sell your products online',
208 brand_name => 'ShopCart',
209 brand_tagline => 'Build a storefront. Sell anywhere.',
210
211 # ----- Sessions / cookies ------------------------------------
212 # NOT editable via UI -- deploy-coupled. Edit MODS/ShopCart/Config.pm.
213 cookie_name => 'shopcart_session',
214 session_minutes => 720,
215 secure_cookies => 1,
216 # Domain scope for the session cookie. Leading-dot form means the
217 # cookie is sent to BOTH apex (shop.3dshawn.com) and every subdomain
218 # (e.g. 3dshawn.shop.3dshawn.com), so when a seller is logged in on the
219 # dashboard their session is recognized on their own storefront
220 # subdomain too (needed for owner-only features like draft bundle
221 # previews, edit-mode bypass, etc). Empty string keeps the cookie
222 # host-only (legacy behavior).
223 cookie_domain => '.shop.3dshawn.com',
224
225 # ----- Asset paths -------------------------------------------
226 # NOT editable via UI -- deploy-coupled.
227 assets => '/assets',
228 assets_css => '/assets/css',
229 assets_js => '/assets/javascript',
230 assets_images => '/assets/images',
231
232 # ----- Email / mail ------------------------------------------
233 feedback_emails => 'support@shop.3dshawn.com',
234 from_email => 'noreply@shop.3dshawn.com',
235 smtp_host => '', # blank = use local sendmail
236 smtp_port => 587,
237 smtp_user => '',
238 smtp_pass => '',
239
240 # ----- Stripe ------------------------------------------------
241 stripe_publishable => 'pk_test_REPLACE_ME',
242 stripe_secret => '',
243 stripe_connect_client => 'ca_REPLACE_ME',
244 stripe_webhook_secret => '',
245 stripe_platform_fee_bps => 1000,
246 stripe_currency_default => 'usd',
247
248 # ----- File storage (R2) -------------------------------------
249 r2_bucket => 'shopcart-files',
250 r2_public_base => 'https://files.shop.3dshawn.com',
251 r2_account_id => '',
252 r2_access_key_id => '',
253 r2_secret_access_key => '',
254 r2_region => 'auto',
255
256 # ----- Plan limits -------------------------------------------
257 plan_free_models => 10,
258 plan_pro_price => 49,
259 plan_business_price => 129,
260
261 # ----- Storage Caps ------------------------------------------
262 # Per-file upload limit (MB) per plan tier.
263 storage_upload_max_mb_free => 25,
264 storage_upload_max_mb_pro => 500,
265 storage_upload_max_mb_business => 5120,
266 # Total storage hard cap (GB) per plan tier.
267 storage_hard_cap_gb_free => 5,
268 storage_hard_cap_gb_pro => 200,
269 storage_hard_cap_gb_business => 2048,
270 # Percent of hard cap that fires the soft-alert email + admin notification.
271 storage_soft_alert_pct => 80,
272
273 # ----- Feature flags fallback (when DB unreachable) ----------
274 # NOT editable via UI -- read when DB is DOWN, so DB rows would
275 # never apply anyway. Keep in code.
276 flag_default__module_publishing => 1,
277 flag_default__module_storefront => 1,
278 flag_default__module_optimization => 1,
279 flag_default__module_pooled_data => 0,
280 flag_default__module_audience => 1,
281
282 # ----- Misc --------------------------------------------------
283 timezone_default => 'UTC',
284 currency_default => 'USD',
285);
286
287# Per-process cache. Populated on first DB hit; survives for the
288# request and is cheap on subsequent settings() calls.
289my %_DB_CACHE;
290my $_DB_LOADED = 0;
291my $_DB_AVAILABLE; # undef = unknown, 0 = table missing, 1 = ready
292
293sub new {
294 my ($class) = @_;
295 return bless({}, $class);
296}
297
298# Public helper for the admin config page. Returns the editable
299# whitelist with current values + source ('db' | 'file' | 'default').
300# is_secret rows have their value masked to a non-empty marker so the
301# UI can show "set" without leaking the actual secret.
302sub editable_settings_meta {
303 my ($self) = @_;
304 my @rows;
305 foreach my $key (sort keys %EDITABLE) {
306 my $meta = $EDITABLE{$key};
307 my ($val, $source) = $self->_resolve_with_source($key);
308 my $masked = ($meta->{secret} && defined($val) && length $val) ? 1 : 0;
309 push @rows, {
310 key => $key,
311 label => $meta->{label},
312 group => $meta->{group},
313 secret => $meta->{secret},
314 description => $meta->{description} || '',
315 value => $masked ? '' : (defined $val ? $val : ''),
316 is_set => (defined $val && length $val) ? 1 : 0,
317 source => $source,
318 };
319 }
320 return @rows;
321}
322
323# Bool -- is this key editable via the admin config page?
324sub is_editable {
325 my ($self, $key) = @_;
326 return exists $EDITABLE{$key} ? 1 : 0;
327}
328
329sub editable_meta_for {
330 my ($self, $key) = @_;
331 return $EDITABLE{$key};
332}
333
334sub settings {
335 my ($self, $key) = @_;
336 return undef unless defined $key;
337
338 my ($val) = $self->_resolve_with_source($key);
339 return $val;
340}
341
342# Core lookup. Returns ($value, $source) where source is one of
343# 'db' | 'file' | 'default'. Centralises the precedence so any caller
344# (settings() / editable_settings_meta) gets the same answer.
345sub _resolve_with_source {
346 my ($self, $key) = @_;
347
348 # 1) DB lookup (cached). Skip for the database_name itself -- we
349 # can't ask the DB which DB to use.
350 if ($key ne 'database_name') {
351 $self->_load_db_cache;
352 if (exists $_DB_CACHE{$key}) {
353 return ($_DB_CACHE{$key}, 'db');
354 }
355 }
356
357 # 2) Private file fallback (legacy Stripe secret path). Keeps
358 # brand-new installs working until the admin opens the config
359 # page.
360 if ($key eq 'stripe_secret' || $key eq 'stripe_webhook_secret') {
361 my $val = _read_private('stripe.conf', $key);
362 return ($val, 'file') if defined $val && length $val;
363 }
364
365 # 3) Hardcoded default.
366 return (exists $DEFAULTS{$key} ? $DEFAULTS{$key} : undef, 'default');
367}
368
369# Single-pass population of %_DB_CACHE. Probes for the table to avoid
370# crashing pre-migration installs. Silently bails on any error so
371# Config.pm never takes the site down.
372sub _load_db_cache {
373 my ($self) = @_;
374 return if $_DB_LOADED;
375 $_DB_LOADED = 1;
376
377 # We deliberately defer the require until called rather than at
378 # module-load -- avoids a circular use between Config and DBConnect
379 # in early-boot paths.
380 my $db_pkg = 'MODS::DBConnect';
381 eval "require $db_pkg; 1" or return;
382 my $db = $db_pkg->new;
383 return unless $db;
384
385 my $dbh = eval { $db->db_connect() };
386 return unless $dbh;
387
388 my $DB = $DEFAULTS{database_name};
389
390 my $col_check = eval {
391 $db->db_readwrite($dbh, qq~
392 SELECT COUNT(*) AS n FROM information_schema.tables
393 WHERE table_schema='$DB' AND table_name='platform_settings'
394 ~, $ENV{SCRIPT_NAME}, __LINE__);
395 };
396 unless ($col_check && $col_check->{n}) {
397 $_DB_AVAILABLE = 0;
398 eval { $db->db_disconnect($dbh) };
399 return;
400 }
401 $_DB_AVAILABLE = 1;
402
403 my @rows;
404 eval {
405 @rows = $db->db_readwrite_multiple($dbh, qq~
406 SELECT setting_key, setting_value
407 FROM `${DB}`.platform_settings
408 ~, $ENV{SCRIPT_NAME}, __LINE__);
409 1;
410 };
411 foreach my $r (@rows) {
412 next unless ref($r) eq 'HASH';
413 next unless defined $r->{setting_key};
414 my $val = defined $r->{setting_value} ? $r->{setting_value} : '';
415 next unless length $val; # empty rows defer to file/code so admins can "blank" a field
416 $_DB_CACHE{ $r->{setting_key} } = $val;
417 }
418 eval { $db->db_disconnect($dbh) };
419}
420
421# Reads /var/www/vhosts/shop.3dshawn.com/private/<file> and returns the
422# value for the requested key. Returns undef if the file or key is
423# missing. Cached per-process.
424my %_PRIVATE_CACHE;
425sub _read_private {
426 my ($file, $key) = @_;
427 my $path = '/var/www/vhosts/shop.3dshawn.com/private/' . $file;
428 unless (exists $_PRIVATE_CACHE{$path}) {
429 my %h;
430 if (open(my $fh, '<', $path)) {
431 while (my $line = <$fh>) {
432 chomp $line;
433 next if $line =~ /^\s*#/;
434 next unless $line =~ /^([A-Za-z0-9_]+)\s*=\s*(.*)$/;
435 $h{$1} = $2;
436 }
437 close $fh;
438 }
439 $_PRIVATE_CACHE{$path} = \%h;
440 }
441 return $_PRIVATE_CACHE{$path}->{$key};
442}
443
444# Returns the {url_key => "/path?cachekey"} hash used by Template.pm's
445# [url:foo] tag. Mirrors MODS::Urls but kept ShopCart-local so we never
446# collide with the existing portal URL registry.
447sub urls {
448 my $self = shift;
449 return {
450 home => '/index.cgi',
451 login => '/login.cgi',
452 signup => '/signup.cgi',
453 logout => '/logout.cgi',
454 dashboard => '/dashboard.cgi',
455 models => '/models.cgi',
456 upload => '/upload_model.cgi',
457 storefront => '/storefront.cgi',
458 analytics => '/analytics.cgi',
459 optimization => '/optimization.cgi',
460 settings => '/profile.cgi',
461 admin => '/admin.cgi',
462 messages => '/messages.cgi',
463 audience => '/audience.cgi',
464 assets => '/assets',
465 assets_css => '/assets/css',
466 assets_js => '/assets/javascript',
467 assets_images => '/assets/images',
468 };
469}
470
4711;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help