Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/MODS/ContactForge/Config.pm
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/MODS/ContactForge/Config.pm

added on local at 2026-07-01 15:02:52

Added
+412
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 1cd2199fe9ad
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::ContactForge::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
164my %DEFAULTS = (
165 # ----- Database ----------------------------------------------
166 database_name => 'crm3dshawn',
167
168 # ----- Branding ----------------------------------------------
169 website_title => 'ContactForge — Customer Relationship Platform',
170 brand_name => 'ContactForge',
171 brand_tagline => 'One place for contacts, deals, activities, and customer chat.',
172
173 # ----- Brand colors (read by Wrapper.pm + admin pages) -------
174 # Electric cyan -- distinct from every sibling site (ABForge sunset,
175 # WebSTLs/AffSoft indigo, ShopCart olive, RePricer green, TaskForge
176 # crimson). Pops on dark bg.
177 color_brand => '#06b6d4',
178 color_brand_2 => '#67e8f9',
179 color_accent => '#5aa9ff',
180
181 # ----- Sessions / cookies ------------------------------------
182 cookie_name => 'cf_session',
183 session_minutes => 720,
184 secure_cookies => 1,
185 cookie_domain => '.crm.3dshawn.com',
186
187 # ----- Asset paths -------------------------------------------
188 assets => '/assets',
189 assets_css => '/assets/css',
190 assets_js => '/assets/javascript',
191 assets_images => '/assets/images',
192
193 # ----- Email / mail ------------------------------------------
194 # PLATFORM (our own) transactional email goes through these creds --
195 # signup confirms, password resets, billing receipts, admin alerts.
196 # Set these in admin Settings; leave smtp_host blank to fall back to
197 # the local Plesk MTA (fine for low volume).
198 feedback_emails => 'support@crm.3dshawn.com',
199 from_email => 'noreply@crm.3dshawn.com',
200 smtp_host => '',
201 smtp_port => 587,
202 smtp_user => '',
203 smtp_pass => '',
204 # BYO SMTP encryption key -- AES key used by AES_ENCRYPT/AES_DECRYPT
205 # to encrypt tenant-supplied SMTP passwords at rest in email_providers.
206 # Long random secret. Rotating this invalidates every saved tenant
207 # password (they must re-enter), so only rotate if it's leaked.
208 byo_smtp_enc_key => 'k7QmZ3RvY9JxL8nWfP2dT5sH4bU6gA1eC0iE-NbX_VqMjOiPyDhRoSlFwUaKuTzM',
209
210 # ----- Stripe ------------------------------------------------
211 stripe_publishable => 'pk_test_REPLACE_ME',
212 stripe_secret => '',
213 stripe_webhook_secret => '',
214 stripe_currency_default => 'usd',
215
216 # ----- Plan defaults ----------------------------------------
217 # Post pricing-v3 (2026-06): 2 paid tiers (Pro $29, Business $99),
218 # 14-day free trial on every signup, contact-count capped during trial.
219 plan_pro_price => 29,
220 plan_business_price => 99,
221 plan_trial_days => 14,
222 plan_trial_contacts_cap => 250,
223
224 # ----- Misc --------------------------------------------------
225 timezone_default => 'UTC',
226 currency_default => 'USD',
227);
228
229# Per-process cache. Populated on first DB hit; survives for the
230# request and is cheap on subsequent settings() calls.
231my %_DB_CACHE;
232my $_DB_LOADED = 0;
233my $_DB_AVAILABLE; # undef = unknown, 0 = table missing, 1 = ready
234
235sub new {
236 my ($class) = @_;
237 return bless({}, $class);
238}
239
240# Public helper for the admin config page. Returns the editable
241# whitelist with current values + source ('db' | 'file' | 'default').
242# is_secret rows have their value masked to a non-empty marker so the
243# UI can show "set" without leaking the actual secret.
244sub editable_settings_meta {
245 my ($self) = @_;
246 my @rows;
247 foreach my $key (sort keys %EDITABLE) {
248 my $meta = $EDITABLE{$key};
249 my ($val, $source) = $self->_resolve_with_source($key);
250 my $masked = ($meta->{secret} && defined($val) && length $val) ? 1 : 0;
251 push @rows, {
252 key => $key,
253 label => $meta->{label},
254 group => $meta->{group},
255 secret => $meta->{secret},
256 description => $meta->{description} || '',
257 value => $masked ? '' : (defined $val ? $val : ''),
258 is_set => (defined $val && length $val) ? 1 : 0,
259 source => $source,
260 };
261 }
262 return @rows;
263}
264
265# Bool -- is this key editable via the admin config page?
266sub is_editable {
267 my ($self, $key) = @_;
268 return exists $EDITABLE{$key} ? 1 : 0;
269}
270
271sub editable_meta_for {
272 my ($self, $key) = @_;
273 return $EDITABLE{$key};
274}
275
276sub settings {
277 my ($self, $key) = @_;
278 return undef unless defined $key;
279
280 my ($val) = $self->_resolve_with_source($key);
281 return $val;
282}
283
284# Core lookup. Returns ($value, $source) where source is one of
285# 'db' | 'file' | 'default'. Centralises the precedence so any caller
286# (settings() / editable_settings_meta) gets the same answer.
287sub _resolve_with_source {
288 my ($self, $key) = @_;
289
290 # 1) DB lookup (cached). Skip for the database_name itself -- we
291 # can't ask the DB which DB to use.
292 if ($key ne 'database_name') {
293 $self->_load_db_cache;
294 if (exists $_DB_CACHE{$key}) {
295 return ($_DB_CACHE{$key}, 'db');
296 }
297 }
298
299 # 2) Private file fallback (legacy Stripe secret path). Keeps
300 # brand-new installs working until the admin opens the config
301 # page.
302 if ($key eq 'stripe_secret' || $key eq 'stripe_webhook_secret') {
303 my $val = _read_private('stripe.conf', $key);
304 return ($val, 'file') if defined $val && length $val;
305 }
306
307 # 3) Hardcoded default.
308 return (exists $DEFAULTS{$key} ? $DEFAULTS{$key} : undef, 'default');
309}
310
311# Single-pass population of %_DB_CACHE. Probes for the table to avoid
312# crashing pre-migration installs. Silently bails on any error so
313# Config.pm never takes the site down.
314sub _load_db_cache {
315 my ($self) = @_;
316 return if $_DB_LOADED;
317 $_DB_LOADED = 1;
318
319 # We deliberately defer the require until called rather than at
320 # module-load -- avoids a circular use between Config and DBConnect
321 # in early-boot paths.
322 my $db_pkg = 'MODS::DBConnect';
323 eval "require $db_pkg; 1" or return;
324 my $db = $db_pkg->new;
325 return unless $db;
326
327 my $dbh = eval { $db->db_connect() };
328 return unless $dbh;
329
330 my $DB = $DEFAULTS{database_name};
331
332 my $col_check = eval {
333 $db->db_readwrite($dbh, qq~
334 SELECT COUNT(*) AS n FROM information_schema.tables
335 WHERE table_schema='$DB' AND table_name='platform_settings'
336 ~, $ENV{SCRIPT_NAME}, __LINE__);
337 };
338 unless ($col_check && $col_check->{n}) {
339 $_DB_AVAILABLE = 0;
340 eval { $db->db_disconnect($dbh) };
341 return;
342 }
343 $_DB_AVAILABLE = 1;
344
345 my @rows;
346 eval {
347 @rows = $db->db_readwrite_multiple($dbh, qq~
348 SELECT setting_key, setting_value
349 FROM `${DB}`.platform_settings
350 ~, $ENV{SCRIPT_NAME}, __LINE__);
351 1;
352 };
353 foreach my $r (@rows) {
354 next unless ref($r) eq 'HASH';
355 next unless defined $r->{setting_key};
356 my $val = defined $r->{setting_value} ? $r->{setting_value} : '';
357 next unless length $val; # empty rows defer to file/code so admins can "blank" a field
358 $_DB_CACHE{ $r->{setting_key} } = $val;
359 }
360 eval { $db->db_disconnect($dbh) };
361}
362
363# Reads /var/www/vhosts/webstls.com/private/<file> and returns the
364# value for the requested key. Returns undef if the file or key is
365# missing. Cached per-process.
366my %_PRIVATE_CACHE;
367sub _read_private {
368 my ($file, $key) = @_;
369 my $path = '/var/www/vhosts/webstls.com/private/' . $file;
370 unless (exists $_PRIVATE_CACHE{$path}) {
371 my %h;
372 if (open(my $fh, '<', $path)) {
373 while (my $line = <$fh>) {
374 chomp $line;
375 next if $line =~ /^\s*#/;
376 next unless $line =~ /^([A-Za-z0-9_]+)\s*=\s*(.*)$/;
377 $h{$1} = $2;
378 }
379 close $fh;
380 }
381 $_PRIVATE_CACHE{$path} = \%h;
382 }
383 return $_PRIVATE_CACHE{$path}->{$key};
384}
385
386# Returns the {url_key => "/path?cachekey"} hash used by Template.pm's
387# [url:foo] tag. Mirrors MODS::Urls but kept WebSTLs-local so we never
388# collide with the existing portal URL registry.
389sub urls {
390 my $self = shift;
391 return {
392 home => '/index.cgi',
393 login => '/login.cgi',
394 signup => '/signup.cgi',
395 logout => '/logout.cgi',
396 dashboard => '/dashboard.cgi',
397 models => '/models.cgi',
398 upload => '/upload_model.cgi',
399 storefront => '/storefront.cgi',
400 analytics => '/analytics.cgi',
401 optimization => '/optimization.cgi',
402 settings => '/profile.cgi',
403 admin => '/admin.cgi',
404 audience => '/audience.cgi',
405 assets => '/assets',
406 assets_css => '/assets/css',
407 assets_js => '/assets/javascript',
408 assets_images => '/assets/images',
409 };
410}
411
4121;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help