added on local at 2026-07-01 14:22:48
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # Meta-Admin -- /settings.cgi | |
| 4 | # | |
| 5 | # Editable platform_settings for the meta-admin app itself: brand, | |
| 6 | # cookie, session minutes, and the AES encryption key used to scramble | |
| 7 | # site_connections passwords at rest. Renders an editable card per | |
| 8 | # setting; POSTs to /settings_action.cgi. | |
| 9 | #====================================================================== | |
| 10 | use strict; | |
| 11 | use warnings; | |
| 12 | use lib '/var/www/vhosts/3dshawn.com/admin.3dshawn.com'; | |
| 13 | use CGI; | |
| 14 | use MODS::Login; | |
| 15 | use MODS::DBConnect; | |
| 16 | use MODS::MetaAdmin::Config; | |
| 17 | use MODS::MetaAdmin::Wrapper; | |
| 18 | ||
| 19 | my $q = CGI->new; | |
| 20 | my $form = $q->Vars; | |
| 21 | my $auth = MODS::Login->new; | |
| 22 | my $cfg = MODS::MetaAdmin::Config->new; | |
| 23 | my $wrap = MODS::MetaAdmin::Wrapper->new; | |
| 24 | my $u = $auth->login_verify; | |
| 25 | ||
| 26 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'settings'; | |
| 27 | ||
| 28 | if ($entry eq 'settings_action') { | |
| 29 | unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 30 | _handle_action($q, $form); | |
| 31 | exit; | |
| 32 | } | |
| 33 | ||
| 34 | unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 35 | ||
| 36 | # Pull every platform_settings row so the editor surfaces ALL keys, | |
| 37 | # not just the ones the code reads. Adding a new row to the DB makes | |
| 38 | # it editable here automatically. | |
| 39 | my $db = MODS::DBConnect->new; | |
| 40 | my $dbh = $db->db_connect; | |
| 41 | my @rows = $db->db_readwrite_multiple($dbh, q~ | |
| 42 | SELECT setting_key, setting_value, is_secret, updated_at | |
| 43 | FROM platform_settings ORDER BY setting_key | |
| 44 | ~, __FILE__, __LINE__); | |
| 45 | $db->db_disconnect($dbh); | |
| 46 | ||
| 47 | # Metadata for the keys we know about -- label + group + description. | |
| 48 | # Anything not in this list still renders under "Other" so the page | |
| 49 | # never hides a settable value. | |
| 50 | my %META = ( | |
| 51 | brand_name => { label => 'Brand name', group => 'Brand', secret => 0, | |
| 52 | desc => 'Shows in the sidebar header + browser title.' }, | |
| 53 | brand_tagline => { label => 'Brand tagline', group => 'Brand', secret => 0, | |
| 54 | desc => 'One-line tag under the brand name.' }, | |
| 55 | cookie_name => { label => 'Session cookie name', group => 'Session', secret => 0, | |
| 56 | desc => 'Name of the cookie used to keep you signed in. Changing kicks every open session.' }, | |
| 57 | session_minutes => { label => 'Session minutes', group => 'Session', secret => 0, | |
| 58 | desc => 'How long a session lasts before re-login. Default 720 (12h).' }, | |
| 59 | site_conn_enc_key => { label => 'Site-connection encryption key', group => 'Security', secret => 1, | |
| 60 | desc => 'AES key used to encrypt site_connections passwords at rest. Rotating invalidates every saved site password (must re-enter on Sites).' }, | |
| 61 | ); | |
| 62 | my @group_order = qw(Brand Session Security Other); | |
| 63 | ||
| 64 | # Bucket rows by group. | |
| 65 | my %by_group; | |
| 66 | foreach my $r (@rows) { | |
| 67 | my $key = $r->{setting_key}; | |
| 68 | my $m = $META{$key} || { label => $key, group => 'Other', secret => $r->{is_secret} ? 1 : 0, desc => '' }; | |
| 69 | push @{ $by_group{ $m->{group} } }, { | |
| 70 | key => $key, | |
| 71 | value => $r->{setting_value}, | |
| 72 | secret => $m->{secret} || $r->{is_secret} ? 1 : 0, | |
| 73 | label => $m->{label}, | |
| 74 | desc => $m->{desc} || '', | |
| 75 | updated_at => $r->{updated_at} || '', | |
| 76 | }; | |
| 77 | } | |
| 78 | ||
| 79 | sub _esc { | |
| 80 | my $s = shift; $s //= ''; | |
| 81 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 82 | $s =~ s/"/"/g; | |
| 83 | return $s; | |
| 84 | } | |
| 85 | ||
| 86 | my $body = ''; | |
| 87 | ||
| 88 | # Flash banners | |
| 89 | my $flash = ''; | |
| 90 | if ($form->{saved}) { $flash = qq~<div class="banner success">Settings saved.</div>~; } | |
| 91 | if ($form->{err}) { | |
| 92 | my $e = _esc($form->{err}); | |
| 93 | $flash = qq~<div class="banner danger">$e</div>~; | |
| 94 | } | |
| 95 | ||
| 96 | $body .= q~ | |
| 97 | <div class="page-head"><div class="left"> | |
| 98 | <span class="page-eyebrow"><span class="dot"></span> Operator</span> | |
| 99 | <h1 class="page-title">Settings</h1> | |
| 100 | <p class="page-subtitle">Brand, session, and security keys for the meta-admin app itself. Site DB credentials live under <a href="/sites.cgi">Sites</a>.</p> | |
| 101 | </div></div> | |
| 102 | ~; | |
| 103 | $body .= $flash; | |
| 104 | ||
| 105 | # One module per group with one form per row. | |
| 106 | foreach my $g (@group_order) { | |
| 107 | my $list = $by_group{$g} or next; | |
| 108 | next unless scalar @$list; | |
| 109 | my $glow = $g eq 'Security' ? 'glow-magenta' | |
| 110 | : $g eq 'Brand' ? 'glow-cyan' | |
| 111 | : $g eq 'Session' ? 'glow-violet' | |
| 112 | : 'glow-lime'; | |
| 113 | my $g_h = _esc($g); | |
| 114 | $body .= qq~<div class="module $glow" style="margin-bottom:16px"> | |
| 115 | <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></div><div class="module-title">$g_h</div></div></div> | |
| 116 | <div class="module-body">~; | |
| 117 | foreach my $r (@$list) { | |
| 118 | my $k = _esc($r->{key}); | |
| 119 | my $lbl = _esc($r->{label}); | |
| 120 | my $desc = _esc($r->{desc}); | |
| 121 | my $val = _esc($r->{value} // ''); | |
| 122 | my $upd = _esc($r->{updated_at}); | |
| 123 | my $input; | |
| 124 | if ($r->{secret}) { | |
| 125 | $input = qq~<input type="password" name="value" placeholder="(unchanged -- leave blank to keep)" autocomplete="new-password">~; | |
| 126 | } elsif (length($r->{value} // '') > 80) { | |
| 127 | $input = qq~<textarea name="value" rows="3">$val</textarea>~; | |
| 128 | } else { | |
| 129 | $input = qq~<input type="text" name="value" value="$val">~; | |
| 130 | } | |
| 131 | $body .= qq~<form method="POST" action="/settings_action.cgi" style="border-bottom:1px solid var(--bg-line);padding:12px 0;display:grid;grid-template-columns:240px 1fr auto;gap:14px;align-items:start"> | |
| 132 | <input type="hidden" name="op" value="save"> | |
| 133 | <input type="hidden" name="key" value="$k"> | |
| 134 | <div> | |
| 135 | <div style="font-weight:600;font-size:13px;color:var(--col-text)">$lbl</div> | |
| 136 | <div style="font-size:11px;color:var(--col-text-3);margin-top:4px;font-family:'JetBrains Mono',monospace">$k</div> | |
| 137 | <div style="font-size:11px;color:var(--col-text-dim);margin-top:6px;line-height:1.5">$desc</div> | |
| 138 | <div style="font-size:10px;color:var(--col-text-dim);margin-top:6px">Updated $upd</div> | |
| 139 | </div> | |
| 140 | <div>$input</div> | |
| 141 | <button type="submit" class="btn btn-sm">Save</button> | |
| 142 | </form>~; | |
| 143 | } | |
| 144 | $body .= q~</div></div>~; | |
| 145 | } | |
| 146 | ||
| 147 | $wrap->render(title => 'Settings', page_key => 'settings', body => $body, userinfo => $u); | |
| 148 | ||
| 149 | #====================================================================== | |
| 150 | # settings_action entry: POST handler | |
| 151 | # op=save -- UPSERT a single platform_settings row by key. | |
| 152 | #====================================================================== | |
| 153 | sub _act_go { | |
| 154 | my $q = shift || ''; | |
| 155 | print "Status: 302 Found\nLocation: /settings.cgi$q\n\n"; | |
| 156 | } | |
| 157 | sub _act_url_esc { | |
| 158 | my $s = shift; $s //= ''; | |
| 159 | $s =~ s/([^A-Za-z0-9 .,'\$+-])/ /g; | |
| 160 | $s =~ s/ /+/g; | |
| 161 | return $s; | |
| 162 | } | |
| 163 | ||
| 164 | sub _handle_action { | |
| 165 | my ($q, $f) = @_; | |
| 166 | ||
| 167 | my $op = lc($f->{op} || ''); | |
| 168 | my $key = $f->{key} // ''; | |
| 169 | my $val = $f->{value}; # blank means "keep existing" | |
| 170 | unless ($op eq 'save' && length $key) { _act_go('?err=' . _act_url_esc('bad input')); return; } | |
| 171 | # Empty value on save means "keep existing" -- the secret pattern. | |
| 172 | if (!defined $val || $val eq '') { _act_go('?saved=1'); return; } | |
| 173 | ||
| 174 | # Sanity-cap absurd values. | |
| 175 | $val = substr($val, 0, 16384); | |
| 176 | ||
| 177 | my $db = MODS::DBConnect->new; | |
| 178 | my $dbh = $db->db_connect; | |
| 179 | unless ($dbh) { _act_go('?err=' . _act_url_esc('db unavailable')); return; } | |
| 180 | my $k_q = $key; $k_q =~ s/'/''/g; | |
| 181 | my $v_q = $val; $v_q =~ s/'/''/g; | |
| 182 | $db->db_readwrite($dbh, qq~ | |
| 183 | INSERT INTO platform_settings (setting_key, setting_value) | |
| 184 | VALUES ('$k_q', '$v_q') | |
| 185 | ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value), updated_at=NOW() | |
| 186 | ~, __FILE__, __LINE__); | |
| 187 | $db->db_disconnect($dbh); | |
| 188 | ||
| 189 | _act_go('?saved=1'); | |
| 190 | return; | |
| 191 | } |