Update -> add new log and analytices users
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
.env.DS_Store
|
.env.DS_Store
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
storage/logs/*.log
|
||||||
|
storage/
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../core/security.php';
|
require_once __DIR__ . '/../core/security.php';
|
||||||
|
require_once __DIR__ . '/../core/logger.php';
|
||||||
$pdo = require __DIR__ . '/../core/db.php';
|
$pdo = require __DIR__ . '/../core/db.php';
|
||||||
require_once __DIR__ . '/../models/Paste.php';
|
require_once __DIR__ . '/../models/Paste.php';
|
||||||
|
require_once __DIR__ . '/../models/Analytics.php';
|
||||||
$config = require __DIR__ . '/../config/config.php';
|
$config = require __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
$logger = new Logger('save');
|
||||||
|
$analytics = new Analytics($pdo);
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$logger->warning('Non-POST request to save endpoint', ['method' => $_SERVER['REQUEST_METHOD']]);
|
||||||
header('Location: /');
|
header('Location: /');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -14,6 +20,8 @@ $password = $_POST['password'] ?? '';
|
|||||||
$expire = isset($_POST['expire']) ? (int)$_POST['expire'] : 0;
|
$expire = isset($_POST['expire']) ? (int)$_POST['expire'] : 0;
|
||||||
|
|
||||||
if ($text === '') {
|
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);
|
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;
|
$expire_time = $expire > 0 ? time() + $expire : null;
|
||||||
|
|
||||||
$paste = new Paste($pdo);
|
$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']), '/');
|
$base = rtrim($config['app']['base_url'] ?: ('http' . (isset($_SERVER['HTTPS']) ? 's' : '') . '://' . $_SERVER['HTTP_HOST']), '/');
|
||||||
$url = $base . '/view/' . $id;
|
$url = $base . '/view/' . $id;
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../core/security.php';
|
require_once __DIR__ . '/../core/security.php';
|
||||||
|
require_once __DIR__ . '/../core/logger.php';
|
||||||
$pdo = require __DIR__ . '/../core/db.php';
|
$pdo = require __DIR__ . '/../core/db.php';
|
||||||
require_once __DIR__ . '/../models/Paste.php';
|
require_once __DIR__ . '/../models/Paste.php';
|
||||||
|
require_once __DIR__ . '/../models/Analytics.php';
|
||||||
$config = require __DIR__ . '/../config/config.php';
|
$config = require __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
$logger = new Logger('view');
|
||||||
|
$analytics = new Analytics($pdo);
|
||||||
|
|
||||||
$id = preg_replace('/[^a-f0-9]/i', '', $_GET['id'] ?? '');
|
$id = preg_replace('/[^a-f0-9]/i', '', $_GET['id'] ?? '');
|
||||||
|
|
||||||
if ($id === '') {
|
if ($id === '') {
|
||||||
|
$logger->warning('View request with invalid or missing paste ID', ['raw_id' => $_GET['id'] ?? '']);
|
||||||
|
$analytics->record('paste_not_found', null, ['reason' => 'invalid_id']);
|
||||||
$errorCode = 404;
|
$errorCode = 404;
|
||||||
$errorMessage = 'Invalid paste ID.';
|
$errorMessage = 'Invalid paste ID.';
|
||||||
require __DIR__ . '/../../public/error.php';
|
require __DIR__ . '/../../public/error.php';
|
||||||
@@ -17,6 +24,8 @@ $paste = new Paste($pdo);
|
|||||||
$data = $paste->get($id);
|
$data = $paste->get($id);
|
||||||
|
|
||||||
if (!$data) {
|
if (!$data) {
|
||||||
|
$logger->info('Paste not found', ['id' => $id]);
|
||||||
|
$analytics->record('paste_not_found', $id);
|
||||||
$errorCode = 404;
|
$errorCode = 404;
|
||||||
$errorMessage = 'Paste not found.';
|
$errorMessage = 'Paste not found.';
|
||||||
require __DIR__ . '/../../public/error.php';
|
require __DIR__ . '/../../public/error.php';
|
||||||
@@ -24,33 +33,49 @@ if (!$data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($data['expire_time'] !== null && time() > (int)$data['expire_time']) {
|
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;
|
$errorCode = 410;
|
||||||
$errorMessage = 'This paste has expired.';
|
$errorMessage = 'This paste has expired.';
|
||||||
require __DIR__ . '/../../public/error.php';
|
require __DIR__ . '/../../public/error.php';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$needsPassword = (bool)$data['password_hash'];
|
$needsPassword = (bool)$data['password_hash'];
|
||||||
$wrongPassword = false;
|
$wrongPassword = false;
|
||||||
$decrypted = null;
|
$decrypted = null;
|
||||||
|
|
||||||
if ($needsPassword) {
|
if ($needsPassword) {
|
||||||
$submitted = $_POST['password'] ?? null;
|
$submitted = $_POST['password'] ?? null;
|
||||||
if ($submitted !== null) {
|
if ($submitted !== null) {
|
||||||
if (password_verify($submitted, $data['password_hash'])) {
|
if (password_verify($submitted, $data['password_hash'])) {
|
||||||
$needsPassword = false;
|
$needsPassword = false;
|
||||||
|
$logger->info('Paste unlocked successfully', ['id' => $id]);
|
||||||
|
$analytics->record('paste_unlocked', $id);
|
||||||
} else {
|
} else {
|
||||||
$wrongPassword = true;
|
$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) {
|
if (!$needsPassword) {
|
||||||
$decrypted = decryptText($data['encrypted_text'], $data['iv'], $config['app']['master_key']);
|
$decrypted = decryptText($data['encrypted_text'], $data['iv'], $config['app']['master_key']);
|
||||||
if ($decrypted === false) {
|
if ($decrypted === false) {
|
||||||
|
$logger->error('Decryption failed for paste', ['id' => $id]);
|
||||||
|
$analytics->record('paste_decrypt_error', $id);
|
||||||
$errorCode = 500;
|
$errorCode = 500;
|
||||||
$errorMessage = 'Decryption failed. The paste may be corrupted.';
|
$errorMessage = 'Decryption failed. The paste may be corrupted.';
|
||||||
require __DIR__ . '/../../public/error.php';
|
require __DIR__ . '/../../public/error.php';
|
||||||
exit;
|
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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/logger.php';
|
||||||
|
|
||||||
$config = require __DIR__ . '/../config/config.php';
|
$config = require __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -14,6 +16,7 @@ try {
|
|||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
]);
|
]);
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
|
(new Logger('db'))->error('Database connection failed', ['error' => $e->getMessage()]);
|
||||||
http_response_code(503);
|
http_response_code(503);
|
||||||
die(json_encode(['success' => false, 'message' => 'Database connection error.']));
|
die(json_encode(['success' => false, 'message' => 'Database connection error.']));
|
||||||
}
|
}
|
||||||
|
|||||||
76
app/core/logger.php
Normal file
76
app/core/logger.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Logger
|
||||||
|
{
|
||||||
|
const INFO = 'INFO';
|
||||||
|
const WARNING = 'WARNING';
|
||||||
|
const ERROR = 'ERROR';
|
||||||
|
|
||||||
|
private string $logDir;
|
||||||
|
private string $context;
|
||||||
|
|
||||||
|
public function __construct(string $context = 'app')
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/models/Analytics.php
Normal file
44
app/models/Analytics.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Analytics
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
17
database/analytics_migration.sql
Normal file
17
database/analytics_migration.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user