- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de segurança da frota (o gancho que o login já tinha passa a funcionar) e a correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele. - config/functions_audit.php: audit() sobre platform_audit(); tabela audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação. - Registado: login, falha de login, bloqueio por excesso de tentativas, logout, pedidos de recuperação, mudança/definição de palavra-passe, registo e criação de utilizadores, convites, perfil (antes/depois), todas as definições (db_update_setting, com valor anterior), cada passo da lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens. - Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
15 KiB
PHP
373 lines
15 KiB
PHP
<?php
|
|
// ─── Backup helpers (shared) ─────────────────────────────────────────────────
|
|
//
|
|
// Path assumptions (when this file lives at <host>/shared/platform-tools/):
|
|
// backup_dir() → <host>/backups/ (where ZIPs land + are listed)
|
|
// trigger_url($t) → base_url('shared/platform-tools/backups/<script>.php?token=…')
|
|
// Override the URL prefix by defining BACKUP_TRIGGER_URL_PREFIX
|
|
// before this file loads.
|
|
//
|
|
// Public API:
|
|
// backup_dir(): string
|
|
// backup_filename_is_safe(string): bool
|
|
// backup_human_size(int): string
|
|
// backup_list(): array
|
|
// backup_trigger(string $type): bool ($type in {files, db})
|
|
// backup_type_label(string): string
|
|
// backup_type_badge(string): string
|
|
// backup_retention_policy(): array
|
|
// backup_retention_summary(?array $policy = null): string
|
|
// backup_created_at(string $filename, int $fallback): int
|
|
// backup_gfs_keep(array $items, array $policy, int $now): array
|
|
// backup_prune(bool $dryRun = false): array
|
|
|
|
function backup_dir(): string {
|
|
$dir = dirname(__DIR__, 2) . '/backups';
|
|
// The directory must exist on the host before we can call realpath().
|
|
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
|
$dir = realpath($dir) ?: $dir;
|
|
backup_dir_protect($dir);
|
|
return $dir;
|
|
}
|
|
|
|
/**
|
|
* Deny direct HTTP access to the backups folder. The ZIPs contain sensitive
|
|
* data (full DB dump, user uploads) and are only meant to leave the server
|
|
* through api/backup_download.php (auth'd + attachment header). Idempotent —
|
|
* writes the .htaccess once and leaves an existing one alone.
|
|
*/
|
|
function backup_dir_protect(string $dir): void {
|
|
$ht = $dir . '/.htaccess';
|
|
if (is_file($ht)) return;
|
|
@file_put_contents($ht,
|
|
"# Gerado por platform-tools. Os backups contêm dados sensíveis (BD completa,\n"
|
|
. "# uploads); só podem sair do servidor via api/backup_download.php.\n"
|
|
. "<IfModule mod_authz_core.c>\n"
|
|
. " Require all denied\n"
|
|
. "</IfModule>\n"
|
|
. "<IfModule !mod_authz_core.c>\n"
|
|
. " Order allow,deny\n"
|
|
. " Deny from all\n"
|
|
. "</IfModule>\n"
|
|
);
|
|
}
|
|
|
|
function backup_filename_is_safe(string $filename): bool {
|
|
// Two generations coexist: the legacy minute-granularity name and the
|
|
// current one with seconds + random suffix (unguessable, no same-minute
|
|
// overwrite). Both must stay listable/downloadable.
|
|
return (bool) preg_match(
|
|
'/^(db_)?backup_\d{2}_\d{2}_\d{4}_\d{2}_\d{2}(?:_\d{2}_[0-9a-f]{6})?\.zip$/',
|
|
$filename
|
|
);
|
|
}
|
|
|
|
function backup_human_size(int $bytes): string {
|
|
// GB matters: um site com galerias produz arquivos de uploads na casa dos
|
|
// milhares de MB, e "4812.9 MB" obriga a dividir de cabeça para perceber a
|
|
// ordem de grandeza — que é exactamente o que se está a tentar ler.
|
|
if ($bytes >= 1_073_741_824) {
|
|
return round($bytes / 1_073_741_824, 1) . ' GB';
|
|
}
|
|
if ($bytes >= 1_048_576) {
|
|
return round($bytes / 1_048_576, 1) . ' MB';
|
|
}
|
|
return round($bytes / 1_024, 1) . ' KB';
|
|
}
|
|
|
|
function backup_list(): array {
|
|
$dir = backup_dir();
|
|
$files = glob($dir . '/*.zip') ?: [];
|
|
$items = [];
|
|
|
|
foreach ($files as $path) {
|
|
$name = basename($path);
|
|
if (!backup_filename_is_safe($name)) {
|
|
continue;
|
|
}
|
|
$type = str_starts_with($name, 'db_backup_') ? 'db' : 'files';
|
|
$bytes = filesize($path);
|
|
$items[] = [
|
|
'filename' => $name,
|
|
'size_bytes' => $bytes,
|
|
'size_human' => backup_human_size($bytes),
|
|
'created_at' => filemtime($path),
|
|
'type' => $type,
|
|
];
|
|
}
|
|
|
|
usort($items, fn($a, $b) => $b['created_at'] - $a['created_at']);
|
|
return $items;
|
|
}
|
|
|
|
function backup_trigger(string $type): bool {
|
|
if (!in_array($type, ['files', 'db'], true)) {
|
|
return false;
|
|
}
|
|
|
|
if (!function_exists('curl_init')) {
|
|
error_log('backup_trigger: cURL não disponível');
|
|
return false;
|
|
}
|
|
|
|
if (!defined('BACKUP_TOKEN')) {
|
|
require_once __DIR__ . '/backups/config.php';
|
|
}
|
|
|
|
$script = $type === 'db' ? 'db_backup.php' : 'backup_maker.php';
|
|
// Where the self-call lands. Default assumes shared/ is inside the document
|
|
// root. Under a public/ docroot it is not, so hosts point this at a thin
|
|
// wrapper instead. Constant wins (fotografia defines it in
|
|
// config/functions_backup.php); .env covers hosts with no such file.
|
|
$prefix = defined('BACKUP_TRIGGER_URL_PREFIX')
|
|
? BACKUP_TRIGGER_URL_PREFIX
|
|
: (function_exists('env')
|
|
? ((string) (env('BACKUP_TRIGGER_URL_PREFIX') ?: 'shared/platform-tools/backups/'))
|
|
: 'shared/platform-tools/backups/');
|
|
// base_url() when the host has one; APP_BASE_URL from .env otherwise, so a
|
|
// host without the helper still works.
|
|
$path = ltrim($prefix . $script, '/');
|
|
if (function_exists('base_url')) {
|
|
$url = base_url($path);
|
|
} else {
|
|
$base = function_exists('env') ? (string) env('APP_BASE_URL', '') : (string) getenv('APP_BASE_URL');
|
|
if ($base === '') {
|
|
error_log('backup_trigger: no base URL available (no base_url(), no APP_BASE_URL).');
|
|
return false;
|
|
}
|
|
$url = rtrim($base, '/') . '/' . $path;
|
|
}
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
// Fire-and-forget; the worker keeps running via ignore_user_abort + set_time_limit(0).
|
|
CURLOPT_TIMEOUT => 5,
|
|
// Self-call to the host's own base_url — hosts frequently sit behind
|
|
// self-signed/internal certs, hence no peer verification here.
|
|
CURLOPT_SSL_VERIFYPEER => false,
|
|
CURLOPT_SSL_VERIFYHOST => false,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
// Token no corpo do POST, não no URL — fora dos access logs.
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => http_build_query(['token' => BACKUP_TOKEN]),
|
|
// Um redirect (http→https, www) não pode transformar o POST em GET
|
|
// e deixar cair o corpo — o token iria com ele.
|
|
CURLOPT_POSTREDIR => 7, // CURL_REDIR_POST_ALL
|
|
]);
|
|
curl_exec($ch);
|
|
$errno = curl_errno($ch);
|
|
curl_close($ch);
|
|
|
|
// errno 28 = CURLE_OPERATION_TIMEDOUT — expected when backup takes longer than 5 s.
|
|
$ok = $errno === 0 || $errno === 28;
|
|
error_log("backup_trigger: type={$type} curl_errno={$errno} ok=" . ($ok ? 'yes' : 'no'));
|
|
return $ok;
|
|
}
|
|
|
|
// ─── Retenção avô-pai-filho ─────────────────────────────────────────────────
|
|
//
|
|
// Antes: tudo o que tivesse mais de BACKUP_KEEP_DAYS dias era apagado, e tudo o
|
|
// que tivesse menos ficava — incluindo cinco cópias do mesmo dia (a da noite, a
|
|
// de antes de atualizar, as do hub, as de segurança de um restauro). Num site
|
|
// com galerias são gigabytes por cópia.
|
|
//
|
|
// Agora, por tipo (db e ficheiros contam em separado):
|
|
// • tudo das últimas 24 h — são os pontos de restauro de uma atualização;
|
|
// • a mais recente de cada um dos últimos BACKUP_KEEP_DAYS dias com cópia;
|
|
// • a mais recente de cada uma das últimas BACKUP_KEEP_WEEKS semanas;
|
|
// • a mais recente de cada um dos últimos BACKUP_KEEP_MONTHS meses;
|
|
// • e a mais recente de todas, sempre.
|
|
//
|
|
// Cada escalão começa onde o anterior acaba: as semanais são semanas mais
|
|
// antigas do que a diária mais antiga, e as mensais meses mais antigos do que a
|
|
// semanal mais antiga. Sem isto a «mensal» era a cópia mais recente do mês
|
|
// corrente — que já é a diária de hoje — e BACKUP_KEEP_MONTHS=1 não guardava
|
|
// nada. Com 7/4/1 cobre-se cerca de dois meses.
|
|
//
|
|
// «Dias com cópia», e não «dias de calendário»: se as cópias pararem durante um
|
|
// mês, a poda não pode ficar sem nada para guardar — é exactamente quando as
|
|
// antigas são as únicas que há.
|
|
|
|
function backup_retention_policy(): array {
|
|
$read = static function (string $key, int $default): int {
|
|
if (defined($key)) return max(0, (int) constant($key));
|
|
$v = function_exists('env') ? env($key, null) : (getenv($key) !== false ? getenv($key) : null);
|
|
return ($v === null || $v === '') ? $default : max(0, (int) $v);
|
|
};
|
|
return [
|
|
'recent_hours' => 24,
|
|
'days' => $read('BACKUP_KEEP_DAYS', 7),
|
|
'weeks' => $read('BACKUP_KEEP_WEEKS', 4),
|
|
'months' => $read('BACKUP_KEEP_MONTHS', 1),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* A data da cópia pelo nome do ficheiro, que não muda quando o ficheiro é
|
|
* copiado ou restaurado de outro disco — ao contrário do mtime.
|
|
*/
|
|
function backup_created_at(string $filename, int $fallback): int {
|
|
if (preg_match('/_(\d{2})_(\d{2})_(\d{4})_(\d{2})_(\d{2})(?:_(\d{2}))?(?:_[0-9a-f]{6})?\.zip$/', $filename, $m)) {
|
|
$ts = mktime((int) $m[4], (int) $m[5], (int) ($m[6] ?? 0), (int) $m[2], (int) $m[1], (int) $m[3]);
|
|
if ($ts !== false) return $ts;
|
|
}
|
|
return $fallback;
|
|
}
|
|
|
|
/**
|
|
* Que cópias ficam. Função pura: recebe a lista (filename, type, created_at) e
|
|
* devolve [filename => motivo]. O que não vier na resposta é para apagar.
|
|
*/
|
|
function backup_gfs_keep(array $items, array $policy, int $now): array {
|
|
$tiers = [
|
|
'days' => ['Y-m-d', 'diária'],
|
|
'weeks' => ['o-\WW', 'semanal'],
|
|
'months' => ['Y-m', 'mensal'],
|
|
];
|
|
|
|
$byType = [];
|
|
foreach ($items as $it) $byType[$it['type']][] = $it;
|
|
|
|
$keep = [];
|
|
foreach ($byType as $list) {
|
|
usort($list, fn($a, $b) => $b['created_at'] <=> $a['created_at']);
|
|
$keep[$list[0]['filename']] = 'mais recente';
|
|
|
|
foreach ($list as $it) {
|
|
if ($now - (int) $it['created_at'] < $policy['recent_hours'] * 3600) {
|
|
$keep[$it['filename']] ??= 'últimas ' . $policy['recent_hours'] . ' h';
|
|
}
|
|
}
|
|
|
|
// $floor: a cópia mais antiga guardada pelo escalão anterior. O escalão
|
|
// seguinte só olha para o que é mais antigo, e salta o balde dela — esse
|
|
// período já está representado.
|
|
$floor = null;
|
|
foreach ($tiers as $tier => [$fmt, $label]) {
|
|
$skip = $floor === null ? null : date($fmt, $floor);
|
|
$buckets = [];
|
|
$oldest = null;
|
|
// Da mais recente para a mais antiga: a primeira que cai num balde é
|
|
// a mais recente desse dia/semana/mês.
|
|
foreach ($list as $it) {
|
|
$t = (int) $it['created_at'];
|
|
if ($floor !== null && $t >= $floor) continue;
|
|
if (count($buckets) >= $policy[$tier]) break;
|
|
$key = date($fmt, $t);
|
|
if ($key === $skip || isset($buckets[$key])) continue;
|
|
$buckets[$key] = true;
|
|
$keep[$it['filename']] ??= $label;
|
|
$oldest = $t;
|
|
}
|
|
if ($oldest !== null) $floor = $oldest;
|
|
}
|
|
}
|
|
return $keep;
|
|
}
|
|
|
|
/**
|
|
* Aplica a retenção à pasta de cópias. Com $dryRun não apaga nada — diz o que
|
|
* apagaria. Devolve ['policy', 'kept' => [[filename, reason]], 'deleted', 'errors'].
|
|
*/
|
|
function backup_prune(bool $dryRun = false): array {
|
|
$dir = backup_dir();
|
|
$policy = backup_retention_policy();
|
|
$now = time();
|
|
|
|
$items = [];
|
|
foreach (backup_list() as $b) {
|
|
$b['created_at'] = backup_created_at($b['filename'], (int) $b['created_at']);
|
|
$items[] = $b;
|
|
}
|
|
$keep = backup_gfs_keep($items, $policy, $now);
|
|
|
|
$out = ['policy' => $policy, 'kept' => [], 'deleted' => [], 'errors' => []];
|
|
foreach ($items as $b) {
|
|
if (isset($keep[$b['filename']])) {
|
|
$out['kept'][] = ['filename' => $b['filename'], 'reason' => $keep[$b['filename']]];
|
|
} elseif ($dryRun || @unlink($dir . '/' . $b['filename'])) {
|
|
$out['deleted'][] = $b['filename'];
|
|
} else {
|
|
$out['errors'][] = $b['filename'];
|
|
}
|
|
}
|
|
|
|
// .sql de dumps interrompidos antes de irem para o ZIP: texto em claro que
|
|
// não aparece na lista. Um dia chega para não apanhar um dump a decorrer.
|
|
foreach (glob($dir . '/db_backup_*.sql') ?: [] as $f) {
|
|
if (filemtime($f) < $now - 86400) {
|
|
if ($dryRun || @unlink($f)) $out['deleted'][] = basename($f);
|
|
else $out['errors'][] = basename($f);
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/** Linha legível da política, para a página e para o log dos workers. */
|
|
function backup_retention_summary(?array $policy = null): string {
|
|
$p = $policy ?? backup_retention_policy();
|
|
return sprintf('tudo das últimas %d h, e a mais recente de cada um dos últimos %d dias, %d semanas e %d meses',
|
|
$p['recent_hours'], $p['days'], $p['weeks'], $p['months']);
|
|
}
|
|
|
|
function backup_type_label(string $type): string {
|
|
return match($type) {
|
|
'db' => 'Base de dados',
|
|
'files' => 'Uploads',
|
|
default => 'Desconhecido',
|
|
};
|
|
}
|
|
|
|
function backup_type_badge(string $type): string {
|
|
return match($type) {
|
|
'db' => 'info',
|
|
'files' => 'secondary',
|
|
default => 'dark',
|
|
};
|
|
}
|
|
|
|
|
|
// ─── Encriptação dos artefactos ─────────────────────────────────────────────
|
|
//
|
|
// Um dump de base de dados em claro no disco é o ficheiro mais sensível que uma
|
|
// destas plataformas produz: leva a tabela users com os hashes, os contactos, e
|
|
// no caso do dance os contratos assinados -- que incluem dados de menores.
|
|
//
|
|
// A chave é derivada da APP_SECRET_KEY, que só existe no .env e nunca entra num
|
|
// dump. Isso tem uma consequência que é preciso dizer alto: **perder a
|
|
// APP_SECRET_KEY passa a significar perder todos os backups encriptados com
|
|
// ela.** Guarde-a onde guarda as credenciais do servidor, não só no .env.
|
|
//
|
|
// Sem chave, o backup sai em claro e diz-se no log. Recusar-se a fazer backup
|
|
// por falta de chave seria pior do que um backup desprotegido: a alternativa a
|
|
// um ficheiro legível não é um ficheiro seguro, é ficheiro nenhum.
|
|
|
|
function platform_backup_key(): ?string
|
|
{
|
|
foreach (['APP_SECRET_KEY', 'BACKUP_TOKEN'] as $k) {
|
|
$v = defined($k) ? (string) constant($k) : '';
|
|
if ($v === '' && function_exists('env')) {
|
|
$v = (string) env($k, '');
|
|
}
|
|
if ($v === '') {
|
|
$v = (string) (getenv($k) ?: '');
|
|
}
|
|
if ($v !== '' && $v !== 'replace_me') {
|
|
// Separada por domínio: a mesma APP_SECRET_KEY cifra as settings, e
|
|
// a mesma chave para dois fins nunca é boa ideia.
|
|
return hash('sha256', 'am-backup|' . $v);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Há chave para encriptar? A página de backups usa isto para avisar. */
|
|
function platform_backup_encryption_available(): bool
|
|
{
|
|
return platform_backup_key() !== null
|
|
&& class_exists('ZipArchive')
|
|
&& method_exists('ZipArchive', 'setEncryptionName')
|
|
&& defined('ZipArchive::EM_AES_256');
|
|
}
|