Files
fitme3070.pt/shared/platform-tools/functions_backup.php
T
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00

227 lines
8.9 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
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 (surf).
$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/');
// Host base-URL helpers differ: fotografia and the config/-stack sites
// expose base_url(); surf exposes getBaseUrl(). Calling base_url()
// unconditionally fataled with "undefined function" on the latter, before
// any backup could be triggered. Fall back to APP_BASE_URL from .env so a
// host with neither helper still works.
$path = ltrim($prefix . $script, '/');
if (function_exists('base_url')) {
$url = base_url($path);
} elseif (function_exists('getBaseUrl')) {
$url = rtrim(getBaseUrl(), '/') . '/' . $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 getBaseUrl(), 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;
}
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');
}