| 1 |
<?php |
| 2 |
namespace Forge12\Shared; |
| 3 |
|
| 4 |
// Sicherstellen, dass Interface existiert |
| 5 |
if (! interface_exists('Forge12\Shared\LoggerInterface')) { |
| 6 |
require_once __DIR__ . '/logger.interface.php'; |
| 7 |
} |
| 8 |
|
| 9 |
if (!defined('F12_DEBUG')) { |
| 10 |
define('F12_DEBUG', false); |
| 11 |
} |
| 12 |
|
| 13 |
if (!defined('F12_DEBUG_LOG_LEVEL')) { |
| 14 |
define('F12_DEBUG_LOG_LEVEL', 200); |
| 15 |
} |
| 16 |
|
| 17 |
if (!class_exists('Forge12\Shared\Logger')) { |
| 18 |
class Logger implements LoggerInterface |
| 19 |
{ |
| 20 |
private static $instance; |
| 21 |
private $log_file; |
| 22 |
private $log_level; |
| 23 |
private $log_dir; |
| 24 |
|
| 25 |
const DEBUG = 100; |
| 26 |
const INFO = 200; |
| 27 |
const NOTICE = 250; |
| 28 |
const WARNING = 300; |
| 29 |
const ERROR = 400; |
| 30 |
const CRITICAL = 500; |
| 31 |
|
| 32 |
private function __construct() |
| 33 |
{ |
| 34 |
// Debug aus → Logger ist komplett deaktiviert |
| 35 |
if (!defined('F12_DEBUG') || !F12_DEBUG) { |
| 36 |
return; |
| 37 |
} |
| 38 |
|
| 39 |
// Sicher WordPress Upload-DIR holen |
| 40 |
$upload_dir = wp_upload_dir(); |
| 41 |
|
| 42 |
if (empty($upload_dir['basedir']) || !is_string($upload_dir['basedir'])) { |
| 43 |
$base = WP_CONTENT_DIR . '/uploads'; |
| 44 |
} else { |
| 45 |
$base = $upload_dir['basedir']; |
| 46 |
} |
| 47 |
|
| 48 |
// Pfad normalisieren |
| 49 |
$base = $this->normalizePath($base); |
| 50 |
|
| 51 |
// Log-Ordner festlegen |
| 52 |
$this->log_dir = $this->normalizePath($base . '/f12-logs'); |
| 53 |
|
| 54 |
// Falls Pfad relativ ist → absolut machen |
| 55 |
$this->log_dir = $this->ensureAbsolutePath($this->log_dir); |
| 56 |
|
| 57 |
// Sicherstellen, dass Verzeichnis existiert |
| 58 |
if (!is_dir($this->log_dir)) { |
| 59 |
wp_mkdir_p($this->log_dir); |
| 60 |
} |
| 61 |
|
| 62 |
// The directory is inside uploads/ and therefore web-reachable. |
| 63 |
// A guessable name (plugins-<date>.log) was downloadable by |
| 64 |
// anyone on a live site. Deny rules first, unguessable file |
| 65 |
// names second — the latter also holds on servers that ignore |
| 66 |
// .htaccess (nginx). |
| 67 |
$secret = self::fileSecret(); |
| 68 |
self::protectDirectory($this->log_dir); |
| 69 |
self::migrateGuessableFiles($this->log_dir, $secret); |
| 70 |
|
| 71 |
// Logdatei definieren |
| 72 |
$this->log_file = $this->normalizePath( |
| 73 |
self::logFilePath($this->log_dir, date('Y-m-d'), $secret) |
| 74 |
); |
| 75 |
|
| 76 |
// Logdatei 100% absolut sicher machen |
| 77 |
$this->log_file = $this->ensureAbsolutePath($this->log_file); |
| 78 |
|
| 79 |
// Log-Level |
| 80 |
$this->log_level = defined('F12_DEBUG_LOG_LEVEL') |
| 81 |
? F12_DEBUG_LOG_LEVEL |
| 82 |
: self::INFO; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Daily log file: plugins-<date>-<secret>.log. The glob in |
| 87 |
* cleanupOldLogs() (plugins-*.log*) matches both shapes. |
| 88 |
*/ |
| 89 |
public static function logFilePath(string $dir, string $date, string $secret): string |
| 90 |
{ |
| 91 |
return rtrim($dir, '/\\') . '/plugins-' . $date . ($secret !== '' ? '-' . $secret : '') . '.log'; |
| 92 |
} |
| 93 |
|
| 94 |
/** |
| 95 |
* A per-site value nobody outside the server knows. Derived from |
| 96 |
* the wp-config keys when they are set (no database access — the |
| 97 |
* logger is constructed while plugins load); otherwise a random |
| 98 |
* value kept in an option. |
| 99 |
*/ |
| 100 |
public static function fileSecret(): string |
| 101 |
{ |
| 102 |
$material = ''; |
| 103 |
foreach (array('AUTH_KEY', 'AUTH_SALT', 'SECURE_AUTH_KEY') as $constant) { |
| 104 |
if (defined($constant)) { |
| 105 |
$value = (string) constant($constant); |
| 106 |
if ($value !== '' && $value !== 'put your unique phrase here') { |
| 107 |
$material .= $value; |
| 108 |
} |
| 109 |
} |
| 110 |
} |
| 111 |
|
| 112 |
if ($material === '' && function_exists('get_option')) { |
| 113 |
$stored = get_option('f12_logs_file_secret'); |
| 114 |
if (!is_string($stored) || strlen($stored) < 32) { |
| 115 |
$stored = bin2hex(random_bytes(16)); |
| 116 |
if (function_exists('update_option')) { |
| 117 |
update_option('f12_logs_file_secret', $stored, false); |
| 118 |
} |
| 119 |
} |
| 120 |
$material = $stored; |
| 121 |
} |
| 122 |
|
| 123 |
return $material === '' ? '' : substr(hash_hmac('sha256', 'f12-logs', $material), 0, 20); |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Deny web access to the log directory: Apache 2.2 and 2.4 |
| 128 |
* (.htaccess), IIS (web.config), and no directory listing |
| 129 |
* (index.php). Existing files are left alone, so an admin's own |
| 130 |
* rules are never overwritten. |
| 131 |
*/ |
| 132 |
public static function protectDirectory(string $dir): void |
| 133 |
{ |
| 134 |
if (!is_dir($dir) || !is_writable($dir)) { |
| 135 |
return; |
| 136 |
} |
| 137 |
|
| 138 |
$files = array( |
| 139 |
'.htaccess' => "# Written by the Forge12 logger. Log files must not be downloadable.\n" |
| 140 |
. "<IfModule mod_authz_core.c>\n\tRequire all denied\n</IfModule>\n" |
| 141 |
. "<IfModule !mod_authz_core.c>\n\tOrder allow,deny\n\tDeny from all\n</IfModule>\n", |
| 142 |
'web.config' => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n" |
| 143 |
. "\t<system.webServer>\n\t\t<authorization>\n\t\t\t<deny users=\"*\" />\n\t\t</authorization>\n" |
| 144 |
. "\t</system.webServer>\n</configuration>\n", |
| 145 |
'index.php' => "<?php\n// Silence is golden.\n", |
| 146 |
); |
| 147 |
|
| 148 |
foreach ($files as $name => $content) { |
| 149 |
$path = $dir . '/' . $name; |
| 150 |
if (!file_exists($path)) { |
| 151 |
@file_put_contents($path, $content); |
| 152 |
} |
| 153 |
} |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* Rename log files from the guessable scheme (plugins-<date>.log, |
| 158 |
* plugins-<date>.log.<n>) so their known URLs stop working. |
| 159 |
*/ |
| 160 |
public static function migrateGuessableFiles(string $dir, string $secret): void |
| 161 |
{ |
| 162 |
if ($secret === '' || !is_dir($dir)) { |
| 163 |
return; |
| 164 |
} |
| 165 |
|
| 166 |
foreach ((array) glob($dir . '/plugins-*.log*') as $file) { |
| 167 |
if (!is_string($file)) { |
| 168 |
continue; |
| 169 |
} |
| 170 |
if (!preg_match('/^plugins-(\d{4}-\d{2}-\d{2})\.log(\.\d+)?$/', basename($file), $m)) { |
| 171 |
continue; |
| 172 |
} |
| 173 |
$target = self::logFilePath($dir, $m[1], $secret) . ($m[2] ?? ''); |
| 174 |
if (file_exists($target)) { |
| 175 |
// Both exist (concurrent request): keep the content. |
| 176 |
@file_put_contents($target, (string) @file_get_contents($file), FILE_APPEND); |
| 177 |
@unlink($file); |
| 178 |
} else { |
| 179 |
@rename($file, $target); |
| 180 |
} |
| 181 |
} |
| 182 |
} |
| 183 |
|
| 184 |
public static function getInstance() |
| 185 |
{ |
| 186 |
if (!self::$instance) { |
| 187 |
self::$instance = new self(); |
| 188 |
} |
| 189 |
return self::$instance; |
| 190 |
} |
| 191 |
|
| 192 |
/** |
| 193 |
* Pfade bereinigen (Windows + Linux) |
| 194 |
*/ |
| 195 |
private function normalizePath(string $path): string |
| 196 |
{ |
| 197 |
// Backslashes → Slashes |
| 198 |
$path = str_replace('\\', '/', $path); |
| 199 |
|
| 200 |
// Doppelte Slashes entfernen, außer nach C: |
| 201 |
$path = preg_replace('#(?<!:)/{2,}#', '/', $path); |
| 202 |
|
| 203 |
return rtrim($path, '/'); |
| 204 |
} |
| 205 |
|
| 206 |
/** |
| 207 |
* ABSOLUTEN Pfad erzwingen |
| 208 |
*/ |
| 209 |
private function ensureAbsolutePath(string $path): string |
| 210 |
{ |
| 211 |
$path = $this->normalizePath($path); |
| 212 |
|
| 213 |
// Linux absolute Pfade: /var/www/... |
| 214 |
if (substr($path, 0, 1) === '/') { |
| 215 |
return $path; |
| 216 |
} |
| 217 |
|
| 218 |
// Windows absolute Pfade: C:/xampp/... |
| 219 |
if (preg_match('#^[A-Za-z]:/#', $path)) { |
| 220 |
return $path; |
| 221 |
} |
| 222 |
|
| 223 |
// → Pfad ist relativ → ABSPATH davor hängen |
| 224 |
$absolute = $this->normalizePath(ABSPATH . '/' . $path); |
| 225 |
|
| 226 |
return $absolute; |
| 227 |
} |
| 228 |
|
| 229 |
|
| 230 |
private function sanitizeContext(array $context): array |
| 231 |
{ |
| 232 |
foreach ($context as $key => $value) { |
| 233 |
if (in_array(strtolower($key), ['ip', 'user_ip'])) { |
| 234 |
$context[$key] = $this->mask_ip($value); |
| 235 |
} |
| 236 |
if (in_array(strtolower($key), ['email', 'user_email'])) { |
| 237 |
$context[$key] = $this->mask_email($value); |
| 238 |
} |
| 239 |
if (in_array(strtolower($key), ['password', 'pwd'])) { |
| 240 |
$context[$key] = $this->mask_password($value); |
| 241 |
} |
| 242 |
} |
| 243 |
return $context; |
| 244 |
} |
| 245 |
|
| 246 |
private function mask_password(string $password): string |
| 247 |
{ |
| 248 |
return '********'; |
| 249 |
} |
| 250 |
|
| 251 |
private function mask_email(string $email): string |
| 252 |
{ |
| 253 |
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { |
| 254 |
return 'invalid'; |
| 255 |
} |
| 256 |
[$user, $domain] = explode('@', $email, 2); |
| 257 |
$len = strlen($user); |
| 258 |
if ($len <= 2) { |
| 259 |
$maskedUser = substr($user, 0, 1) . '*'; |
| 260 |
} else { |
| 261 |
$maskedUser = substr($user, 0, 1) . str_repeat('*', $len - 2) . substr($user, -1); |
| 262 |
} |
| 263 |
return $maskedUser . '@' . $domain; |
| 264 |
} |
| 265 |
|
| 266 |
private function mask_ip(string $ip): string |
| 267 |
{ |
| 268 |
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { |
| 269 |
$parts = explode('.', $ip); |
| 270 |
$parts[3] = '0'; |
| 271 |
return implode('.', $parts); |
| 272 |
} |
| 273 |
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { |
| 274 |
return substr($ip, 0, 20) . '::'; |
| 275 |
} |
| 276 |
return 'unknown'; |
| 277 |
} |
| 278 |
|
| 279 |
private function rotateLogs(int $maxSize = 52428800, int $maxFiles = 5): void |
| 280 |
{ |
| 281 |
if (file_exists($this->log_file) && filesize($this->log_file) > $maxSize) { |
| 282 |
|
| 283 |
for ($i = $maxFiles - 1; $i >= 1; $i--) { |
| 284 |
$old = $this->log_file . '.' . $i; |
| 285 |
$new = $this->log_file . '.' . ($i + 1); |
| 286 |
if (file_exists($old)) { |
| 287 |
rename($old, $new); |
| 288 |
} |
| 289 |
} |
| 290 |
|
| 291 |
rename($this->log_file, $this->log_file . '.1'); |
| 292 |
} |
| 293 |
} |
| 294 |
|
| 295 |
private function cleanupOldLogs(int $days = 7): void |
| 296 |
{ |
| 297 |
foreach (glob($this->log_dir . '/plugins-*.log*') as $file) { |
| 298 |
if (filemtime($file) < strtotime("-{$days} days")) { |
| 299 |
@unlink($file); |
| 300 |
} |
| 301 |
} |
| 302 |
} |
| 303 |
|
| 304 |
private function writeLog($level, $levelName, $message, array $context = []) |
| 305 |
{ |
| 306 |
if (!defined('F12_DEBUG') || !F12_DEBUG) { |
| 307 |
return; |
| 308 |
} |
| 309 |
|
| 310 |
if ($level < $this->log_level) { |
| 311 |
return; |
| 312 |
} |
| 313 |
|
| 314 |
// Vor jedem Schreiben absolut sicherstellen |
| 315 |
$this->log_file = $this->ensureAbsolutePath($this->log_file); |
| 316 |
|
| 317 |
// Cleanup & Rotation |
| 318 |
$this->cleanupOldLogs(); |
| 319 |
$this->rotateLogs(); |
| 320 |
|
| 321 |
$context = $this->sanitizeContext($context); |
| 322 |
|
| 323 |
$time = date('Y-m-d H:i:s'); |
| 324 |
$plugin = $context['plugin'] ?? 'unknown'; |
| 325 |
|
| 326 |
$msg = sprintf( |
| 327 |
"[%s] [%s] [%s] %s %s\n", |
| 328 |
$time, |
| 329 |
strtoupper($levelName), |
| 330 |
$plugin, |
| 331 |
$message, |
| 332 |
$context ? json_encode($context) : '' |
| 333 |
); |
| 334 |
|
| 335 |
// Schreiben in absolut sicheren Pfad |
| 336 |
error_log($msg, 3, $this->log_file); |
| 337 |
} |
| 338 |
|
| 339 |
public function debug($message, array $context = []): void |
| 340 |
{ |
| 341 |
if ( ! F12_DEBUG ) { return; } |
| 342 |
$this->writeLog(self::DEBUG, 'DEBUG', $message, $context); |
| 343 |
} |
| 344 |
|
| 345 |
public function info($message, array $context = []): void |
| 346 |
{ |
| 347 |
if ( ! F12_DEBUG ) { return; } |
| 348 |
$this->writeLog(self::INFO, 'INFO', $message, $context); |
| 349 |
} |
| 350 |
|
| 351 |
public function error($message, array $context = []): void |
| 352 |
{ |
| 353 |
if ( ! F12_DEBUG ) { return; } |
| 354 |
$this->writeLog(self::ERROR, 'ERROR', $message, $context); |
| 355 |
} |
| 356 |
|
| 357 |
public function warning($message, array $context = []): void |
| 358 |
{ |
| 359 |
if ( ! F12_DEBUG ) { return; } |
| 360 |
$this->writeLog(self::WARNING, 'WARNING', $message, $context); |
| 361 |
} |
| 362 |
|
| 363 |
public function notice($message, array $context = []): void |
| 364 |
{ |
| 365 |
if ( ! F12_DEBUG ) { return; } |
| 366 |
$this->writeLog(self::NOTICE, 'NOTICE', $message, $context); |
| 367 |
} |
| 368 |
|
| 369 |
public function critical($message, array $context = []): void |
| 370 |
{ |
| 371 |
if ( ! F12_DEBUG ) { return; } |
| 372 |
$this->writeLog(self::CRITICAL, 'CRITICAL', $message, $context); |
| 373 |
} |
| 374 |
} |
| 375 |
} |
| 376 |
|