added on local at 2026-07-01 12:34:21
| 1 | package MODS::PTMatrix::Config; | |
| 2 | #====================================================================== | |
| 3 | # TaskForge -- central configuration. | |
| 4 | # | |
| 5 | # Lookup order for settings(): | |
| 6 | # 1. platform_settings table (DB-backed) -- editable by super_admin | |
| 7 | # via /admin_config.cgi. Highest precedence so no redeploy needed. | |
| 8 | # 2. The hardcoded %DEFAULTS hash below -- final fallback. | |
| 9 | # | |
| 10 | # Lookups are cached per-process in %_DB_CACHE so a page that reads | |
| 11 | # ten settings only hits the DB once. | |
| 12 | #====================================================================== | |
| 13 | ||
| 14 | use strict; | |
| 15 | use warnings; | |
| 16 | ||
| 17 | my %EDITABLE = ( | |
| 18 | session_minutes => { | |
| 19 | label => 'Session length (minutes)', | |
| 20 | group => 'Defaults', secret => 0, | |
| 21 | 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.', | |
| 22 | }, | |
| 23 | # ----- Branding --------------------------------------------------- | |
| 24 | website_title => { label => 'Site title', group => 'Branding', secret => 0, | |
| 25 | description => 'The HTML <title> tag value -- shown in browser tabs and link previews.' }, | |
| 26 | brand_name => { label => 'Brand name', group => 'Branding', secret => 0, | |
| 27 | description => 'Short product name shown in the wrapper header.' }, | |
| 28 | brand_tagline => { label => 'Tagline', group => 'Branding', secret => 0, | |
| 29 | description => 'One-line tagline shown under the brand name on marketing pages.' }, | |
| 30 | ||
| 31 | # ----- Email ------------------------------------------------------ | |
| 32 | feedback_emails => { label => 'Support / feedback inbox', group => 'Email', secret => 0, | |
| 33 | description => 'Inbox that receives "Contact us" submissions. Comma-separate for multiple.' }, | |
| 34 | from_email => { label => 'Outbound "From" address', group => 'Email', secret => 0, | |
| 35 | description => 'Sender address on system emails (invites, resets, due-soon notices).' }, | |
| 36 | smtp_host => { label => 'SMTP host', group => 'Email', secret => 0, | |
| 37 | description => 'SMTP server hostname. Leave blank to use local sendmail / Plesk MTA.' }, | |
| 38 | smtp_port => { label => 'SMTP port', group => 'Email', secret => 0, | |
| 39 | description => 'Usually 587 for STARTTLS or 465 for implicit TLS.' }, | |
| 40 | smtp_user => { label => 'SMTP username', group => 'Email', secret => 0, | |
| 41 | description => 'Authentication username for the SMTP server.' }, | |
| 42 | smtp_pass => { label => 'SMTP password', group => 'Email', secret => 1, | |
| 43 | description => 'Authentication password / app-specific password / API token.' }, | |
| 44 | ||
| 45 | # ----- Stripe ----------------------------------------------------- | |
| 46 | stripe_publishable => { label => 'Stripe publishable key', group => 'Stripe', secret => 0, | |
| 47 | description => 'pk_live_ or pk_test_ from Stripe Dashboard > Developers > API keys.' }, | |
| 48 | stripe_secret => { label => 'Stripe secret key', group => 'Stripe', secret => 1, | |
| 49 | description => 'sk_live_ or sk_test_. NEVER paste anywhere outside this field.' }, | |
| 50 | stripe_webhook_secret => { label => 'Stripe webhook signing secret', group => 'Stripe', secret => 1, | |
| 51 | description => 'whsec_... from your webhook endpoint config in Stripe.' }, | |
| 52 | stripe_currency_default => { label => 'Default currency code', group => 'Stripe', secret => 0, | |
| 53 | description => 'Three-letter ISO 4217, lowercase: usd, eur, gbp, cad.' }, | |
| 54 | ||
| 55 | # ----- SEO -------------------------------------------------------- | |
| 56 | 'seo.title' => { label => 'SEO title', group => 'SEO', secret => 0, description => 'Default <title> for marketing pages.' }, | |
| 57 | 'seo.description' => { label => 'SEO description', group => 'SEO', secret => 0, description => 'Default meta description.' }, | |
| 58 | 'seo.keywords' => { label => 'SEO keywords', group => 'SEO', secret => 0, description => 'Comma-separated keyword list.' }, | |
| 59 | 'seo.canonical_base' => { label => 'Canonical URL base', group => 'SEO', secret => 0, description => 'https://ptmatrix.3dshawn.com (no trailing slash)' }, | |
| 60 | 'seo.og_site_name' => { label => 'Open Graph site name', group => 'SEO', secret => 0, description => 'Used in social cards.' }, | |
| 61 | ||
| 62 | # ----- Defaults --------------------------------------------------- | |
| 63 | timezone_default => { label => 'Default timezone', group => 'Defaults', secret => 0, | |
| 64 | description => 'IANA timezone for new accounts. Display only.' }, | |
| 65 | currency_default => { label => 'Default currency', group => 'Defaults', secret => 0, | |
| 66 | description => 'Three-letter ISO 4217. Display only.' }, | |
| 67 | ||
| 68 | # ----- Billing ---------------------------------------------------- | |
| 69 | annual_discount_pct => { label => 'Annual prepay discount %', group => 'Billing', secret => 0, | |
| 70 | description => 'Percent off when customers pay yearly instead of monthly. Industry-standard: 15-20. Aggressive: 25-30.' }, | |
| 71 | ||
| 72 | ); | |
| 73 | ||
| 74 | my %DEFAULTS = ( | |
| 75 | # ----- Database (NOT editable -- lookup depends on this) --------- | |
| 76 | database_name => 'ptmatrix3dshawn', | |
| 77 | ||
| 78 | # ----- Branding -------------------------------------------------- | |
| 79 | website_title => 'PTMatrix — Project Task Matrix', | |
| 80 | brand_name => 'PTMatrix', | |
| 81 | brand_tagline => 'Project Task Matrix — every project, every task, connected at a glance.', | |
| 82 | ||
| 83 | # ----- Sessions / cookies ---------------------------------------- | |
| 84 | cookie_name => 'taskforge_session', | |
| 85 | session_minutes => 720, | |
| 86 | secure_cookies => 1, | |
| 87 | cookie_domain => '', | |
| 88 | ||
| 89 | # ----- Asset paths ----------------------------------------------- | |
| 90 | assets => '/assets', | |
| 91 | assets_css => '/assets/css', | |
| 92 | assets_js => '/assets/javascript', | |
| 93 | assets_images => '/assets/images', | |
| 94 | ||
| 95 | # ----- Email ----------------------------------------------------- | |
| 96 | feedback_emails => 'programmershawn@gmail.com', | |
| 97 | from_email => 'noreply@ptmatrix.3dshawn.com', | |
| 98 | smtp_host => '', | |
| 99 | smtp_port => 587, | |
| 100 | smtp_user => '', | |
| 101 | smtp_pass => '', | |
| 102 | ||
| 103 | # ----- Stripe ---------------------------------------------------- | |
| 104 | stripe_publishable => 'pk_test_REPLACE_ME', | |
| 105 | stripe_secret => '', | |
| 106 | stripe_webhook_secret => '', | |
| 107 | stripe_currency_default => 'usd', | |
| 108 | ||
| 109 | # ----- SEO ------------------------------------------------------- | |
| 110 | 'seo.title' => 'TaskForge — Project Management for Teams', | |
| 111 | 'seo.description' => 'Kanban + Gantt + Reports + Time Tracking. Run every project, every department, every team from one place.', | |
| 112 | 'seo.keywords' => 'project management, kanban, gantt chart, task tracking, team collaboration, time tracking, ticketing system', | |
| 113 | 'seo.canonical_base' => 'https://ptmatrix.3dshawn.com', | |
| 114 | 'seo.og_site_name' => 'PTMatrix', | |
| 115 | ||
| 116 | # ----- Misc ------------------------------------------------------ | |
| 117 | timezone_default => 'UTC', | |
| 118 | currency_default => 'USD', | |
| 119 | annual_discount_pct => 20, | |
| 120 | ||
| 121 | # ----- Upload limits --------------------------------------------- | |
| 122 | max_upload_bytes => 25 * 1024 * 1024, # 25 MB attachments | |
| 123 | uploads_dir => '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/uploads', | |
| 124 | ); | |
| 125 | ||
| 126 | # Per-process cache. Populated on first DB hit; cheap after. | |
| 127 | my %_DB_CACHE; | |
| 128 | my $_DB_LOADED = 0; | |
| 129 | my $_DB_AVAILABLE; | |
| 130 | ||
| 131 | sub new { | |
| 132 | my ($class) = @_; | |
| 133 | return bless({}, $class); | |
| 134 | } | |
| 135 | ||
| 136 | sub editable_settings_meta { | |
| 137 | my ($self) = @_; | |
| 138 | my @rows; | |
| 139 | foreach my $key (sort keys %EDITABLE) { | |
| 140 | my $meta = $EDITABLE{$key}; | |
| 141 | my ($val, $source) = $self->_resolve_with_source($key); | |
| 142 | my $masked = ($meta->{secret} && defined($val) && length $val) ? 1 : 0; | |
| 143 | push @rows, { | |
| 144 | key => $key, | |
| 145 | label => $meta->{label}, | |
| 146 | group => $meta->{group}, | |
| 147 | secret => $meta->{secret}, | |
| 148 | description => $meta->{description} || '', | |
| 149 | value => $masked ? '' : (defined $val ? $val : ''), | |
| 150 | is_set => (defined $val && length $val) ? 1 : 0, | |
| 151 | source => $source, | |
| 152 | }; | |
| 153 | } | |
| 154 | return @rows; | |
| 155 | } | |
| 156 | ||
| 157 | sub is_editable { | |
| 158 | my ($self, $key) = @_; | |
| 159 | return exists $EDITABLE{$key} ? 1 : 0; | |
| 160 | } | |
| 161 | ||
| 162 | sub editable_meta_for { | |
| 163 | my ($self, $key) = @_; | |
| 164 | return $EDITABLE{$key}; | |
| 165 | } | |
| 166 | ||
| 167 | sub settings { | |
| 168 | my ($self, $key) = @_; | |
| 169 | return undef unless defined $key; | |
| 170 | my ($val) = $self->_resolve_with_source($key); | |
| 171 | return $val; | |
| 172 | } | |
| 173 | ||
| 174 | sub _resolve_with_source { | |
| 175 | my ($self, $key) = @_; | |
| 176 | ||
| 177 | if ($key ne 'database_name') { | |
| 178 | $self->_load_db_cache; | |
| 179 | if (exists $_DB_CACHE{$key}) { | |
| 180 | return ($_DB_CACHE{$key}, 'db'); | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 184 | return (exists $DEFAULTS{$key} ? $DEFAULTS{$key} : undef, 'default'); | |
| 185 | } | |
| 186 | ||
| 187 | sub _load_db_cache { | |
| 188 | my ($self) = @_; | |
| 189 | return if $_DB_LOADED; | |
| 190 | $_DB_LOADED = 1; | |
| 191 | ||
| 192 | eval "require MODS::DBConnect; 1" or return; | |
| 193 | my $db = MODS::DBConnect->new; | |
| 194 | return unless $db; | |
| 195 | ||
| 196 | my $dbh = eval { $db->db_connect() }; | |
| 197 | return unless $dbh; | |
| 198 | ||
| 199 | my $DB = $DEFAULTS{database_name}; | |
| 200 | ||
| 201 | my $col_check = eval { | |
| 202 | $db->db_readwrite($dbh, qq~ | |
| 203 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 204 | WHERE table_schema='$DB' AND table_name='platform_settings' | |
| 205 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 206 | }; | |
| 207 | unless ($col_check && $col_check->{n}) { | |
| 208 | $_DB_AVAILABLE = 0; | |
| 209 | eval { $db->db_disconnect($dbh) }; | |
| 210 | return; | |
| 211 | } | |
| 212 | $_DB_AVAILABLE = 1; | |
| 213 | ||
| 214 | my @rows; | |
| 215 | eval { | |
| 216 | @rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 217 | SELECT setting_key, setting_value | |
| 218 | FROM `${DB}`.platform_settings | |
| 219 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 220 | 1; | |
| 221 | }; | |
| 222 | foreach my $r (@rows) { | |
| 223 | next unless ref($r) eq 'HASH'; | |
| 224 | next unless defined $r->{setting_key}; | |
| 225 | my $val = defined $r->{setting_value} ? $r->{setting_value} : ''; | |
| 226 | next unless length $val; | |
| 227 | $_DB_CACHE{ $r->{setting_key} } = $val; | |
| 228 | } | |
| 229 | eval { $db->db_disconnect($dbh) }; | |
| 230 | } | |
| 231 | ||
| 232 | sub urls { | |
| 233 | my $self = shift; | |
| 234 | return { | |
| 235 | home => '/', | |
| 236 | login => '/login.cgi', | |
| 237 | signup => '/signup.cgi', | |
| 238 | logout => '/logout.cgi', | |
| 239 | dashboard => '/dashboard.cgi', | |
| 240 | projects => '/projects.cgi', | |
| 241 | kanban => '/kanban.cgi', | |
| 242 | gantt => '/gantt.cgi', | |
| 243 | reports => '/reports.cgi', | |
| 244 | team => '/team.cgi', | |
| 245 | departments => '/departments.cgi', | |
| 246 | settings => '/settings.cgi', | |
| 247 | billing => '/billing.cgi', | |
| 248 | admin => '/admin.cgi', | |
| 249 | assets => '/assets', | |
| 250 | assets_css => '/assets/css', | |
| 251 | assets_js => '/assets/javascript', | |
| 252 | assets_images => '/assets/images', | |
| 253 | }; | |
| 254 | } | |
| 255 | ||
| 256 | 1; |