/shared/platform-tools/): // backup_dir() → /backups/ (source of artefacts) // BACKUP_FILES_DIR → subtree the files backup archives, relative to // // 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; }