Commit fb8477ef authored by Administrator's avatar Administrator

Update 2 files via Son of Anton

parent a03e3d0d
...@@ -3,62 +3,46 @@ declare(strict_types=1); ...@@ -3,62 +3,46 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
final class Database class Database
{ {
private string $dbName; private \PDO $pdo;
private ?\PDO $pdo = null; private array $afterInsertHooks = [];
private string $host; private array $beforeUpdateHooks = [];
private int $port; private array $afterUpdateHooks = [];
private string $user; private array $afterDeleteHooks = [];
private string $pass; private array $tableCache = [];
private string $charset;
public function __construct(array $config)
private array $onAfterInsertCallbacks = []; {
private array $onBeforeUpdateCallbacks = []; $host = $config['host'] ?? '127.0.0.1';
private array $onAfterDeleteCallbacks = []; $port = $config['port'] ?? '3306';
$dbName = $config['name'] ?? '';
public function __construct( $user = $config['user'] ?? 'root';
string $host = '127.0.0.1', $pass = $config['pass'] ?? '';
int $port = 3306, $charset = $config['charset'] ?? 'utf8mb4';
string $name = '',
string $user = 'root', $dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset={$charset}";
string $pass = '',
string $charset = 'utf8mb4'
) {
$this->host = $host;
$this->port = $port;
$this->dbName = $name;
$this->user = $user;
$this->pass = $pass;
$this->charset = $charset;
}
private function connect(): \PDO
{
if ($this->pdo === null) {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->host,
$this->port,
$this->dbName,
$this->charset
);
$this->pdo = new \PDO($dsn, $this->user, $this->pass, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
\PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES '{$this->charset}' COLLATE '{$this->charset}_unicode_ci'",
]);
}
$this->pdo = new \PDO($dsn, $user, $pass, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
\PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES {$charset}",
]);
}
public function getPdo(): \PDO
{
return $this->pdo; return $this->pdo;
} }
// ═══════════════════════════════════════════
// CORE QUERY
// ═══════════════════════════════════════════
public function query(string $sql, array $params = []): \PDOStatement public function query(string $sql, array $params = []): \PDOStatement
{ {
$pdo = $this->connect(); $stmt = $this->pdo->prepare($sql);
$stmt = $pdo->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
return $stmt; return $stmt;
} }
...@@ -70,125 +54,266 @@ final class Database ...@@ -70,125 +54,266 @@ final class Database
public function selectOne(string $sql, array $params = []): ?array public function selectOne(string $sql, array $params = []): ?array
{ {
$result = $this->query($sql, $params)->fetch(); $row = $this->query($sql, $params)->fetch();
return $result ?: null; return $row ?: null;
} }
// ═══════════════════════════════════════════
// INSERT
// ═══════════════════════════════════════════
public function insert(string $table, array $data): int public function insert(string $table, array $data): int
{ {
$columns = implode(', ', array_map(fn($c) => "`{$c}`", array_keys($data))); $columns = array_keys($data);
$placeholders = implode(', ', array_fill(0, count($data), '?')); $placeholders = array_fill(0, count($columns), '?');
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$sql = sprintf(
'INSERT INTO `%s` (%s) VALUES (%s)',
$table,
implode(', ', array_map(fn($c) => "`{$c}`", $columns)),
implode(', ', $placeholders)
);
$this->query($sql, array_values($data)); $this->query($sql, array_values($data));
$id = (int) $this->connect()->lastInsertId(); $id = (int) $this->pdo->lastInsertId();
foreach ($this->onAfterInsertCallbacks as $cb) { // Fire after-insert hooks
foreach ($this->afterInsertHooks as $hook) {
try { try {
$cb($table, $data, $id); $hook($table, $data, $id);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Don't let callback errors break inserts // Hooks must never break the main operation
} }
} }
return $id; return $id;
} }
// ═══════════════════════════════════════════
// UPDATE — with before/after audit support
// ═══════════════════════════════════════════
public function update(string $table, array $data, string $where, array $whereParams = []): int public function update(string $table, array $data, string $where, array $whereParams = []): int
{ {
foreach ($this->onBeforeUpdateCallbacks as $cb) { // ── 1. Try to fetch old record(s) BEFORE updating ──
$oldRecord = null;
$entityId = null;
if (!empty($this->afterUpdateHooks)) {
try { try {
$cb($table, $data, null); // Try to extract entity ID from WHERE clause
$entityId = $this->extractIdFromWhere($where, $whereParams);
if ($entityId !== null) {
$oldRecord = $this->selectOne("SELECT * FROM `{$table}` WHERE `id` = ?", [$entityId]);
} else {
// Fallback: fetch first matching record
$oldRecord = $this->selectOne("SELECT * FROM `{$table}` WHERE {$where} LIMIT 1", $whereParams);
if ($oldRecord && isset($oldRecord['id'])) {
$entityId = (int) $oldRecord['id'];
}
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Don't let callback errors break updates // Silently continue — audit is best-effort
} }
} }
$set = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($data))); // ── 2. Fire before-update hooks ──
$sql = "UPDATE `{$table}` SET {$set} WHERE {$where}"; foreach ($this->beforeUpdateHooks as $hook) {
try {
$hook($table, $data, $entityId, $oldRecord);
} catch (\Throwable $e) {}
}
// ── 3. Execute the UPDATE ──
$setParts = [];
foreach (array_keys($data) as $col) {
$setParts[] = "`{$col}` = ?";
}
$sql = sprintf('UPDATE `%s` SET %s WHERE %s', $table, implode(', ', $setParts), $where);
$params = array_merge(array_values($data), $whereParams); $params = array_merge(array_values($data), $whereParams);
$stmt = $this->query($sql, $params); $stmt = $this->query($sql, $params);
return $stmt->rowCount(); $affected = $stmt->rowCount();
// ── 4. Fire after-update hooks with before/after data ──
if ($affected > 0) {
$newRecord = null;
if (!empty($this->afterUpdateHooks)) {
try {
if ($entityId !== null) {
$newRecord = $this->selectOne("SELECT * FROM `{$table}` WHERE `id` = ?", [$entityId]);
} elseif ($oldRecord) {
// Construct approximate new record from old + changes
$newRecord = array_merge($oldRecord, $data);
}
} catch (\Throwable $e) {
$newRecord = $data;
}
}
foreach ($this->afterUpdateHooks as $hook) {
try {
$hook($table, $data, $entityId, $oldRecord, $newRecord);
} catch (\Throwable $e) {}
}
}
return $affected;
} }
// ═══════════════════════════════════════════
// DELETE
// ═══════════════════════════════════════════
public function delete(string $table, string $where, array $whereParams = []): int public function delete(string $table, string $where, array $whereParams = []): int
{ {
$sql = "DELETE FROM `{$table}` WHERE {$where}"; // Fetch record before deleting
$oldRecord = null;
$entityId = null;
try {
$entityId = $this->extractIdFromWhere($where, $whereParams);
if ($entityId !== null) {
$oldRecord = $this->selectOne("SELECT * FROM `{$table}` WHERE `id` = ?", [$entityId]);
} else {
$oldRecord = $this->selectOne("SELECT * FROM `{$table}` WHERE {$where} LIMIT 1", $whereParams);
if ($oldRecord && isset($oldRecord['id'])) {
$entityId = (int) $oldRecord['id'];
}
}
} catch (\Throwable $e) {}
$sql = sprintf('DELETE FROM `%s` WHERE %s', $table, $where);
$stmt = $this->query($sql, $whereParams); $stmt = $this->query($sql, $whereParams);
$count = $stmt->rowCount(); $affected = $stmt->rowCount();
foreach ($this->onAfterDeleteCallbacks as $cb) { if ($affected > 0) {
try { foreach ($this->afterDeleteHooks as $hook) {
$cb($table, [], null); try {
} catch (\Throwable $e) { $hook($table, $oldRecord ?? [], $entityId);
// Don't let callback errors break deletes } catch (\Throwable $e) {}
} }
} }
return $count; return $affected;
} }
public function raw(string $sql): void // ═══════════════════════════════════════════
{ // TRANSACTIONS
$this->connect()->exec($sql); // ═══════════════════════════════════════════
}
public function beginTransaction(): void public function beginTransaction(): void
{ {
$this->connect()->beginTransaction(); if (!$this->pdo->inTransaction()) {
$this->pdo->beginTransaction();
}
} }
public function commit(): void public function commit(): void
{ {
$this->connect()->commit(); if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
} }
public function rollBack(): void public function rollBack(): void
{ {
$this->connect()->rollBack(); if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
} }
public function inTransaction(): bool public function inTransaction(): bool
{ {
return $this->connect()->inTransaction(); return $this->pdo->inTransaction();
} }
public function lastInsertId(): int // ═══════════════════════════════════════════
{ // TABLE EXISTS CHECK
return (int) $this->connect()->lastInsertId(); // ═══════════════════════════════════════════
}
public function tableExists(string $table): bool public function tableExists(string $table): bool
{ {
if (isset($this->tableCache[$table])) {
return $this->tableCache[$table];
}
try { try {
$result = $this->selectOne( $this->query("SELECT 1 FROM `{$table}` LIMIT 1");
"SELECT COUNT(*) as cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", $this->tableCache[$table] = true;
[$this->dbName, $table]
);
return (int) ($result['cnt'] ?? 0) > 0;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return false; $this->tableCache[$table] = false;
} }
return $this->tableCache[$table];
} }
public function getDbName(): string // ═══════════════════════════════════════════
// HOOK REGISTRATION
// ═══════════════════════════════════════════
/**
* Hook: called after a successful INSERT.
* Callback: function(string $table, array $data, ?int $insertId)
*/
public function onAfterInsert(callable $callback): void
{ {
return $this->dbName; $this->afterInsertHooks[] = $callback;
} }
public function onAfterInsert(callable $fn): void /**
* Hook: called before an UPDATE executes.
* Callback: function(string $table, array $newData, ?int $entityId, ?array $oldRecord)
*/
public function onBeforeUpdate(callable $callback): void
{ {
$this->onAfterInsertCallbacks[] = $fn; $this->beforeUpdateHooks[] = $callback;
} }
public function onBeforeUpdate(callable $fn): void /**
* Hook: called after a successful UPDATE.
* Callback: function(string $table, array $changedData, ?int $entityId, ?array $oldRecord, ?array $newRecord)
*/
public function onAfterUpdate(callable $callback): void
{ {
$this->onBeforeUpdateCallbacks[] = $fn; $this->afterUpdateHooks[] = $callback;
} }
public function onAfterDelete(callable $fn): void /**
* Hook: called after a successful DELETE.
* Callback: function(string $table, array $oldRecord, ?int $entityId)
*/
public function onAfterDelete(callable $callback): void
{
$this->afterDeleteHooks[] = $callback;
}
// ═══════════════════════════════════════════
// INTERNAL HELPERS
// ═══════════════════════════════════════════
/**
* Try to extract the entity ID from a WHERE clause like `id` = ? or id = ?
*/
private function extractIdFromWhere(string $where, array $params): ?int
{
// Match patterns: `id` = ?, id = ?, `id`=?
if (preg_match('/^`?id`?\s*=\s*\?/', trim($where)) && !empty($params)) {
$val = $params[0];
if (is_numeric($val)) {
return (int) $val;
}
}
return null;
}
/**
* Get last insert ID
*/
public function lastInsertId(): int
{ {
$this->onAfterDeleteCallbacks[] = $fn; return (int) $this->pdo->lastInsertId();
} }
} }
\ No newline at end of file
...@@ -8,6 +8,40 @@ use App\Core\Logger; ...@@ -8,6 +8,40 @@ use App\Core\Logger;
final class AuditService final class AuditService
{ {
/**
* Tables we NEVER audit (they'd cause infinite loops or are just noise).
*/
private const IGNORE_TABLES = [
'audit_trail',
'login_attempts',
'active_sessions',
'migrations',
'seeds',
'async_event_queue',
'cron_job_log',
'password_history',
'sms_log',
'notification_queue',
];
/**
* Sensitive fields — values are masked in audit logs.
*/
private const SENSITIVE_FIELDS = [
'password_hash',
'password',
];
/**
* Fields to skip in change tracking (they change on every save and add noise).
*/
private const NOISE_FIELDS = [
'updated_at',
];
/**
* Log an audit entry directly.
*/
public static function log( public static function log(
string $action, string $action,
?string $entityType = null, ?string $entityType = null,
...@@ -17,48 +51,137 @@ final class AuditService ...@@ -17,48 +51,137 @@ final class AuditService
?array $after = null, ?array $after = null,
?string $notes = null ?string $notes = null
): void { ): void {
$app = App::getInstance(); try {
$db = $app->db(); $app = App::getInstance();
$employee = $app->currentEmployee(); $db = $app->db();
$session = $app->session(); $employee = $app->currentEmployee();
$session = $app->session();
$changedFields = null;
if ($before !== null && $after !== null) { // Calculate changed fields
$changed = []; $changedFields = null;
$allKeys = array_unique(array_merge(array_keys($before), array_keys($after))); if ($before !== null && $after !== null) {
foreach ($allKeys as $key) { $changed = self::computeChangedFields($before, $after);
$oldVal = $before[$key] ?? null; if (!empty($changed)) {
$newVal = $after[$key] ?? null; $changedFields = $changed;
if ((string) $oldVal !== (string) $newVal) {
$changed[] = $key;
} }
} }
if (!empty($changed)) {
$changedFields = $changed; // Mask sensitive fields
if ($before !== null) $before = self::maskSensitive($before);
if ($after !== null) $after = self::maskSensitive($after);
$data = [
'employee_id' => $employee ? ($employee->id ?? null) : null,
'employee_name' => $employee ? ($employee->full_name_ar ?? null) : null,
'action' => $action,
'entity_type' => $entityType,
'entity_id' => $entityId,
'entity_label' => $entityLabel ? mb_substr($entityLabel, 0, 255) : null,
'before_data_json' => $before !== null ? json_encode($before, JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR) : null,
'after_data_json' => $after !== null ? json_encode($after, JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR) : null,
'changed_fields_json' => $changedFields !== null ? json_encode($changedFields, JSON_UNESCAPED_UNICODE) : null,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? mb_substr($_SERVER['HTTP_USER_AGENT'], 0, 500) : null,
'session_id' => $session ? ($session->id() ?: null) : null,
'route' => $_SERVER['REQUEST_URI'] ?? null,
'notes' => $notes,
'created_at' => date('Y-m-d H:i:s'),
];
// Direct insert — bypasses hooks to avoid infinite loop
$columns = array_keys($data);
$placeholders = array_fill(0, count($columns), '?');
$sql = sprintf(
'INSERT INTO `audit_trail` (%s) VALUES (%s)',
implode(', ', array_map(fn($c) => "`{$c}`", $columns)),
implode(', ', $placeholders)
);
$db->query($sql, array_values($data));
} catch (\Throwable $e) {
// Audit must NEVER crash the application
try {
Logger::warning('Audit log failed: ' . $e->getMessage());
} catch (\Throwable $e2) {}
}
}
/**
* Compute which fields actually changed between two records.
* Returns array of field names with old/new values.
*/
private static function computeChangedFields(array $before, array $after): array
{
$changed = [];
$allKeys = array_unique(array_merge(array_keys($before), array_keys($after)));
foreach ($allKeys as $key) {
// Skip noise fields
if (in_array($key, self::NOISE_FIELDS, true)) {
continue;
}
$oldVal = $before[$key] ?? null;
$newVal = $after[$key] ?? null;
// Normalize for comparison
$oldStr = ($oldVal === null) ? '' : (string) $oldVal;
$newStr = ($newVal === null) ? '' : (string) $newVal;
if ($oldStr !== $newStr) {
// Mask sensitive
if (in_array($key, self::SENSITIVE_FIELDS, true)) {
$changed[$key] = ['from' => '***', 'to' => '***'];
} else {
$changed[$key] = [
'from' => $oldVal,
'to' => $newVal,
];
}
} }
} }
$data = [ return $changed;
'employee_id' => $employee ? ($employee->id ?? null) : null, }
'employee_name' => $employee ? ($employee->full_name_ar ?? null) : null,
'action' => $action, /**
'entity_type' => $entityType, * Mask sensitive field values.
'entity_id' => $entityId, */
'entity_label' => $entityLabel, private static function maskSensitive(array $data): array
'before_data_json' => $before !== null ? json_encode($before, JSON_UNESCAPED_UNICODE) : null, {
'after_data_json' => $after !== null ? json_encode($after, JSON_UNESCAPED_UNICODE) : null, foreach (self::SENSITIVE_FIELDS as $field) {
'changed_fields_json' => $changedFields !== null ? json_encode($changedFields, JSON_UNESCAPED_UNICODE) : null, if (array_key_exists($field, $data)) {
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, $data[$field] = '***MASKED***';
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? mb_substr($_SERVER['HTTP_USER_AGENT'], 0, 500) : null, }
'session_id' => $session->id() ?: null, }
'route' => $_SERVER['REQUEST_URI'] ?? null, return $data;
'notes' => $notes, }
'created_at' => date('Y-m-d H:i:s'),
/**
* Get a human-readable label for a record.
*/
private static function extractLabel(string $table, array $data): ?string
{
// Try common label fields in priority order
$labelFields = [
'full_name_ar', 'name_ar', 'username', 'membership_number',
'form_number', 'branch_code', 'role_code', 'carnet_number',
'receipt_number', 'rule_code', 'template_code', 'service_code',
'form_code', 'workflow_code', 'report_code', 'discount_code',
]; ];
$db->insert('audit_trail', $data); foreach ($labelFields as $field) {
if (!empty($data[$field])) {
return mb_substr((string) $data[$field], 0, 255);
}
}
return null;
} }
/**
* Log login events.
*/
public static function logLogin(string $username, bool $success, ?string $reason = null): void public static function logLogin(string $username, bool $success, ?string $reason = null): void
{ {
self::log( self::log(
...@@ -72,6 +195,9 @@ final class AuditService ...@@ -72,6 +195,9 @@ final class AuditService
); );
} }
/**
* Log logout events.
*/
public static function logLogout(): void public static function logLogout(): void
{ {
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
...@@ -79,6 +205,10 @@ final class AuditService ...@@ -79,6 +205,10 @@ final class AuditService
self::log('logout', 'employee', $employee ? (int) $employee->id : null, $name); self::log('logout', 'employee', $employee ? (int) $employee->id : null, $name);
} }
/**
* Register database hooks for AUTOMATIC audit logging of ALL operations.
* This is the nuclear option — every INSERT, UPDATE, DELETE gets logged.
*/
public static function registerDatabaseHooks(): void public static function registerDatabaseHooks(): void
{ {
try { try {
...@@ -88,34 +218,86 @@ final class AuditService ...@@ -88,34 +218,86 @@ final class AuditService
return; return;
} }
$ignoreTables = ['audit_trail', 'login_attempts', 'active_sessions', 'migrations', 'seeds', 'async_event_queue', 'cron_job_log', 'password_history']; // ══════════════════════════════════════
// HOOK: After INSERT — log creation
$db->onAfterInsert(function (string $table, array $data, ?int $id) use ($ignoreTables) { // ══════════════════════════════════════
if (in_array($table, $ignoreTables)) { $db->onAfterInsert(function (string $table, array $data, ?int $id) {
if (in_array($table, self::IGNORE_TABLES, true)) {
return; return;
} }
$label = $data['full_name_ar'] ?? $data['name_ar'] ?? $data['username'] ?? $data['branch_code'] ?? $data['role_code'] ?? null;
self::log('create', $table, $id, $label, null, $data); $label = self::extractLabel($table, $data);
self::log(
'create',
$table,
$id,
$label,
null, // no before (it's a creation)
$data, // after = the inserted data
null
);
}); });
$db->onBeforeUpdate(function (string $table, array $data, ?int $id) use ($ignoreTables) { // ══════════════════════════════════════
if (in_array($table, $ignoreTables)) { // HOOK: After UPDATE — log changes with before/after
// ══════════════════════════════════════
$db->onAfterUpdate(function (string $table, array $changedData, ?int $entityId, ?array $oldRecord, ?array $newRecord) {
if (in_array($table, self::IGNORE_TABLES, true)) {
return; return;
} }
// We store a request-level flag so afterUpdate can use it
// This is a simplistic approach: store last before-data on a static // Don't log if nothing actually changed
// In a real production system, you'd use a more robust approach if ($oldRecord && $newRecord) {
$changed = self::computeChangedFields($oldRecord, $newRecord);
if (empty($changed)) {
return; // No real changes
}
}
$label = null;
if ($newRecord) {
$label = self::extractLabel($table, $newRecord);
} elseif ($oldRecord) {
$label = self::extractLabel($table, $oldRecord);
}
self::log(
'update',
$table,
$entityId,
$label,
$oldRecord, // full record before
$newRecord, // full record after
null
);
}); });
$db->onAfterDelete(function (string $table, array $data, ?int $id) use ($ignoreTables) { // ══════════════════════════════════════
if (in_array($table, $ignoreTables)) { // HOOK: After DELETE — log deletion with old data
// ══════════════════════════════════════
$db->onAfterDelete(function (string $table, array $oldRecord, ?int $entityId) {
if (in_array($table, self::IGNORE_TABLES, true)) {
return; return;
} }
self::log('delete', $table, null, null, null, null, "Deleted from {$table}");
$label = !empty($oldRecord) ? self::extractLabel($table, $oldRecord) : null;
self::log(
'delete',
$table,
$entityId,
$label,
$oldRecord, // what was deleted
null, // nothing after (it's gone)
"Deleted from {$table}" . ($entityId ? " ID: {$entityId}" : '')
);
}); });
} catch (\Throwable $e) { } catch (\Throwable $e) {
Logger::warning('Failed to register audit hooks: ' . $e->getMessage()); try {
Logger::warning('Failed to register audit hooks: ' . $e->getMessage());
} catch (\Throwable $e2) {}
} }
} }
} }
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment