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

O Operator
Diff

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

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

Added
+0
lines
Removed
-0
lines
Context
412
unchanged
Blobs
from 1cd2199fe9ad
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.
11package MODS::ContactForge::Config;
22#======================================================================
33# WebSTLs — central configuration.
44#
55# Lookup order for settings():
66# 1. platform_settings table (DB-backed) -- editable via
77# /admin_config.cgi by a super-admin. Highest precedence so an
88# admin can override anything without a code deploy.
99# 2. /var/www/vhosts/webstls.com/private/stripe.conf -- legacy
1010# bootstrap path for the Stripe secret + webhook secret on a
1111# brand-new install (before the admin has opened the config page).
1212# 3. The hardcoded %DEFAULTS hash below -- final fallback. These are
1313# also the deploy-coupled keys that must stay in code so they're
1414# readable before the DB is reachable (database_name, asset paths,
1515# cookie/session, feature-flag fallbacks).
1616#
1717# Lookups are cached per request inside %_DB_CACHE so a page that
1818# reads ten settings only hits the DB once. The DB probe is column-
1919# existence-guarded so pre-migration installs degrade cleanly to the
2020# file + code fallbacks.
2121#======================================================================
2222
2323use strict;
2424use warnings;
2525
2626# Whitelist of keys the admin can override via the Software
2727# Configuration page. Anything not in this list is read straight from
2828# %DEFAULTS and is_secret marking controls UI masking.
2929#
3030# Note: this also defines what /admin_config.cgi can WRITE. Adding a
3131# key here is the one-line "expose this to the admin" switch.
3232my %EDITABLE = (
3333 session_minutes => {
3434 label => 'Session length (minutes)',
3535 group => 'Defaults', secret => 0,
3636 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.',
3737 },
3838 # Stripe
3939 stripe_publishable => {
4040 label => 'Stripe publishable key',
4141 group => 'Stripe', secret => 0,
4242 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.',
4343 },
4444 stripe_secret => {
4545 label => 'Stripe secret key',
4646 group => 'Stripe', secret => 1,
4747 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.',
4848 },
4949 stripe_connect_client => {
5050 label => 'Stripe Connect client id',
5151 group => 'Stripe', secret => 0,
5252 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.',
5353 },
5454 stripe_webhook_secret => {
5555 label => 'Stripe webhook signing secret',
5656 group => 'Stripe', secret => 1,
5757 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.',
5858 },
5959 stripe_platform_fee_bps => {
6060 label => 'Platform fee (basis points)',
6161 group => 'Stripe', secret => 0,
6262 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).',
6363 },
6464 stripe_currency_default => {
6565 label => 'Default currency code',
6666 group => 'Stripe', secret => 0,
6767 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.',
6868 },
6969
7070 # Branding
7171 website_title => {
7272 label => 'Site title',
7373 group => 'Branding', secret => 0,
7474 description => 'The HTML <title> tag value -- shown in browser tabs, search results, and link previews. HTML entities are allowed (e.g. —).',
7575 },
7676 brand_name => {
7777 label => 'Brand name',
7878 group => 'Branding', secret => 0,
7979 description => 'Short product name shown in the wrapper header, profile dropdown, marketing pages, and the From-line on system emails.',
8080 },
8181 brand_tagline => {
8282 label => 'Tagline',
8383 group => 'Branding', secret => 0,
8484 description => 'One-line tagline shown under the brand name in the sidebar header and a few marketing pages.',
8585 },
8686
8787 # Email
8888 feedback_emails => {
8989 label => 'Support / feedback inbox',
9090 group => 'Email', secret => 0,
9191 description => 'Inbox that receives "Contact us" form submissions. Comma-separate for multiple recipients (e.g. "support@example.com, ops@example.com").',
9292 },
9393 from_email => {
9494 label => 'Outbound "From" address',
9595 group => 'Email', secret => 0,
9696 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.',
9797 },
9898 smtp_host => {
9999 label => 'SMTP host',
100100 group => 'Email', secret => 0,
101101 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.',
102102 },
103103 smtp_port => {
104104 label => 'SMTP port',
105105 group => 'Email', secret => 0,
106106 description => 'Usually 587 for STARTTLS or 465 for implicit TLS. Only consulted when SMTP host is set.',
107107 },
108108 smtp_user => {
109109 label => 'SMTP username',
110110 group => 'Email', secret => 0,
111111 description => 'Authentication username for the SMTP server. Often your full email address. Leave blank if your SMTP server allows unauthenticated relay (uncommon).',
112112 },
113113 smtp_pass => {
114114 label => 'SMTP password',
115115 group => 'Email', secret => 1,
116116 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.',
117117 },
118118
119119 # File storage (R2)
120120 r2_bucket => {
121121 label => 'R2 bucket name',
122122 group => 'Storage', secret => 0,
123123 description => 'Cloudflare R2 bucket where uploaded model files and storefront images are stored. The CGIs use this when generating signed upload and download URLs.',
124124 },
125125 r2_public_base => {
126126 label => 'R2 public base URL',
127127 group => 'Storage', secret => 0,
128128 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.',
129129 },
130130 r2_account_id => {
131131 label => 'R2 account ID',
132132 group => 'Storage', secret => 0,
133133 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.',
134134 },
135135 r2_access_key_id => {
136136 label => 'R2 access key ID',
137137 group => 'Storage', secret => 0,
138138 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).',
139139 },
140140 r2_secret_access_key => {
141141 label => 'R2 secret access key',
142142 group => 'Storage', secret => 1,
143143 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.',
144144 },
145145 r2_region => {
146146 label => 'R2 region',
147147 group => 'Storage', secret => 0,
148148 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.',
149149 },
150150
151151 # Defaults
152152 timezone_default => {
153153 label => 'Default timezone',
154154 group => 'Defaults', secret => 0,
155155 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.',
156156 },
157157 currency_default => {
158158 label => 'Default currency',
159159 group => 'Defaults', secret => 0,
160160 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.',
161161 },
162162);
163163
164164my %DEFAULTS = (
165165 # ----- Database ----------------------------------------------
166166 database_name => 'crm3dshawn',
167167
168168 # ----- Branding ----------------------------------------------
169169 website_title => 'ContactForge — Customer Relationship Platform',
170170 brand_name => 'ContactForge',
171171 brand_tagline => 'One place for contacts, deals, activities, and customer chat.',
172172
173173 # ----- Brand colors (read by Wrapper.pm + admin pages) -------
174174 # Electric cyan -- distinct from every sibling site (ABForge sunset,
175175 # WebSTLs/AffSoft indigo, ShopCart olive, RePricer green, TaskForge
176176 # crimson). Pops on dark bg.
177177 color_brand => '#06b6d4',
178178 color_brand_2 => '#67e8f9',
179179 color_accent => '#5aa9ff',
180180
181181 # ----- Sessions / cookies ------------------------------------
182182 cookie_name => 'cf_session',
183183 session_minutes => 720,
184184 secure_cookies => 1,
185185 cookie_domain => '.crm.3dshawn.com',
186186
187187 # ----- Asset paths -------------------------------------------
188188 assets => '/assets',
189189 assets_css => '/assets/css',
190190 assets_js => '/assets/javascript',
191191 assets_images => '/assets/images',
192192
193193 # ----- Email / mail ------------------------------------------
194194 # PLATFORM (our own) transactional email goes through these creds --
195195 # signup confirms, password resets, billing receipts, admin alerts.
196196 # Set these in admin Settings; leave smtp_host blank to fall back to
197197 # the local Plesk MTA (fine for low volume).
198198 feedback_emails => 'support@crm.3dshawn.com',
199199 from_email => 'noreply@crm.3dshawn.com',
200200 smtp_host => '',
201201 smtp_port => 587,
202202 smtp_user => '',
203203 smtp_pass => '',
204204 # BYO SMTP encryption key -- AES key used by AES_ENCRYPT/AES_DECRYPT
205205 # to encrypt tenant-supplied SMTP passwords at rest in email_providers.
206206 # Long random secret. Rotating this invalidates every saved tenant
207207 # password (they must re-enter), so only rotate if it's leaked.
208208 byo_smtp_enc_key => 'k7QmZ3RvY9JxL8nWfP2dT5sH4bU6gA1eC0iE-NbX_VqMjOiPyDhRoSlFwUaKuTzM',
209209
210210 # ----- Stripe ------------------------------------------------
211211 stripe_publishable => 'pk_test_REPLACE_ME',
212212 stripe_secret => '',
213213 stripe_webhook_secret => '',
214214 stripe_currency_default => 'usd',
215215
216216 # ----- Plan defaults ----------------------------------------
217217 # Post pricing-v3 (2026-06): 2 paid tiers (Pro $29, Business $99),
218218 # 14-day free trial on every signup, contact-count capped during trial.
219219 plan_pro_price => 29,
220220 plan_business_price => 99,
221221 plan_trial_days => 14,
222222 plan_trial_contacts_cap => 250,
223223
224224 # ----- Misc --------------------------------------------------
225225 timezone_default => 'UTC',
226226 currency_default => 'USD',
227227);
228228
229229# Per-process cache. Populated on first DB hit; survives for the
230230# request and is cheap on subsequent settings() calls.
231231my %_DB_CACHE;
232232my $_DB_LOADED = 0;
233233my $_DB_AVAILABLE; # undef = unknown, 0 = table missing, 1 = ready
234234
235235sub new {
236236 my ($class) = @_;
237237 return bless({}, $class);
238238}
239239
240240# Public helper for the admin config page. Returns the editable
241241# whitelist with current values + source ('db' | 'file' | 'default').
242242# is_secret rows have their value masked to a non-empty marker so the
243243# UI can show "set" without leaking the actual secret.
244244sub editable_settings_meta {
245245 my ($self) = @_;
246246 my @rows;
247247 foreach my $key (sort keys %EDITABLE) {
248248 my $meta = $EDITABLE{$key};
249249 my ($val, $source) = $self->_resolve_with_source($key);
250250 my $masked = ($meta->{secret} && defined($val) && length $val) ? 1 : 0;
251251 push @rows, {
252252 key => $key,
253253 label => $meta->{label},
254254 group => $meta->{group},
255255 secret => $meta->{secret},
256256 description => $meta->{description} || '',
257257 value => $masked ? '' : (defined $val ? $val : ''),
258258 is_set => (defined $val && length $val) ? 1 : 0,
259259 source => $source,
260260 };
261261 }
262262 return @rows;
263263}
264264
265265# Bool -- is this key editable via the admin config page?
266266sub is_editable {
267267 my ($self, $key) = @_;
268268 return exists $EDITABLE{$key} ? 1 : 0;
269269}
270270
271271sub editable_meta_for {
272272 my ($self, $key) = @_;
273273 return $EDITABLE{$key};
274274}
275275
276276sub settings {
277277 my ($self, $key) = @_;
278278 return undef unless defined $key;
279279
280280 my ($val) = $self->_resolve_with_source($key);
281281 return $val;
282282}
283283
284284# Core lookup. Returns ($value, $source) where source is one of
285285# 'db' | 'file' | 'default'. Centralises the precedence so any caller
286286# (settings() / editable_settings_meta) gets the same answer.
287287sub _resolve_with_source {
288288 my ($self, $key) = @_;
289289
290290 # 1) DB lookup (cached). Skip for the database_name itself -- we
291291 # can't ask the DB which DB to use.
292292 if ($key ne 'database_name') {
293293 $self->_load_db_cache;
294294 if (exists $_DB_CACHE{$key}) {
295295 return ($_DB_CACHE{$key}, 'db');
296296 }
297297 }
298298
299299 # 2) Private file fallback (legacy Stripe secret path). Keeps
300300 # brand-new installs working until the admin opens the config
301301 # page.
302302 if ($key eq 'stripe_secret' || $key eq 'stripe_webhook_secret') {
303303 my $val = _read_private('stripe.conf', $key);
304304 return ($val, 'file') if defined $val && length $val;
305305 }
306306
307307 # 3) Hardcoded default.
308308 return (exists $DEFAULTS{$key} ? $DEFAULTS{$key} : undef, 'default');
309309}
310310
311311# Single-pass population of %_DB_CACHE. Probes for the table to avoid
312312# crashing pre-migration installs. Silently bails on any error so
313313# Config.pm never takes the site down.
314314sub _load_db_cache {
315315 my ($self) = @_;
316316 return if $_DB_LOADED;
317317 $_DB_LOADED = 1;
318318
319319 # We deliberately defer the require until called rather than at
320320 # module-load -- avoids a circular use between Config and DBConnect
321321 # in early-boot paths.
322322 my $db_pkg = 'MODS::DBConnect';
323323 eval "require $db_pkg; 1" or return;
324324 my $db = $db_pkg->new;
325325 return unless $db;
326326
327327 my $dbh = eval { $db->db_connect() };
328328 return unless $dbh;
329329
330330 my $DB = $DEFAULTS{database_name};
331331
332332 my $col_check = eval {
333333 $db->db_readwrite($dbh, qq~
334334 SELECT COUNT(*) AS n FROM information_schema.tables
335335 WHERE table_schema='$DB' AND table_name='platform_settings'
336336 ~, $ENV{SCRIPT_NAME}, __LINE__);
337337 };
338338 unless ($col_check && $col_check->{n}) {
339339 $_DB_AVAILABLE = 0;
340340 eval { $db->db_disconnect($dbh) };
341341 return;
342342 }
343343 $_DB_AVAILABLE = 1;
344344
345345 my @rows;
346346 eval {
347347 @rows = $db->db_readwrite_multiple($dbh, qq~
348348 SELECT setting_key, setting_value
349349 FROM `${DB}`.platform_settings
350350 ~, $ENV{SCRIPT_NAME}, __LINE__);
351351 1;
352352 };
353353 foreach my $r (@rows) {
354354 next unless ref($r) eq 'HASH';
355355 next unless defined $r->{setting_key};
356356 my $val = defined $r->{setting_value} ? $r->{setting_value} : '';
357357 next unless length $val; # empty rows defer to file/code so admins can "blank" a field
358358 $_DB_CACHE{ $r->{setting_key} } = $val;
359359 }
360360 eval { $db->db_disconnect($dbh) };
361361}
362362
363363# Reads /var/www/vhosts/webstls.com/private/<file> and returns the
364364# value for the requested key. Returns undef if the file or key is
365365# missing. Cached per-process.
366366my %_PRIVATE_CACHE;
367367sub _read_private {
368368 my ($file, $key) = @_;
369369 my $path = '/var/www/vhosts/webstls.com/private/' . $file;
370370 unless (exists $_PRIVATE_CACHE{$path}) {
371371 my %h;
372372 if (open(my $fh, '<', $path)) {
373373 while (my $line = <$fh>) {
374374 chomp $line;
375375 next if $line =~ /^\s*#/;
376376 next unless $line =~ /^([A-Za-z0-9_]+)\s*=\s*(.*)$/;
377377 $h{$1} = $2;
378378 }
379379 close $fh;
380380 }
381381 $_PRIVATE_CACHE{$path} = \%h;
382382 }
383383 return $_PRIVATE_CACHE{$path}->{$key};
384384}
385385
386386# Returns the {url_key => "/path?cachekey"} hash used by Template.pm's
387387# [url:foo] tag. Mirrors MODS::Urls but kept WebSTLs-local so we never
388388# collide with the existing portal URL registry.
389389sub urls {
390390 my $self = shift;
391391 return {
392392 home => '/index.cgi',
393393 login => '/login.cgi',
394394 signup => '/signup.cgi',
395395 logout => '/logout.cgi',
396396 dashboard => '/dashboard.cgi',
397397 models => '/models.cgi',
398398 upload => '/upload_model.cgi',
399399 storefront => '/storefront.cgi',
400400 analytics => '/analytics.cgi',
401401 optimization => '/optimization.cgi',
402402 settings => '/profile.cgi',
403403 admin => '/admin.cgi',
404404 audience => '/audience.cgi',
405405 assets => '/assets',
406406 assets_css => '/assets/css',
407407 assets_js => '/assets/javascript',
408408 assets_images => '/assets/images',
409409 };
410410}
411411
4124121;