Files
fitme3070.pt/shared/platform-tools/functions_backup.php
T
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- 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>
2026-09-25 07:28:13 +01:00

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');
}