Files
tinker_tickets/helpers/Database.php
T
jaredandClaude Opus 4.8 c5f7a01e1d Fix helpers/config: timezone, comment leak, silent misconfig, cache perms
- Database.php: pin MySQL session time_zone to the configured named zone
  (mysql.time_zone tables now loaded on the DB) with a fixed-offset
  fallback, so NOW()/TIMESTAMP and PHP agree regardless of the DB server's
  SYSTEM tz. Best-effort, never fatals the connection.
- NotificationHelper: redact comment-body previews for internal/
  confidential tickets in sendCommentNotification and notifyWatchers so
  they are not leaked to the shared Matrix notify list (new $visibility
  param; callers wired in the API batch).
- config.php: die with a clear error if parse_ini_file fails instead of
  silently falling back to insecure defaults (empty DB pass / proxies).
- CacheHelper: create cache dir 0700 and cache files 0600 so other local
  users cannot read or poison security-relevant cached data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:17:07 -04:00

204 lines
5.9 KiB
PHP

<?php
/**
* Database Connection Factory
*
* Centralizes database connection creation and management.
* Provides a singleton connection for the request lifecycle.
*/
class Database
{
private static ?mysqli $connection = null;
/**
* Get database connection (singleton pattern)
*
* @return mysqli Database connection
* @throws Exception If connection fails
*/
public static function getConnection(): mysqli
{
if (self::$connection === null) {
self::$connection = self::createConnection();
}
// Check if connection is still alive
if (!self::$connection->ping()) {
self::$connection = self::createConnection();
}
return self::$connection;
}
/**
* Create a new database connection
*
* @return mysqli Database connection
* @throws Exception If connection fails
*/
private static function createConnection(): mysqli
{
// Ensure config is loaded
if (!isset($GLOBALS['config'])) {
require_once dirname(__DIR__) . '/config/config.php';
}
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed: " . $conn->connect_error);
}
// Set charset to utf8mb4 for proper Unicode support
$conn->set_charset('utf8mb4');
// Pin the MySQL session time zone to the app's configured zone so that
// NOW()/CURRENT_TIMESTAMP and PHP agree on wall-clock time regardless of
// the DB server's SYSTEM tz. Prefer the named zone (requires the
// mysql.time_zone_* tables); if that isn't available, fall back to the
// fixed numeric offset PHP computes for the same zone. Best-effort: a
// failure here must never fatal the connection.
$tz = $GLOBALS['config']['TIMEZONE'] ?? 'UTC';
try {
$escaped = $conn->real_escape_string($tz);
try {
// mysqli throws (does not return false) on failure under the
// default PHP 8.1+ report mode, so catch it rather than testing
// the return value.
$conn->query("SET time_zone = '{$escaped}'");
} catch (\Throwable $inner) {
// Named zone unavailable (mysql.time_zone_* not populated) — fall
// back to a fixed numeric offset so PHP and MySQL still agree on
// wall-clock time regardless of the DB server's SYSTEM tz.
$offset = (new DateTime('now', new DateTimeZone($tz)))->format('P');
$escapedOffset = $conn->real_escape_string($offset);
$conn->query("SET time_zone = '{$escapedOffset}'");
}
} catch (\Throwable $e) {
error_log('Database: failed to set session time_zone: ' . $e->getMessage());
}
return $conn;
}
/**
* Close the database connection
*/
public static function close(): void
{
if (self::$connection !== null) {
self::$connection->close();
self::$connection = null;
}
}
/**
* Begin a transaction
*
* @return bool Success
*/
public static function beginTransaction(): bool
{
return self::getConnection()->begin_transaction();
}
/**
* Commit a transaction
*
* @return bool Success
*/
public static function commit(): bool
{
return self::getConnection()->commit();
}
/**
* Rollback a transaction
*
* @return bool Success
*/
public static function rollback(): bool
{
return self::getConnection()->rollback();
}
/**
* Execute a query and return results
*
* @param string $sql SQL query with placeholders
* @param string $types Parameter types (i=int, s=string, d=double, b=blob)
* @param array $params Parameters to bind
* @return mysqli_result|bool Query result
*/
public static function query(string $sql, string $types = '', array $params = [])
{
$conn = self::getConnection();
if (empty($types) || empty($params)) {
return $conn->query($sql);
}
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new Exception("Query preparation failed: " . $conn->error);
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
return $result;
}
/**
* Execute an INSERT/UPDATE/DELETE and return affected rows
*
* @param string $sql SQL query with placeholders
* @param string $types Parameter types
* @param array $params Parameters to bind
* @return int Affected rows (-1 on failure)
*/
public static function execute(string $sql, string $types = '', array $params = []): int
{
$conn = self::getConnection();
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new Exception("Query preparation failed: " . $conn->error);
}
if (!empty($types) && !empty($params)) {
$stmt->bind_param($types, ...$params);
}
if ($stmt->execute()) {
$affected = $stmt->affected_rows;
$stmt->close();
return $affected;
}
$error = $stmt->error;
$stmt->close();
throw new Exception("Query execution failed: " . $error);
}
/**
* Get the last insert ID
*
* @return int Last insert ID
*/
public static function lastInsertId(): int
{
return self::getConnection()->insert_id;
}
// escape() removed — use prepared statements with bind_param() instead
}