Files
Isaac ConnorandClaude Opus 5 9c06eb6ba2 refactor: connect to the database on first use, not on include
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
2026-08-17 20:52:10 -04:00

564 lines
17 KiB
PHP

<?php
namespace ZM;
require_once('config.php');
class Logger {
private static $instance;
const DEBUG = 1;
const INFO = 0;
const WARNING = -1;
const ERROR = -2;
const FATAL = -3;
const PANIC = -4;
const AUDIT = -5;
const NOLOG = -6; // Special artificial level to prevent logging
private $initialised = false;
private $id = 'web';
private $idRoot = 'web';
private $idArgs = '';
private $useErrorLog = true;
private $level = self::INFO;
private $termLevel = self::NOLOG;
private $databaseLevel = self::NOLOG;
private $fileLevel = self::NOLOG;
private $weblogLevel = self::NOLOG;
private $syslogLevel = self::NOLOG;
private $effectiveLevel = self::NOLOG;
private $hasTerm = false;
private $logPath = ZM_PATH_LOGS;
private $logFile = '';
private $logFd = NULL;
private $savedLogErrors = null;
private $savedErrorReporting = null;
private $savedDisplayErrors = null;
public static $codes = array(
self::DEBUG => 'DBG',
self::INFO => 'INF',
self::WARNING => 'WAR',
self::ERROR => 'ERR',
self::FATAL => 'FAT',
self::PANIC => 'PNC',
self::AUDIT => 'AUD',
self::NOLOG => 'OFF',
);
private static $syslogPriorities = array(
self::DEBUG => LOG_DEBUG,
self::INFO => LOG_INFO,
self::WARNING => LOG_WARNING,
self::ERROR => LOG_ERR,
self::FATAL => LOG_ERR,
self::PANIC => LOG_ERR,
self::AUDIT => LOG_NOTICE,
);
private static $phpErrorLevels = array(
self::DEBUG => E_USER_NOTICE,
self::INFO => E_USER_NOTICE,
self::WARNING => E_USER_WARNING,
self::ERROR => E_USER_WARNING,
self::FATAL => E_USER_ERROR,
self::PANIC => E_USER_ERROR,
self::AUDIT => E_USER_NOTICE,
);
private function __construct() {
$this->hasTerm = (php_sapi_name() == 'cli' && empty($_SERVER['REMOTE_ADDR']));
$this->logFile = $this->id.'.log';
}
public function __destruct() {
$this->terminate();
}
public function initialise($options=array()) {
if ( !empty($options['id']) )
$this->id = $options['id'];
//if ( isset($options['useErrorLog']) )
//$this->useErrorLog = $options['useErrorLog'];
if ( isset($options['logPath']) ) {
$this->logPath = $options['logPath'];
$tempLogFile = $this->logPath.'/'.$this->id.'.log';
}
if ( isset($options['logFile']) )
$tempLogFile = basename($options['logFile']);
else
$tempLogFile = $this->id.'.log';
if ( !is_null($logFile = $this->getTargettedEnv('LOG_FILE')) )
$tempLogFile = basename($logFile);
$tempLevel = self::INFO;
$tempTermLevel = $this->termLevel;
$tempDatabaseLevel = $this->databaseLevel;
$tempFileLevel = $this->fileLevel;
$tempSyslogLevel = $this->syslogLevel;
$tempWeblogLevel = $this->weblogLevel;
if ( isset($options['termLevel']) )
$tempTermLevel = $options['termLevel'];
if ( isset($options['databaseLevel']) )
$tempDatabaseLevel = $options['databaseLevel'];
else
$tempDatabaseLevel = ZM_LOG_LEVEL_DATABASE;
if ( isset($options['fileLevel']) )
$tempFileLevel = $options['fileLevel'];
else
$tempFileLevel = ZM_LOG_LEVEL_FILE;
if ( isset($options['weblogLevel']) )
$tempWeblogLevel = $options['weblogLevel'];
else
$tempWeblogLevel = ZM_LOG_LEVEL_WEBLOG;
if ( isset($options['syslogLevel']) )
$tempSyslogLevel = $options['syslogLevel'];
else
$tempSyslogLevel = ZM_LOG_LEVEL_SYSLOG;
if ( $value = getenv('LOG_PRINT') )
$tempTermLevel = $value ? self::DEBUG : self::NOLOG;
if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL')) )
$tempLevel = $level;
if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL_TERM')) )
$tempTermLevel = $level;
if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL_DATABASE')) )
$tempDatabaseLevel = $level;
if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL_FILE')) )
$tempFileLevel = $level;
if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL_SYSLOG')) )
$tempSyslogLevel = $level;
if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL_WEBLOG')) )
$tempWeblogLevel = $level;
if ( ZM_LOG_DEBUG ) {
foreach ( explode( '|', ZM_LOG_DEBUG_TARGET ) as $target ) {
if ( $target == $this->id || $target == '_'.$this->id || $target == $this->idRoot || $target == '_'.$this->idRoot || $target == '' ) {
if ( ZM_LOG_DEBUG_LEVEL > self::NOLOG ) {
$tempLevel = $this->limit( ZM_LOG_DEBUG_LEVEL );
if ( ZM_LOG_DEBUG_FILE != '' ) {
$tempLogFile = basename(ZM_LOG_DEBUG_FILE);
$tempFileLevel = $tempLevel;
}
}
}
} // end foreach target
} // end if DEBUG
$this->logFile( $tempLogFile );
$this->termLevel( $tempTermLevel );
$this->databaseLevel( $tempDatabaseLevel );
$this->fileLevel( $tempFileLevel );
$this->syslogLevel( $tempSyslogLevel );
$this->weblogLevel( $tempWeblogLevel );
$this->level( $tempLevel );
$this->initialised = true;
//Debug( "LogOpts: level=".self::$codes[$this->level]."/".self::$codes[$this->effectiveLevel].", screen=".self::$codes[$this->termLevel].", database=".self::$codes[$this->databaseLevel].", logfile=".self::$codes[$this->fileLevel]."->".$this->logFile.", weblog=".self::$codes[$this->weblogLevel].", syslog=".self::$codes[$this->syslogLevel] );
}
private function terminate() {
if ( $this->initialised ) {
if ( $this->fileLevel > self::NOLOG )
$this->closeFile();
if ( $this->syslogLevel > self::NOLOG )
$this->closeSyslog();
}
$this->initialised = false;
}
private function limit( $level ) {
if ( $level > self::DEBUG )
return( self::DEBUG );
if ( $level < self::NOLOG )
return( self::NOLOG );
return( $level );
}
private function getTargettedEnv( $name ) {
$envName = $name.'_'.$this->id;
$value = getenv( $envName );
if ( $value === false && $this->id != $this->idRoot )
$value = getenv( $name.'_'.$this->idRoot );
if ( $value === false )
$value = getenv( $name );
return( $value !== false ? $value : NULL );
}
public static function fetch( $initialise=true ) {
if ( !isset(self::$instance) ) {
$class = __CLASS__;
self::$instance = new $class;
if ( $initialise )
self::$instance->initialise( array( 'id'=>'web_php', 'syslogLevel'=>self::INFO, 'weblogLevel'=>self::INFO ) );
}
return self::$instance;
}
public function id( $id=NULL ) {
if ( isset($id) && $this->id != $id ) {
// Remove whitespace
$id = preg_replace( '/\S/', '', $id );
// Replace non-alphanum with underscore
$id = preg_replace( '/[^a-zA-Z_]/', '_', $id );
if ( $this->id != $id ) {
$this->id = $this->idRoot = $id;
if ( preg_match( '/^([^_]+)_(.+)$/', $id, $matches ) ) {
$this->idRoot = $matches[1];
$this->idArgs = $matches[2];
}
}
}
return $this->id;
}
public function level( $level ) {
if ( isset($level) ) {
$lastLevel = $this->level;
$this->level = $this->limit($level);
$this->effectiveLevel = self::NOLOG;
if ( $this->termLevel > $this->effectiveLevel )
$this->effectiveLevel = $this->termLevel;
if ( $this->databaseLevel > $this->effectiveLevel )
$this->effectiveLevel = $this->databaseLevel;
if ( $this->fileLevel > $this->effectiveLevel )
$this->effectiveLevel = $this->fileLevel;
if ( $this->weblogLevel > $this->effectiveLevel )
$this->effectiveLevel = $this->weblogLevel;
if ( $this->syslogLevel > $this->effectiveLevel )
$this->effectiveLevel = $this->syslogLevel;
if ( $this->effectiveLevel > $this->level )
$this->effectiveLevel = $this->level;
if ( !$this->hasTerm ) {
if ( $lastLevel < self::DEBUG && $this->level >= self::DEBUG ) {
$this->savedErrorReporting = error_reporting(E_ALL);
$this->savedDisplayErrors = ini_set('display_errors', false);
} elseif ( $lastLevel >= self::DEBUG && $this->level < self::DEBUG ) {
error_reporting($this->savedErrorReporting);
ini_set('display_errors', $this->savedDisplayErrors);
}
}
}
return $this->level;
}
public function debugOn() {
return( $this->effectiveLevel >= self::DEBUG );
}
public function termLevel($termLevel) {
if ( isset($termLevel) ) {
$termLevel = $this->limit($termLevel);
if ( $this->termLevel != $termLevel )
$this->termLevel = $termLevel;
}
return $this->termLevel;
}
public function databaseLevel($databaseLevel=NULL) {
if ( !is_null($databaseLevel) ) {
$databaseLevel = $this->limit($databaseLevel);
if ( $this->databaseLevel != $databaseLevel ) {
$this->databaseLevel = $databaseLevel;
if ( $this->databaseLevel > self::NOLOG ) {
if ( (include_once 'database.php') === FALSE ) {
$this->databaseLevel = self::NOLOG;
Warning('Unable to write log entries to DB, database.php not found');
}
}
}
}
return $this->databaseLevel;
}
public function fileLevel($fileLevel) {
if ( isset($fileLevel) ) {
$fileLevel = $this->limit($fileLevel);
if ( $this->fileLevel != $fileLevel ) {
if ( $this->fileLevel > self::NOLOG )
$this->closeFile();
$this->fileLevel = $fileLevel;
if ( $this->fileLevel > self::NOLOG )
$this->openFile();
}
}
return $this->fileLevel;
}
public function weblogLevel($weblogLevel) {
if ( isset($weblogLevel) ) {
$weblogLevel = $this->limit($weblogLevel);
if ( $this->weblogLevel != $weblogLevel ) {
if ( $weblogLevel > self::NOLOG && $this->weblogLevel <= self::NOLOG ) {
$this->savedLogErrors = ini_set('log_errors', true);
} elseif ( $weblogLevel <= self::NOLOG && $this->weblogLevel > self::NOLOG ) {
ini_set('log_errors', $this->savedLogErrors);
}
$this->weblogLevel = $weblogLevel;
}
}
return $this->weblogLevel;
}
public function syslogLevel($syslogLevel) {
if ( isset($syslogLevel) ) {
$syslogLevel = $this->limit($syslogLevel);
if ( $this->syslogLevel != $syslogLevel ) {
if ( $this->syslogLevel > self::NOLOG )
$this->closeSyslog();
$this->syslogLevel = $syslogLevel;
if ( $this->syslogLevel > self::NOLOG )
$this->openSyslog();
}
}
return $this->syslogLevel;
}
private function openSyslog() {
openlog($this->id, LOG_PID|LOG_NDELAY, LOG_LOCAL1);
}
private function closeSyslog() {
closelog();
}
private function logFile($logFile) {
if ( preg_match('/^(.+)\+$/', $logFile, $matches) ) {
$this->logFile = $matches[1].'.'.getmypid();
} else {
$this->logFile = $logFile;
}
}
private function openFile() {
if ( !$this->useErrorLog ) {
if ( $this->logFd = fopen($this->logPath.'/'.$this->logFile, 'a+') ) {
if ( strnatcmp(phpversion(), '5.2.0') >= 0 ) {
$error = error_get_last();
trigger_error("Can't open log file '$logFile': ".$error['message'].' @ '.$error['file'].'/'.$error['line'], E_USER_ERROR);
}
$this->fileLevel = self::NOLOG;
}
}
}
private function closeFile() {
if ( $this->logFd )
fclose($this->logFd);
}
public function logPrint($level, $string, $file=NULL, $line=NULL) {
if ( $level > $this->effectiveLevel ) {
return;
}
$string = preg_replace('/[\r\n]+$/', '', $string);
$string = substr($string, 0, 65535); # Message Column has max length
$code = self::$codes[$level];
global $dateTimeFormatter;
$time = gettimeofday();
$message = sprintf('%s.%06d %s[%d].%s [%s] [%s]',
$dateTimeFormatter->format($time['sec']), $time['usec'],
$this->id, getmypid(), $code, (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : $_SERVER['REMOTE_ADDR']), $string);
if ( is_null($file) ) {
if ( $this->useErrorLog || ($this->databaseLevel > self::NOLOG) ) {
$backTrace = debug_backtrace();
$file = $backTrace[1]['file'];
$line = $backTrace[1]['line'];
if ( $this->hasTerm )
$rootPath = getcwd();
else
$rootPath = $_SERVER['DOCUMENT_ROOT'];
$file = preg_replace('/^'.addcslashes($rootPath, '/').'\/?/', '', $file);
}
}
if ( $this->useErrorLog ) {
$message .= ' at '.$file.' line '.$line;
}
if ( $level <= $this->termLevel ) {
if ( $this->hasTerm ) {
print($message."\n");
} else {
// Didn't we already replace all newlines with spaces above?
print(preg_replace('/\n/', '<br/>', htmlspecialchars($message)).'<br/>');
}
}
if ( $level <= $this->fileLevel ) {
if ( $this->useErrorLog ) {
if ( !error_log($message."\n", 3, $this->logPath.'/'.$this->logFile) ) {
if ( strnatcmp(phpversion(), '5.2.0') >= 0 ) {
$error = error_get_last();
$this->fileLevel = self::NOLOG;
Error("Can't write to log file '".$this->logFile."': ".$error['message'].' @ '.$error['file'].'/'.$error['line'], E_USER_ERROR);
}
}
} else if ( $this->logFd ) {
fprintf($this->logFd, $message."\n");
} else {
$this->fileLevel = self::NOLOG;
Error('No logFd but have fileLevel logging!?');
}
}
if ( $level <= $this->syslogLevel )
syslog(self::$syslogPriorities[$level], $message);
$message = $code.' ['.$string.']';
if ( $level <= $this->databaseLevel ) {
if ( strlen($file) > 255 )
$file = substr($file, 0, 255);
try {
$sql = 'INSERT INTO `Logs` ( `TimeKey`, `Component`, `ServerId`, `Pid`, `Level`, `Code`, `Message`, `File`, `Line` ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ? )';
// Never open a connection just to write a log line, and never let a
// logging call end the request. The catch below drops to NOLOG, so this
// falls through to the weblog/error_log targets and does not retry.
$dbConn = zmDbConnOrNull();
if (!$dbConn) throw new PDOException('No database connection');
$stmt = $dbConn->prepare($sql);
$result = $stmt->execute(array(sprintf('%d.%06d', $time['sec'], $time['usec']), $this->id,
(defined('ZM_SERVER_ID') ? ZM_SERVER_ID : null), getmypid(), $level, $code, $string, $file, $line));
} catch(PDOException $ex) {
$this->databaseLevel = self::NOLOG;
Error("Can't write log entry '$sql': ". $ex->getMessage());
}
}
// This has to be last as trigger_error can be fatal
if ( $level <= $this->weblogLevel ) {
if ( $this->useErrorLog ) {
error_log($message, 0);
} else {
trigger_error($message, self::$phpErrorLevels[$level]);
}
}
}
};
function logInit( $options=array() ) {
$logger = Logger::fetch();
$logger->initialise( $options );
set_error_handler( 'ZM\ErrorHandler' );
}
function logToDatabase( $level=NULL ) {
return( Logger::fetch()->databaseLevel( $level ) );
}
function Mark( $level=Logger::DEBUG, $tag='Mark' ) {
Logger::fetch()->logPrint( $level, $tag );
}
function Dump( &$var, $label='VAR' ) {
ob_start();
print( $label.' => ' );
print_r( $var );
Logger::fetch()->logPrint( Logger::DEBUG, ob_get_clean() );
}
function Debug( $string ) {
Logger::fetch()->logPrint( Logger::DEBUG, $string );
}
function Info( $string ) {
Logger::fetch()->logPrint( Logger::INFO, $string );
}
function Warning( $string ) {
Logger::fetch()->logPrint( Logger::WARNING, $string );
}
function Error( $string ) {
Logger::fetch()->logPrint( Logger::ERROR, $string );
}
function Fatal( $string ) {
Logger::fetch()->logPrint( Logger::FATAL, $string );
if (Logger::fetch()->debugOn()) {
echo(htmlentities($string));
}
exit(1);
}
function Panic( $string ) {
if ( true ) {
// Use builtin function
ob_start();
debug_print_backtrace();
$backtrace = "\n".ob_get_clean();
} else {
// Roll your own
$backtrace = '';
$frames = debug_backtrace();
for ( $i = 0; $i < count($frames); $i++ ) {
$frame = $frames[$i];
$backtrace .= sprintf( "\n#%d %s() at %s/%d", $i, $frame['function'], $frame['file'], $frame['line'] );
}
}
Logger::fetch()->logPrint( Logger::PANIC, $string.$backtrace );
if (Logger::fetch()->debugOn()) {
echo $string;
}
exit(1);
}
function Audit($string) {
Logger::fetch()->logPrint(Logger::AUDIT, $string);
}
function AuditAction($action, $target_type, $target_id, $details) {
global $user;
$username = $user ? $user->Username() : 'system';
$ip = !empty($_SERVER['HTTP_X_FORWARDED_FOR'])
? $_SERVER['HTTP_X_FORWARDED_FOR']
: ($_SERVER['REMOTE_ADDR'] ?? 'local');
Audit("user=$username action=$action target=$target_type"
." id=$target_id details=\"$details\" from=$ip");
}
function ErrorHandler( $error, $string, $file, $line ) {
if ( ! (error_reporting() & $error) ) {
// This error code is not included in error_reporting
return false;
}
switch ( $error ) {
case E_USER_ERROR:
Logger::fetch()->logPrint( Logger::FATAL, $string, $file, $line );
break;
case E_USER_WARNING:
Logger::fetch()->logPrint( Logger::ERROR, $string, $file, $line );
break;
case E_USER_NOTICE:
Logger::fetch()->logPrint( Logger::WARNING, $string, $file, $line );
break;
default:
Panic( "Unknown error type: [$error] $string" );
break;
}
return true;
}
?>