From 827354a622c341a588ec00b457d6c7ddc3b837c1 Mon Sep 17 00:00:00 2001 From: Ferywir Date: Fri, 12 Jun 2026 15:25:57 +0200 Subject: [PATCH] feat(admin): add transparent debug error-log mode Add an admin-controlled debug mode that captures PHP errors of all players into var/log/debug-players.log, to hunt remaining PHP 8.3 bugs from real play sessions. Fully transparent to players: no redirect, no gameplay change, errors are never displayed. - DB: new debug_log table (one row), mirroring the maintenance pattern. - Database: getDebugMode()/setDebugMode()/setDebugSettings(), defensive when the table is absent (no blank page). - Session: register a custom error + shutdown handler when enabled; the handler runs even when php.ini error_reporting masks warnings/notices, so capture is complete without a Docker rebuild. Auto-disables after a configurable window. - DebugErrorLogger: size-capped file with a single .log.1 rotation, honours the @ operator, never throws. - Admin: new "Debug Error Log" page (levels, size cap, auto-off, on-page viewer, clear, download) + debugLog action mod. - Menu: admin-only quick on/off widget (TZ_DEBUG_ON/OFF, EN/FR/RO). Co-Authored-By: Claude Opus 4.8 --- Admin/Templates/debug_log.tpl | 146 ++++++++++++++++++++++++ Admin/admin.php | 5 + GameEngine/Admin/Mods/debugLog.php | 75 ++++++++++++ GameEngine/Database.php | 64 ++++++++++- GameEngine/Lang/en.php | 2 + GameEngine/Lang/fr.php | 2 + GameEngine/Lang/ro.php | 2 + GameEngine/Session.php | 15 +++ Templates/debug_status.tpl | 49 ++++++++ Templates/menu.tpl | 5 + src/Utils/DebugErrorLogger.php | 176 +++++++++++++++++++++++++++++ var/db/struct.sql | 25 ++++ 12 files changed, 563 insertions(+), 3 deletions(-) create mode 100644 Admin/Templates/debug_log.tpl create mode 100644 GameEngine/Admin/Mods/debugLog.php create mode 100644 Templates/debug_status.tpl create mode 100644 src/Utils/DebugErrorLogger.php diff --git a/Admin/Templates/debug_log.tpl b/Admin/Templates/debug_log.tpl new file mode 100644 index 00000000..f1b0e921 --- /dev/null +++ b/Admin/Templates/debug_log.tpl @@ -0,0 +1,146 @@ + + + + <?php echo ($_SESSION['access'] == ADMIN ? 'Admin Control Panel' : 'Multihunter Control Panel'); ?> - TravianZ + + + + + + + +getDebugMode(); +$isOn = !empty($cfg['active']); + +// Resolve project root (max 5 levels up) to find the log file. +$autoprefix = ''; +for ($i = 0; $i < 5; $i++) { + $autoprefix = str_repeat('../', $i); + if (file_exists($autoprefix . 'autoloader.php')) { + break; + } +} +$logFile = $autoprefix . 'var/log/debug-players.log'; + +// Read the last lines for the on-screen viewer. +$maxLines = 400; +$lines = []; +$logSize = 0; +if (is_file($logFile)) { + $logSize = filesize($logFile); + $all = file($logFile, FILE_IGNORE_NEW_LINES); + if ($all !== false) { + $lines = array_slice($all, -$maxLines); + } +} + +// Active-since label + auto-off info. +$since = !empty($cfg['started_at']) ? date('d.m.Y H:i', $cfg['started_at']) : '-'; +$autoOff = (int)($cfg['auto_off_hours'] ?? 0); +?> +
+ +
+

🐞 Debug Error Log

+ + + +
+ +
+

Status

+
+ Capture is . + Auto-off: 0 ? $autoOff.' h' : 'never'; ?> + Log size: KB +
+
+ + + +
+

Transparent to players: errors are only written to the log file, never shown in-game and gameplay is unaffected.

+
+ +
+

Capture settings

+
+ +
+ + + + +
+
+ + + +
+

Beyond the size cap the file is rotated to a single .log.1 backup, so the total volume stays bounded.

+
+
+ +
+

Last lines

+
+ ⬇ Download full log +
+ + +
+ ↻ Refresh +
+
+
+ +
diff --git a/Admin/admin.php b/Admin/admin.php index 33ed821c..19f1a1f0 100644 --- a/Admin/admin.php +++ b/Admin/admin.php @@ -147,6 +147,10 @@ if (!empty($_GET['p'])) { $subpage = 'Server Settings'; break; + case 'debug_log': + $subpage = 'Debug Error Log'; + break; + case 'editServerSet': $subpage = 'Server Configuration'; break; @@ -622,6 +626,7 @@ body.app #lmid3 a{color:#15803d !important;font-weight:600 !important;}
  • Admin
    • Admin Log
    • +
    • Debug Error Log
    • Server Settings
    • Server Maintenance
    • Server Resetting
    • diff --git a/GameEngine/Admin/Mods/debugLog.php b/GameEngine/Admin/Mods/debugLog.php new file mode 100644 index 00000000..d6b35e59 --- /dev/null +++ b/GameEngine/Admin/Mods/debugLog.php @@ -0,0 +1,75 @@ + persist capture settings (levels, size cap, auto-off) ## +## do=toggle -> turn the debug capture on/off ## +## do=clear -> empty the log file(s) ## +## do=download -> stream the log file as a download ## +################################################################################# + +if(!isset($_SESSION)) session_start(); +if(($_SESSION['access'] ?? 0) < 9) die("Access denied: You are not Admin!"); + +include_once("../../Database.php"); + +// Resolve project root (max 5 levels up), like the rest of the codebase. +$autoprefix = ''; +for ($i = 0; $i < 5; $i++) { + $autoprefix = str_repeat('../', $i); + if (file_exists($autoprefix . 'autoloader.php')) { + break; + } +} +$logFile = $autoprefix . 'var/log/debug-players.log'; + +$uid = (int)($_SESSION['id_user'] ?? 0); +$do = $_REQUEST['do'] ?? ''; + +switch ($do) { + + case 'save': + $database->setDebugSettings( + isset($_POST['lvl_warning']), + isset($_POST['lvl_notice']), + isset($_POST['lvl_deprecated']), + isset($_POST['lvl_fatal']), + $_POST['max_size_mb'] ?? 5, + $_POST['auto_off_hours'] ?? 6 + ); + $database->query("Insert into ".TB_PREFIX."admin_log values (0,".$uid.",'Changed Debug Error Log settings',".time().")"); + break; + + case 'toggle': + $active = (int)($_POST['active'] ?? 0); + $database->setDebugMode($active, $uid); + $database->query("Insert into ".TB_PREFIX."admin_log values (0,".$uid.",'".($active ? 'Enabled' : 'Disabled')." Debug Error Log',".time().")"); + break; + + case 'clear': + @file_put_contents($logFile, ''); + @unlink($logFile . '.1'); + $database->query("Insert into ".TB_PREFIX."admin_log values (0,".$uid.",'Cleared Debug Error Log',".time().")"); + break; + + case 'download': + if (is_file($logFile) && filesize($logFile) > 0) { + header('Content-Type: text/plain; charset=UTF-8'); + header('Content-Disposition: attachment; filename="debug-players-'.date('Ymd-His').'.log"'); + header('Content-Length: ' . filesize($logFile)); + readfile($logFile); + } else { + header('Content-Type: text/plain; charset=UTF-8'); + echo "The debug log is empty."; + } + exit; +} + +header("Location: ../../../Admin/admin.php?p=debug_log"); +exit; diff --git a/GameEngine/Database.php b/GameEngine/Database.php index d294f4e9..45fae4fe 100755 --- a/GameEngine/Database.php +++ b/GameEngine/Database.php @@ -8878,12 +8878,70 @@ public function setMaintenance($active, $uid=0) { $active = (int)$active; $uid = (int)$uid; // REPLACE creează rândul dacă nu există - return $this->query("REPLACE INTO ".TB_PREFIX."maintenance - (id, active, started_by, started_at) + return $this->query("REPLACE INTO ".TB_PREFIX."maintenance + (id, active, started_by, started_at) VALUES (1, $active, $uid, $time)"); } - +/** + * Debug error-log mode (admin-controlled, transparent to players). + * Returns the single config row, falling back to safe defaults when the + * table does not exist yet (so deploying the code before creating the table + * never produces a blank page). + */ +public function getDebugMode() { + $default = [ + 'active' => 0, + 'lvl_warning' => 1, + 'lvl_notice' => 1, + 'lvl_deprecated' => 1, + 'lvl_fatal' => 1, + 'max_size_mb' => 5, + 'auto_off_hours' => 6, + 'started_by' => null, + 'started_at' => null, + ]; + try { + $res = @mysqli_query($this->dblink, "SELECT * FROM ".TB_PREFIX."debug_log WHERE id=1 LIMIT 1"); + if (!$res) { + return $default; + } + $row = mysqli_fetch_assoc($res); + return $row ?: $default; + } catch (\Throwable $e) { + return $default; + } +} + +/** + * Toggle the debug mode on/off (stamps who/when on activation). + */ +public function setDebugMode($active, $uid = 0) { + $active = (int)$active; + $uid = (int)$uid; + $time = time(); + return $this->query("UPDATE ".TB_PREFIX."debug_log + SET active = $active, started_by = $uid, started_at = $time + WHERE id = 1"); +} + +/** + * Persist the debug capture parameters (levels, size cap, auto-off window). + */ +public function setDebugSettings($warning, $notice, $deprecated, $fatal, $maxSizeMb, $autoOffHours) { + $warning = $warning ? 1 : 0; + $notice = $notice ? 1 : 0; + $deprecated = $deprecated ? 1 : 0; + $fatal = $fatal ? 1 : 0; + $maxSizeMb = max(1, (int)$maxSizeMb); + $autoOffHours = max(0, (int)$autoOffHours); + return $this->query("UPDATE ".TB_PREFIX."debug_log + SET lvl_warning = $warning, lvl_notice = $notice, lvl_deprecated = $deprecated, + lvl_fatal = $fatal, max_size_mb = $maxSizeMb, auto_off_hours = $autoOffHours + WHERE id = 1"); +} + + /** * Changed the actual capital with a new one * diff --git a/GameEngine/Lang/en.php b/GameEngine/Lang/en.php index c7386ab3..24a92d1f 100755 --- a/GameEngine/Lang/en.php +++ b/GameEngine/Lang/en.php @@ -1932,6 +1932,8 @@ tz_def('TZ_CONSTRUCT_WAREHOUSE', 'Construct Warehouse.'); tz_def('TZ_CP_DAY', 'CP/day'); tz_def('TZ_CROP_25_5_GOLD', 'Crop +25% (5 gold)'); tz_def('TZ_DATE_AND_TIME', 'Date and time'); +tz_def('TZ_DEBUG_OFF', 'Debug log OFF'); +tz_def('TZ_DEBUG_ON', 'Debug log ON'); tz_def('TZ_DECLARE_WAR', 'declare war'); tz_def('TZ_DEFAULT', 'Default:'); tz_def('TZ_DELETE_ACCOUNT', 'Delete account?'); diff --git a/GameEngine/Lang/fr.php b/GameEngine/Lang/fr.php index b8560dd1..0c173ec4 100644 --- a/GameEngine/Lang/fr.php +++ b/GameEngine/Lang/fr.php @@ -1931,6 +1931,8 @@ define('TZ_CONSTRUCT_WAREHOUSE', 'Construisez un entrepôt.'); define('TZ_CP_DAY', 'PC/jour'); define('TZ_CROP_25_5_GOLD', 'Céréales +25% (5 or)'); define('TZ_DATE_AND_TIME', 'Date et heure'); +define('TZ_DEBUG_OFF', 'Journal debug DÉSACTIVÉ'); +define('TZ_DEBUG_ON', 'Journal debug ACTIVÉ'); define('TZ_DECLARE_WAR', 'déclarer la guerre'); define('TZ_DEFAULT', 'Par défaut :'); define('TZ_DELETE_ACCOUNT', 'Supprimer le compte ?'); diff --git a/GameEngine/Lang/ro.php b/GameEngine/Lang/ro.php index eedd5c0a..99e85ae9 100644 --- a/GameEngine/Lang/ro.php +++ b/GameEngine/Lang/ro.php @@ -1926,6 +1926,8 @@ define('TZ_CONSTRUCT_WAREHOUSE', 'Construiește un depozit.'); define('TZ_CP_DAY', 'PC/zi'); define('TZ_CROP_25_5_GOLD', 'Grâne +25% (5 aur)'); define('TZ_DATE_AND_TIME', 'Data și ora'); +define('TZ_DEBUG_OFF', 'Jurnal debug OPRIT'); +define('TZ_DEBUG_ON', 'Jurnal debug PORNIT'); define('TZ_DECLARE_WAR', 'declară război'); define('TZ_DEFAULT', 'Implicit:'); define('TZ_DELETE_ACCOUNT', 'Ștergi contul?'); diff --git a/GameEngine/Session.php b/GameEngine/Session.php index be9c20ef..4d00a052 100755 --- a/GameEngine/Session.php +++ b/GameEngine/Session.php @@ -129,6 +129,21 @@ function __construct() { } } + // === DEBUG ERROR LOG (admin-controlled, transparent to players) === + // When enabled from the admin panel, capture the selected PHP errors of + // every player into var/log/debug-players.log. Auto-disables itself after + // the configured window so a forgotten debug session cannot run forever. + $dbg = $database->getDebugMode(); + if (!empty($dbg['active'])) { + $autoOff = (int)($dbg['auto_off_hours'] ?? 0); + if ($autoOff > 0 && !empty($dbg['started_at']) + && ($dbg['started_at'] + $autoOff * 3600) < $this->time) { + $database->setDebugMode(0, $this->uid ?? 0); + } else { + \App\Utils\DebugErrorLogger::enable($dbg, $this->uid ?? 0, $this->username ?? ''); + } + } + $this->referrer = $_SESSION['url'] ?? "/"; $this->url = $_SESSION['url'] = $_SERVER['PHP_SELF']; diff --git a/Templates/debug_status.tpl b/Templates/debug_status.tpl new file mode 100644 index 00000000..fb2636af --- /dev/null +++ b/Templates/debug_status.tpl @@ -0,0 +1,49 @@ +setDebugMode($newState, $session->uid); + // redirect to clean the URL + $cleanUrl = strtok($_SERVER["REQUEST_URI"], '?'); + header("Location: $cleanUrl"); + exit; + } + + $dbg = $database->getDebugMode(); + + if(!empty($dbg['active'])) { + $started = !empty($dbg['started_at']) ? date('H:i d.m.Y', $dbg['started_at']) : '-'; + ?> + + + + + + + + diff --git a/Templates/menu.tpl b/Templates/menu.tpl index 8be9ab80..e9f1f94a 100644 --- a/Templates/menu.tpl +++ b/Templates/menu.tpl @@ -195,6 +195,11 @@ $idUser = isset($_SESSION['id_user']) ? (int)$_SESSION['id_user'] : 0; */ include("Templates/maintenance_status.tpl"); + /** + * Debug Error Log quick toggle for admins + */ + include("Templates/debug_status.tpl"); + ?>

      diff --git a/src/Utils/DebugErrorLogger.php b/src/Utils/DebugErrorLogger.php new file mode 100644 index 00000000..e1be7caf --- /dev/null +++ b/src/Utils/DebugErrorLogger.php @@ -0,0 +1,176 @@ + Which severities to capture. */ + private static $capture = []; + + /** @var string Per-request context appended to every line. */ + private static $context = ''; + + /** @var bool Guards against double registration. */ + private static $registered = false; + + /** + * Turn the capture on for the current request. + * + * @param array $cfg The debug_log config row (levels + max_size_mb). + * @param int $uid Current player id (0 when not logged in). + * @param string $name Current player username (for context). + */ + public static function enable(array $cfg, $uid = 0, $name = '') { + if (self::$registered) { + return; + } + self::$registered = true; + + // Resolve the project root (max 5 levels up), like AccessLogger. + $autoprefix = ''; + for ($i = 0; $i < 5; $i++) { + $autoprefix = str_repeat('../', $i); + if (file_exists($autoprefix . 'autoloader.php')) { + break; + } + } + + self::$file = $autoprefix . 'var/log/debug-players.log'; + self::$maxBytes = max(1, (int)($cfg['max_size_mb'] ?? 5)) * 1024 * 1024; + + self::$capture = [ + 'WARNING' => !empty($cfg['lvl_warning']), + 'NOTICE' => !empty($cfg['lvl_notice']), + 'DEPRECATED' => !empty($cfg['lvl_deprecated']), + 'FATAL' => !empty($cfg['lvl_fatal']), + ]; + + $page = $_SERVER['PHP_SELF'] ?? 'cli'; + self::$context = 'uid=' . (int)$uid + . ($name !== '' ? ' (' . $name . ')' : '') + . ' | page=' . $page; + + set_error_handler([self::class, 'handleError']); + register_shutdown_function([self::class, 'handleShutdown']); + } + + /** + * Map a PHP error constant to one of our four severity buckets. + */ + private static function bucket($errno) { + switch ($errno) { + case E_WARNING: + case E_USER_WARNING: + case E_CORE_WARNING: + case E_COMPILE_WARNING: + return 'WARNING'; + case E_NOTICE: + case E_USER_NOTICE: + return 'NOTICE'; + case E_DEPRECATED: + case E_USER_DEPRECATED: + return 'DEPRECATED'; + default: + // E_ERROR, E_PARSE, E_RECOVERABLE_ERROR, E_USER_ERROR, ... + return 'FATAL'; + } + } + + /** + * Runtime error handler. Returns false so PHP's default handling still + * runs (respecting display_errors/error_reporting), keeping behaviour + * unchanged. Suppressed errors (the @ operator) are ignored on purpose. + */ + public static function handleError($errno, $errstr, $errfile = '', $errline = 0) { + // Respect the @ operator: in PHP 8 error_reporting() returns 0 inside + // the handler for a suppressed error. + if (error_reporting() === 0) { + return false; + } + + $bucket = self::bucket($errno); + if (!empty(self::$capture[$bucket])) { + self::write($bucket, $errstr, $errfile, $errline); + } + + return false; + } + + /** + * Shutdown handler: catches fatal errors that the error handler cannot + * (E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR). + */ + public static function handleShutdown() { + if (empty(self::$capture['FATAL'])) { + return; + } + + $err = error_get_last(); + if ($err === null) { + return; + } + + $fatal = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR; + if (($err['type'] & $fatal) === 0) { + return; + } + + self::write('FATAL', $err['message'], $err['file'], $err['line']); + } + + /** + * Append one formatted line, rotating the file first if it grew past the + * size cap. Never throws — logging must not break the game. + */ + private static function write($severity, $message, $file, $line) { + try { + if (self::$file === null) { + return; + } + + // Size-cap rotation: keep one ".1" backup, then start fresh. + if (@file_exists(self::$file) && @filesize(self::$file) >= self::$maxBytes) { + @rename(self::$file, self::$file . '.1'); + } + + $message = str_replace(["\r", "\n"], ' ', (string)$message); + $entry = '[' . date('Y-m-d H:i:s') . '] ' + . $severity . ': ' . $message + . ' in ' . $file . ':' . $line + . ' | ' . self::$context . "\n"; + + @file_put_contents(self::$file, $entry, FILE_APPEND | LOCK_EX); + } catch (\Throwable $e) { + // swallow: a logging failure must never surface to players + } + } +} diff --git a/var/db/struct.sql b/var/db/struct.sql index 6cc55bc1..6897a85d 100644 --- a/var/db/struct.sql +++ b/var/db/struct.sql @@ -1828,3 +1828,28 @@ CREATE TABLE IF NOT EXISTS `%PREFIX%maintenance` ( -- INSERT INTO `%PREFIX%maintenance` (`id`, `active`, `message`, `started_by`, `started_at`) VALUES (1, 0, 'Server in maintenance', NULL, NULL); + +-- +-- Table structure for table `%PREFIX%debug_log` +-- Admin-controlled PHP error capture (transparent to players). One row (id=1). +-- + +CREATE TABLE IF NOT EXISTS `%PREFIX%debug_log` ( + `id` tinyint(1) NOT NULL DEFAULT 1, + `active` tinyint(1) NOT NULL DEFAULT 0, + `lvl_warning` tinyint(1) NOT NULL DEFAULT 1, + `lvl_notice` tinyint(1) NOT NULL DEFAULT 1, + `lvl_deprecated` tinyint(1) NOT NULL DEFAULT 1, + `lvl_fatal` tinyint(1) NOT NULL DEFAULT 1, + `max_size_mb` int(11) NOT NULL DEFAULT 5, + `auto_off_hours` int(11) NOT NULL DEFAULT 6, + `started_by` int(11) DEFAULT NULL, + `started_at` int(11) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- +-- Dumping data for table `%PREFIX%debug_log` +-- +INSERT INTO `%PREFIX%debug_log` (`id`, `active`, `lvl_warning`, `lvl_notice`, `lvl_deprecated`, `lvl_fatal`, `max_size_mb`, `auto_off_hours`, `started_by`, `started_at`) VALUES +(1, 0, 1, 1, 1, 1, 5, 6, NULL, NULL);