Files
fitme3070.pt/shared/platform-tools/functions_restore.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

356 lines
14 KiB
PHP

<?php
// ─── Restore helpers (shared) ────────────────────────────────────────────────
//
// Counterpart to functions_backup.php. Restores an artefact produced by
// backups/db_backup.php or backups/backup_maker.php.
//
// Path assumptions (when this file lives at <host>/shared/platform-tools/):
// backup_dir() → <host>/backups/ (source of artefacts)
// BACKUP_FILES_DIR → subtree the files backup archives, relative to <host>
//
// Public API:
// restore_inspect(string $filename): array
// restore_db(PDO, string $filename, array $opt = []): array
// restore_files(string $filename, array $opt = []): array
//
// EVERY restore is destructive. Both restore_db() and restore_files() take a
// safety backup of the CURRENT state first and return its filename in the
// report, so a restore of the wrong artefact is itself recoverable. Pass
// ['safety' => false] only from a caller that has already taken one.
require_once __DIR__ . '/functions_backup.php';
require_once __DIR__ . '/functions_migrations.php'; // migrations_split_sql()
/**
* Abre um artefacto, com a chave de encriptação quando existe.
*
* Os backups passaram a sair encriptados com AES-256 (ver db_backup.php), mas
* os anteriores estão em claro. setPassword() num ZIP não encriptado é
* inofensivo -- é ignorado -- por isso a mesma chamada serve os dois e não é
* preciso adivinhar qual é qual pelo nome do ficheiro.
*/
function restore_open_zip(string $path): ?ZipArchive
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
return null;
}
$key = function_exists('platform_backup_key') ? platform_backup_key() : null;
if ($key !== null) {
$zip->setPassword($key);
}
return $zip;
}
function restore_site_root(): string {
return dirname(__DIR__, 2);
}
/**
* Takes a backup of the CURRENT state, synchronously, and returns its filename.
*
* Deliberately does NOT use backup_trigger(): that fires an HTTP self-call at
* base_url(), which (a) is defined in the host app rather than the toolkit, so
* it is absent when the runner is invoked standalone, and (b) requires the host
* to resolve its own public name -- which containerised hosts frequently cannot.
* Including the worker in-process is synchronous, needs no network, and removes
* the race where we polled for the artefact to appear.
*/
function restore_safety_backup(string $type): ?string {
$before = array_column(backup_list(), 'filename');
$script = __DIR__ . '/backups/' . ($type === 'db' ? 'db_backup.php' : 'backup_maker.php');
if (!is_file($script)) return null;
// The workers gate non-CLI invocation on BACKUP_TOKEN. Satisfy that guard
// explicitly for the in-process call, then restore the superglobal.
$prevGet = $_GET;
$_GET['token'] = defined('BACKUP_TOKEN') ? BACKUP_TOKEN : '';
ob_start();
try {
include $script; // echoes progress; we discard it
} catch (Throwable $e) {
ob_end_clean();
$_GET = $prevGet;
return null;
}
ob_end_clean();
$_GET = $prevGet;
clearstatcache();
$new = array_diff(array_column(backup_list(), 'filename'), $before);
return $new ? (string) reset($new) : null;
}
/**
* Validates an artefact and reports what restoring it would do. Never mutates
* anything — safe to call from a confirmation screen.
*/
function restore_inspect(string $filename): array {
$out = ['ok' => false, 'filename' => $filename, 'type' => null, 'entries' => 0, 'error' => null];
// Reuse the backup naming contract rather than inventing a second one: this
// is what stops ../../ and absolute paths reaching ZipArchive::open().
if (!backup_filename_is_safe($filename)) {
$out['error'] = 'Nome de ficheiro inválido.';
return $out;
}
$path = backup_dir() . '/' . $filename;
if (!is_file($path)) {
$out['error'] = 'Ficheiro não encontrado.';
return $out;
}
$out['path'] = $path;
$out['size_bytes'] = filesize($path) ?: 0;
$out['size_human'] = backup_human_size($out['size_bytes']);
// Type comes from the name, matching how backup_list() labels them.
$out['type'] = str_starts_with($filename, 'db_backup_') ? 'db' : 'files';
if (!class_exists('ZipArchive')) {
$out['error'] = 'Extensão zip indisponível.';
return $out;
}
$zip = restore_open_zip($path);
if ($zip === null) {
$out['error'] = 'ZIP ilegível ou corrompido.';
return $out;
}
$entries = [];
$unsafe = [];
// BACKUP_FILES_DIR pode listar várias pastas (ver backup_maker.php), por
// isso o artefacto traz entradas com prefixos diferentes. Validar contra um
// único prefixo marcaria TODAS as outras como inseguras e o restore
// recusava um backup perfeitamente válido.
$prefixes = array_values(array_filter(array_map(
fn($d) => trim($d, " \t/"),
explode(',', defined('BACKUP_FILES_DIR') ? BACKUP_FILES_DIR : 'uploads')
), fn($d) => $d !== ''));
if (!$prefixes) { $prefixes = ['uploads']; }
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
$entries[] = $name;
// Zip-slip: an entry may not escape the extraction root. Absolute paths,
// parent traversal, and Windows drive/UNC prefixes are all rejected.
if (str_starts_with($name, '/')
|| str_contains($name, '..')
|| preg_match('#^[A-Za-z]:#', $name)
|| str_starts_with($name, '\\\\')) {
$unsafe[] = $name;
continue;
}
// A files artefact may only contain the archived subtree. This keeps an
// extraction from writing anywhere else in the repo -- which matters
// doubly when the source tree is mounted read-only.
if ($out['type'] === 'files' && $name !== '') {
$inScope = false;
foreach ($prefixes as $pfx) {
if ($name === $pfx || str_starts_with($name, $pfx . '/')) { $inScope = true; break; }
}
if (!$inScope) $unsafe[] = $name;
}
}
if ($out['type'] === 'db') {
$sqlEntries = array_values(array_filter($entries, fn($e) => str_ends_with(strtolower($e), '.sql')));
if (count($sqlEntries) !== 1) {
$zip->close();
$out['error'] = 'Esperava exactamente um .sql no ZIP, encontrei ' . count($sqlEntries) . '.';
return $out;
}
$out['sql_entry'] = $sqlEntries[0];
$st = $zip->statName($sqlEntries[0]);
$out['sql_bytes'] = $st['size'] ?? 0;
}
$zip->close();
$out['entries'] = count($entries);
if ($unsafe) {
$out['error'] = 'ZIP contém caminhos inseguros: ' . implode(', ', array_slice($unsafe, 0, 5));
$out['unsafe'] = $unsafe;
return $out;
}
$out['ok'] = true;
return $out;
}
/**
* Restores a database artefact. Replaces the current schema and data.
*
* $opt:
* safety bool take a safety dump first (default true)
* drop_extra_tables bool drop tables absent from the dump (default true)
*/
function restore_db(PDO $pdo, string $filename, array $opt = []): array {
$safety = $opt['safety'] ?? true;
$dropExtra = $opt['drop_extra_tables'] ?? true;
$report = ['ok' => false, 'filename' => $filename, 'safety_backup' => null,
'statements' => 0, 'applied' => 0, 'dropped_extra' => [], 'error' => null];
$info = restore_inspect($filename);
if (!$info['ok']) { $report['error'] = $info['error']; return $report; }
if ($info['type'] !== 'db') { $report['error'] = 'Não é um backup de base de dados.'; return $report; }
// Safety first: capture the state we are about to destroy.
if ($safety) {
$report['safety_backup'] = restore_safety_backup('db');
if ($report['safety_backup'] === null) {
$report['error'] = 'Backup de segurança falhou — restauro abortado.';
return $report;
}
}
$zip = restore_open_zip($info['path']);
if ($zip === null) { $report['error'] = 'ZIP ilegível.'; return $report; }
$sql = $zip->getFromName($info['sql_entry']);
$zip->close();
if ($sql === false || $sql === '') { $report['error'] = 'Dump vazio.'; return $report; }
$tablesBefore = restore_current_tables($pdo);
// Reuse the migration splitter: it is string-, backtick- and comment-aware,
// so it will not sever a statement inside a quoted value.
$statements = migrations_split_sql($sql);
$report['statements'] = count($statements);
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
try {
foreach ($statements as $st) {
if (trim($st) === '') continue;
$pdo->exec($st);
$report['applied']++;
}
// The dump DROPs and CREATEs everything it knows about, but a table that
// exists here and not in the dump would survive and leave the schema in
// a state the artefact never described.
if ($dropExtra) {
$after = restore_current_tables($pdo);
$inDump = [];
if (preg_match_all('/DROP TABLE IF EXISTS `([^`]+)`/i', $sql, $m)) $inDump = $m[1];
if (preg_match_all('/CREATE TABLE `([^`]+)`/i', $sql, $m2)) $inDump = array_merge($inDump, $m2[1]);
$inDump = array_unique($inDump);
foreach ($after as $t) {
if (!in_array($t, $inDump, true)) {
$pdo->exec('DROP TABLE `' . str_replace('`', '``', $t) . '`');
$report['dropped_extra'][] = $t;
}
}
}
} catch (Throwable $e) {
$report['error'] = 'Erro no statement #' . ($report['applied'] + 1) . ': ' . $e->getMessage();
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
return $report;
}
// Always restore FK enforcement -- the dump switches it off and never back on.
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
$report['tables_before'] = count($tablesBefore);
$report['tables_after'] = count(restore_current_tables($pdo));
$report['ok'] = true;
return $report;
}
/** Table names in the current schema. Buffered so no cursor is left open. */
function restore_current_tables(PDO $pdo): array {
$st = $pdo->query("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'");
$rows = $st->fetchAll(PDO::FETCH_COLUMN); // fetchAll, not fetch: leaving an
$st->closeCursor(); // unbuffered cursor open makes the
return $rows ?: []; // next exec() fail with error 2014.
}
/**
* Restores a files artefact into BACKUP_FILES_DIR.
*
* $opt:
* safety bool archive the current subtree first (default true)
* purge bool delete files absent from the artefact (default false)
*/
function restore_files(string $filename, array $opt = []): array {
$safety = $opt['safety'] ?? true;
$purge = $opt['purge'] ?? false;
$report = ['ok' => false, 'filename' => $filename, 'safety_backup' => null,
'extracted' => 0, 'purged' => 0, 'error' => null];
$info = restore_inspect($filename);
if (!$info['ok']) { $report['error'] = $info['error']; return $report; }
if ($info['type'] !== 'files') { $report['error'] = 'Não é um backup de ficheiros.'; return $report; }
$root = restore_site_root();
$prefix = trim(defined('BACKUP_FILES_DIR') ? BACKUP_FILES_DIR : 'uploads', '/');
// Empty BACKUP_FILES_DIR means the host keeps no local files (see
// backup_maker.php). Without this guard $target would collapse to the
// repository root and a files artefact would be extracted over the source.
if ($prefix === '') {
$report['error'] = 'Restauro de ficheiros desactivado (BACKUP_FILES_DIR vazio) — este host não guarda ficheiros localmente.';
return $report;
}
$target = $root . '/' . $prefix;
if (!is_dir($target)) { $report['error'] = "Pasta de destino não existe: $target"; return $report; }
if (!is_writable($target)){ $report['error'] = "Pasta de destino não é escrevível: $target"; return $report; }
if ($safety) {
$report['safety_backup'] = restore_safety_backup('files');
if ($report['safety_backup'] === null) {
$report['error'] = 'Backup de segurança falhou — restauro abortado.';
return $report;
}
}
$zip = restore_open_zip($info['path']);
if ($zip === null) { $report['error'] = 'ZIP ilegível.'; return $report; }
$seen = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
if ($name === '' || str_ends_with($name, '/')) continue;
// restore_inspect() already rejected unsafe names, but re-check here:
// the guard must sit immediately before the write, not only in a
// separate function a future caller might skip.
if (str_contains($name, '..') || str_starts_with($name, '/')
|| !str_starts_with($name, $prefix . '/')) {
continue;
}
$dest = $root . '/' . $name;
$dir = dirname($dest);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) continue;
$data = $zip->getFromIndex($i);
if ($data === false) continue;
if (@file_put_contents($dest, $data) !== false) {
$report['extracted']++;
$seen[] = $dest;
}
}
$zip->close();
// Optional exact-mirror: remove anything the artefact does not contain.
// Off by default -- a partial artefact would otherwise delete live uploads.
if ($purge) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($target, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $f) {
if ($f->isFile() && !in_array($f->getPathname(), $seen, true)
&& basename($f->getPathname()) !== '.htaccess') {
if (@unlink($f->getPathname())) $report['purged']++;
}
}
}
$report['ok'] = true;
return $report;
}