added on local at 2026-07-01 12:33:54
| 1 | #!/usr/bin/perl | |
| 2 | ||
| 3 | ################################################################################################################################## | |
| 4 | # ______ ____ ____ ______ ______ ____ ___ __ ___ ______ _ __ ____ ____ __ __ _ __ _____ ____ | |
| 5 | # / ____// __ \ / __ \ / ____/ / ____// __ \ / | / |/ // ____/| | / // __ \ / __ \ / //_/ | | / /|__ / / __ \ | |
| 6 | # / / / / / // / / // __/ / /_ / /_/ // /| | / /|_/ // __/ | | /| / // / / // /_/ // ,< | | / / /_ < / / / / | |
| 7 | # / /___ / /_/ // /_/ // /___ / __/ / _, _// ___ | / / / // /___ | |/ |/ // /_/ // _, _// /| | | |/ / ___/ /_ / /_/ / | |
| 8 | # \____/ \____//_____//_____/ /_/ /_/ |_|/_/ |_|/_/ /_//_____/ |__/|__/ \____//_/ |_|/_/ |_| |___/ /____/(_)\____/ | |
| 9 | # | |
| 10 | # - Written by: Shawn Pickering | |
| 11 | # - shawn@shawnpickering.com | |
| 12 | ################################################################################################################################## | |
| 13 | use strict; | |
| 14 | use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com'; | |
| 15 | ||
| 16 | ||
| 17 | ############################################################# | |
| 18 | ## Variables | |
| 19 | ############################################################# | |
| 20 | ||
| 21 | ||
| 22 | ||
| 23 | ############################################################ | |
| 24 | # Modules | |
| 25 | ############################################################ | |
| 26 | use CGI; my $query = new CGI; my $form = $query->Vars; | |
| 27 | use MODS::Login; my $login = new MODS::Login; | |
| 28 | use MODS::PTMatrix::Urls; my $getlist = new MODS::PTMatrix::Urls; my $url = $getlist->urls(); | |
| 29 | use MODS::Template; my $tfile = new MODS::Template; | |
| 30 | use MODS::PTMatrix::Wrapper; my $load = new MODS::PTMatrix::Wrapper; | |
| 31 | use MODS::DBConnect; my $db = new MODS::DBConnect; | |
| 32 | use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name'); | |
| 33 | use MODS::PTMatrix::PM; | |
| 34 | ||
| 35 | ||
| 36 | ||
| 37 | ############################################################# | |
| 38 | ## Get form data | |
| 39 | ############################################################# | |
| 40 | #Snapshot raw values - platform settings hold URLs / API keys / SMTP passwords that | |
| 41 | #quotemeta would mangle. sql_quote on save handles escaping. | |
| 42 | my %rawform = %$form; | |
| 43 | ||
| 44 | foreach my $line(keys %$form){ | |
| 45 | $form->{$line} =~ s/[^!-~\s]//g; | |
| 46 | $form->{$line} = quotemeta("$form->{$line}"); | |
| 47 | } | |
| 48 | ||
| 49 | ||
| 50 | ||
| 51 | ############################################################ | |
| 52 | # Program Controls | |
| 53 | ############################################################ | |
| 54 | $|=1; | |
| 55 | my $userinfo = $login->login_verify(); | |
| 56 | print qq~Content-type:text/html; Cache-Control:no-cache;\n\n~; | |
| 57 | if(!$userinfo){print qq~<script>window.location="$url->{login}";</script>~;} | |
| 58 | elsif(!($userinfo->{is_super_admin} || $userinfo->{_admin_is_super_admin})){print qq~<script>window.location="$url->{dashboard}";</script>~;} | |
| 59 | elsif(($ENV{REQUEST_METHOD}||'') eq 'POST'){&save_settings;} | |
| 60 | else{&config_panel;} | |
| 61 | ||
| 62 | ||
| 63 | ############################################################ | |
| 64 | # Subroutines | |
| 65 | ############################################################ | |
| 66 | sub save_settings{ | |
| 67 | my $dbh = $db->db_connect(); | |
| 68 | ||
| 69 | #Every k_<key> field on the form is a platform setting to upsert | |
| 70 | foreach my $key(sort keys %rawform){ | |
| 71 | next unless $key =~ /^k_(.+)$/; | |
| 72 | my $real_key = $1; | |
| 73 | next unless $config->is_editable($real_key); | |
| 74 | ||
| 75 | my $val = $rawform{$key}; | |
| 76 | ||
| 77 | #For secret fields, an empty submission means "don't change" - skip | |
| 78 | my $meta = $config->editable_meta_for($real_key); | |
| 79 | next if $meta->{secret} && !length($val); | |
| 80 | ||
| 81 | my $sq_key = MODS::PTMatrix::PM::sql_quote($real_key); | |
| 82 | my $sq_val = MODS::PTMatrix::PM::sql_quote($val); | |
| 83 | my $sql = qq~INSERT INTO `${database_name}`.platform_settings (setting_key, setting_value) VALUES ('$sq_key', '$sq_val') ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)~; | |
| 84 | $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 85 | } | |
| 86 | ||
| 87 | $db->db_disconnect($dbh); | |
| 88 | &config_panel('Settings saved. Some take effect on next page load (cache).'); | |
| 89 | } | |
| 90 | ||
| 91 | sub config_panel{ | |
| 92 | my($flash) = @_; | |
| 93 | $flash ||= ''; | |
| 94 | ||
| 95 | my @rows = $config->editable_settings_meta; | |
| 96 | ||
| 97 | #Group editable settings by their group label | |
| 98 | my %by_group; | |
| 99 | foreach my $r(@rows){push @{$by_group{$r->{group}}}, $r;} | |
| 100 | ||
| 101 | #Per-group help content - rendered as a click-to-open overlay next to the section title | |
| 102 | my %group_help = ( | |
| 103 | 'Billing' => { | |
| 104 | summary => 'Knobs that affect what your customers pay.', | |
| 105 | detail => qq~<p><strong>Annual prepay discount %:</strong> the percent off shown when a customer picks the "billed yearly" toggle on the pricing page. Industry-standard is 15-20%; aggressive is 25-30%.</p> | |
| 106 | <p>Other billing knobs live in the dedicated <a href="$url->{admin_billing}">Platform Billing</a> page (price overrides per company, locked rates, credits) and <a href="$url->{admin_discounts}">Discounts & Deals</a> (promo codes, intro offers).</p>~, | |
| 107 | }, | |
| 108 | 'Branding' => { | |
| 109 | summary => 'How your product appears to customers.', | |
| 110 | detail => q[<p><strong>Brand name and tagline</strong> show in the sidebar, marketing pages, and email headers.</p> | |
| 111 | <p><strong>Site title</strong> is the title tag of marketing pages — this is what shows in browser tabs and Google search results, so include a clear value-prop.</p> | |
| 112 | <p>Changes take effect on next page load (template cache).</p>], | |
| 113 | }, | |
| 114 | 'Defaults' => { | |
| 115 | summary => 'Sensible defaults for new accounts.', | |
| 116 | detail => q[<p><strong>Default timezone & currency</strong> are applied when a new company signs up. Each customer can override their own settings later.</p>], | |
| 117 | }, | |
| 118 | 'Email' => { | |
| 119 | summary => 'Outbound email delivery for receipts, invites, and notifications.', | |
| 120 | detail => q[<p><strong>SMTP host / port / user / password:</strong> credentials for the SMTP server PTMatrix will use to send mail. Common choices: SendGrid (<em>smtp.sendgrid.net:587</em>), Mailgun, Amazon SES, or your own mail server.</p> | |
| 121 | <p><strong>From address:</strong> the "From:" header on outbound mail. Use a real address on a domain whose DNS you control (SPF / DKIM passing) or deliverability tanks.</p> | |
| 122 | <p><strong>Support / feedback inbox:</strong> where customer support replies are routed. Customers reply to a per-ticket address; replies get parsed back into PTMatrix automatically and the rest are forwarded here.</p>], | |
| 123 | }, | |
| 124 | 'SEO' => { | |
| 125 | summary => 'Default search / social metadata for marketing pages.', | |
| 126 | detail => q[<p><strong>Title & description</strong> are what Google shows in search results. Aim for 50-60 chars for title, 150-160 for description, with a clear value-prop and one keyword phrase.</p> | |
| 127 | <p><strong>Canonical URL base:</strong> the absolute base for canonical link headers (e.g. <code>https://ptmatrix.3dshawn.com</code>, no trailing slash). Prevents duplicate-content penalties when pages are reachable via multiple URLs.</p> | |
| 128 | <p><strong>OG site name:</strong> the "site" that appears on social-share cards (Open Graph). Usually just your brand name.</p>], | |
| 129 | }, | |
| 130 | 'Stripe' => { | |
| 131 | summary => 'Card-processing credentials. Without them, paid plans cannot collect money.', | |
| 132 | detail => q[<p><strong>Publishable key</strong> (starts <code>pk_</code>) is safe to expose in client-side code — it goes into the checkout JavaScript.</p> | |
| 133 | <p><strong>Secret key</strong> (starts <code>sk_</code>) <em>never</em> leaves the server. Anyone with this key can charge cards on your account, so rotate it if exposed.</p> | |
| 134 | <p><strong>Webhook signing secret</strong> (starts <code>whsec_</code>) verifies that incoming webhook events actually came from Stripe (and not an attacker spoofing payment events). Generate it from the Stripe Dashboard <em>Developers → Webhooks → (your endpoint) → Signing secret</em>.</p> | |
| 135 | <p><strong>Live vs. test:</strong> test keys (<code>pk_test_...</code>, <code>sk_test_...</code>) only process test card numbers. Live keys (<code>pk_live_...</code>) process real money. Always start in test mode.</p>], | |
| 136 | }, | |
| 137 | ); | |
| 138 | ||
| 139 | #Pre-render the modal overlays in Perl - the template loop engine | |
| 140 | #was double-emitting the second iteration, so we concat once and pass | |
| 141 | #the finished HTML to the template as $modals_html | |
| 142 | my @groups; | |
| 143 | my $modals_html = ''; | |
| 144 | foreach my $g(sort keys %by_group){ | |
| 145 | my $h = $group_help{$g} || { summary => '', detail => '' }; | |
| 146 | my $slug = lc $g; $slug =~ s/[^a-z0-9]/_/g; | |
| 147 | my $has_help = (length($h->{summary}) || length($h->{detail})) ? 1 : 0; | |
| 148 | ||
| 149 | push @groups, { | |
| 150 | name => $g, | |
| 151 | items => $by_group{$g}, | |
| 152 | help_summary => $h->{summary}, | |
| 153 | help_detail => $h->{detail}, | |
| 154 | has_help => $has_help, | |
| 155 | slug => $slug, | |
| 156 | }; | |
| 157 | ||
| 158 | if($has_help){ | |
| 159 | my $name_esc = $g; $name_esc =~ s/&/&/g; $name_esc =~ s/</</g; $name_esc =~ s/>/>/g; | |
| 160 | my $summary_esc = $h->{summary}; | |
| 161 | $modals_html .= qq~ | |
| 162 | <div class="acfg-modal-overlay" id="acfg-help-$slug" onclick="acfgBackdrop(event, '$slug')"> | |
| 163 | <div class="acfg-modal" role="dialog" aria-modal="true"> | |
| 164 | <div class="acfg-modal-head"> | |
| 165 | <div> | |
| 166 | <h2>$name_esc</h2> | |
| 167 | <div class="acfg-sum">$summary_esc</div> | |
| 168 | </div> | |
| 169 | <button type="button" class="acfg-modal-close" onclick="acfgClose('$slug')" aria-label="Close">×</button> | |
| 170 | </div> | |
| 171 | <div class="acfg-modal-body">$h->{detail}</div> | |
| 172 | </div> | |
| 173 | </div>~; | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | #Create all template variables | |
| 178 | my $tvars; | |
| 179 | $tvars->{groups} = \@groups; | |
| 180 | $tvars->{flash} = $flash; | |
| 181 | $tvars->{has_flash} = length($flash) ? 1 : 0; | |
| 182 | $tvars->{modals_html} = $modals_html; | |
| 183 | ||
| 184 | my $body = join('', $tfile->template('tf_admin_config.html', $tvars, $userinfo)); | |
| 185 | ||
| 186 | $load->render({ | |
| 187 | userinfo => $userinfo, | |
| 188 | page_key => 'admin_config', | |
| 189 | title => 'Configuration', | |
| 190 | body => $body, | |
| 191 | }); | |
| 192 | } |