added on local at 2026-07-01 14:20:15
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # Meta-Admin -- /admin_maintenance.cgi | |
| 4 | # | |
| 5 | # Lock meta-admin for non-super-admins. WYSIWYG edit of the message | |
| 6 | # users see while the site is locked. | |
| 7 | #====================================================================== | |
| 8 | use strict; | |
| 9 | use warnings; | |
| 10 | use lib '/var/www/vhosts/3dshawn.com/admin.3dshawn.com'; | |
| 11 | use CGI; | |
| 12 | use MODS::Login; | |
| 13 | use MODS::DBConnect; | |
| 14 | use MODS::MetaAdmin::Config; | |
| 15 | use MODS::MetaAdmin::Wrapper; | |
| 16 | use MODS::MetaAdmin::SiteConn; | |
| 17 | ||
| 18 | my $q = CGI->new; | |
| 19 | my $form = $q->Vars; | |
| 20 | my $auth = MODS::Login->new; | |
| 21 | my $u = $auth->login_verify; | |
| 22 | ||
| 23 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_maintenance'; | |
| 24 | ||
| 25 | if ($entry eq 'admin_maintenance_action') { | |
| 26 | unless ($u && $u->{is_super_admin}) { | |
| 27 | print "Status: 403 Forbidden\nContent-Type: text/plain\n\nsuper-admin only\n"; | |
| 28 | exit; | |
| 29 | } | |
| 30 | _handle_action($q, $form); | |
| 31 | exit; | |
| 32 | } | |
| 33 | ||
| 34 | unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 35 | unless ($u->{is_super_admin}) { | |
| 36 | print "Status: 403 Forbidden\nContent-Type: text/html\n\n<h1>Forbidden</h1><p>Only super-admins can manage maintenance mode.</p>\n"; | |
| 37 | exit; | |
| 38 | } | |
| 39 | ||
| 40 | my $cfg = MODS::MetaAdmin::Config->new; | |
| 41 | my $locked = int($cfg->settings('maintenance_locked') || 0); | |
| 42 | my $msg = $cfg->settings('maintenance_message') || ''; | |
| 43 | ||
| 44 | sub _esc { | |
| 45 | my $s = shift; $s //= ''; | |
| 46 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; | |
| 47 | return $s; | |
| 48 | } | |
| 49 | ||
| 50 | my $flash = ''; | |
| 51 | if ($form->{saved}) { $flash = '<div class="banner success">Maintenance message saved.</div>'; } | |
| 52 | if ($form->{locked}) { $flash = '<div class="banner warn"><strong>Meta-admin is now LOCKED.</strong> Only super-admins can sign in or stay signed in.</div>'; } | |
| 53 | if ($form->{unlocked}) { $flash = '<div class="banner success">Meta-admin is now open.</div>'; } | |
| 54 | if ($form->{applied_locked} || $form->{applied_unlocked}) { | |
| 55 | my @parts; | |
| 56 | if ($form->{applied_locked}) { my $n = _esc($form->{applied_locked}); push @parts, "<strong>$n</strong> site" . ($n eq '1' ? '' : 's') . ' LOCKED'; } | |
| 57 | if ($form->{applied_unlocked}) { my $n = _esc($form->{applied_unlocked}); push @parts, "<strong>$n</strong> site" . ($n eq '1' ? '' : 's') . ' UNLOCKED'; } | |
| 58 | $flash = '<div class="banner success">' . join(' · ', @parts) . '.</div>'; | |
| 59 | } | |
| 60 | if ($form->{applied_none}) { $flash = '<div class="banner">No changes to apply.</div>'; } | |
| 61 | if ($form->{all_locked}) { my $n = _esc($form->{all_locked}); $flash = qq~<div class="banner warn"><strong>$n site~ . ($n eq '1' ? '' : 's') . qq~ now LOCKED.</strong></div>~; } | |
| 62 | if ($form->{all_unlocked}) { my $n = _esc($form->{all_unlocked}); $flash = qq~<div class="banner success">$n site~ . ($n eq '1' ? '' : 's') . qq~ now OPEN.</div>~; } | |
| 63 | if ($form->{site_locked}) { my $s = _esc($form->{site_locked}); $flash = qq~<div class="banner warn"><strong>$s</strong> now LOCKED.</div>~; } | |
| 64 | if ($form->{site_unlocked}) { my $s = _esc($form->{site_unlocked}); $flash = qq~<div class="banner success"><strong>$s</strong> now OPEN.</div>~; } | |
| 65 | if ($form->{err}) { my $e = _esc($form->{err}); $flash = qq~<div class="banner danger">$e</div>~; } | |
| 66 | ||
| 67 | my $msg_h = _esc($msg); | |
| 68 | ||
| 69 | ## === Pre-compute portfolio status (8 sites: meta-admin + 7 sister) === | |
| 70 | my $_sc = MODS::MetaAdmin::SiteConn->new; | |
| 71 | my $_sites = $_sc->list_sites(active => 1); | |
| 72 | my $_meta_locked = int($cfg->settings('maintenance_locked') || 0); | |
| 73 | # Build a combined site rows list with meta-admin first. | |
| 74 | my @combined_rows = ({ | |
| 75 | slug => 'meta-admin', | |
| 76 | display_name => 'Meta-Admin', | |
| 77 | brand_color => '#d97706', | |
| 78 | locked => $_meta_locked, | |
| 79 | is_meta => 1, | |
| 80 | }); | |
| 81 | foreach my $_s (@$_sites) { | |
| 82 | my $st = $_sc->get_maintenance_status($_s->{slug}); | |
| 83 | push @combined_rows, { | |
| 84 | slug => $_s->{slug}, | |
| 85 | display_name => $_s->{display_name}, | |
| 86 | brand_color => $_s->{brand_color}, | |
| 87 | locked => $st, | |
| 88 | is_meta => 0, | |
| 89 | }; | |
| 90 | } | |
| 91 | my $_n_total = scalar @combined_rows; | |
| 92 | my $_n_locked = (grep { $_->{locked} } @combined_rows); | |
| 93 | my $_all_pill_cls = ($_n_locked == $_n_total) ? 'pill-bad' : ($_n_locked == 0 ? 'pill-ok' : 'pill-warn'); | |
| 94 | my $_all_status_lbl = ($_n_locked == $_n_total) ? "ALL $_n_total LOCKED" : ($_n_locked == 0 ? "ALL $_n_total OPEN" : "$_n_locked OF $_n_total LOCKED"); | |
| 95 | ||
| 96 | my $body = qq~ | |
| 97 | <div class="page-head"><div class="left"> | |
| 98 | <span class="page-eyebrow"><span class="dot"></span> Operations</span> | |
| 99 | <h1 class="page-title">Maintenance mode</h1> | |
| 100 | <p class="page-subtitle">Block all non-super-admin users from signing in (or staying signed in), with a custom message shown on the login page.</p> | |
| 101 | </div></div> | |
| 102 | $flash | |
| 103 | ||
| 104 | <div class="module"> | |
| 105 | <div class="module-head"><div class="left"><div class="module-title">Message users see while locked</div></div></div> | |
| 106 | <div class="module-body"> | |
| 107 | <form method="POST" action="/admin_maintenance_action.cgi"> | |
| 108 | <input type="hidden" name="op" value="save_message"> | |
| 109 | <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:12px"> | |
| 110 | <div> | |
| 111 | <label class="lbl"><span class="t">HTML source</span> | |
| 112 | <style> | |
| 113 | #maint_src{width:100%;height:360px;padding:12px 14px;background-color:#0a0a0a;color:#cbd5e1;color-scheme:dark;border:1px solid rgba(255,255,255,.08);border-radius:8px;font-family:'JetBrains Mono','Consolas',monospace;font-size:12px;line-height:1.5;resize:vertical;box-sizing:border-box} | |
| 114 | </style> | |
| 115 | <textarea id="maint_src" name="message" spellcheck="false">$msg_h</textarea> | |
| 116 | </label> | |
| 117 | <div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:4px;font-size:11px"> | |
| 118 | <button type="button" class="btn btn-sm" onclick="wrap('<strong>','</strong>')">B</button> | |
| 119 | <button type="button" class="btn btn-sm" onclick="wrap('<em>','</em>')"><em>I</em></button> | |
| 120 | <button type="button" class="btn btn-sm" onclick="wrap('<h2>','</h2>')">H2</button> | |
| 121 | <button type="button" class="btn btn-sm" onclick="wrap('<p>','</p>')">P</button> | |
| 122 | <button type="button" class="btn btn-sm" onclick="ins('<br>')">BR</button> | |
| 123 | <button type="button" class="btn btn-sm" onclick="linkPrompt()">Link</button> | |
| 124 | <button type="button" class="btn btn-sm" onclick="resetMsg()">Reset to default</button> | |
| 125 | </div> | |
| 126 | </div> | |
| 127 | <div> | |
| 128 | <label class="lbl"><span class="t">Live preview</span> | |
| 129 | <iframe id="maint_preview" | |
| 130 | style="width:100%;height:360px;background:#0a0a0a;border:1px solid var(--col-border);border-radius:5px"></iframe> | |
| 131 | </label> | |
| 132 | <div class="dim" style="font-size:11px;margin-top:4px">Preview updates as you type. Bg is the dark theme users see on /login.cgi.</div> | |
| 133 | </div> | |
| 134 | </div> | |
| 135 | <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"> | |
| 136 | <button type="submit" class="btn btn-primary">Save message</button> | |
| 137 | <a href="/login.cgi?_preview_maintenance=1" target="_blank" class="btn">Preview lock screen (opens login in new tab)</a> | |
| 138 | <div class="dim" style="font-size:11px;margin-left:auto">Preview opens meta-admin's login page with the lock overlay rendered, the way a non-admin would see it.</div> | |
| 139 | </div> | |
| 140 | </form> | |
| 141 | </div> | |
| 142 | </div> | |
| 143 | ||
| 144 | <script> | |
| 145 | (function(){ | |
| 146 | var ta = document.getElementById('maint_src'); | |
| 147 | var pv = document.getElementById('maint_preview'); | |
| 148 | function refresh() { | |
| 149 | var html = '<style>body{font-family:system-ui,-apple-system,Segoe UI,Arial,sans-serif;background:#0a0a0a;color:#e5e5e5;margin:0;padding:24px;min-height:100vh;display:flex;align-items:center;justify-content:center}</style>' + ta.value; | |
| 150 | pv.srcdoc = html; | |
| 151 | } | |
| 152 | ta.addEventListener('input', refresh); | |
| 153 | refresh(); | |
| 154 | window.wrap = function(o,c){ | |
| 155 | var s=ta.selectionStart, e=ta.selectionEnd, v=ta.value; | |
| 156 | ta.value = v.slice(0,s) + o + v.slice(s,e) + c + v.slice(e); | |
| 157 | ta.focus(); ta.selectionStart=s+o.length; ta.selectionEnd=e+o.length; refresh(); | |
| 158 | }; | |
| 159 | window.ins = function(t){ | |
| 160 | var s=ta.selectionStart, v=ta.value; | |
| 161 | ta.value = v.slice(0,s) + t + v.slice(s); | |
| 162 | ta.focus(); ta.selectionStart=ta.selectionEnd=s+t.length; refresh(); | |
| 163 | }; | |
| 164 | window.linkPrompt = function(){ | |
| 165 | var url = prompt('Link URL', 'https://'); | |
| 166 | if (!url) return; | |
| 167 | wrap('<a href="' + url + '">', '</a>'); | |
| 168 | }; | |
| 169 | window.resetMsg = function(){ | |
| 170 | showConfirm({ | |
| 171 | title: 'Reset message to default?', | |
| 172 | body: 'Unsaved edits to the lock-screen message will be lost.', | |
| 173 | confirmLabel: 'Reset', | |
| 174 | confirmClass: 'btn btn-danger', | |
| 175 | onConfirm: function(){ | |
| 176 | ta.value = '<div style="text-align:center;padding:40px 20px;max-width:560px;margin:0 auto">' + | |
| 177 | '<h2 style="margin:0 0 14px;font-size:28px">We will be right back</h2>' + | |
| 178 | '<p style="margin:0 0 14px;color:#aaa;font-size:16px;line-height:1.6">We are rolling out a quick improvement to the system. Most updates wrap up in under an hour.</p>' + | |
| 179 | '<p style="margin:24px 0 0;color:#888;font-size:13px">Thanks for your patience. -- The team</p>' + | |
| 180 | '</div>'; | |
| 181 | refresh(); | |
| 182 | } | |
| 183 | }); | |
| 184 | }; | |
| 185 | // Override the inline prompt() in linkPrompt with the styled modal. | |
| 186 | window.linkPrompt = function(){ | |
| 187 | showPrompt({ | |
| 188 | title: 'Insert link', | |
| 189 | body: 'Enter the URL the selected text should link to.', | |
| 190 | defaultValue: 'https://', | |
| 191 | confirmLabel: 'Insert link', | |
| 192 | onConfirm: function(url){ | |
| 193 | if (!url) return; | |
| 194 | wrap('<a href="' + url + '">', '</a>'); | |
| 195 | } | |
| 196 | }); | |
| 197 | }; | |
| 198 | })(); | |
| 199 | </script> | |
| 200 | ||
| 201 | <!-- === Styled modal (notice/confirm/prompt) -- replaces native alert/confirm/prompt === --> | |
| 202 | <div id="ma-modal-backdrop" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:10000;align-items:center;justify-content:center;padding:24px"> | |
| 203 | <div id="ma-modal-card" role="dialog" aria-modal="true" aria-labelledby="ma-modal-title" style="background:#0f0f0f;border:1px solid rgba(217,119,6,0.6);border-radius:12px;max-width:520px;width:100%;padding:32px;box-shadow:0 30px 90px rgba(0,0,0,0.6),0 0 0 1px rgba(217,119,6,0.15);position:relative"> | |
| 204 | <div id="ma-modal-title" style="font-size:20px;font-weight:600;color:#fff;margin-bottom:12px"></div> | |
| 205 | <div id="ma-modal-body" style="color:#bbb;font-size:14px;line-height:1.55;margin-bottom:22px"></div> | |
| 206 | <div id="ma-modal-input-wrap" style="display:none;margin-bottom:22px"> | |
| 207 | <input id="ma-modal-input" type="text" style="width:100%;padding:10px 12px;background:#1a1a1f;color:#fff;border:1px solid #333;border-radius:6px;font-size:14px;box-sizing:border-box"> | |
| 208 | </div> | |
| 209 | <div style="display:flex;gap:10px;justify-content:flex-end"> | |
| 210 | <button id="ma-modal-cancel" type="button" class="btn">Cancel</button> | |
| 211 | <button id="ma-modal-confirm" type="button" class="btn btn-primary">OK</button> | |
| 212 | </div> | |
| 213 | </div> | |
| 214 | </div> | |
| 215 | <script> | |
| 216 | (function(){ | |
| 217 | var backdrop = document.getElementById('ma-modal-backdrop'); | |
| 218 | var card = document.getElementById('ma-modal-card'); | |
| 219 | var titleEl = document.getElementById('ma-modal-title'); | |
| 220 | var bodyEl = document.getElementById('ma-modal-body'); | |
| 221 | var inputWrap= document.getElementById('ma-modal-input-wrap'); | |
| 222 | var inputEl = document.getElementById('ma-modal-input'); | |
| 223 | var cancelBtn= document.getElementById('ma-modal-cancel'); | |
| 224 | var confirmBtn=document.getElementById('ma-modal-confirm'); | |
| 225 | var current = null; | |
| 226 | ||
| 227 | function close() { | |
| 228 | backdrop.style.display = 'none'; | |
| 229 | document.body.style.overflow = ''; | |
| 230 | current = null; | |
| 231 | } | |
| 232 | function open(opts) { | |
| 233 | current = opts || {}; | |
| 234 | titleEl.textContent = opts.title || ''; | |
| 235 | bodyEl.textContent = opts.body || ''; | |
| 236 | if (opts.showInput) { | |
| 237 | inputWrap.style.display = 'block'; | |
| 238 | inputEl.value = opts.defaultValue || ''; | |
| 239 | } else { | |
| 240 | inputWrap.style.display = 'none'; | |
| 241 | } | |
| 242 | confirmBtn.textContent = opts.confirmLabel || 'OK'; | |
| 243 | confirmBtn.className = opts.confirmClass || 'btn btn-primary'; | |
| 244 | cancelBtn.style.display = (opts.hideCancel ? 'none' : ''); | |
| 245 | backdrop.style.display = 'flex'; | |
| 246 | document.body.style.overflow = 'hidden'; | |
| 247 | // Default focus to Cancel for destructive ops; to input or confirm otherwise. | |
| 248 | setTimeout(function(){ | |
| 249 | if (opts.showInput) { inputEl.focus(); inputEl.select(); } | |
| 250 | else if (opts.confirmClass && opts.confirmClass.indexOf('btn-danger') >= 0) { cancelBtn.focus(); } | |
| 251 | else { confirmBtn.focus(); } | |
| 252 | }, 30); | |
| 253 | } | |
| 254 | ||
| 255 | cancelBtn.addEventListener('click', function(){ | |
| 256 | var c = current; | |
| 257 | close(); | |
| 258 | if (c && c.onCancel) c.onCancel(); | |
| 259 | }); | |
| 260 | confirmBtn.addEventListener('click', function(){ | |
| 261 | var c = current; | |
| 262 | var v = c && c.showInput ? inputEl.value : true; | |
| 263 | close(); | |
| 264 | if (c && c.onConfirm) c.onConfirm(v); | |
| 265 | }); | |
| 266 | // Backdrop click closes (treated as cancel). | |
| 267 | backdrop.addEventListener('click', function(e){ | |
| 268 | if (e.target === backdrop) { cancelBtn.click(); } | |
| 269 | }); | |
| 270 | // Esc closes (cancel). | |
| 271 | document.addEventListener('keydown', function(e){ | |
| 272 | if (backdrop.style.display !== 'none' && e.key === 'Escape') { cancelBtn.click(); } | |
| 273 | }); | |
| 274 | inputEl.addEventListener('keydown', function(e){ | |
| 275 | if (e.key === 'Enter') { e.preventDefault(); confirmBtn.click(); } | |
| 276 | }); | |
| 277 | ||
| 278 | // Public helpers | |
| 279 | window.showNotice = function(opts) { | |
| 280 | open({ | |
| 281 | title: opts.title || 'Notice', | |
| 282 | body: opts.body || '', | |
| 283 | confirmLabel: opts.confirmLabel || 'OK', | |
| 284 | confirmClass: 'btn btn-primary', | |
| 285 | hideCancel: true | |
| 286 | }); | |
| 287 | }; | |
| 288 | window.showConfirm = function(opts) { | |
| 289 | open({ | |
| 290 | title: opts.title || 'Confirm', | |
| 291 | body: opts.body || '', | |
| 292 | confirmLabel: opts.confirmLabel || 'OK', | |
| 293 | confirmClass: opts.confirmClass || 'btn btn-primary', | |
| 294 | onConfirm: opts.onConfirm, | |
| 295 | onCancel: opts.onCancel | |
| 296 | }); | |
| 297 | }; | |
| 298 | window.showPrompt = function(opts) { | |
| 299 | open({ | |
| 300 | title: opts.title || 'Enter value', | |
| 301 | body: opts.body || '', | |
| 302 | confirmLabel: opts.confirmLabel || 'OK', | |
| 303 | confirmClass: opts.confirmClass || 'btn btn-primary', | |
| 304 | showInput: true, | |
| 305 | defaultValue: opts.defaultValue || '', | |
| 306 | onConfirm: opts.onConfirm, | |
| 307 | onCancel: opts.onCancel | |
| 308 | }); | |
| 309 | }; | |
| 310 | })(); | |
| 311 | </script> | |
| 312 | ~; | |
| 313 | ||
| 314 | ## === Portfolio-wide maintenance controls === | |
| 315 | my $sc = MODS::MetaAdmin::SiteConn->new; | |
| 316 | my $sites = $sc->list_sites(active => 1); | |
| 317 | my @site_rows; | |
| 318 | foreach my $s (@$sites) { | |
| 319 | my $st = $sc->get_maintenance_status($s->{slug}); | |
| 320 | push @site_rows, { | |
| 321 | slug => $s->{slug}, | |
| 322 | display_name => $s->{display_name}, | |
| 323 | brand_color => $s->{brand_color}, | |
| 324 | locked => $st, | |
| 325 | }; | |
| 326 | } | |
| 327 | my $n_total = scalar @site_rows; | |
| 328 | my $n_locked = (grep { $_->{locked} } @site_rows); | |
| 329 | my $portfolio_all_locked = ($n_total > 0 && $n_locked == $n_total) ? 1 : 0; | |
| 330 | my $portfolio_status_lbl = $portfolio_all_locked ? 'ALL LOCKED' : ($n_locked == 0 ? 'ALL OPEN' : "$n_locked OF $n_total LOCKED"); | |
| 331 | my $portfolio_status_cls = $portfolio_all_locked ? 'pill-bad' : ($n_locked == 0 ? 'pill-ok' : 'pill-warn'); | |
| 332 | ||
| 333 | my %brand_letters = ( | |
| 334 | webstls => 'WS', ptmatrix => 'PT', affsoft => 'AS', shopcart => 'SC', | |
| 335 | repricer => 'RP', abforge => 'AB', contactforge => 'CF', | |
| 336 | ); | |
| 337 | ||
| 338 | # Per-site lock control (now uses @combined_rows which includes meta-admin) | |
| 339 | %brand_letters = (%brand_letters, 'meta-admin' => 'MT'); | |
| 340 | my $portfolio_html = qq~ | |
| 341 | <form method="POST" action="/admin_maintenance_action.cgi" id="bulk-form" style="margin:0;margin-bottom:24px"> | |
| 342 | <input type="hidden" name="op" value="apply_state"> | |
| 343 | <div class="module"> | |
| 344 | <div class="module-head"><div class="left"><div class="module-title">Per-site lock control (all 8 sites)</div></div></div> | |
| 345 | <div class="module-body" style="padding:0"> | |
| 346 | <div style="display:flex;gap:10px;padding:14px 18px;border-bottom:1px solid var(--col-border);align-items:center;flex-wrap:wrap"> | |
| 347 | <span class="pill $_all_pill_cls" style="font-size:13px;padding:3px 12px">$_all_status_lbl</span> | |
| 348 | <button type="button" onclick="saveChanges()" class="btn btn-primary btn-sm">Save changes</button> | |
| 349 | <span class="dim" style="font-size:12px">Tick = locked, untick = open. Save to apply.</span> | |
| 350 | </div> | |
| 351 | <table class="tbl"> | |
| 352 | <thead><tr> | |
| 353 | <th style="padding:10px 4px 10px 12px;white-space:nowrap;overflow:visible"> | |
| 354 | <label style="display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-weight:600;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--col-text-dim);vertical-align:middle"> | |
| 355 | <input type="checkbox" id="select-all" onchange="toggleAll(this)" style="width:auto;margin:0;cursor:pointer;vertical-align:middle"> | |
| 356 | Select all | |
| 357 | </label> | |
| 358 | </th> | |
| 359 | <th></th><th>Site</th><th style="text-align:right">Status</th> | |
| 360 | </tr></thead> | |
| 361 | <tbody> | |
| 362 | ~; | |
| 363 | ||
| 364 | foreach my $r (@combined_rows) { | |
| 365 | my $name = _esc($r->{display_name}); | |
| 366 | my $color = _esc($r->{brand_color} || '#92400e'); | |
| 367 | my $ltr = _esc($brand_letters{$r->{slug}} || uc(substr($r->{display_name} || '?', 0, 1))); | |
| 368 | my $st_pill = $r->{locked} ? '<span class="pill pill-bad">LOCKED</span>' : '<span class="pill pill-ok">OPEN</span>'; | |
| 369 | my $slug = _esc($r->{slug}); | |
| 370 | my $meta_badge = $r->{is_meta} ? ' <span class="dim" style="font-size:11px;margin-left:6px">(this console)</span>' : ''; | |
| 371 | # Meta-admin gets its own plate variant (matches the sidebar brand mark). | |
| 372 | my $plate_class = $r->{is_meta} ? 'brand-icon meta-plate' : 'brand-icon'; | |
| 373 | # Pre-check the checkbox if this site is currently locked, so the | |
| 374 | # column doubles as a visual status indicator at a glance. | |
| 375 | # data-current encodes server-side state so the JS diff knows what | |
| 376 | # actually needs changing when the user hits Save. | |
| 377 | my $checked = $r->{locked} ? ' checked' : ''; | |
| 378 | my $current = $r->{locked} ? '1' : '0'; | |
| 379 | $portfolio_html .= qq~<tr> | |
| 380 | <td><input type="checkbox" name="slugs" value="$slug" class="row-cb"$checked data-current="$current" data-name="$name" style="width:auto;margin:0;cursor:pointer"></td> | |
| 381 | <td><span class="$plate_class" style="--site-glow:$color" data-letter="$ltr"></span></td> | |
| 382 | <td><strong>$name</strong>$meta_badge</td> | |
| 383 | <td style="text-align:right">$st_pill</td> | |
| 384 | </tr>~; | |
| 385 | } | |
| 386 | ||
| 387 | $portfolio_html .= q~ | |
| 388 | </tbody> | |
| 389 | </table> | |
| 390 | </div> | |
| 391 | </div> | |
| 392 | </form> | |
| 393 | <script> | |
| 394 | function saveChanges() { | |
| 395 | var rows = document.querySelectorAll('.row-cb'); | |
| 396 | var toLock = [], toUnlock = []; | |
| 397 | rows.forEach(function(cb){ | |
| 398 | var current = cb.getAttribute('data-current') === '1'; | |
| 399 | var wanted = cb.checked; | |
| 400 | if (wanted === current) return; | |
| 401 | if (wanted) toLock.push(cb.getAttribute('data-name') || cb.value); | |
| 402 | else toUnlock.push(cb.getAttribute('data-name') || cb.value); | |
| 403 | }); | |
| 404 | if (toLock.length === 0 && toUnlock.length === 0) { | |
| 405 | showNotice({ | |
| 406 | title: 'No changes to save', | |
| 407 | body: 'The current lock state already matches what is ticked. Adjust the checkboxes first, then Save.' | |
| 408 | }); | |
| 409 | return; | |
| 410 | } | |
| 411 | var lines = []; | |
| 412 | if (toLock.length) lines.push('LOCK (' + toLock.length + '): ' + toLock.join(', ')); | |
| 413 | if (toUnlock.length) lines.push('UNLOCK (' + toUnlock.length + '): ' + toUnlock.join(', ')); | |
| 414 | var summary = lines.join(' | '); | |
| 415 | var isDestructive = toLock.length > 0; | |
| 416 | showConfirm({ | |
| 417 | title: 'Save lock state changes?', | |
| 418 | body: summary + '. Other sites stay as they are.', | |
| 419 | confirmLabel: 'Save changes', | |
| 420 | confirmClass: isDestructive ? 'btn btn-danger' : 'btn btn-primary', | |
| 421 | onConfirm: function(){ document.getElementById('bulk-form').submit(); } | |
| 422 | }); | |
| 423 | } | |
| 424 | function toggleAll(cb) { | |
| 425 | document.querySelectorAll('.row-cb').forEach(function(r){ r.checked = cb.checked; }); | |
| 426 | } | |
| 427 | </script> | |
| 428 | ~; | |
| 429 | ||
| 430 | # Insert the bulk table AFTER the page-head/flash but BEFORE the message editor. | |
| 431 | # Look for the message editor's opening tag and inject before it. | |
| 432 | my $msg_editor_marker = '<div class="module">' . "\n" . ' <div class="module-head"><div class="left"><div class="module-title">Message users see while locked</div></div></div>'; | |
| 433 | $body =~ s/\Q$msg_editor_marker\E/$portfolio_html$msg_editor_marker/; | |
| 434 | ||
| 435 | MODS::MetaAdmin::Wrapper->new->render(title => 'Maintenance', page_key => 'maintenance', body => $body, userinfo => $u); | |
| 436 | ||
| 437 | #====================================================================== | |
| 438 | # admin_maintenance_action entry: POST handler | |
| 439 | #====================================================================== | |
| 440 | sub _handle_action { | |
| 441 | my ($q, $form) = @_; | |
| 442 | ||
| 443 | my $db = MODS::DBConnect->new; | |
| 444 | my $dbh = $db->db_connect; | |
| 445 | unless ($dbh) { | |
| 446 | print "Status: 302 Found\nLocation: /admin_maintenance.cgi?err=db_unavailable\n\n"; | |
| 447 | return; | |
| 448 | } | |
| 449 | ||
| 450 | my $op = $form->{op} || ''; | |
| 451 | ||
| 452 | if ($op eq 'lock' || $op eq 'unlock') { | |
| 453 | my $v = ($op eq 'lock') ? '1' : '0'; | |
| 454 | $db->db_readwrite($dbh, qq~ | |
| 455 | INSERT INTO platform_settings (setting_key, setting_value) | |
| 456 | VALUES ('maintenance_locked', '$v') | |
| 457 | ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) | |
| 458 | ~, __FILE__, __LINE__); | |
| 459 | $db->db_disconnect($dbh); | |
| 460 | my $flag = ($op eq 'lock') ? 'locked=1' : 'unlocked=1'; | |
| 461 | print "Status: 302 Found\nLocation: /admin_maintenance.cgi?$flag\n\n"; | |
| 462 | return; | |
| 463 | } | |
| 464 | elsif ($op eq 'save_message') { | |
| 465 | my $msg = $form->{message} // ''; | |
| 466 | $msg = substr($msg, 0, 20000) if length($msg) > 20000; | |
| 467 | my $msg_q = $msg; $msg_q =~ s/\\/\\\\/g; $msg_q =~ s/'/''/g; | |
| 468 | $db->db_readwrite($dbh, qq~ | |
| 469 | INSERT INTO platform_settings (setting_key, setting_value) | |
| 470 | VALUES ('maintenance_message', '$msg_q') | |
| 471 | ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) | |
| 472 | ~, __FILE__, __LINE__); | |
| 473 | $db->db_disconnect($dbh); | |
| 474 | print "Status: 302 Found\nLocation: /admin_maintenance.cgi?saved=1\n\n"; | |
| 475 | return; | |
| 476 | } | |
| 477 | elsif ($op eq 'apply_state') { | |
| 478 | my @checked = $q->multi_param('slugs'); | |
| 479 | if (!@checked && defined $form->{slugs}) { | |
| 480 | @checked = split /\0/, $form->{slugs}; | |
| 481 | } | |
| 482 | my %wanted; foreach my $s (@checked) { | |
| 483 | $s =~ s/[^a-z0-9_\-]//g; | |
| 484 | $wanted{$s} = 1 if length $s; | |
| 485 | } | |
| 486 | ||
| 487 | my $sc = MODS::MetaAdmin::SiteConn->new; | |
| 488 | my $sites = $sc->list_sites(active => 1); | |
| 489 | my @all = ({ slug => 'meta-admin', is_meta => 1 }); | |
| 490 | push @all, { slug => $_->{slug}, is_meta => 0 } for @$sites; | |
| 491 | ||
| 492 | my $locked_n = 0; my $unlocked_n = 0; my $fail_n = 0; | |
| 493 | foreach my $s (@all) { | |
| 494 | my $desired = $wanted{ $s->{slug} } ? 1 : 0; | |
| 495 | my $current; | |
| 496 | if ($s->{is_meta}) { | |
| 497 | my $cur = $db->db_readwrite($dbh, q{ | |
| 498 | SELECT setting_value FROM platform_settings WHERE setting_key='maintenance_locked' LIMIT 1 | |
| 499 | }, __FILE__, __LINE__); | |
| 500 | $current = ($cur && $cur->{setting_value}) ? int($cur->{setting_value}) : 0; | |
| 501 | } else { | |
| 502 | $current = $sc->get_maintenance_status($s->{slug}); | |
| 503 | } | |
| 504 | next if $current == $desired; | |
| 505 | ||
| 506 | my $ok = 0; | |
| 507 | if ($s->{is_meta}) { | |
| 508 | $db->db_readwrite($dbh, qq~ | |
| 509 | INSERT INTO platform_settings (setting_key, setting_value) | |
| 510 | VALUES ('maintenance_locked', '$desired') | |
| 511 | ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) | |
| 512 | ~, __FILE__, __LINE__); | |
| 513 | $ok = 1; | |
| 514 | } else { | |
| 515 | $ok = $sc->set_maintenance_locked($s->{slug}, $desired); | |
| 516 | } | |
| 517 | if ($ok) { $desired ? $locked_n++ : $unlocked_n++; } else { $fail_n++; } | |
| 518 | } | |
| 519 | $db->db_disconnect($dbh); | |
| 520 | ||
| 521 | my @flags; | |
| 522 | push @flags, "applied_locked=$locked_n" if $locked_n; | |
| 523 | push @flags, "applied_unlocked=$unlocked_n" if $unlocked_n; | |
| 524 | push @flags, "fail=$fail_n" if $fail_n; | |
| 525 | push @flags, "applied_none=1" unless @flags; | |
| 526 | print "Status: 302 Found\nLocation: /admin_maintenance.cgi?" . join('&', @flags) . "\n\n"; | |
| 527 | return; | |
| 528 | } | |
| 529 | elsif ($op eq 'lock_selected' || $op eq 'unlock_selected') { | |
| 530 | my $v = ($op eq 'lock_selected') ? 1 : 0; | |
| 531 | my @slugs = $q->multi_param('slugs'); | |
| 532 | if (!@slugs && defined $form->{slugs}) { | |
| 533 | @slugs = split /\0/, $form->{slugs}; | |
| 534 | } | |
| 535 | my $sc = MODS::MetaAdmin::SiteConn->new; | |
| 536 | my $ok = 0; my $fail = 0; | |
| 537 | foreach my $slug (@slugs) { | |
| 538 | $slug =~ s/[^a-z0-9_\-]//g; | |
| 539 | next unless length $slug; | |
| 540 | if ($slug eq 'meta-admin' || $slug eq 'metaadmin') { | |
| 541 | $db->db_readwrite($dbh, qq~ | |
| 542 | INSERT INTO platform_settings (setting_key, setting_value) | |
| 543 | VALUES ('maintenance_locked', '$v') | |
| 544 | ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) | |
| 545 | ~, __FILE__, __LINE__); | |
| 546 | $ok++; | |
| 547 | } | |
| 548 | elsif ($sc->set_maintenance_locked($slug, $v)) { $ok++; } | |
| 549 | else { $fail++; } | |
| 550 | } | |
| 551 | $db->db_disconnect($dbh); | |
| 552 | my $flag = ($op eq 'lock_selected') ? "all_locked=$ok" : "all_unlocked=$ok"; | |
| 553 | print "Status: 302 Found\nLocation: /admin_maintenance.cgi?$flag" . ($fail ? "&fail=$fail" : "") . "\n\n"; | |
| 554 | return; | |
| 555 | } | |
| 556 | elsif ($op eq 'lock_all_8' || $op eq 'unlock_all_8') { | |
| 557 | my $v = ($op eq 'lock_all_8') ? 1 : 0; | |
| 558 | $db->db_readwrite($dbh, qq~ | |
| 559 | INSERT INTO platform_settings (setting_key, setting_value) | |
| 560 | VALUES ('maintenance_locked', '$v') | |
| 561 | ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) | |
| 562 | ~, __FILE__, __LINE__); | |
| 563 | my $sc = MODS::MetaAdmin::SiteConn->new; | |
| 564 | my $sites = $sc->list_sites(active => 1); | |
| 565 | my $ok = 1; | |
| 566 | my $fail = 0; | |
| 567 | foreach my $s (@$sites) { | |
| 568 | if ($sc->set_maintenance_locked($s->{slug}, $v)) { $ok++; } else { $fail++; } | |
| 569 | } | |
| 570 | $db->db_disconnect($dbh); | |
| 571 | my $flag = ($op eq 'lock_all_8') ? "all_locked=$ok" : "all_unlocked=$ok"; | |
| 572 | print "Status: 302 Found\nLocation: /admin_maintenance.cgi?$flag" . ($fail ? "&fail=$fail" : "") . "\n\n"; | |
| 573 | return; | |
| 574 | } | |
| 575 | elsif ($op eq 'lock_all' || $op eq 'unlock_all') { | |
| 576 | my $v = ($op eq 'lock_all') ? 1 : 0; | |
| 577 | my $sc = MODS::MetaAdmin::SiteConn->new; | |
| 578 | my $sites = $sc->list_sites(active => 1); | |
| 579 | my $ok = 0; my $fail = 0; | |
| 580 | foreach my $s (@$sites) { | |
| 581 | if ($sc->set_maintenance_locked($s->{slug}, $v)) { $ok++; } else { $fail++; } | |
| 582 | } | |
| 583 | $db->db_disconnect($dbh); | |
| 584 | my $flag = ($op eq 'lock_all') ? "all_locked=$ok" : "all_unlocked=$ok"; | |
| 585 | print "Status: 302 Found\nLocation: /admin_maintenance.cgi?$flag" . ($fail ? "&fail=$fail" : "") . "\n\n"; | |
| 586 | return; | |
| 587 | } | |
| 588 | elsif ($op eq 'lock_site' || $op eq 'unlock_site') { | |
| 589 | my $slug = $form->{slug} || ''; | |
| 590 | $slug =~ s/[^a-z0-9_\-]//g; | |
| 591 | unless (length $slug) { | |
| 592 | $db->db_disconnect($dbh); | |
| 593 | print "Status: 302 Found\nLocation: /admin_maintenance.cgi?err=missing_slug\n\n"; | |
| 594 | return; | |
| 595 | } | |
| 596 | my $v = ($op eq 'lock_site') ? 1 : 0; | |
| 597 | my $sc = MODS::MetaAdmin::SiteConn->new; | |
| 598 | my $ok = $sc->set_maintenance_locked($slug, $v); | |
| 599 | $db->db_disconnect($dbh); | |
| 600 | my $flag = $ok ? ($v ? "site_locked=$slug" : "site_unlocked=$slug") : "err=" . ($sc->last_error || 'failed'); | |
| 601 | print "Status: 302 Found\nLocation: /admin_maintenance.cgi?$flag\n\n"; | |
| 602 | return; | |
| 603 | } | |
| 604 | ||
| 605 | $db->db_disconnect($dbh); | |
| 606 | print "Status: 302 Found\nLocation: /admin_maintenance.cgi?err=unknown_op\n\n"; | |
| 607 | } |