added on local at 2026-07-01 15:02:52
| 1 | package 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 | ||
| 23 | use strict; | |
| 24 | use 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. | |
| 32 | my %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 | ||
| 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 | ||
| 164 | my %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. | |
| 231 | my %_DB_CACHE; | |
| 232 | my $_DB_LOADED = 0; | |
| 233 | my $_DB_AVAILABLE; # undef = unknown, 0 = table missing, 1 = ready | |
| 234 | ||
| 235 | sub 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. | |
| 244 | sub 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? | |
| 266 | sub is_editable { | |
| 267 | my ($self, $key) = @_; | |
| 268 | return exists $EDITABLE{$key} ? 1 : 0; | |
| 269 | } | |
| 270 | ||
| 271 | sub editable_meta_for { | |
| 272 | my ($self, $key) = @_; | |
| 273 | return $EDITABLE{$key}; | |
| 274 | } | |
| 275 | ||
| 276 | sub 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. | |
| 287 | sub _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. | |
| 314 | sub _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. | |
| 366 | my %_PRIVATE_CACHE; | |
| 367 | sub _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. | |
| 389 | sub 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 | ||
| 412 | 1; |