Diff -- /var/www/vhosts/webstls.com/httpdocs/MODS/WebSTLs/Config.pm
Diff

/var/www/vhosts/webstls.com/httpdocs/MODS/WebSTLs/Config.pm

added on WebSTLs (webstls.com) at 2026-07-01 22:26:38

Added
+480
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 44ed52fd5dd7
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::WebSTLs::Config;
2#======================================================================
3# WebSTLs — 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/webstls.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://webstls.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 => 'WebSTLs 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.webstls.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 # Defaults
152 timezone_default => {
153 label => 'Default timezone',
154 group => 'Defaults', secret => 0,
155 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.',
156 },
157 currency_default => {
158 label => 'Default currency',
159 group => 'Defaults', secret => 0,
160 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.',
161 },
162
163 # ----- Storage caps + compression ---------------------------------
164 # Per-tier per-file upload caps (megabytes). Drives the upload gate
165 # in /upload_model.cgi and the user dashboard storage gauge.
166 upload_max_mb_free => { label => 'Free upload cap (MB)', group => 'Storage', secret => 0,
167 description => 'Max size of a single model file a Free user can upload. Bigger files are rejected at /upload_model.cgi.' },
168 upload_max_mb_starter => { label => 'Starter upload cap (MB)', group => 'Storage', secret => 0,
169 description => 'Max single-file upload size for Starter users.' },
170 upload_max_mb_pro => { label => 'Pro upload cap (MB)', group => 'Storage', secret => 0,
171 description => 'Max single-file upload size for Pro users.' },
172 upload_max_mb_studio => { label => 'Studio upload cap (MB)', group => 'Storage', secret => 0,
173 description => 'Max single-file upload size for Studio users.' },
174
175 # Per-tier total-storage caps (gigabytes).
176 storage_hard_cap_gb_free => { label => 'Free total storage (GB)', group => 'Storage', secret => 0,
177 description => 'Total bytes a Free user can store. Per-user override on /admin_user.cgi wins.' },
178 storage_hard_cap_gb_starter => { label => 'Starter total storage (GB)', group => 'Storage', secret => 0,
179 description => 'Total bytes a Starter user can store.' },
180 storage_hard_cap_gb_pro => { label => 'Pro total storage (GB)', group => 'Storage', secret => 0,
181 description => 'Total bytes a Pro user can store.' },
182 storage_hard_cap_gb_studio => { label => 'Studio total storage (GB)', group => 'Storage', secret => 0,
183 description => 'Total bytes a Studio user can store.' },
184
185 # Allowed extensions -- per-tier (preferred) + legacy global fallback.
186 # Resolution: user override > tier-specific > global > hardcoded.
187 allowed_file_extensions_free => { label => 'Free allowed extensions', group => 'Storage', secret => 0,
188 description => 'Comma-separated, lowercase, no dots. Shipped narrow (e.g. stl,obj) so .step / .iges / .fbx require an upgrade.' },
189 allowed_file_extensions_starter => { label => 'Starter allowed extensions', group => 'Storage', secret => 0,
190 description => 'Comma-separated, lowercase, no dots. Ship default adds 3MF + PLY + glTF so hobbyists with Bambu/Prusa workflows are covered.' },
191 allowed_file_extensions_pro => { label => 'Pro allowed extensions', group => 'Storage', secret => 0,
192 description => 'Comma-separated, lowercase, no dots. Ships with the full CAD set including STEP/IGES/FBX.' },
193 allowed_file_extensions_studio => { label => 'Studio allowed extensions', group => 'Storage', secret => 0,
194 description => 'Comma-separated, lowercase, no dots. Ships identical to Pro by default; raise the floor here if you want to differentiate Studio further.' },
195 allowed_file_extensions => { label => 'Global allowed extensions (legacy fallback)', group => 'Storage', secret => 0,
196 description => 'Comma-separated, lowercase, no dots. Used only when the per-tier key for the user\'s tier is unset. Kept for back-compat -- prefer the per-tier keys above.' },
197
198 storage_soft_alert_pct => { label => 'Soft-alert threshold (%)', group => 'Storage', secret => 0,
199 description => 'Percent of total cap at which the dashboard gauge turns amber and the hourly worker queues an email nudge. Default 80.' },
200
201 # On-store compression toggles.
202 storage_compression_enabled => { label => 'Compress uploads on store', group => 'Storage', secret => 0,
203 description => '1 = compress STL/OBJ/etc. on upload (zstd if available, else gzip). 0 = store raw. Already-packed formats (3MF / GLB / ZIP) are always stored raw regardless.' },
204 storage_compression_algo => { label => 'Compression algorithm', group => 'Storage', secret => 0,
205 description => '"auto" (recommended) picks zstd when /usr/bin/zstd is present, otherwise gzip. Force-pin to "zstd" or "gzip" only if you need a specific algo on this host.' },
206
207 # The actual root for paid model files. MUST be OUTSIDE httpdocs so
208 # Apache can never serve the bytes directly -- everything flows
209 # through /download.cgi which checks order + ownership before
210 # streaming. The CGI user (stlsweb) needs read+write on this path.
211 private_files_dir => { label => 'Private files directory', group => 'Storage', secret => 0,
212 description => 'Absolute filesystem path OUTSIDE the web root where uploaded model files (.stl/.obj/etc) are stored. The CGI user must own it. NOTE: changing this does NOT move existing files -- run /_storage_backfill.pl on the server to migrate.' },
213);
214
215my %DEFAULTS = (
216 # ----- Database ----------------------------------------------
217 # NOT editable via UI -- the lookup itself depends on this.
218 database_name => 'WEBSTLs',
219
220 # Storage root for paid model files. OUTSIDE httpdocs by design --
221 # served only through /download.cgi after order-ownership check.
222 private_files_dir => '/var/www/vhosts/webstls.com/private/files',
223
224 # ----- Branding ----------------------------------------------
225 website_title => 'WebSTLs — 3D Printable Designs Platform',
226 brand_name => 'WebSTLs',
227 brand_tagline => 'The operating system for 3D creators',
228
229 # ----- Sessions / cookies ------------------------------------
230 # NOT editable via UI -- deploy-coupled. Edit MODS/WebSTLs/Config.pm.
231 cookie_name => 'webstls_session',
232 session_minutes => 720,
233 secure_cookies => 1,
234 # Domain scope for the session cookie. Leading-dot form means the
235 # cookie is sent to BOTH apex (webstls.com) and every subdomain
236 # (e.g. 3dshawn.webstls.com), so when a seller is logged in on the
237 # dashboard their session is recognized on their own storefront
238 # subdomain too (needed for owner-only features like draft bundle
239 # previews, edit-mode bypass, etc). Empty string keeps the cookie
240 # host-only (legacy behavior).
241 cookie_domain => '.webstls.com',
242
243 # ----- Asset paths -------------------------------------------
244 # NOT editable via UI -- deploy-coupled.
245 assets => '/assets',
246 assets_css => '/assets/css',
247 assets_js => '/assets/javascript',
248 assets_images => '/assets/images',
249
250 # ----- Email / mail ------------------------------------------
251 feedback_emails => 'support@webstls.com',
252 from_email => 'noreply@webstls.com',
253 smtp_host => '', # blank = use local sendmail
254 smtp_port => 587,
255 smtp_user => '',
256 smtp_pass => '',
257
258 # ----- Stripe ------------------------------------------------
259 stripe_publishable => 'pk_test_REPLACE_ME',
260 stripe_secret => '',
261 stripe_connect_client => 'ca_REPLACE_ME',
262 stripe_webhook_secret => '',
263 stripe_platform_fee_bps => 1000,
264 stripe_currency_default => 'usd',
265
266 # ----- File storage (R2) -------------------------------------
267 r2_bucket => 'webstls-files',
268 r2_public_base => 'https://files.webstls.com',
269 r2_account_id => '',
270 r2_access_key_id => '',
271 r2_secret_access_key => '',
272 r2_region => 'auto',
273
274 # ----- Plan limits -------------------------------------------
275 plan_free_models => 10,
276 plan_starter_price => 19,
277 plan_pro_price => 49,
278 plan_studio_price => 129,
279
280 # ----- Feature flags fallback (when DB unreachable) ----------
281 # NOT editable via UI -- read when DB is DOWN, so DB rows would
282 # never apply anyway. Keep in code.
283 flag_default__module_publishing => 1,
284 flag_default__module_storefront => 1,
285 flag_default__module_optimization => 1,
286 flag_default__module_pooled_data => 0,
287 flag_default__module_audience => 1,
288
289 # ----- Misc --------------------------------------------------
290 timezone_default => 'UTC',
291 currency_default => 'USD',
292);
293
294# Per-process cache. Populated on first DB hit; survives for the
295# request and is cheap on subsequent settings() calls.
296my %_DB_CACHE;
297my $_DB_LOADED = 0;
298my $_DB_AVAILABLE; # undef = unknown, 0 = table missing, 1 = ready
299
300sub new {
301 my ($class) = @_;
302 return bless({}, $class);
303}
304
305# Public helper for the admin config page. Returns the editable
306# whitelist with current values + source ('db' | 'file' | 'default').
307# is_secret rows have their value masked to a non-empty marker so the
308# UI can show "set" without leaking the actual secret.
309sub editable_settings_meta {
310 my ($self) = @_;
311 my @rows;
312 foreach my $key (sort keys %EDITABLE) {
313 my $meta = $EDITABLE{$key};
314 my ($val, $source) = $self->_resolve_with_source($key);
315 my $masked = ($meta->{secret} && defined($val) && length $val) ? 1 : 0;
316 push @rows, {
317 key => $key,
318 label => $meta->{label},
319 group => $meta->{group},
320 secret => $meta->{secret},
321 description => $meta->{description} || '',
322 value => $masked ? '' : (defined $val ? $val : ''),
323 is_set => (defined $val && length $val) ? 1 : 0,
324 source => $source,
325 };
326 }
327 return @rows;
328}
329
330# Bool -- is this key editable via the admin config page?
331sub is_editable {
332 my ($self, $key) = @_;
333 return exists $EDITABLE{$key} ? 1 : 0;
334}
335
336sub editable_meta_for {
337 my ($self, $key) = @_;
338 return $EDITABLE{$key};
339}
340
341sub settings {
342 my ($self, $key) = @_;
343 return undef unless defined $key;
344
345 my ($val) = $self->_resolve_with_source($key);
346 return $val;
347}
348
349# Core lookup. Returns ($value, $source) where source is one of
350# 'db' | 'file' | 'default'. Centralises the precedence so any caller
351# (settings() / editable_settings_meta) gets the same answer.
352sub _resolve_with_source {
353 my ($self, $key) = @_;
354
355 # 1) DB lookup (cached). Skip for the database_name itself -- we
356 # can't ask the DB which DB to use.
357 if ($key ne 'database_name') {
358 $self->_load_db_cache;
359 if (exists $_DB_CACHE{$key}) {
360 return ($_DB_CACHE{$key}, 'db');
361 }
362 }
363
364 # 2) Private file fallback (legacy Stripe secret path). Keeps
365 # brand-new installs working until the admin opens the config
366 # page.
367 if ($key eq 'stripe_secret' || $key eq 'stripe_webhook_secret') {
368 my $val = _read_private('stripe.conf', $key);
369 return ($val, 'file') if defined $val && length $val;
370 }
371
372 # 3) Hardcoded default.
373 return (exists $DEFAULTS{$key} ? $DEFAULTS{$key} : undef, 'default');
374}
375
376# Single-pass population of %_DB_CACHE. Probes for the table to avoid
377# crashing pre-migration installs. Silently bails on any error so
378# Config.pm never takes the site down.
379sub _load_db_cache {
380 my ($self) = @_;
381 return if $_DB_LOADED;
382 $_DB_LOADED = 1;
383
384 # We deliberately defer the require until called rather than at
385 # module-load -- avoids a circular use between Config and DBConnect
386 # in early-boot paths.
387 my $db_pkg = 'MODS::DBConnect';
388 eval "require $db_pkg; 1" or return;
389 my $db = $db_pkg->new;
390 return unless $db;
391
392 my $dbh = eval { $db->db_connect() };
393 return unless $dbh;
394
395 my $DB = $DEFAULTS{database_name};
396
397 my $col_check = eval {
398 $db->db_readwrite($dbh, qq~
399 SELECT COUNT(*) AS n FROM information_schema.tables
400 WHERE table_schema='$DB' AND table_name='platform_settings'
401 ~, $ENV{SCRIPT_NAME}, __LINE__);
402 };
403 unless ($col_check && $col_check->{n}) {
404 $_DB_AVAILABLE = 0;
405 eval { $db->db_disconnect($dbh) };
406 return;
407 }
408 $_DB_AVAILABLE = 1;
409
410 my @rows;
411 eval {
412 @rows = $db->db_readwrite_multiple($dbh, qq~
413 SELECT setting_key, setting_value
414 FROM `${DB}`.platform_settings
415 ~, $ENV{SCRIPT_NAME}, __LINE__);
416 1;
417 };
418 foreach my $r (@rows) {
419 next unless ref($r) eq 'HASH';
420 next unless defined $r->{setting_key};
421 my $val = defined $r->{setting_value} ? $r->{setting_value} : '';
422 next unless length $val; # empty rows defer to file/code so admins can "blank" a field
423 $_DB_CACHE{ $r->{setting_key} } = $val;
424 }
425 eval { $db->db_disconnect($dbh) };
426}
427
428# Reads /var/www/vhosts/webstls.com/private/<file> and returns the
429# value for the requested key. Returns undef if the file or key is
430# missing. Cached per-process.
431my %_PRIVATE_CACHE;
432sub _read_private {
433 my ($file, $key) = @_;
434 my $path = '/var/www/vhosts/webstls.com/private/' . $file;
435 unless (exists $_PRIVATE_CACHE{$path}) {
436 my %h;
437 if (open(my $fh, '<', $path)) {
438 while (my $line = <$fh>) {
439 chomp $line;
440 next if $line =~ /^\s*#/;
441 next unless $line =~ /^([A-Za-z0-9_]+)\s*=\s*(.*)$/;
442 $h{$1} = $2;
443 }
444 close $fh;
445 }
446 $_PRIVATE_CACHE{$path} = \%h;
447 }
448 return $_PRIVATE_CACHE{$path}->{$key};
449}
450
451# Returns the {url_key => "/path?cachekey"} hash used by Template.pm's
452# [url:foo] tag. Mirrors MODS::Urls but kept WebSTLs-local so we never
453# collide with the existing portal URL registry.
454sub urls {
455 my $self = shift;
456 return {
457 home => '/index.cgi',
458 login => '/login.cgi',
459 signup => '/signup.cgi',
460 logout => '/logout.cgi',
461 dashboard => '/dashboard.cgi',
462 models => '/models.cgi',
463 upload => '/upload_model.cgi',
464 storefront => '/storefront.cgi',
465 analytics => '/analytics.cgi',
466 optimization => '/optimization.cgi',
467 settings => '/profile.cgi',
468 profile => '/profile.cgi',
469 preferences => '/preferences.cgi',
470 admin => '/admin.cgi',
471 messages => '/messages.cgi',
472 audience => '/audience.cgi',
473 assets => '/assets',
474 assets_css => '/assets/css',
475 assets_js => '/assets/javascript',
476 assets_images => '/assets/images',
477 };
478}
479
4801;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help