From a6a414c2f59057164a014d0b1d88e030a0705960 Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Fri, 10 Apr 2026 12:01:36 +0330 Subject: [PATCH] Update -> add new log and analytices users --- .gitignore | 5 +- app/controllers/SaveController.php | 31 +++++++++++- app/controllers/ViewController.php | 31 ++++++++++-- app/core/db.php | 3 ++ app/core/logger.php | 76 ++++++++++++++++++++++++++++++ app/models/Analytics.php | 44 +++++++++++++++++ database/analytics_migration.sql | 17 +++++++ 7 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 app/core/logger.php create mode 100644 app/models/Analytics.php create mode 100644 database/analytics_migration.sql diff --git a/.gitignore b/.gitignore index f6282b4..85bfd28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .env.DS_Store .env -.DS_Store \ No newline at end of file +.DS_Store + +storage/logs/*.log +storage/ diff --git a/app/controllers/SaveController.php b/app/controllers/SaveController.php index 3330479..dc1a259 100644 --- a/app/controllers/SaveController.php +++ b/app/controllers/SaveController.php @@ -1,10 +1,16 @@ warning('Non-POST request to save endpoint', ['method' => $_SERVER['REQUEST_METHOD']]); header('Location: /'); exit; } @@ -14,6 +20,8 @@ $password = $_POST['password'] ?? ''; $expire = isset($_POST['expire']) ? (int)$_POST['expire'] : 0; if ($text === '') { + $logger->warning('Empty paste submission attempt'); + $analytics->record('paste_validation_failed', null, ['reason' => 'empty_text']); jsonResponse(['success' => false, 'message' => 'Text cannot be empty.'], 422); } @@ -23,7 +31,28 @@ $password_hash = $password !== '' ? password_hash($password, PASSWORD_DEFAULT) : $expire_time = $expire > 0 ? time() + $expire : null; $paste = new Paste($pdo); -$paste->save($id, $enc['cipher'], $enc['iv'], $expire_time, $password_hash); +$saved = $paste->save($id, $enc['cipher'], $enc['iv'], $expire_time, $password_hash); + +if (!$saved) { + $logger->error('Failed to save paste to storage', ['id' => $id]); + $analytics->record('paste_save_failed', $id); + jsonResponse(['success' => false, 'message' => 'Failed to save paste. Please try again.'], 500); +} + +$charCount = mb_strlen($text, 'UTF-8'); +$logger->info('Paste created', [ + 'id' => $id, + 'char_count' => $charCount, + 'has_password' => $password_hash !== null, + 'expires_in' => $expire > 0 ? "{$expire}s" : 'never', +]); + +$analytics->record('paste_created', $id, [ + 'char_count' => $charCount, + 'has_password' => $password_hash !== null, + 'expire_secs' => $expire > 0 ? $expire : null, + 'storage' => $expire_time !== null ? 'redis' : 'mysql', +]); $base = rtrim($config['app']['base_url'] ?: ('http' . (isset($_SERVER['HTTPS']) ? 's' : '') . '://' . $_SERVER['HTTP_HOST']), '/'); $url = $base . '/view/' . $id; diff --git a/app/controllers/ViewController.php b/app/controllers/ViewController.php index bfa3f76..76615b6 100644 --- a/app/controllers/ViewController.php +++ b/app/controllers/ViewController.php @@ -1,12 +1,19 @@ warning('View request with invalid or missing paste ID', ['raw_id' => $_GET['id'] ?? '']); + $analytics->record('paste_not_found', null, ['reason' => 'invalid_id']); $errorCode = 404; $errorMessage = 'Invalid paste ID.'; require __DIR__ . '/../../public/error.php'; @@ -17,6 +24,8 @@ $paste = new Paste($pdo); $data = $paste->get($id); if (!$data) { + $logger->info('Paste not found', ['id' => $id]); + $analytics->record('paste_not_found', $id); $errorCode = 404; $errorMessage = 'Paste not found.'; require __DIR__ . '/../../public/error.php'; @@ -24,33 +33,49 @@ if (!$data) { } if ($data['expire_time'] !== null && time() > (int)$data['expire_time']) { + $logger->info('Expired paste accessed', ['id' => $id, 'expired_at' => $data['expire_time']]); + $analytics->record('paste_expired', $id, ['expired_at' => $data['expire_time']]); $errorCode = 410; $errorMessage = 'This paste has expired.'; require __DIR__ . '/../../public/error.php'; exit; } -$needsPassword = (bool)$data['password_hash']; -$wrongPassword = false; -$decrypted = null; +$needsPassword = (bool)$data['password_hash']; +$wrongPassword = false; +$decrypted = null; if ($needsPassword) { $submitted = $_POST['password'] ?? null; if ($submitted !== null) { if (password_verify($submitted, $data['password_hash'])) { $needsPassword = false; + $logger->info('Paste unlocked successfully', ['id' => $id]); + $analytics->record('paste_unlocked', $id); } else { $wrongPassword = true; + $logger->warning('Failed password attempt for paste', ['id' => $id]); + $analytics->record('paste_failed_password', $id); } + } else { + $analytics->record('paste_password_prompt', $id); } } if (!$needsPassword) { $decrypted = decryptText($data['encrypted_text'], $data['iv'], $config['app']['master_key']); if ($decrypted === false) { + $logger->error('Decryption failed for paste', ['id' => $id]); + $analytics->record('paste_decrypt_error', $id); $errorCode = 500; $errorMessage = 'Decryption failed. The paste may be corrupted.'; require __DIR__ . '/../../public/error.php'; exit; } + + $logger->info('Paste viewed', ['id' => $id, 'char_count' => mb_strlen($decrypted, 'UTF-8')]); + $analytics->record('paste_viewed', $id, [ + 'char_count' => mb_strlen($decrypted, 'UTF-8'), + 'has_expiry' => $data['expire_time'] !== null, + ]); } diff --git a/app/core/db.php b/app/core/db.php index 4a41208..9426454 100644 --- a/app/core/db.php +++ b/app/core/db.php @@ -1,4 +1,6 @@ false, ]); } catch (PDOException $e) { + (new Logger('db'))->error('Database connection failed', ['error' => $e->getMessage()]); http_response_code(503); die(json_encode(['success' => false, 'message' => 'Database connection error.'])); } diff --git a/app/core/logger.php b/app/core/logger.php new file mode 100644 index 0000000..b923d56 --- /dev/null +++ b/app/core/logger.php @@ -0,0 +1,76 @@ +logDir = __DIR__ . '/../../storage/logs'; + $this->context = $context; + + if (!is_dir($this->logDir)) { + mkdir($this->logDir, 0755, true); + } + } + + public function info(string $message, array $extra = []): void + { + $this->write(self::INFO, $message, $extra); + } + + public function warning(string $message, array $extra = []): void + { + $this->write(self::WARNING, $message, $extra); + } + + public function error(string $message, array $extra = []): void + { + $this->write(self::ERROR, $message, $extra); + } + + private function write(string $level, string $message, array $extra = []): void + { + $date = date('Y-m-d'); + $file = "{$this->logDir}/{$date}.log"; + $time = date('Y-m-d H:i:s'); + $ip = $this->getClientIp(); + $extraStr = empty($extra) ? '' : ' ' . json_encode($extra, JSON_UNESCAPED_UNICODE); + + $line = "[{$time}] [{$level}] [{$this->context}] [{$ip}] {$message}{$extraStr}" . PHP_EOL; + + file_put_contents($file, $line, FILE_APPEND | LOCK_EX); + } + + private function getClientIp(): string + { + foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $key) { + if (!empty($_SERVER[$key])) { + $ip = trim(explode(',', $_SERVER[$key])[0]); + if (filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + } + } + return 'unknown'; + } + + public static function cleanOldLogs(int $keepDays = 30): void + { + $logDir = __DIR__ . '/../../storage/logs'; + if (!is_dir($logDir)) { + return; + } + + foreach (glob("{$logDir}/*.log") as $file) { + if (filemtime($file) < time() - ($keepDays * 86400)) { + unlink($file); + } + } + } +} diff --git a/app/models/Analytics.php b/app/models/Analytics.php new file mode 100644 index 0000000..188bbfe --- /dev/null +++ b/app/models/Analytics.php @@ -0,0 +1,44 @@ +pdo = $pdo; + } + + public function record(string $event, ?string $pasteId = null, array $extra = []): void + { + try { + $stmt = $this->pdo->prepare( + 'INSERT INTO analytics (event, paste_id, ip, user_agent, referer, extra) + VALUES (?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $event, + $pasteId, + $this->getClientIp(), + substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512), + substr($_SERVER['HTTP_REFERER'] ?? '', 0, 512), + empty($extra) ? null : json_encode($extra, JSON_UNESCAPED_UNICODE), + ]); + } catch (PDOException $e) { + // analytics failure must never break the app + } + } + + private function getClientIp(): string + { + foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $key) { + if (!empty($_SERVER[$key])) { + $ip = trim(explode(',', $_SERVER[$key])[0]); + if (filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + } + } + return ''; + } +} diff --git a/database/analytics_migration.sql b/database/analytics_migration.sql new file mode 100644 index 0000000..2802b05 --- /dev/null +++ b/database/analytics_migration.sql @@ -0,0 +1,17 @@ +-- analytics table: tracks all user actions +CREATE TABLE IF NOT EXISTS analytics ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event VARCHAR(50) NOT NULL COMMENT 'paste_created, paste_viewed, paste_unlocked, paste_failed_password, paste_expired, paste_not_found, decrypt_error', + paste_id CHAR(32) DEFAULT NULL COMMENT 'related paste ID if applicable', + ip VARCHAR(45) NOT NULL DEFAULT '', + user_agent VARCHAR(512) DEFAULT NULL, + referer VARCHAR(512) DEFAULT NULL, + extra JSON DEFAULT NULL COMMENT 'expire_time, char_count, has_password, source, etc.', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + INDEX idx_event (event), + INDEX idx_paste_id (paste_id), + INDEX idx_ip (ip), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;