Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/MODS/DBConnect.pm
Diff
/var/www/vhosts/3dshawn.com/admin.3dshawn.com/MODS/DBConnect.pm
added on local at 2026-07-10 15:12:08
Added
+0
lines
Removed
-0
lines
Context
278
unchanged
Blobs
from 0215813bf347
to 0215813bf347
to 0215813bf347
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | package MODS::DBConnect; |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # Meta-Admin -- MySQL connection wrapper around DBI. |
| 4 | 4 | # |
| 5 | 5 | # One canonical body across the entire portfolio (7 sister sites + |
| 6 | 6 | # Meta-Admin). Only the credentials block below and the on-disk error |
| 7 | 7 | # log path in error() differ per site. |
| 8 | 8 | # |
| 9 | 9 | # Public API (all methods take $dbh as first non-self arg): |
| 10 | 10 | # $dbh = $db->db_connect -- open a handle to the default DB |
| 11 | 11 | # $db->db_disconnect($dbh) -- close a handle |
| 12 | 12 | # $db->set_session_timezone($dbh, $tz) -- SET time_zone = ... (IANA name or +HH:MM) |
| 13 | 13 | # $hashref = $db->db_readwrite($dbh, $sql, __FILE__, __LINE__) |
| 14 | 14 | # -- run any single-row statement; |
| 15 | 15 | # on SELECT/SHOW/EXPLAIN returns the |
| 16 | 16 | # first row hashref; hashref also |
| 17 | 17 | # carries {mysql_insertid, rows_affected} |
| 18 | 18 | # @rows = $db->db_readwrite_multiple($dbh, $sql, __FILE__, __LINE__) |
| 19 | 19 | # -- returns list (or arrayref in scalar) |
| 20 | 20 | # $sth = $db->db_query($dbh, $sql, @binds) |
| 21 | 21 | # -- placeholder-based helper for |
| 22 | 22 | # user-supplied inputs |
| 23 | 23 | # $bool = $db->ping($dbh) -- SELECT 1 liveness check |
| 24 | 24 | # $csv = $db->quote_list($dbh, \@vals)-- safe "IN (a,b,c)" builder |
| 25 | 25 | # $code = MODS::DBConnect::generate($n)-- random alphanumeric of length $n |
| 26 | 26 | # |
| 27 | 27 | # On any statement failure db_readwrite/_multiple call error() which |
| 28 | 28 | # writes a structured entry to the on-disk log AND prints a marker to |
| 29 | 29 | # STDERR AND exits. That's the "strict canary" behavior -- bad SQL kills |
| 30 | 30 | # the request loud instead of swallowing quietly. Debuggable > silent. |
| 31 | 31 | #====================================================================== |
| 32 | 32 | |
| 33 | 33 | # --- Credentials (only block that differs per site) ------------------ |
| 34 | 34 | my $default_db_host = 'localhost'; |
| 35 | 35 | my $default_db_name = 'admin3dshawn'; |
| 36 | 36 | my $default_db_login = 'metaadmin'; |
| 37 | 37 | my $default_db_password = 'Sh@wn135'; |
| 38 | 38 | my $default_db_port = '3306'; |
| 39 | 39 | # --------------------------------------------------------------------- |
| 40 | 40 | |
| 41 | 41 | use strict; |
| 42 | 42 | use warnings; |
| 43 | 43 | use DBI; |
| 44 | 44 | |
| 45 | 45 | # On-disk error log for structured DBI failures. Per-site path. |
| 46 | 46 | my $DB_ERROR_LOG = '/var/www/vhosts/3dshawn.com/admin.3dshawn.com/MODS/DB_ERRORS.log'; |
| 47 | 47 | |
| 48 | 48 | sub new { |
| 49 | 49 | my ($class, %args) = @_; |
| 50 | 50 | return bless({}, $class); |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | 53 | #--------------------------------------------------------------------- |
| 54 | 54 | # db_connect -- open a DBI handle to the default DB. Applies the |
| 55 | 55 | # viewer's IANA time_zone if MODS::Login has cached one, so every |
| 56 | 56 | # TIMESTAMP-reading query returns values in the viewer's local time. |
| 57 | 57 | # Explicit connect flags silence DBI's default warn-to-STDERR behavior |
| 58 | 58 | # (we handle errors ourselves via error()). |
| 59 | 59 | #--------------------------------------------------------------------- |
| 60 | 60 | sub db_connect { |
| 61 | 61 | my $self = shift; |
| 62 | 62 | my $dsn = "DBI:mysql:$default_db_name:$default_db_host:$default_db_port"; |
| 63 | 63 | my $dbh = DBI->connect($dsn, $default_db_login, $default_db_password, { |
| 64 | 64 | PrintError => 0, # silence bare DBI warnings to STDERR |
| 65 | 65 | RaiseError => 0, # we handle errors via error() |
| 66 | 66 | AutoCommit => 1, |
| 67 | 67 | mysql_connect_timeout => 5, # fail fast on wedged MySQL |
| 68 | 68 | mysql_enable_utf8mb4 => 1, # emoji + international UTF-8 support |
| 69 | 69 | }); |
| 70 | 70 | return undef unless $dbh; |
| 71 | 71 | |
| 72 | 72 | # Session-level UTF-8 (charset + collation). Belt-and-suspenders |
| 73 | 73 | # with mysql_enable_utf8mb4 above -- some hosts default to latin1. |
| 74 | 74 | eval { $dbh->do("SET NAMES utf8mb4") }; |
| 75 | 75 | |
| 76 | 76 | # Apply the viewer's IANA timezone if MODS::Login::login_verify has |
| 77 | 77 | # cached one. When no user is signed in yet, MariaDB keeps SYSTEM tz. |
| 78 | 78 | if (defined $MODS::Login::CURRENT_USER_TZ && length $MODS::Login::CURRENT_USER_TZ) { |
| 79 | 79 | my $tz = $MODS::Login::CURRENT_USER_TZ; |
| 80 | 80 | $tz =~ s/[^A-Za-z0-9_+\-:\/]//g; |
| 81 | 81 | if (length $tz) { |
| 82 | 82 | my $ok = eval { $dbh->do("SET time_zone = " . $dbh->quote($tz)) }; |
| 83 | 83 | if (!$ok || $@) { |
| 84 | 84 | # MariaDB rejects named zones if mysql.time_zone tables |
| 85 | 85 | # are not loaded. Fall back to UTC silently rather than 500. |
| 86 | 86 | eval { $dbh->do("SET time_zone = '+00:00'") }; |
| 87 | 87 | } |
| 88 | 88 | } |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | 91 | return $dbh; |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | 94 | #--------------------------------------------------------------------- |
| 95 | 95 | # set_session_timezone($dbh, $tz) -- change tz mid-request. Rare; |
| 96 | 96 | # db_connect picks it up automatically from MODS::Login. |
| 97 | 97 | #--------------------------------------------------------------------- |
| 98 | 98 | sub set_session_timezone { |
| 99 | 99 | my ($self, $dbh, $tz) = @_; |
| 100 | 100 | return unless $dbh; |
| 101 | 101 | $tz = 'UTC' unless defined $tz && length $tz; |
| 102 | 102 | $tz =~ s/[^A-Za-z0-9_+\-:\/]//g; |
| 103 | 103 | $tz = 'UTC' unless length $tz; |
| 104 | 104 | my $ok = eval { $dbh->do("SET time_zone = " . $dbh->quote($tz)) }; |
| 105 | 105 | if (!$ok || $@) { |
| 106 | 106 | eval { $dbh->do("SET time_zone = '+00:00'") }; |
| 107 | 107 | } |
| 108 | 108 | return 1; |
| 109 | 109 | } |
| 110 | 110 | |
| 111 | 111 | #--------------------------------------------------------------------- |
| 112 | 112 | # db_readwrite -- single-row statement runner. For SELECT/SHOW/EXPLAIN |
| 113 | 113 | # returns the first row as a hashref; for INSERT/UPDATE/DELETE returns |
| 114 | 114 | # an empty hashref decorated with {mysql_insertid, rows_affected}. |
| 115 | 115 | # Any DBI failure calls error() which logs + exits. |
| 116 | 116 | #--------------------------------------------------------------------- |
| 117 | 117 | sub db_readwrite { |
| 118 | 118 | my ($self, $dbh, $sql, $script_name, $error_line, $prevent_looping) = @_; |
| 119 | 119 | my $data; |
| 120 | 120 | |
| 121 | 121 | my $sth = $dbh->prepare($sql); |
| 122 | 122 | unless ($sth) { |
| 123 | 123 | return error($self, $dbh, $script_name, $error_line, $sql, $prevent_looping); |
| 124 | 124 | } |
| 125 | 125 | my $rows_affected = $sth->execute; |
| 126 | 126 | unless ($rows_affected) { |
| 127 | 127 | # One retry for transient deadlocks / connection blips. |
| 128 | 128 | $rows_affected = $sth->execute; |
| 129 | 129 | } |
| 130 | 130 | unless ($rows_affected) { |
| 131 | 131 | return error($self, $dbh, $script_name, $error_line, $sql, $prevent_looping); |
| 132 | 132 | } |
| 133 | 133 | |
| 134 | 134 | if ($sql =~ m/^\s*\t*(explain|select|show)/i) { |
| 135 | 135 | $data = $sth->fetchrow_hashref; |
| 136 | 136 | } |
| 137 | 137 | $data->{mysql_insertid} = $sth->{mysql_insertid} if $sth->{mysql_insertid}; |
| 138 | 138 | $data->{rows_affected} = $rows_affected if $rows_affected; |
| 139 | 139 | |
| 140 | 140 | $sth->finish; # release cursor so db_disconnect stays clean |
| 141 | 141 | return $data; |
| 142 | 142 | } |
| 143 | 143 | |
| 144 | 144 | #--------------------------------------------------------------------- |
| 145 | 145 | # db_readwrite_multiple -- multi-row statement runner. Returns list in |
| 146 | 146 | # list context, arrayref in scalar. On non-SELECT/SHOW/EXPLAIN returns |
| 147 | 147 | # empty (the run still executed for its side effects). |
| 148 | 148 | #--------------------------------------------------------------------- |
| 149 | 149 | sub db_readwrite_multiple { |
| 150 | 150 | my ($self, $dbh, $sql, $script_name, $error_line, $nospaces) = @_; |
| 151 | 151 | my @data; |
| 152 | 152 | |
| 153 | 153 | my $sth = $dbh->prepare($sql); |
| 154 | 154 | unless ($sth) { |
| 155 | 155 | return error($self, $dbh, $script_name, $error_line, $sql); |
| 156 | 156 | } |
| 157 | 157 | unless ($sth->execute) { |
| 158 | 158 | return error($self, $dbh, $script_name, $error_line, $sql); |
| 159 | 159 | } |
| 160 | 160 | if ($sql =~ m/^\s*\t*(explain|select|show)/i) { |
| 161 | 161 | while (my $row = $sth->fetchrow_hashref) { push @data, $row; } |
| 162 | 162 | } |
| 163 | 163 | $sth->finish; # explicit finish belt-and-suspenders |
| 164 | 164 | return wantarray ? @data : \@data; |
| 165 | 165 | } |
| 166 | 166 | |
| 167 | 167 | #--------------------------------------------------------------------- |
| 168 | 168 | # db_query($dbh, $sql, @binds) -- placeholder-based statement helper. |
| 169 | 169 | # Use this whenever any part of the SQL comes from user input; the |
| 170 | 170 | # ?-placeholder path is safer than $dbh->quote interpolation because |
| 171 | 171 | # it defers escaping to DBI's own driver-level routines. |
| 172 | 172 | # |
| 173 | 173 | # Returns the statement handle so callers can iterate however they |
| 174 | 174 | # want (fetchrow_hashref / fetchall_arrayref / rows). The caller MUST |
| 175 | 175 | # call $sth->finish (or exhaust the cursor) before db_disconnect. |
| 176 | 176 | # |
| 177 | 177 | # Example: |
| 178 | 178 | # my $sth = $db->db_query($dbh, |
| 179 | 179 | # "SELECT id, email FROM users WHERE created_at >= ? AND role = ?", |
| 180 | 180 | # $start_date, $role); |
| 181 | 181 | # while (my $r = $sth->fetchrow_hashref) { ... } |
| 182 | 182 | # $sth->finish; |
| 183 | 183 | #--------------------------------------------------------------------- |
| 184 | 184 | sub db_query { |
| 185 | 185 | my ($self, $dbh, $sql, @binds) = @_; |
| 186 | 186 | my $sth = $dbh->prepare($sql) or return undef; |
| 187 | 187 | return undef unless $sth->execute(@binds); |
| 188 | 188 | return $sth; |
| 189 | 189 | } |
| 190 | 190 | |
| 191 | 191 | #--------------------------------------------------------------------- |
| 192 | 192 | # ping($dbh) -- liveness check. Cheap SELECT 1. Returns 1/0. |
| 193 | 193 | # Useful for admin_maintenance.cgi and long-running scripts. |
| 194 | 194 | #--------------------------------------------------------------------- |
| 195 | 195 | sub ping { |
| 196 | 196 | my ($self, $dbh) = @_; |
| 197 | 197 | return 0 unless $dbh; |
| 198 | 198 | my $r = eval { $dbh->selectrow_array("SELECT 1") }; |
| 199 | 199 | return ($r && !$@) ? 1 : 0; |
| 200 | 200 | } |
| 201 | 201 | |
| 202 | 202 | #--------------------------------------------------------------------- |
| 203 | 203 | # quote_list($dbh, \@values) -- build a safely quoted comma-list for |
| 204 | 204 | # an IN (...) clause. Empty list returns "''" (never matches). |
| 205 | 205 | # |
| 206 | 206 | # Example: |
| 207 | 207 | # my $csv = $db->quote_list($dbh, \@user_ids); |
| 208 | 208 | # my $sql = "SELECT * FROM orders WHERE user_id IN ($csv)"; |
| 209 | 209 | #--------------------------------------------------------------------- |
| 210 | 210 | sub quote_list { |
| 211 | 211 | my ($self, $dbh, $vals) = @_; |
| 212 | 212 | return "''" unless $vals && ref($vals) eq 'ARRAY' && @$vals; |
| 213 | 213 | return join(',', map { $dbh->quote($_) } @$vals); |
| 214 | 214 | } |
| 215 | 215 | |
| 216 | 216 | sub db_disconnect { |
| 217 | 217 | my ($self, $dbh) = @_; |
| 218 | 218 | return unless $dbh; |
| 219 | 219 | eval { $dbh->disconnect }; |
| 220 | 220 | return 1; |
| 221 | 221 | } |
| 222 | 222 | |
| 223 | 223 | #--------------------------------------------------------------------- |
| 224 | 224 | # generate($len) -- random alphanumeric code of $len characters. |
| 225 | 225 | # Kept here for back-compat with legacy callers that pulled it via |
| 226 | 226 | # MODS::DBConnect::generate. Not a DB function per se. |
| 227 | 227 | #--------------------------------------------------------------------- |
| 228 | 228 | sub generate { |
| 229 | 229 | my $code_length = shift; |
| 230 | 230 | my $code = ''; |
| 231 | 231 | my @chars = ('a'..'z','A'..'Z','0'..'9'); |
| 232 | 232 | for (1..$code_length) { $code .= $chars[rand @chars]; } |
| 233 | 233 | return $code; |
| 234 | 234 | } |
| 235 | 235 | |
| 236 | 236 | #--------------------------------------------------------------------- |
| 237 | 237 | # error -- called by db_readwrite / _multiple on DBI failure. |
| 238 | 238 | # Writes a structured entry to the on-disk log, mirrors a one-liner |
| 239 | 239 | # to STDERR (Apache error_log), prints any extra @_ args to STDOUT, |
| 240 | 240 | # then exit. That "print + exit" is intentional -- surfaces bugs |
| 241 | 241 | # loud instead of leaving callers to work with undef in silence. |
| 242 | 242 | #--------------------------------------------------------------------- |
| 243 | 243 | sub error { |
| 244 | 244 | my ($self, $dbh, $script_name, $error_line, $application_sql, $prevent_looping) = @_; |
| 245 | 245 | |
| 246 | 246 | my $unique_id = generate(15) . '_' . time(); |
| 247 | 247 | my $ltime = localtime(); |
| 248 | 248 | |
| 249 | 249 | my $dbi_err = ''; |
| 250 | 250 | if (ref($dbh) && $dbh->can('errstr')) { |
| 251 | 251 | $dbi_err = $dbh->errstr // ''; |
| 252 | 252 | } |
| 253 | 253 | $dbi_err ||= $DBI::errstr // ''; |
| 254 | 254 | $dbi_err = '(no DBI error reported)' unless length $dbi_err; |
| 255 | 255 | |
| 256 | 256 | if (open(my $log_fh, '>>', $DB_ERROR_LOG)) { |
| 257 | 257 | print $log_fh qq~----- UNIQUE ID:($unique_id) -----\n~; |
| 258 | 258 | print $log_fh qq~Date: $ltime\n~; |
| 259 | 259 | print $log_fh qq~File: $script_name\n~; |
| 260 | 260 | print $log_fh qq~Line: $error_line\n~; |
| 261 | 261 | print $log_fh qq~DBI: $dbi_err\n~; |
| 262 | 262 | print $log_fh qq~SQL:\n$application_sql\n\n~; |
| 263 | 263 | close $log_fh; |
| 264 | 264 | } |
| 265 | 265 | |
| 266 | 266 | # Mirror a one-line summary to the Apache error log via STDERR. |
| 267 | 267 | my $sql_copy = $application_sql; |
| 268 | 268 | $sql_copy =~ s/\s+/ /g; |
| 269 | 269 | $sql_copy =~ s/\n$//g; |
| 270 | 270 | print STDERR qq~-----DB_MODULE_ERROR----- UNIQUE ID:($unique_id) LINE:($error_line) FILE:($script_name) SQL:($sql_copy)\n~; |
| 271 | 271 | |
| 272 | 272 | # Legacy: any extra args passed to error() are printed. Historical |
| 273 | 273 | # callers occasionally pass a user-facing error message here. |
| 274 | 274 | print qq~@_~; |
| 275 | 275 | exit; |
| 276 | 276 | } |
| 277 | 277 | |
| 278 | 278 | 1; |