added on local at 2026-07-01 13:47:27
| 1 | package 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 | ||
| 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 => '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 | ||
| 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 | ||
| 176 | my %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. | |
| 250 | my %_DB_CACHE; | |
| 251 | my $_DB_LOADED = 0; | |
| 252 | my $_DB_AVAILABLE; # undef = unknown, 0 = table missing, 1 = ready | |
| 253 | ||
| 254 | sub 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. | |
| 263 | sub 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? | |
| 285 | sub is_editable { | |
| 286 | my ($self, $key) = @_; | |
| 287 | return exists $EDITABLE{$key} ? 1 : 0; | |
| 288 | } | |
| 289 | ||
| 290 | sub editable_meta_for { | |
| 291 | my ($self, $key) = @_; | |
| 292 | return $EDITABLE{$key}; | |
| 293 | } | |
| 294 | ||
| 295 | sub 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. | |
| 306 | sub _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. | |
| 333 | sub _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. | |
| 385 | my %_PRIVATE_CACHE; | |
| 386 | sub _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. | |
| 408 | sub 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 | ||
| 431 | 1; |