Diff -- /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/MODS/AffSoft/Config.pm
Diff

/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/MODS/AffSoft/Config.pm

added on local at 2026-07-01 13:47:27

Added
+431
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 267a08b5f1a0
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::AffSoft::Config;
2#======================================================================
3# AffSoft — 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 => 'AffSoft 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 # Attachments (Inbox DM uploads)
164 dm_attachment_max_size_mb => {
165 label => 'DM attachment max size (MB)',
166 group => 'Attachments', secret => 0,
167 description => 'Hard cap on direct-message attachment size, in megabytes. Enforced both client-side (file picker) and server-side (request rejected if CONTENT_LENGTH or actual read exceeds the cap). 25 MB is a safe default for screenshots, PDFs, and small zip bundles; bump it up for heavier creative assets, down to deter abuse. Capped at 100 in the UI.',
168 },
169 dm_attachment_allowed_types => {
170 label => 'DM attachment allowed file types',
171 group => 'Attachments', secret => 0,
172 description => 'Comma-separated list of lowercase file extensions the inbox accepts on DM uploads (no dots, e.g. "png,jpg,pdf,zip"). The file picker filters on these and the server rejects anything not in the list. Server-side magic-byte sniffing further checks PNG/JPEG/GIF/WebP/PDF/ZIP so a renamed binary cannot sneak through. Tighten to lock down what affiliates and merchants can pass to each other.',
173 },
174);
175
176my %DEFAULTS = (
177 # ----- Database ----------------------------------------------
178 # NOT editable via UI -- the lookup itself depends on this.
179 database_name => 'affiliate3dshawn',
180
181 # ----- Branding ----------------------------------------------
182 website_title => 'AffSoft — Affiliate Program Platform',
183 brand_name => 'AffSoft',
184 brand_tagline => 'Affiliate-program software',
185
186 # ----- Sessions / cookies ------------------------------------
187 # NOT editable via UI -- deploy-coupled. Edit MODS/AffSoft/Config.pm.
188 cookie_name => 'affsoft_session',
189 session_minutes => 720,
190 secure_cookies => 1,
191 cookie_domain => '.3dshawn.com',
192
193 # ----- Asset paths -------------------------------------------
194 # NOT editable via UI -- deploy-coupled.
195 assets => '/assets',
196 assets_css => '/assets/css',
197 assets_js => '/assets/javascript',
198 assets_images => '/assets/images',
199
200 # ----- Email / mail ------------------------------------------
201 feedback_emails => 'programmershawn@gmail.com',
202 from_email => 'noreply@3dshawn.com',
203 smtp_host => '', # blank = use local sendmail
204 smtp_port => 587,
205 smtp_user => '',
206 smtp_pass => '',
207
208 # ----- Stripe ------------------------------------------------
209 stripe_publishable => 'pk_test_REPLACE_ME',
210 stripe_secret => '',
211 stripe_connect_client => 'ca_REPLACE_ME',
212 stripe_webhook_secret => '',
213 stripe_platform_fee_bps => 1000,
214 stripe_currency_default => 'usd',
215
216 # ----- File storage (R2) -------------------------------------
217 r2_bucket => 'webstls-files',
218 r2_public_base => 'https://files.webstls.com',
219 r2_account_id => '',
220 r2_access_key_id => '',
221 r2_secret_access_key => '',
222 r2_region => 'auto',
223
224 # ----- Plan limits -------------------------------------------
225 plan_free_models => 10,
226 plan_starter_price => 19,
227 plan_pro_price => 49,
228 plan_studio_price => 129,
229
230 # ----- Feature flags fallback (when DB unreachable) ----------
231 # NOT editable via UI -- read when DB is DOWN, so DB rows would
232 # never apply anyway. Keep in code.
233 flag_default__module_publishing => 1,
234 flag_default__module_storefront => 1,
235 flag_default__module_optimization => 1,
236 flag_default__module_pooled_data => 0,
237 flag_default__module_audience => 1,
238
239 # ----- Misc --------------------------------------------------
240 timezone_default => 'UTC',
241 currency_default => 'USD',
242
243 # ----- Attachments (DM uploads) ------------------------------
244 dm_attachment_max_size_mb => 25,
245 dm_attachment_allowed_types => 'png,jpg,jpeg,gif,webp,pdf,zip,csv,xlsx,docx,txt,md',
246);
247
248# Per-process cache. Populated on first DB hit; survives for the
249# request and is cheap on subsequent settings() calls.
250my %_DB_CACHE;
251my $_DB_LOADED = 0;
252my $_DB_AVAILABLE; # undef = unknown, 0 = table missing, 1 = ready
253
254sub new {
255 my ($class) = @_;
256 return bless({}, $class);
257}
258
259# Public helper for the admin config page. Returns the editable
260# whitelist with current values + source ('db' | 'file' | 'default').
261# is_secret rows have their value masked to a non-empty marker so the
262# UI can show "set" without leaking the actual secret.
263sub editable_settings_meta {
264 my ($self) = @_;
265 my @rows;
266 foreach my $key (sort keys %EDITABLE) {
267 my $meta = $EDITABLE{$key};
268 my ($val, $source) = $self->_resolve_with_source($key);
269 my $masked = ($meta->{secret} && defined($val) && length $val) ? 1 : 0;
270 push @rows, {
271 key => $key,
272 label => $meta->{label},
273 group => $meta->{group},
274 secret => $meta->{secret},
275 description => $meta->{description} || '',
276 value => $masked ? '' : (defined $val ? $val : ''),
277 is_set => (defined $val && length $val) ? 1 : 0,
278 source => $source,
279 };
280 }
281 return @rows;
282}
283
284# Bool -- is this key editable via the admin config page?
285sub is_editable {
286 my ($self, $key) = @_;
287 return exists $EDITABLE{$key} ? 1 : 0;
288}
289
290sub editable_meta_for {
291 my ($self, $key) = @_;
292 return $EDITABLE{$key};
293}
294
295sub settings {
296 my ($self, $key) = @_;
297 return undef unless defined $key;
298
299 my ($val) = $self->_resolve_with_source($key);
300 return $val;
301}
302
303# Core lookup. Returns ($value, $source) where source is one of
304# 'db' | 'file' | 'default'. Centralises the precedence so any caller
305# (settings() / editable_settings_meta) gets the same answer.
306sub _resolve_with_source {
307 my ($self, $key) = @_;
308
309 # 1) DB lookup (cached). Skip for the database_name itself -- we
310 # can't ask the DB which DB to use.
311 if ($key ne 'database_name') {
312 $self->_load_db_cache;
313 if (exists $_DB_CACHE{$key}) {
314 return ($_DB_CACHE{$key}, 'db');
315 }
316 }
317
318 # 2) Private file fallback (legacy Stripe secret path). Keeps
319 # brand-new installs working until the admin opens the config
320 # page.
321 if ($key eq 'stripe_secret' || $key eq 'stripe_webhook_secret') {
322 my $val = _read_private('stripe.conf', $key);
323 return ($val, 'file') if defined $val && length $val;
324 }
325
326 # 3) Hardcoded default.
327 return (exists $DEFAULTS{$key} ? $DEFAULTS{$key} : undef, 'default');
328}
329
330# Single-pass population of %_DB_CACHE. Probes for the table to avoid
331# crashing pre-migration installs. Silently bails on any error so
332# Config.pm never takes the site down.
333sub _load_db_cache {
334 my ($self) = @_;
335 return if $_DB_LOADED;
336 $_DB_LOADED = 1;
337
338 # We deliberately defer the require until called rather than at
339 # module-load -- avoids a circular use between Config and DBConnect
340 # in early-boot paths.
341 my $db_pkg = 'MODS::DBConnect';
342 eval "require $db_pkg; 1" or return;
343 my $db = $db_pkg->new;
344 return unless $db;
345
346 my $dbh = eval { $db->db_connect() };
347 return unless $dbh;
348
349 my $DB = $DEFAULTS{database_name};
350
351 my $col_check = eval {
352 $db->db_readwrite($dbh, qq~
353 SELECT COUNT(*) AS n FROM information_schema.tables
354 WHERE table_schema='$DB' AND table_name='platform_settings'
355 ~, $ENV{SCRIPT_NAME}, __LINE__);
356 };
357 unless ($col_check && $col_check->{n}) {
358 $_DB_AVAILABLE = 0;
359 eval { $db->db_disconnect($dbh) };
360 return;
361 }
362 $_DB_AVAILABLE = 1;
363
364 my @rows;
365 eval {
366 @rows = $db->db_readwrite_multiple($dbh, qq~
367 SELECT setting_key, setting_value
368 FROM `${DB}`.platform_settings
369 ~, $ENV{SCRIPT_NAME}, __LINE__);
370 1;
371 };
372 foreach my $r (@rows) {
373 next unless ref($r) eq 'HASH';
374 next unless defined $r->{setting_key};
375 my $val = defined $r->{setting_value} ? $r->{setting_value} : '';
376 next unless length $val; # empty rows defer to file/code so admins can "blank" a field
377 $_DB_CACHE{ $r->{setting_key} } = $val;
378 }
379 eval { $db->db_disconnect($dbh) };
380}
381
382# Reads /var/www/vhosts/webstls.com/private/<file> and returns the
383# value for the requested key. Returns undef if the file or key is
384# missing. Cached per-process.
385my %_PRIVATE_CACHE;
386sub _read_private {
387 my ($file, $key) = @_;
388 my $path = '/var/www/vhosts/3dshawn.com/private/' . $file;
389 unless (exists $_PRIVATE_CACHE{$path}) {
390 my %h;
391 if (open(my $fh, '<', $path)) {
392 while (my $line = <$fh>) {
393 chomp $line;
394 next if $line =~ /^\s*#/;
395 next unless $line =~ /^([A-Za-z0-9_]+)\s*=\s*(.*)$/;
396 $h{$1} = $2;
397 }
398 close $fh;
399 }
400 $_PRIVATE_CACHE{$path} = \%h;
401 }
402 return $_PRIVATE_CACHE{$path}->{$key};
403}
404
405# Returns the {url_key => "/path?cachekey"} hash used by Template.pm's
406# [url:foo] tag. Mirrors MODS::Urls but kept AffSoft-local so we never
407# collide with the existing portal URL registry.
408sub urls {
409 my $self = shift;
410 return {
411 home => '/index.cgi',
412 login => '/login.cgi',
413 signup => '/signup.cgi',
414 logout => '/logout.cgi',
415 dashboard => '/dashboard.cgi',
416 models => '/models.cgi',
417 upload => '/upload_model.cgi',
418 storefront => '/storefront.cgi',
419 analytics => '/analytics.cgi',
420 optimization => '/optimization.cgi',
421 settings => '/profile.cgi',
422 admin => '/admin.cgi',
423 audience => '/audience.cgi',
424 assets => '/assets',
425 assets_css => '/assets/css',
426 assets_js => '/assets/javascript',
427 assets_images => '/assets/images',
428 };
429}
430
4311;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help