mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-02 23:45:08 -04:00
database.php called dbConnect() at file scope, so including it opened a socket,
and on failure rendered views/no_database_connection.php and exit()ed from
inside a library include. Every model in web/includes requires this file, so
merely loading a class did both.
Connect on first use instead. $dbConn becomes tri-state - false for "not
attempted", null for "attempt failed", a PDO for connected - and two accessors
sit on top of it:
zmDbConn() opens if needed; on failure renders the error view and
stops, which is what the include used to do, just at the
point a query is actually attempted.
zmDbConnOrNull() opens if needed but returns null instead of ending the
request, for callers with a fallback.
dbQuery() is the funnel every fetch helper goes through, so routing it plus
dbEscape(), dbError() and dbInsertId() through the accessors covers the library.
The five callers that reached for the raw global are updated: config.php.in,
Event.php and ajax/console.php need a connection and take zmDbConn(); logger.php
takes zmDbConnOrNull() and falls through to its error_log target, so a logging
call can no longer end the request or open a connection by itself.
ZMSessionHandler captured $dbConn in its constructor. It is constructed while
session.php is being included, before anything has needed the database, so with
a lazy connection that captured false. It now resolves per call and its methods
return "no session" rather than dereferencing a bool.
Two smaller fixes fall out. The error view was included by a relative path that
only resolved when the cwd was web/, so it never worked for requests served out
of web/api/; it is now anchored with __DIR__. And dbDisconnect() set $dbConn to
null, which in the new tri-state means "connecting failed" and would send the
next query to the error page; it sets false so a later query can reconnect.
Nothing calls dbDisconnect() today.
This does NOT make database.php includable without a database. It requires
logger.php, which requires config.php, which reads ZoneMinder's configuration
out of the Config table at include time. Until that cycle is broken the
connection still happens during bootstrap, just from config.php rather than from
here.
Tests: tests/php/test_database_lazy_connect.php, 7 assertions, all pass. It
tokenises database.php and asserts nothing runs at include time, that dbQuery()
goes through the accessor, and that only the connection plumbing touches the
global. Verified it reports the pre-refactor file's `if ( !dbConnect() )` - an
earlier version of the check skipped tokens inside parentheses and so passed on
exactly the code it exists to reject.
Not covered by tests: behaviour when the database is genuinely unreachable, and
the session handler against a live database. Needs manual testing on an
installed tree, including stopping mysql to confirm the error view still renders
for both a web request and an API request.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
239 lines
9.2 KiB
PHP
239 lines
9.2 KiB
PHP
<?php
|
|
require_once(__DIR__.'/Network.php');
|
|
|
|
// Record the client address for this request, keeping the previous one when it
|
|
// changes so an auth hash issued against it can still be validated. How long
|
|
// that stays acceptable is auth's decision; see authHashCandidateAddrs() in
|
|
// auth.php.
|
|
function zm_session_set_remote_addr() {
|
|
$addr = getRemoteAddr();
|
|
if (isset($_SESSION['remoteAddr']) and ($_SESSION['remoteAddr'] !== '') and ($_SESSION['remoteAddr'] !== $addr)) {
|
|
// Only ever keep one previous address. Drop the cached hash belonging to
|
|
// the one being displaced so a client whose address changes repeatedly
|
|
// cannot accumulate AuthHash slots in the session.
|
|
if (isset($_SESSION['prevRemoteAddr']) and ($_SESSION['prevRemoteAddr'] !== $addr)) {
|
|
unset($_SESSION['AuthHash'.$_SESSION['prevRemoteAddr']]);
|
|
}
|
|
$_SESSION['prevRemoteAddr'] = $_SESSION['remoteAddr'];
|
|
$_SESSION['prevRemoteAddrAt'] = time();
|
|
}
|
|
$_SESSION['remoteAddr'] = $addr;
|
|
}
|
|
|
|
// Wrapper around setcookie that auto-sets samesite, and deals with older versions of php
|
|
function zm_setcookie($cookie, $value, $options=array()) {
|
|
if (!isset($options['path'])) {
|
|
$options['path'] = '/';
|
|
}
|
|
if (!isset($options['expires'])) {
|
|
$options['expires'] = time()+3600*24*30*12*10; // 10 years?!
|
|
}
|
|
if (!isset($options['samesite'])) {
|
|
$options['samesite'] = 'Strict';
|
|
}
|
|
|
|
if (version_compare(phpversion(), '7.3.0', '>=')) {
|
|
setcookie($cookie, $value, $options);
|
|
} else {
|
|
setcookie($cookie, $value, $options['expires'], '/; samesite=strict');
|
|
}
|
|
//ZM\Debug("Setting cookie for $cookie to $value");
|
|
}
|
|
|
|
// ZM session start function support timestamp management
|
|
function zm_session_start() {
|
|
if (ini_get('session.name') != 'ZMSESSID') {
|
|
// Make sure use_strict_mode is enabled.
|
|
// use_strict_mode is mandatory for security reasons.
|
|
ini_set('session.use_strict_mode', 1);
|
|
|
|
$currentCookieParams = session_get_cookie_params();
|
|
if (defined('ZM_OPT_USE_REMEMBER_ME') && ZM_OPT_USE_REMEMBER_ME != 'None' && ZM_OPT_USE_REMEMBER_ME != '' && ZM_OPT_USE_REMEMBER_ME != '0' && empty($_COOKIE['ZM_REMEMBER_ME'])) {
|
|
$currentCookieParams['lifetime'] = 0;
|
|
} else {
|
|
$currentCookieParams['lifetime'] = ZM_COOKIE_LIFETIME;
|
|
}
|
|
$currentCookieParams['httponly'] = true;
|
|
if ( version_compare(phpversion(), '7.3.0', '<') ) {
|
|
session_set_cookie_params(
|
|
$currentCookieParams['lifetime'],
|
|
$currentCookieParams['path'].'; samesite=strict',
|
|
$currentCookieParams['domain'],
|
|
$currentCookieParams['secure'],
|
|
$currentCookieParams['httponly']
|
|
);
|
|
} else {
|
|
# samesite was introduced in 7.3.0
|
|
$currentCookieParams['samesite'] = 'Strict';
|
|
session_set_cookie_params($currentCookieParams);
|
|
}
|
|
|
|
ini_set('session.name', 'ZMSESSID');
|
|
//ZM\Debug('Setting cookie parameters to '.print_r($currentCookieParams, true));
|
|
}
|
|
session_start();
|
|
// To help prevent session hijacking, remember the client address. See
|
|
// Network.php / getRemoteAddr() for the X-Forwarded-For handling.
|
|
zm_session_set_remote_addr();
|
|
$now = time();
|
|
// Do not allow to use expired session ID
|
|
if ( !empty($_SESSION['last_time']) && ($_SESSION['last_time'] < ($now - 180)) ) {
|
|
//ZM\Info('Destroying session due to timeout.');
|
|
session_destroy();
|
|
session_start();
|
|
} else if ( !empty($_SESSION['generated_at']) ) {
|
|
if ( $_SESSION['generated_at']<($now-(ZM_COOKIE_LIFETIME/2)) ) {
|
|
ZM\Debug('Regenerating session because generated_at ' . $_SESSION['generated_at'] . ' < ' . $now . '-'.ZM_COOKIE_LIFETIME.'/2 = '.($now-ZM_COOKIE_LIFETIME/2));
|
|
zm_session_regenerate_id();
|
|
}
|
|
}
|
|
} // function zm_session_start()
|
|
|
|
// session regenerate id function
|
|
// Assumes that zm_session_start has been called previously
|
|
function zm_session_regenerate_id() {
|
|
if (!is_session_started()) session_start();
|
|
|
|
// Set deleted timestamp. Session data must not be deleted immediately for reasons.
|
|
$_SESSION['last_time'] = time();
|
|
session_write_close();
|
|
|
|
session_start();
|
|
|
|
//ZM\Debug("Regenerating session. Old id was " . session_id());
|
|
session_regenerate_id();
|
|
//ZM\Debug("Regenerating session. New id was " . session_id());
|
|
unset($_SESSION['last_time']);
|
|
$_SESSION['generated_at'] = time();
|
|
zm_session_set_remote_addr();
|
|
} // function zm_session_regenerate_id()
|
|
|
|
// Regenerate the session id at a privilege boundary (login).
|
|
// When called with an already-started session (the normal login flow), this
|
|
// should emit a single Set-Cookie via session_regenerate_id(true) while
|
|
// discarding any pre-auth session data and deleting the old session server-side.
|
|
// Assumes zm_session_start() has been called previously.
|
|
function zm_session_regenerate_id_login() {
|
|
if (!is_session_started()) zm_session_start();
|
|
// Discard any pre-auth session contents so nothing carries across the
|
|
// authentication boundary.
|
|
$_SESSION = array();
|
|
// New id + delete the old session file server-side. Emits a single Set-Cookie.
|
|
session_regenerate_id(true);
|
|
$_SESSION['generated_at'] = time();
|
|
// Bind a fresh login to the address it came from only. Any address carried
|
|
// over from before the privilege boundary must not stay acceptable.
|
|
unset($_SESSION['prevRemoteAddr']);
|
|
unset($_SESSION['prevRemoteAddrAt']);
|
|
$_SESSION['remoteAddr'] = getRemoteAddr();
|
|
} // function zm_session_regenerate_id_login()
|
|
|
|
function is_session_started() {
|
|
if ( php_sapi_name() !== 'cli' ) {
|
|
if ( version_compare(phpversion(), '5.4.0', '>=') ) {
|
|
return session_status() === PHP_SESSION_ACTIVE ? TRUE : FALSE;
|
|
} else {
|
|
return session_id() === '' ? FALSE : TRUE;
|
|
}
|
|
} else {
|
|
Warning("php_sapi_name === 'cli'");
|
|
}
|
|
return FALSE;
|
|
} // function is_session_started()
|
|
|
|
function zm_session_clear() {
|
|
if (!is_session_started()) session_start();
|
|
$_SESSION = array();
|
|
if ( ini_get('session.use_cookies') ) {
|
|
$p = session_get_cookie_params();
|
|
# Update the cookie to expire in the past.
|
|
$p['expires'] = time() - 31536000;
|
|
unset($p['lifetime']); // Not valid for a cookie
|
|
zm_setcookie(session_name(), '', $p);
|
|
}
|
|
session_unset();
|
|
session_destroy();
|
|
session_write_close();
|
|
} // function zm_session_clear()
|
|
|
|
// The connection is fetched per call rather than held as a member. This handler
|
|
// is constructed while session.php is being included, before anything has
|
|
// needed the database, so there is nothing to capture at that point - which is
|
|
// what the old `$this->db = $dbConn` constructor got wrong. zmDbConnOrNull()
|
|
// returns null when the database is unreachable and the methods below degrade
|
|
// to "no session" rather than ending the request.
|
|
class ZMSessionHandler implements SessionHandlerInterface {
|
|
public function open($path, $name): bool {
|
|
return zmDbConnOrNull() ? true : false;
|
|
}
|
|
public function close() : bool {
|
|
// The example code closed the db connection.. I don't think we care to.
|
|
return true;
|
|
}
|
|
#[\ReturnTypeWillChange]
|
|
public function read($id){
|
|
if (!($db = zmDbConnOrNull())) return '';
|
|
$sth = $db->prepare('SELECT data FROM Sessions WHERE id = :id');
|
|
if (!$sth->bindParam(':id', $id, PDO::PARAM_STR, 32)) {
|
|
ZM\Error("Failed to bind param");
|
|
if (!$sth->bindParam(':id', $id, PDO::PARAM_STR)) {
|
|
ZM\Error("Failed to bind param");
|
|
}
|
|
}
|
|
|
|
if ( $sth->execute() ) {
|
|
if (( $row = $sth->fetch(PDO::FETCH_ASSOC) ) ) {
|
|
return $row['data'];
|
|
}
|
|
}
|
|
// Return an empty string
|
|
return '';
|
|
}
|
|
public function write($id, $data) : bool {
|
|
if (!($db = zmDbConnOrNull())) return false;
|
|
// Create time stamp
|
|
$access = time();
|
|
|
|
$sth = $db->prepare('REPLACE INTO Sessions VALUES (:id, :access, :data)');
|
|
|
|
$sth->bindParam(':id', $id, PDO::PARAM_STR, 32);
|
|
$sth->bindParam(':access', $access, PDO::PARAM_INT);
|
|
$sth->bindParam(':data', $data);
|
|
|
|
return $sth->execute() ? true : false;
|
|
}
|
|
public function destroy($id) : bool {
|
|
if (!($db = zmDbConnOrNull())) return false;
|
|
$sth = $db->prepare('DELETE FROM Sessions WHERE Id = :id');
|
|
$sth->bindParam(':id', $id, PDO::PARAM_STR, 32);
|
|
return $sth->execute() ? true : false;
|
|
}
|
|
#[\ReturnTypeWillChange]
|
|
public function gc($max) {
|
|
if (!($db = zmDbConnOrNull())) return false;
|
|
// Calculate what is to be deemed old
|
|
$now = time();
|
|
$old = $now - $max;
|
|
ZM\Debug('doing session gc ' . $now . '-' . $max. '='.$old);
|
|
|
|
// Two-phase delete: find expired ids via the access index (consistent read, no locks),
|
|
// then delete by primary key so InnoDB only takes record locks on the matched rows
|
|
// and not gap locks across the access range — avoids deadlocks with concurrent
|
|
// REPLACE INTO Sessions on every authenticated request.
|
|
$sel = $db->prepare('SELECT id FROM Sessions WHERE access < :old LIMIT 100');
|
|
$sel->bindParam(':old', $old, PDO::PARAM_INT);
|
|
if (!$sel->execute()) return false;
|
|
$ids = $sel->fetchAll(PDO::FETCH_COLUMN);
|
|
if (!$ids) return true;
|
|
|
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
|
$del = $db->prepare("DELETE FROM Sessions WHERE id IN ($placeholders)");
|
|
return $del->execute($ids) ? true : false;
|
|
}
|
|
public function validateId($key) : bool {return true;}
|
|
} # end class Session
|
|
|
|
$session = new ZMSessionHandler;
|
|
session_set_save_handler($session, true);
|
|
?>
|