Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/MODS/MetaAdmin/SiteConn.pm
Diff
/var/www/vhosts/3dshawn.com/admin.3dshawn.com/MODS/MetaAdmin/SiteConn.pm
added on local at 2026-07-10 15:02:12
Added
+0
lines
Removed
-0
lines
Context
380
unchanged
Blobs
from 68dfbdf69eec
to 68dfbdf69eec
to 68dfbdf69eec
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | package MODS::MetaAdmin::SiteConn; |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # Meta-Admin -- READ-ONLY adapter for the other portfolio sites' DBs. |
| 4 | 4 | # |
| 5 | 5 | # RULES (enforced by convention -- do NOT relax): |
| 6 | 6 | # * Every method here takes a slug or id and reads from |
| 7 | 7 | # site_connections to get the connection details. |
| 8 | 8 | # * Decrypted password lives only in this process's memory for the |
| 9 | 9 | # life of the query. Never logged, never returned to the browser. |
| 10 | 10 | # * Only SELECT queries. The DBI handle is opened with PrintWarn=0 |
| 11 | 11 | # and we never call do() / prepare() with anything except SELECT. |
| 12 | 12 | # * On any failure we return undef + stash the error on $self; |
| 13 | 13 | # callers degrade gracefully (a missing site never breaks the page). |
| 14 | 14 | # |
| 15 | 15 | # Public API: |
| 16 | 16 | # list_sites() -> arrayref of every active site row |
| 17 | 17 | # row_for_slug($slug) -> single row hashref |
| 18 | 18 | # query_one($slug, $sql) -> hashref of first row, or undef |
| 19 | 19 | # query_many($slug, $sql) -> arrayref of hashrefs |
| 20 | 20 | # test_connection($slug) -> (1, undef) | (0, $err) |
| 21 | 21 | #====================================================================== |
| 22 | 22 | use strict; |
| 23 | 23 | use warnings; |
| 24 | 24 | use DBI; |
| 25 | 25 | |
| 26 | 26 | use MODS::DBConnect; |
| 27 | 27 | use MODS::MetaAdmin::Config; |
| 28 | 28 | |
| 29 | 29 | my $cfg = MODS::MetaAdmin::Config->new; |
| 30 | 30 | |
| 31 | 31 | sub new { |
| 32 | 32 | my ($class) = @_; |
| 33 | 33 | return bless({ err => '' }, $class); |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | 36 | sub last_error { return $_[0]->{err} || ''; } |
| 37 | 37 | |
| 38 | 38 | #--------------------------------------------------------------------- |
| 39 | 39 | # list_sites -- all active rows, sorted, brand_color + display_name + |
| 40 | 40 | # vhost_path included for UI use. NOT the password. |
| 41 | 41 | #--------------------------------------------------------------------- |
| 42 | 42 | sub list_sites { |
| 43 | 43 | my ($self, %a) = @_; |
| 44 | 44 | my $only_active = exists $a{active} ? $a{active} : 1; |
| 45 | 45 | my $where = $only_active ? 'WHERE is_active=1' : ''; |
| 46 | 46 | |
| 47 | 47 | my $db = MODS::DBConnect->new; |
| 48 | 48 | my $dbh = $db->db_connect; |
| 49 | 49 | return [] unless $dbh; |
| 50 | 50 | my @rows = $db->db_readwrite_multiple($dbh, qq~ |
| 51 | 51 | SELECT id, slug, display_name, display_url, brand_color, |
| 52 | 52 | db_host, db_port, db_name, db_user, vhost_path, |
| 53 | 53 | is_active, last_test_at, last_test_result, sort_order |
| 54 | 54 | FROM site_connections |
| 55 | 55 | $where |
| 56 | 56 | ORDER BY sort_order, id |
| 57 | 57 | ~, __FILE__, __LINE__); |
| 58 | 58 | $db->db_disconnect($dbh); |
| 59 | 59 | return \@rows; |
| 60 | 60 | } |
| 61 | 61 | |
| 62 | 62 | #--------------------------------------------------------------------- |
| 63 | 63 | # Internal: pull a single site_connections row + decrypt the password |
| 64 | 64 | # in the same query via AES_DECRYPT. Returns hashref with cleartext |
| 65 | 65 | # pass OR undef if the site is unknown / inactive / pass not set. |
| 66 | 66 | #--------------------------------------------------------------------- |
| 67 | 67 | sub _conn_row { |
| 68 | 68 | my ($self, $slug) = @_; |
| 69 | 69 | return undef unless $slug; |
| 70 | 70 | my $slug_q = $slug; $slug_q =~ s/'/''/g; |
| 71 | 71 | my $key = $cfg->settings('site_conn_enc_key') || ''; |
| 72 | 72 | my $key_q = $key; $key_q =~ s/'/''/g; |
| 73 | 73 | |
| 74 | 74 | my $db = MODS::DBConnect->new; |
| 75 | 75 | my $dbh = $db->db_connect; |
| 76 | 76 | return undef unless $dbh; |
| 77 | 77 | my $r = $db->db_readwrite($dbh, qq~ |
| 78 | 78 | SELECT id, slug, display_name, db_host, db_port, db_name, db_user, |
| 79 | 79 | CAST(AES_DECRYPT(db_pass_enc, '$key_q') AS CHAR) AS db_pass, |
| 80 | 80 | is_active |
| 81 | 81 | FROM site_connections |
| 82 | 82 | WHERE slug='$slug_q' AND is_active=1 |
| 83 | 83 | LIMIT 1 |
| 84 | 84 | ~, __FILE__, __LINE__); |
| 85 | 85 | $db->db_disconnect($dbh); |
| 86 | 86 | return $r; |
| 87 | 87 | } |
| 88 | 88 | |
| 89 | 89 | sub row_for_slug { |
| 90 | 90 | my ($self, $slug) = @_; |
| 91 | 91 | return $self->_conn_row($slug); |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | 94 | #--------------------------------------------------------------------- |
| 95 | 95 | # Internal: open a DBI handle against the remote site. AutoCommit on, |
| 96 | 96 | # RaiseError off (we want to fail soft). |
| 97 | 97 | #--------------------------------------------------------------------- |
| 98 | 98 | sub _open { |
| 99 | 99 | my ($self, $slug) = @_; |
| 100 | 100 | my $row = $self->_conn_row($slug); |
| 101 | 101 | unless ($row && $row->{db_name}) { |
| 102 | 102 | $self->{err} = "site '$slug' not configured / inactive"; |
| 103 | 103 | return undef; |
| 104 | 104 | } |
| 105 | 105 | my $host = $row->{db_host} || 'localhost'; |
| 106 | 106 | my $port = $row->{db_port} || 3306; |
| 107 | 107 | my $name = $row->{db_name}; |
| 108 | 108 | my $user = $row->{db_user}; |
| 109 | 109 | my $pass = $row->{db_pass} || ''; |
| 110 | 110 | |
| 111 | 111 | my $dsn = "DBI:mysql:database=$name;host=$host;port=$port"; |
| 112 | 112 | my $dbh = eval { DBI->connect($dsn, $user, $pass, { |
| 113 | 113 | PrintError => 0, RaiseError => 0, AutoCommit => 1, mysql_connect_timeout => 5 |
| 114 | 114 | }) }; |
| 115 | 115 | unless ($dbh) { |
| 116 | 116 | $self->{err} = "connect failed: " . ($DBI::errstr || $@ || 'unknown'); |
| 117 | 117 | return undef; |
| 118 | 118 | } |
| 119 | 119 | return $dbh; |
| 120 | 120 | } |
| 121 | 121 | |
| 122 | 122 | #--------------------------------------------------------------------- |
| 123 | 123 | # Read-only enforcement at the call layer: refuse anything that |
| 124 | 124 | # isn't a SELECT or SHOW. Keeps an accidental UPDATE from sneaking |
| 125 | 125 | # through. |
| 126 | 126 | #--------------------------------------------------------------------- |
| 127 | 127 | sub _is_safe_sql { |
| 128 | 128 | my ($sql) = @_; |
| 129 | 129 | return 0 unless defined $sql; |
| 130 | 130 | my $stripped = $sql; |
| 131 | 131 | $stripped =~ s/^\s+//; |
| 132 | 132 | return ($stripped =~ /^(SELECT|SHOW|DESC|EXPLAIN)\b/i) ? 1 : 0; |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | 135 | sub query_one { |
| 136 | 136 | my ($self, $slug, $sql) = @_; |
| 137 | 137 | $self->{err} = ''; |
| 138 | 138 | unless (_is_safe_sql($sql)) { |
| 139 | 139 | $self->{err} = 'read-only adapter refuses non-SELECT'; return undef; |
| 140 | 140 | } |
| 141 | 141 | my $dbh = $self->_open($slug); |
| 142 | 142 | return undef unless $dbh; |
| 143 | 143 | my $sth = $dbh->prepare($sql); |
| 144 | 144 | unless ($sth && $sth->execute) { |
| 145 | 145 | $self->{err} = $dbh->errstr || 'execute failed'; |
| 146 | 146 | $dbh->disconnect; |
| 147 | 147 | return undef; |
| 148 | 148 | } |
| 149 | 149 | my $r = $sth->fetchrow_hashref; |
| 150 | 150 | $sth->finish; # release cursor so ->disconnect is clean |
| 151 | 151 | $dbh->disconnect; |
| 152 | 152 | return $r; |
| 153 | 153 | } |
| 154 | 154 | |
| 155 | 155 | sub query_many { |
| 156 | 156 | my ($self, $slug, $sql) = @_; |
| 157 | 157 | $self->{err} = ''; |
| 158 | 158 | unless (_is_safe_sql($sql)) { |
| 159 | 159 | $self->{err} = 'read-only adapter refuses non-SELECT'; return []; |
| 160 | 160 | } |
| 161 | 161 | my $dbh = $self->_open($slug); |
| 162 | 162 | return [] unless $dbh; |
| 163 | 163 | my $sth = $dbh->prepare($sql); |
| 164 | 164 | unless ($sth && $sth->execute) { |
| 165 | 165 | $self->{err} = $dbh->errstr || 'execute failed'; |
| 166 | 166 | $dbh->disconnect; |
| 167 | 167 | return []; |
| 168 | 168 | } |
| 169 | 169 | my @out; |
| 170 | 170 | while (my $r = $sth->fetchrow_hashref) { push @out, $r; } |
| 171 | 171 | $sth->finish; # belt+suspenders (while-loop exhaustion should already finish it) |
| 172 | 172 | $dbh->disconnect; |
| 173 | 173 | return \@out; |
| 174 | 174 | } |
| 175 | 175 | |
| 176 | 176 | #--------------------------------------------------------------------- |
| 177 | 177 | # test_connection -- runs SELECT 1 and stamps last_test_at / |
| 178 | 178 | # last_test_result on the site_connections row. |
| 179 | 179 | #--------------------------------------------------------------------- |
| 180 | 180 | sub test_connection { |
| 181 | 181 | my ($self, $slug) = @_; |
| 182 | 182 | $self->{err} = ''; |
| 183 | 183 | my $r = $self->query_one($slug, 'SELECT 1 AS ok'); |
| 184 | 184 | my $ok = ($r && $r->{ok}) ? 1 : 0; |
| 185 | 185 | my $msg = $ok ? 'ok' : ($self->{err} || 'failed'); |
| 186 | 186 | |
| 187 | 187 | # Stamp result on local site_connections row. |
| 188 | 188 | my $db = MODS::DBConnect->new; |
| 189 | 189 | my $dbh = $db->db_connect; |
| 190 | 190 | if ($dbh) { |
| 191 | 191 | my $slug_q = $slug; $slug_q =~ s/'/''/g; |
| 192 | 192 | my $msg_q = substr($msg, 0, 250); $msg_q =~ s/'/''/g; |
| 193 | 193 | $db->db_readwrite($dbh, qq~ |
| 194 | 194 | UPDATE site_connections |
| 195 | 195 | SET last_test_at=NOW(), last_test_result='$msg_q' |
| 196 | 196 | WHERE slug='$slug_q' |
| 197 | 197 | ~, __FILE__, __LINE__); |
| 198 | 198 | $db->db_disconnect($dbh); |
| 199 | 199 | } |
| 200 | 200 | return ($ok, $ok ? undef : $msg); |
| 201 | 201 | } |
| 202 | 202 | |
| 203 | 203 | #--------------------------------------------------------------------- |
| 204 | 204 | # Helper for UPSERTing a row in local site_connections. Encrypts the |
| 205 | 205 | # password via AES_ENCRYPT(plain, $key). Blank pass keeps the |
| 206 | 206 | # existing one (so admin can edit a row without retyping). |
| 207 | 207 | #--------------------------------------------------------------------- |
| 208 | 208 | sub upsert { |
| 209 | 209 | my ($self, %a) = @_; |
| 210 | 210 | my $id = ($a{id} || 0) + 0; |
| 211 | 211 | my $slug = $a{slug} || ''; |
| 212 | 212 | my $display_name= $a{display_name}|| $slug; |
| 213 | 213 | my $display_url = $a{display_url} || ''; |
| 214 | 214 | my $brand_color = $a{brand_color} || '#00f0ff'; |
| 215 | 215 | my $db_host = $a{db_host} || 'localhost'; |
| 216 | 216 | my $db_port = ($a{db_port} || 3306) + 0; |
| 217 | 217 | my $db_name = $a{db_name} || ''; |
| 218 | 218 | my $db_user = $a{db_user} || ''; |
| 219 | 219 | my $db_pass = defined $a{db_pass} ? $a{db_pass} : ''; |
| 220 | 220 | my $vhost_path = $a{vhost_path} || ''; |
| 221 | 221 | my $is_active = $a{is_active} ? 1 : 0; |
| 222 | 222 | my $sort_order = ($a{sort_order} || 100) + 0; |
| 223 | 223 | return (0, 'slug required') unless length $slug; |
| 224 | 224 | return (0, 'db_name required') unless length $db_name; |
| 225 | 225 | return (0, 'db_user required') unless length $db_user; |
| 226 | 226 | |
| 227 | 227 | my $key = $cfg->settings('site_conn_enc_key') || ''; |
| 228 | 228 | my $q = sub { my $v = shift; $v //= ''; $v =~ s/'/''/g; return $v; }; |
| 229 | 229 | |
| 230 | 230 | my $key_q = $q->($key); |
| 231 | 231 | my $pass_sql; |
| 232 | 232 | if (length $db_pass) { |
| 233 | 233 | my $p_q = $q->($db_pass); |
| 234 | 234 | $pass_sql = "AES_ENCRYPT('$p_q','$key_q')"; |
| 235 | 235 | } else { |
| 236 | 236 | $pass_sql = undef; # keep existing |
| 237 | 237 | } |
| 238 | 238 | |
| 239 | 239 | my $db = MODS::DBConnect->new; |
| 240 | 240 | my $dbh = $db->db_connect; |
| 241 | 241 | return (0, 'local db unavailable') unless $dbh; |
| 242 | 242 | |
| 243 | 243 | my @sets = ( |
| 244 | 244 | "slug='" . $q->($slug) . "'", |
| 245 | 245 | "display_name='" . $q->($display_name) . "'", |
| 246 | 246 | "display_url='" . $q->($display_url) . "'", |
| 247 | 247 | "brand_color='" . $q->($brand_color) . "'", |
| 248 | 248 | "db_host='" . $q->($db_host) . "'", |
| 249 | 249 | "db_port='$db_port'", |
| 250 | 250 | "db_name='" . $q->($db_name) . "'", |
| 251 | 251 | "db_user='" . $q->($db_user) . "'", |
| 252 | 252 | "vhost_path='" . $q->($vhost_path) . "'", |
| 253 | 253 | "is_active='$is_active'", |
| 254 | 254 | "sort_order='$sort_order'", |
| 255 | 255 | ); |
| 256 | 256 | push @sets, "db_pass_enc=$pass_sql" if defined $pass_sql; |
| 257 | 257 | my $set_sql = join(",\n ", @sets); |
| 258 | 258 | |
| 259 | 259 | if ($id) { |
| 260 | 260 | $db->db_readwrite($dbh, qq~ |
| 261 | 261 | UPDATE site_connections SET $set_sql WHERE id='$id' |
| 262 | 262 | ~, __FILE__, __LINE__); |
| 263 | 263 | } else { |
| 264 | 264 | # On insert, password is REQUIRED -- otherwise we'd have a |
| 265 | 265 | # half-configured row. |
| 266 | 266 | unless (defined $pass_sql) { |
| 267 | 267 | $db->db_disconnect($dbh); |
| 268 | 268 | return (0, 'password required on new sites'); |
| 269 | 269 | } |
| 270 | 270 | $db->db_readwrite($dbh, qq~ |
| 271 | 271 | INSERT INTO site_connections SET $set_sql |
| 272 | 272 | ~, __FILE__, __LINE__); |
| 273 | 273 | my $r = $db->db_readwrite($dbh, q~SELECT LAST_INSERT_ID() AS n~, __FILE__, __LINE__); |
| 274 | 274 | $id = ($r && $r->{n}) ? $r->{n} : 0; |
| 275 | 275 | } |
| 276 | 276 | $db->db_disconnect($dbh); |
| 277 | 277 | return ($id, undef); |
| 278 | 278 | } |
| 279 | 279 | |
| 280 | 280 | #--------------------------------------------------------------------- |
| 281 | 281 | # set_session_override / clear_session_override -- tightly scoped |
| 282 | 282 | # write carve-out. Only ever touches users.session_minutes_override. |
| 283 | 283 | # Everything else is still read-only. |
| 284 | 284 | #--------------------------------------------------------------------- |
| 285 | 285 | sub set_session_override { |
| 286 | 286 | my ($self, $slug, $user_id, $minutes) = @_; |
| 287 | 287 | $self->{err} = ''; |
| 288 | 288 | $user_id = int($user_id || 0); return 0 unless $user_id > 0; |
| 289 | 289 | $minutes = int($minutes || 0); return 0 unless $minutes > 0 && $minutes <= 525600 * 5; # cap 5y |
| 290 | 290 | my $dbh = $self->_open($slug); |
| 291 | 291 | return 0 unless $dbh; |
| 292 | 292 | my $sth = $dbh->prepare('UPDATE users SET session_minutes_override=? WHERE id=?'); |
| 293 | 293 | my $ok = $sth && $sth->execute($minutes, $user_id); |
| 294 | 294 | $self->{err} = $dbh->errstr if !$ok; |
| 295 | 295 | $dbh->disconnect; |
| 296 | 296 | return $ok ? 1 : 0; |
| 297 | 297 | } |
| 298 | 298 | |
| 299 | 299 | sub clear_session_override { |
| 300 | 300 | my ($self, $slug, $user_id) = @_; |
| 301 | 301 | $self->{err} = ''; |
| 302 | 302 | $user_id = int($user_id || 0); return 0 unless $user_id > 0; |
| 303 | 303 | my $dbh = $self->_open($slug); |
| 304 | 304 | return 0 unless $dbh; |
| 305 | 305 | my $sth = $dbh->prepare('UPDATE users SET session_minutes_override=NULL WHERE id=?'); |
| 306 | 306 | my $ok = $sth && $sth->execute($user_id); |
| 307 | 307 | $self->{err} = $dbh->errstr if !$ok; |
| 308 | 308 | $dbh->disconnect; |
| 309 | 309 | return $ok ? 1 : 0; |
| 310 | 310 | } |
| 311 | 311 | |
| 312 | 312 | #--------------------------------------------------------------------- |
| 313 | 313 | # Maintenance lockdown carve-out (per-site write). |
| 314 | 314 | # Two tight, fixed UPDATEs against platform_settings only -- everything |
| 315 | 315 | # else stays read-only. |
| 316 | 316 | #--------------------------------------------------------------------- |
| 317 | 317 | sub set_maintenance_locked { |
| 318 | 318 | my ($self, $slug, $value) = @_; |
| 319 | 319 | $self->{err} = ''; |
| 320 | 320 | $value = $value ? 1 : 0; |
| 321 | 321 | my $dbh = $self->_open($slug); |
| 322 | 322 | return 0 unless $dbh; |
| 323 | 323 | # Upsert via prepared statement |
| 324 | 324 | my $sth = $dbh->prepare(q{ |
| 325 | 325 | INSERT INTO platform_settings (setting_key, setting_value) |
| 326 | 326 | VALUES ('maintenance_locked', ?) |
| 327 | 327 | ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) |
| 328 | 328 | }); |
| 329 | 329 | my $ok = $sth && $sth->execute($value); |
| 330 | 330 | $self->{err} = $dbh->errstr if !$ok; |
| 331 | 331 | $dbh->disconnect; |
| 332 | 332 | return $ok ? 1 : 0; |
| 333 | 333 | } |
| 334 | 334 | |
| 335 | 335 | sub get_maintenance_status { |
| 336 | 336 | my ($self, $slug) = @_; |
| 337 | 337 | $self->{err} = ''; |
| 338 | 338 | my $dbh = $self->_open($slug); |
| 339 | 339 | return undef unless $dbh; |
| 340 | 340 | my $sth = $dbh->prepare(q{ |
| 341 | 341 | SELECT setting_value FROM platform_settings WHERE setting_key='maintenance_locked' LIMIT 1 |
| 342 | 342 | }); |
| 343 | 343 | my $locked = 0; |
| 344 | 344 | if ($sth && $sth->execute) { |
| 345 | 345 | my $row = $sth->fetchrow_hashref; |
| 346 | 346 | $locked = ($row && int($row->{setting_value})) ? 1 : 0; |
| 347 | 347 | } |
| 348 | 348 | $dbh->disconnect; |
| 349 | 349 | return $locked; |
| 350 | 350 | } |
| 351 | 351 | |
| 352 | 352 | sub deactivate { |
| 353 | 353 | my ($self, $id) = @_; |
| 354 | 354 | $id =~ s/[^0-9]//g if defined $id; |
| 355 | 355 | return 0 unless $id; |
| 356 | 356 | my $db = MODS::DBConnect->new; |
| 357 | 357 | my $dbh = $db->db_connect; |
| 358 | 358 | return 0 unless $dbh; |
| 359 | 359 | $db->db_readwrite($dbh, qq~ |
| 360 | 360 | UPDATE site_connections SET is_active=0 WHERE id='$id' |
| 361 | 361 | ~, __FILE__, __LINE__); |
| 362 | 362 | $db->db_disconnect($dbh); |
| 363 | 363 | return 1; |
| 364 | 364 | } |
| 365 | 365 | |
| 366 | 366 | sub reactivate { |
| 367 | 367 | my ($self, $id) = @_; |
| 368 | 368 | $id =~ s/[^0-9]//g if defined $id; |
| 369 | 369 | return 0 unless $id; |
| 370 | 370 | my $db = MODS::DBConnect->new; |
| 371 | 371 | my $dbh = $db->db_connect; |
| 372 | 372 | return 0 unless $dbh; |
| 373 | 373 | $db->db_readwrite($dbh, qq~ |
| 374 | 374 | UPDATE site_connections SET is_active=1 WHERE id='$id' |
| 375 | 375 | ~, __FILE__, __LINE__); |
| 376 | 376 | $db->db_disconnect($dbh); |
| 377 | 377 | return 1; |
| 378 | 378 | } |
| 379 | 379 | |
| 380 | 380 | 1; |