added on local at 2026-07-01 22:09:37
| 1 | package MODS::ShopCart::Config; | |
| 2 | #====================================================================== | |
| 3 | # ShopCart — 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/shop.3dshawn.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://shop.3dshawn.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 => 'ShopCart 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.shop.3dshawn.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 | # Storage Caps | |
| 152 | storage_upload_max_mb_free => { | |
| 153 | label => 'Free plan: per-file upload limit (MB)', | |
| 154 | group => 'Storage Caps', secret => 0, | |
| 155 | description => 'Maximum single-file upload size (MB) for Free-tier accounts. Enforced server-side before the body is read. Applies to model files, gallery images, page-editor images, and support attachments. A per-user override on the user record (upload_max_mb_override) wins over this value.', | |
| 156 | }, | |
| 157 | storage_upload_max_mb_pro => { | |
| 158 | label => 'Pro plan: per-file upload limit (MB)', | |
| 159 | group => 'Storage Caps', secret => 0, | |
| 160 | description => 'Maximum single-file upload size (MB) for Pro-tier accounts. Enforced server-side before the body is read. A per-user override on the user record (upload_max_mb_override) wins over this value.', | |
| 161 | }, | |
| 162 | storage_upload_max_mb_business => { | |
| 163 | label => 'Business plan: per-file upload limit (MB)', | |
| 164 | group => 'Storage Caps', secret => 0, | |
| 165 | description => 'Maximum single-file upload size (MB) for Business-tier accounts. Default 5120 = 5 GB. Enforced server-side before the body is read. A per-user override on the user record (upload_max_mb_override) wins over this value.', | |
| 166 | }, | |
| 167 | storage_hard_cap_gb_free => { | |
| 168 | label => 'Free plan: total storage cap (GB)', | |
| 169 | group => 'Storage Caps', secret => 0, | |
| 170 | description => 'Total storage ceiling (GB) for Free-tier accounts. Hidden from marketing copy by default. When a user reaches this cap, new uploads are rejected with a clear message. A per-user override (storage_hard_cap_gb_override) wins over this value.', | |
| 171 | }, | |
| 172 | storage_hard_cap_gb_pro => { | |
| 173 | label => 'Pro plan: total storage cap (GB)', | |
| 174 | group => 'Storage Caps', secret => 0, | |
| 175 | description => 'Total storage ceiling (GB) for Pro-tier accounts. A per-user override (storage_hard_cap_gb_override) wins over this value.', | |
| 176 | }, | |
| 177 | storage_hard_cap_gb_business => { | |
| 178 | label => 'Business plan: total storage cap (GB)', | |
| 179 | group => 'Storage Caps', secret => 0, | |
| 180 | description => 'Total storage ceiling (GB) for Business-tier accounts. Default 2048 = 2 TB. A per-user override (storage_hard_cap_gb_override) wins over this value.', | |
| 181 | }, | |
| 182 | storage_soft_alert_pct => { | |
| 183 | label => 'Soft-alert threshold (% of hard cap)', | |
| 184 | group => 'Storage Caps', secret => 0, | |
| 185 | description => 'Percent of the hard cap that triggers the storage-usage warning email and admin notification. Default 80 = email the user when they cross 80% of their cap. Set to 0 to disable soft alerts (uploads still hard-stop at the cap).', | |
| 186 | }, | |
| 187 | ||
| 188 | # Defaults | |
| 189 | timezone_default => { | |
| 190 | label => 'Default timezone', | |
| 191 | group => 'Defaults', secret => 0, | |
| 192 | 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.', | |
| 193 | }, | |
| 194 | currency_default => { | |
| 195 | label => 'Default currency', | |
| 196 | group => 'Defaults', secret => 0, | |
| 197 | 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.', | |
| 198 | }, | |
| 199 | ); | |
| 200 | ||
| 201 | my %DEFAULTS = ( | |
| 202 | # ----- Database ---------------------------------------------- | |
| 203 | # NOT editable via UI -- the lookup itself depends on this. | |
| 204 | database_name => 'shop3dshawn', | |
| 205 | ||
| 206 | # ----- Branding ---------------------------------------------- | |
| 207 | website_title => 'ShopCart — Sell your products online', | |
| 208 | brand_name => 'ShopCart', | |
| 209 | brand_tagline => 'Build a storefront. Sell anywhere.', | |
| 210 | ||
| 211 | # ----- Sessions / cookies ------------------------------------ | |
| 212 | # NOT editable via UI -- deploy-coupled. Edit MODS/ShopCart/Config.pm. | |
| 213 | cookie_name => 'shopcart_session', | |
| 214 | session_minutes => 720, | |
| 215 | secure_cookies => 1, | |
| 216 | # Domain scope for the session cookie. Leading-dot form means the | |
| 217 | # cookie is sent to BOTH apex (shop.3dshawn.com) and every subdomain | |
| 218 | # (e.g. 3dshawn.shop.3dshawn.com), so when a seller is logged in on the | |
| 219 | # dashboard their session is recognized on their own storefront | |
| 220 | # subdomain too (needed for owner-only features like draft bundle | |
| 221 | # previews, edit-mode bypass, etc). Empty string keeps the cookie | |
| 222 | # host-only (legacy behavior). | |
| 223 | cookie_domain => '.shop.3dshawn.com', | |
| 224 | ||
| 225 | # ----- Asset paths ------------------------------------------- | |
| 226 | # NOT editable via UI -- deploy-coupled. | |
| 227 | assets => '/assets', | |
| 228 | assets_css => '/assets/css', | |
| 229 | assets_js => '/assets/javascript', | |
| 230 | assets_images => '/assets/images', | |
| 231 | ||
| 232 | # ----- Email / mail ------------------------------------------ | |
| 233 | feedback_emails => 'support@shop.3dshawn.com', | |
| 234 | from_email => 'noreply@shop.3dshawn.com', | |
| 235 | smtp_host => '', # blank = use local sendmail | |
| 236 | smtp_port => 587, | |
| 237 | smtp_user => '', | |
| 238 | smtp_pass => '', | |
| 239 | ||
| 240 | # ----- Stripe ------------------------------------------------ | |
| 241 | stripe_publishable => 'pk_test_REPLACE_ME', | |
| 242 | stripe_secret => '', | |
| 243 | stripe_connect_client => 'ca_REPLACE_ME', | |
| 244 | stripe_webhook_secret => '', | |
| 245 | stripe_platform_fee_bps => 1000, | |
| 246 | stripe_currency_default => 'usd', | |
| 247 | ||
| 248 | # ----- File storage (R2) ------------------------------------- | |
| 249 | r2_bucket => 'shopcart-files', | |
| 250 | r2_public_base => 'https://files.shop.3dshawn.com', | |
| 251 | r2_account_id => '', | |
| 252 | r2_access_key_id => '', | |
| 253 | r2_secret_access_key => '', | |
| 254 | r2_region => 'auto', | |
| 255 | ||
| 256 | # ----- Plan limits ------------------------------------------- | |
| 257 | plan_free_models => 10, | |
| 258 | plan_pro_price => 49, | |
| 259 | plan_business_price => 129, | |
| 260 | ||
| 261 | # ----- Storage Caps ------------------------------------------ | |
| 262 | # Per-file upload limit (MB) per plan tier. | |
| 263 | storage_upload_max_mb_free => 25, | |
| 264 | storage_upload_max_mb_pro => 500, | |
| 265 | storage_upload_max_mb_business => 5120, | |
| 266 | # Total storage hard cap (GB) per plan tier. | |
| 267 | storage_hard_cap_gb_free => 5, | |
| 268 | storage_hard_cap_gb_pro => 200, | |
| 269 | storage_hard_cap_gb_business => 2048, | |
| 270 | # Percent of hard cap that fires the soft-alert email + admin notification. | |
| 271 | storage_soft_alert_pct => 80, | |
| 272 | ||
| 273 | # ----- Feature flags fallback (when DB unreachable) ---------- | |
| 274 | # NOT editable via UI -- read when DB is DOWN, so DB rows would | |
| 275 | # never apply anyway. Keep in code. | |
| 276 | flag_default__module_publishing => 1, | |
| 277 | flag_default__module_storefront => 1, | |
| 278 | flag_default__module_optimization => 1, | |
| 279 | flag_default__module_pooled_data => 0, | |
| 280 | flag_default__module_audience => 1, | |
| 281 | ||
| 282 | # ----- Misc -------------------------------------------------- | |
| 283 | timezone_default => 'UTC', | |
| 284 | currency_default => 'USD', | |
| 285 | ); | |
| 286 | ||
| 287 | # Per-process cache. Populated on first DB hit; survives for the | |
| 288 | # request and is cheap on subsequent settings() calls. | |
| 289 | my %_DB_CACHE; | |
| 290 | my $_DB_LOADED = 0; | |
| 291 | my $_DB_AVAILABLE; # undef = unknown, 0 = table missing, 1 = ready | |
| 292 | ||
| 293 | sub new { | |
| 294 | my ($class) = @_; | |
| 295 | return bless({}, $class); | |
| 296 | } | |
| 297 | ||
| 298 | # Public helper for the admin config page. Returns the editable | |
| 299 | # whitelist with current values + source ('db' | 'file' | 'default'). | |
| 300 | # is_secret rows have their value masked to a non-empty marker so the | |
| 301 | # UI can show "set" without leaking the actual secret. | |
| 302 | sub editable_settings_meta { | |
| 303 | my ($self) = @_; | |
| 304 | my @rows; | |
| 305 | foreach my $key (sort keys %EDITABLE) { | |
| 306 | my $meta = $EDITABLE{$key}; | |
| 307 | my ($val, $source) = $self->_resolve_with_source($key); | |
| 308 | my $masked = ($meta->{secret} && defined($val) && length $val) ? 1 : 0; | |
| 309 | push @rows, { | |
| 310 | key => $key, | |
| 311 | label => $meta->{label}, | |
| 312 | group => $meta->{group}, | |
| 313 | secret => $meta->{secret}, | |
| 314 | description => $meta->{description} || '', | |
| 315 | value => $masked ? '' : (defined $val ? $val : ''), | |
| 316 | is_set => (defined $val && length $val) ? 1 : 0, | |
| 317 | source => $source, | |
| 318 | }; | |
| 319 | } | |
| 320 | return @rows; | |
| 321 | } | |
| 322 | ||
| 323 | # Bool -- is this key editable via the admin config page? | |
| 324 | sub is_editable { | |
| 325 | my ($self, $key) = @_; | |
| 326 | return exists $EDITABLE{$key} ? 1 : 0; | |
| 327 | } | |
| 328 | ||
| 329 | sub editable_meta_for { | |
| 330 | my ($self, $key) = @_; | |
| 331 | return $EDITABLE{$key}; | |
| 332 | } | |
| 333 | ||
| 334 | sub settings { | |
| 335 | my ($self, $key) = @_; | |
| 336 | return undef unless defined $key; | |
| 337 | ||
| 338 | my ($val) = $self->_resolve_with_source($key); | |
| 339 | return $val; | |
| 340 | } | |
| 341 | ||
| 342 | # Core lookup. Returns ($value, $source) where source is one of | |
| 343 | # 'db' | 'file' | 'default'. Centralises the precedence so any caller | |
| 344 | # (settings() / editable_settings_meta) gets the same answer. | |
| 345 | sub _resolve_with_source { | |
| 346 | my ($self, $key) = @_; | |
| 347 | ||
| 348 | # 1) DB lookup (cached). Skip for the database_name itself -- we | |
| 349 | # can't ask the DB which DB to use. | |
| 350 | if ($key ne 'database_name') { | |
| 351 | $self->_load_db_cache; | |
| 352 | if (exists $_DB_CACHE{$key}) { | |
| 353 | return ($_DB_CACHE{$key}, 'db'); | |
| 354 | } | |
| 355 | } | |
| 356 | ||
| 357 | # 2) Private file fallback (legacy Stripe secret path). Keeps | |
| 358 | # brand-new installs working until the admin opens the config | |
| 359 | # page. | |
| 360 | if ($key eq 'stripe_secret' || $key eq 'stripe_webhook_secret') { | |
| 361 | my $val = _read_private('stripe.conf', $key); | |
| 362 | return ($val, 'file') if defined $val && length $val; | |
| 363 | } | |
| 364 | ||
| 365 | # 3) Hardcoded default. | |
| 366 | return (exists $DEFAULTS{$key} ? $DEFAULTS{$key} : undef, 'default'); | |
| 367 | } | |
| 368 | ||
| 369 | # Single-pass population of %_DB_CACHE. Probes for the table to avoid | |
| 370 | # crashing pre-migration installs. Silently bails on any error so | |
| 371 | # Config.pm never takes the site down. | |
| 372 | sub _load_db_cache { | |
| 373 | my ($self) = @_; | |
| 374 | return if $_DB_LOADED; | |
| 375 | $_DB_LOADED = 1; | |
| 376 | ||
| 377 | # We deliberately defer the require until called rather than at | |
| 378 | # module-load -- avoids a circular use between Config and DBConnect | |
| 379 | # in early-boot paths. | |
| 380 | my $db_pkg = 'MODS::DBConnect'; | |
| 381 | eval "require $db_pkg; 1" or return; | |
| 382 | my $db = $db_pkg->new; | |
| 383 | return unless $db; | |
| 384 | ||
| 385 | my $dbh = eval { $db->db_connect() }; | |
| 386 | return unless $dbh; | |
| 387 | ||
| 388 | my $DB = $DEFAULTS{database_name}; | |
| 389 | ||
| 390 | my $col_check = eval { | |
| 391 | $db->db_readwrite($dbh, qq~ | |
| 392 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 393 | WHERE table_schema='$DB' AND table_name='platform_settings' | |
| 394 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 395 | }; | |
| 396 | unless ($col_check && $col_check->{n}) { | |
| 397 | $_DB_AVAILABLE = 0; | |
| 398 | eval { $db->db_disconnect($dbh) }; | |
| 399 | return; | |
| 400 | } | |
| 401 | $_DB_AVAILABLE = 1; | |
| 402 | ||
| 403 | my @rows; | |
| 404 | eval { | |
| 405 | @rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 406 | SELECT setting_key, setting_value | |
| 407 | FROM `${DB}`.platform_settings | |
| 408 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 409 | 1; | |
| 410 | }; | |
| 411 | foreach my $r (@rows) { | |
| 412 | next unless ref($r) eq 'HASH'; | |
| 413 | next unless defined $r->{setting_key}; | |
| 414 | my $val = defined $r->{setting_value} ? $r->{setting_value} : ''; | |
| 415 | next unless length $val; # empty rows defer to file/code so admins can "blank" a field | |
| 416 | $_DB_CACHE{ $r->{setting_key} } = $val; | |
| 417 | } | |
| 418 | eval { $db->db_disconnect($dbh) }; | |
| 419 | } | |
| 420 | ||
| 421 | # Reads /var/www/vhosts/shop.3dshawn.com/private/<file> and returns the | |
| 422 | # value for the requested key. Returns undef if the file or key is | |
| 423 | # missing. Cached per-process. | |
| 424 | my %_PRIVATE_CACHE; | |
| 425 | sub _read_private { | |
| 426 | my ($file, $key) = @_; | |
| 427 | my $path = '/var/www/vhosts/shop.3dshawn.com/private/' . $file; | |
| 428 | unless (exists $_PRIVATE_CACHE{$path}) { | |
| 429 | my %h; | |
| 430 | if (open(my $fh, '<', $path)) { | |
| 431 | while (my $line = <$fh>) { | |
| 432 | chomp $line; | |
| 433 | next if $line =~ /^\s*#/; | |
| 434 | next unless $line =~ /^([A-Za-z0-9_]+)\s*=\s*(.*)$/; | |
| 435 | $h{$1} = $2; | |
| 436 | } | |
| 437 | close $fh; | |
| 438 | } | |
| 439 | $_PRIVATE_CACHE{$path} = \%h; | |
| 440 | } | |
| 441 | return $_PRIVATE_CACHE{$path}->{$key}; | |
| 442 | } | |
| 443 | ||
| 444 | # Returns the {url_key => "/path?cachekey"} hash used by Template.pm's | |
| 445 | # [url:foo] tag. Mirrors MODS::Urls but kept ShopCart-local so we never | |
| 446 | # collide with the existing portal URL registry. | |
| 447 | sub urls { | |
| 448 | my $self = shift; | |
| 449 | return { | |
| 450 | home => '/index.cgi', | |
| 451 | login => '/login.cgi', | |
| 452 | signup => '/signup.cgi', | |
| 453 | logout => '/logout.cgi', | |
| 454 | dashboard => '/dashboard.cgi', | |
| 455 | models => '/models.cgi', | |
| 456 | upload => '/upload_model.cgi', | |
| 457 | storefront => '/storefront.cgi', | |
| 458 | analytics => '/analytics.cgi', | |
| 459 | optimization => '/optimization.cgi', | |
| 460 | settings => '/profile.cgi', | |
| 461 | admin => '/admin.cgi', | |
| 462 | messages => '/messages.cgi', | |
| 463 | audience => '/audience.cgi', | |
| 464 | assets => '/assets', | |
| 465 | assets_css => '/assets/css', | |
| 466 | assets_js => '/assets/javascript', | |
| 467 | assets_images => '/assets/images', | |
| 468 | }; | |
| 469 | } | |
| 470 | ||
| 471 | 1; |