diff --git a/config/env.php b/config/env.php index a768fdf..fbf4512 100644 --- a/config/env.php +++ b/config/env.php @@ -44,31 +44,16 @@ function env(string $key, $default = null) { return $v === false ? $default : $v; } -// IP do visitante. Em produção o Apache só vê o Nginx Proxy Manager -// (REMOTE_ADDR = 172.18.0.2 para toda a gente), e o throttle do login contava -// tudo contra esse IP — cinco falhas de qualquer pessoa bloqueavam todos. -// -// Só se confia no proxy quando TRUSTED_PROXY=true E o pedido vem mesmo de uma -// rede privada. Nesse caso: -// - X-Real-IP primeiro: o NPM escreve-o com o $remote_addr que viu, por cima -// de qualquer valor que o cliente tenha mandado; -// - senão o ÚLTIMO valor do X-Forwarded-For, que é o que o proxy acrescentou. -// O primeiro é escrito pelo cliente (o NPM acrescenta, não substitui) e -// serve para forjar identidades — o platform_client_ip() partilhado ainda -// o usa. +// IP do visitante — a regra vive no platform-tools (platform_client_ip(): +// X-Real-IP, senão o último X-Forwarded-For, e só vindo do proxy com +// TRUSTED_PROXY). Em produção o Apache só vê o Nginx Proxy Manager +// (REMOTE_ADDR = 172.18.0.2 para toda a gente); o throttle do login contava +// tudo contra esse IP. A correção nasceu aqui e foi promovida ao partilhado +// (platform-tools 12f26fd), para throttle, audit e IP ban usarem a mesma. +require_once __DIR__ . '/../shared/platform-tools/functions_client_ip.php'; + function client_ip(): string { - $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); - $trusted = filter_var(env('TRUSTED_PROXY', 'false'), FILTER_VALIDATE_BOOLEAN); - $fromProxy = $remote !== '' && filter_var($remote, FILTER_VALIDATE_IP) - && !filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); - if ($trusted && $fromProxy) { - $real = trim((string) ($_SERVER['HTTP_X_REAL_IP'] ?? '')); - if (filter_var($real, FILTER_VALIDATE_IP)) return $real; - $xff = array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))); - $last = end($xff); - if ($last !== false && filter_var($last, FILTER_VALIDATE_IP)) return $last; - } - return $remote !== '' ? $remote : '0.0.0.0'; + return platform_client_ip() ?? '0.0.0.0'; } // True when the current request is HTTPS — including when TLS terminates at a diff --git a/config/functions.php b/config/functions.php index c6b7843..4d4742b 100644 --- a/config/functions.php +++ b/config/functions.php @@ -13,6 +13,7 @@ require_once __DIR__ . '/config.php'; // APP_ENV + hub notify URL + require_once __DIR__ . '/functions_settings.php'; // setting() + system_settings cache require_once __DIR__ . '/functions_secrets.php'; // setting_or_env()/setting_secret() + AES-GCM at rest require_once __DIR__ . '/functions_db.php'; // generic DB helpers +require_once __DIR__ . '/functions_audit.php'; // registo de auditoria (platform-tools) + audit() require_once __DIR__ . '/functions_trash.php'; // lixeira: soft delete + eliminação só após 30 dias require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers diff --git a/config/functions_audit.php b/config/functions_audit.php new file mode 100644 index 0000000..b7d36b8 --- /dev/null +++ b/config/functions_audit.php @@ -0,0 +1,25 @@ +. +// (team.update, trash.purge, auth.login_failed, export.membership_leads…). + +require_once __DIR__ . '/../shared/platform-tools/functions_audit.php'; + +function audit(string $action, array $d = []): void { + platform_audit($GLOBALS['pdo'] ?? null, $action, $d); +} + +// Email mascarado para registos de falhas: permite ver padrões (o mesmo +// endereço atacado muitas vezes) sem guardar o endereço de quem se enganou. +function audit_mask_email(string $email): string { + $email = strtolower(trim($email)); + if (!str_contains($email, '@')) return $email === '' ? '' : mb_substr($email, 0, 1) . '…'; + [$u, $d] = explode('@', $email, 2); + return mb_substr($u, 0, 1) . '…@' . $d; +} diff --git a/config/functions_settings.php b/config/functions_settings.php index 6763c5d..fac775c 100644 --- a/config/functions_settings.php +++ b/config/functions_settings.php @@ -80,6 +80,16 @@ function setting(string $key, $default = null) { } function db_update_setting(PDO $pdo, string $key, string $value, ?int $userId = null): void { + // Valor anterior para o audit (os segredos são ocultados pelo módulo, pelo + // nome da chave: *password*, *token*, *key*, *secret*). + $old = null; + try { + $o = $pdo->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ?"); + $o->execute([$key]); + $old = $o->fetchColumn(); + $old = $old === false ? null : (string) $old; + } catch (Throwable $e) {} + $s = $pdo->prepare( "INSERT INTO system_settings (setting_key, setting_value, updated_by) VALUES (?, ?, ?) @@ -88,4 +98,10 @@ function db_update_setting(PDO $pdo, string $key, string $value, ?int $userId = ); $s->execute([$key, $value, $userId]); settings_cache_clear(); + if ($old !== $value && function_exists('platform_audit')) { + platform_audit($pdo, 'settings.update', [ + 'target_type' => 'setting', 'target_id' => $key, 'target_label' => $key, + 'changes' => [$key => ['antes' => $old, 'depois' => $value]], + ]); + } } diff --git a/config/functions_trash.php b/config/functions_trash.php index d2d961a..17fbb1b 100644 --- a/config/functions_trash.php +++ b/config/functions_trash.php @@ -88,7 +88,10 @@ function trash_delete(PDO $pdo, string $type, int $id): bool { $stmt = $pdo->prepare("UPDATE `{$t['table']}` SET deleted_at = NOW(), deleted_by = ? WHERE `{$t['pk']}` = ? AND deleted_at IS NULL"); $stmt->execute([_trash_user_id(), $id]); $ok = $stmt->rowCount() > 0; - if ($ok) _trash_after($pdo, $type, $id, 'delete'); + if ($ok) { + _trash_after($pdo, $type, $id, 'delete'); + _trash_audit($pdo, 'trash.delete', $type, $id, 'Enviado para a lixeira'); + } return $ok; } @@ -97,7 +100,10 @@ function trash_restore(PDO $pdo, string $type, int $id): bool { $stmt = $pdo->prepare("UPDATE `{$t['table']}` SET deleted_at = NULL, deleted_by = NULL WHERE `{$t['pk']}` = ? AND deleted_at IS NOT NULL"); $stmt->execute([$id]); $ok = $stmt->rowCount() > 0; - if ($ok) _trash_after($pdo, $type, $id, 'restore'); + if ($ok) { + _trash_after($pdo, $type, $id, 'restore'); + _trash_audit($pdo, 'trash.restore', $type, $id, 'Restaurado da lixeira'); + } return $ok; } @@ -128,6 +134,7 @@ function trash_purge(PDO $pdo, string $type, int $id): bool { $stmt->execute([$id]); $ok = $stmt->rowCount() > 0; $ok ? $pdo->commit() : $pdo->rollBack(); + if ($ok) _trash_audit($pdo, 'trash.purge', $type, $id, 'Eliminado de vez', (string) $row['title']); return $ok; } catch (Throwable $e) { $pdo->rollBack(); @@ -181,6 +188,29 @@ function trash_count(PDO $pdo): int { return count(trash_list($pdo)); } +// Cada passo da lixeira fica no audit_log, com o nome do registo (na +// eliminação definitiva é a única coisa que resta dele). +function _trash_audit(PDO $pdo, string $action, string $type, int $id, string $summary, ?string $label = null): void { + if (!function_exists('platform_audit')) return; + if ($label === null) { + $row = trash_get($pdo, $type, $id); + if ($row === null) { + // Restaurado: já não está na lixeira; o nome vem do próprio registo. + $t = trash_type($type); + try { + $st = $pdo->prepare("SELECT {$t['title']} FROM `{$t['table']}` t WHERE t.`{$t['pk']}` = ?"); + $st->execute([$id]); + $label = (string) $st->fetchColumn(); + } catch (Throwable $e) { $label = ''; } + } else { + $label = (string) $row['title']; + } + } + platform_audit($pdo, $action, [ + 'target_type' => $type, 'target_id' => $id, 'target_label' => $label, 'summary' => $summary, + ]); +} + // ─── Efeitos em ficheiros ──────────────────────────────────────────────────── // O site público lista as fotos da galeria com glob() na pasta, não pela BD. // Por isso uma imagem apagada tem de sair da pasta: vai para .trash/ (o diff --git a/config/functions_user.php b/config/functions_user.php index 39cec4f..baa3e33 100644 --- a/config/functions_user.php +++ b/config/functions_user.php @@ -75,6 +75,11 @@ function auth_login(PDO $pdo, string $email, string $password): ?array { function auth_set_password(PDO $pdo, int $userId, string $password): void { $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?") ->execute([password_hash($password, PASSWORD_DEFAULT), $userId]); + $self = (int) ($_SESSION['user_id'] ?? 0) === $userId; + audit($self ? 'auth.password_change' : 'user.password_set', [ + 'target_type' => 'user', 'target_id' => $userId, + 'summary' => $self ? 'O próprio alterou a palavra-passe' : 'Palavra-passe definida por outra pessoa ou por link de recuperação', + ]); // Best-effort: the password itself is already saved, so a missing // `password_resets` table (migration not applied yet) must not turn a @@ -116,6 +121,8 @@ function auth_register_client(PDO $pdo, array $data): array { $pdo->prepare( "UPDATE users SET name = ?, phone = COALESCE(?, phone), password_hash = ?, is_active = 1 WHERE id = ?" )->execute([$name, $phone, password_hash($data['password'], PASSWORD_DEFAULT), $row['id']]); + audit('user.register', ['target_type' => 'user', 'target_id' => (int) $row['id'], 'target_label' => $name, + 'summary' => 'Registo que reclamou uma conta de cliente sem palavra-passe', 'actor' => null]); return ['id' => (int) $row['id']]; } @@ -136,7 +143,10 @@ function auth_register_client(PDO $pdo, array $data): array { if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken']; throw $e; } - return ['id' => (int) $pdo->lastInsertId()]; + $newId = (int) $pdo->lastInsertId(); + audit('user.register', ['target_type' => 'user', 'target_id' => $newId, 'target_label' => $name, + 'summary' => 'Novo cliente registado no site', 'actor' => null]); + return ['id' => $newId]; } // ─── Backoffice user creation ──────────────────────────────────────────────── @@ -153,7 +163,10 @@ function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $pas strtolower(trim($email)), password_hash($password, PASSWORD_DEFAULT), ]); - return (int) $pdo->lastInsertId(); + $id = (int) $pdo->lastInsertId(); + audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name, + 'summary' => 'Sysadmin criado pelo setup inicial', 'actor' => null]); + return $id; } // Returns ['id' => int] on success, or ['error' => 'email_taken']. @@ -184,7 +197,10 @@ function _create_backoffice_user(PDO $pdo, string $role, string $name, string $e if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken']; throw $e; } - return ['id' => (int) $pdo->lastInsertId()]; + $id = (int) $pdo->lastInsertId(); + audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name, + 'changes' => ['role' => ['antes' => null, 'depois' => $role]]]); + return ['id' => $id]; } // ─── Password reset ────────────────────────────────────────────────────────── @@ -257,6 +273,7 @@ function send_password_reset_email(string $name, string $email, string $resetUrl // same mechanism, different copy. function send_password_invite_email(PDO $pdo, array $user): bool { if (empty($user['email'])) return false; + audit('user.invite', ['target_type' => 'user', 'target_id' => (int) $user['id'], 'target_label' => (string) ($user['name'] ?? '')]); $token = auth_create_reset_token($pdo, (int) $user['id']); $url = base_url('views/auth/reset_password.php?token=' . $token); $name = (string) ($user['name'] ?? ''); diff --git a/includes/header.php b/includes/header.php index d0bf3f1..b507b82 100644 --- a/includes/header.php +++ b/includes/header.php @@ -178,6 +178,10 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; Mensagens +