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>
227 lines
8.9 KiB
PHP
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');
|
|
}
|