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>
This commit is contained in:
1 parent
0e034232e9
commit
4d40adbf64
67 files changed
+17478
-5
No files matched your search
@@ -0,0 +1,355 @@
|
||||
<?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;
|
||||
}
|
||||
Reference in new issue
Block a user