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>
188 lines
6.8 KiB
PHP
188 lines
6.8 KiB
PHP
<?php
|
|
/**
|
|
* Backup da base de dados — gera um dump SQL completo em ZIP.
|
|
*
|
|
* Assumed location: <host>/shared/platform-tools/backups/db_backup.php
|
|
* → $backupDir = <host>/backups (where the ZIP lands)
|
|
*
|
|
* Via CLI : php shared/platform-tools/backups/db_backup.php
|
|
* Via HTTP: GET shared/platform-tools/backups/db_backup.php?token=SEU_TOKEN
|
|
*/
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
require_once __DIR__ . '/../functions_backup.php';
|
|
|
|
// --- Autenticação -----------------------------------------------------------
|
|
if (php_sapi_name() !== 'cli') {
|
|
$token = $_GET['token'] ?? $_POST['token'] ?? '';
|
|
if (!hash_equals(BACKUP_TOKEN, $token)) {
|
|
http_response_code(403);
|
|
die(json_encode(['status' => 'error', 'message' => 'Acesso negado.']));
|
|
}
|
|
ignore_user_abort(true);
|
|
set_time_limit(0);
|
|
}
|
|
|
|
// --- Configuração -----------------------------------------------------------
|
|
$siteRoot = dirname(__DIR__, 3);
|
|
$backupDir = $siteRoot . '/backups';
|
|
if (!is_dir($backupDir)) @mkdir($backupDir, 0775, true);
|
|
$backupDir = realpath($backupDir) ?: $backupDir;
|
|
backup_dir_protect($backupDir);
|
|
|
|
// Sem ZipArchive não há backup: um .sql em claro na pasta backups/ nunca pode
|
|
// ser o artefacto final (não aparece na UI, não roda, e é texto simples).
|
|
if (!class_exists('ZipArchive')) {
|
|
die("Erro: extensão ZipArchive não disponível — backup cancelado.\n");
|
|
}
|
|
|
|
// Seconds + random suffix: no same-minute overwrite, unguessable URL.
|
|
$sqlFile = 'db_backup_' . date('d_m_Y_H_i_s') . '_' . bin2hex(random_bytes(3)) . '.sql';
|
|
$zipFile = str_replace('.sql', '.zip', $sqlFile);
|
|
$sqlPath = $backupDir . '/' . $sqlFile;
|
|
$zipPath = $backupDir . '/' . $zipFile;
|
|
|
|
echo "Base de dados: " . DB_NAME . "\n";
|
|
echo "Destino ZIP : $zipPath\n\n";
|
|
|
|
if (!is_writable($backupDir)) {
|
|
die("Erro: sem permissão de escrita em: $backupDir\n");
|
|
}
|
|
|
|
// --- Ligação PDO ------------------------------------------------------------
|
|
try {
|
|
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s', DB_HOST, DB_PORT, DB_NAME, DB_CHARSET);
|
|
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
]);
|
|
} catch (PDOException $e) {
|
|
die("Erro de ligação à base de dados: " . $e->getMessage() . "\n");
|
|
}
|
|
|
|
// --- Gerar dump SQL (streaming) ----------------------------------------------
|
|
// Escreve o dump diretamente para o .sql à medida que lê — nunca constrói a
|
|
// base de dados inteira numa string PHP. As leituras de dados usam queries
|
|
// não-bufferizadas (uma de cada vez, cursor fechado antes da query seguinte),
|
|
// por isso o pico de memória é ~1 batch de 500 linhas, independentemente do
|
|
// tamanho das tabelas.
|
|
function dumpToFile(PDO $pdo, string $sqlPath): void
|
|
{
|
|
$fh = fopen($sqlPath, 'wb');
|
|
if ($fh === false) {
|
|
die("Erro: não foi possível escrever o ficheiro SQL.\n");
|
|
}
|
|
|
|
fwrite($fh, "-- Backup: " . DB_NAME . "\n");
|
|
fwrite($fh, "-- Data : " . date('Y-m-d H:i:s') . "\n\n");
|
|
fwrite($fh, "SET NAMES utf8mb4;\n");
|
|
fwrite($fh, "SET FOREIGN_KEY_CHECKS = 0;\n\n");
|
|
|
|
$tables = $pdo->query("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN);
|
|
|
|
foreach ($tables as $table) {
|
|
$quoted = '`' . str_replace('`', '``', (string) $table) . '`';
|
|
|
|
// Estrutura
|
|
$create = $pdo->query("SHOW CREATE TABLE $quoted")->fetch();
|
|
fwrite($fh, "DROP TABLE IF EXISTS $quoted;\n");
|
|
fwrite($fh, $create['Create Table'] . ";\n\n");
|
|
|
|
// Dados — linha a linha, INSERTs em blocos de 500.
|
|
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
|
|
$stmt = $pdo->query("SELECT * FROM $quoted");
|
|
$columns = null;
|
|
$values = [];
|
|
$flush = function () use ($fh, $quoted, &$columns, &$values) {
|
|
if (!$values) return;
|
|
fwrite($fh, "INSERT INTO $quoted ($columns) VALUES\n" . implode(",\n", $values) . ";\n");
|
|
$values = [];
|
|
};
|
|
|
|
while (($row = $stmt->fetch(PDO::FETCH_ASSOC)) !== false) {
|
|
if ($columns === null) {
|
|
$columns = '`' . implode('`, `', array_keys($row)) . '`';
|
|
}
|
|
$escaped = array_map(
|
|
fn($v) => $v === null ? 'NULL' : $pdo->quote((string) $v),
|
|
array_values($row)
|
|
);
|
|
$values[] = '(' . implode(', ', $escaped) . ')';
|
|
if (count($values) >= 500) $flush();
|
|
}
|
|
$flush();
|
|
$stmt->closeCursor();
|
|
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true);
|
|
|
|
fwrite($fh, "\n");
|
|
}
|
|
|
|
fwrite($fh, "SET FOREIGN_KEY_CHECKS = 1;\n");
|
|
fclose($fh);
|
|
}
|
|
|
|
echo "A gerar dump SQL...\n";
|
|
dumpToFile($pdo, $sqlPath);
|
|
|
|
// --- Empacotar em ZIP -------------------------------------------------------
|
|
$zip = new ZipArchive();
|
|
$result = $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
|
|
|
if ($result !== true) {
|
|
@unlink($sqlPath);
|
|
die("Erro ao criar ZIP (código: $result)\n");
|
|
}
|
|
|
|
// addFile() streams no close() — o .sql não passa pela memória.
|
|
$zip->addFile($sqlPath, $sqlFile);
|
|
|
|
// Encriptação AES-256 quando há chave. Um dump em claro no disco leva a tabela
|
|
// users com os hashes, os contactos, e os contratos assinados -- que no dance
|
|
// incluem dados de menores.
|
|
//
|
|
// Sem chave sai em claro, e diz-se. Recusar o backup por falta de chave seria
|
|
// pior: a alternativa a um ficheiro legível não é um ficheiro seguro, é
|
|
// ficheiro nenhum.
|
|
require_once dirname(__DIR__) . '/functions_backup.php';
|
|
$bkKey = platform_backup_key();
|
|
if ($bkKey !== null && method_exists($zip, 'setEncryptionName') && defined('ZipArchive::EM_AES_256')) {
|
|
$zip->setPassword($bkKey);
|
|
if ($zip->setEncryptionName($sqlFile, ZipArchive::EM_AES_256)) {
|
|
echo "Encriptação: AES-256\n";
|
|
} else {
|
|
echo "AVISO: a encriptação falhou — o backup vai em claro.\n";
|
|
error_log('[backup] setEncryptionName falhou; artefacto em claro');
|
|
}
|
|
} else {
|
|
echo "AVISO: sem APP_SECRET_KEY — o backup vai em claro.\n";
|
|
error_log('[backup] sem chave de encriptação; artefacto em claro');
|
|
}
|
|
|
|
if (!$zip->close()) {
|
|
@unlink($sqlPath);
|
|
die("Erro: falha ao finalizar o ficheiro ZIP.\n");
|
|
}
|
|
|
|
// Remover .sql temporário
|
|
@unlink($sqlPath);
|
|
|
|
clearstatcache(true, $zipPath);
|
|
|
|
if (!file_exists($zipPath)) {
|
|
die("Erro: o ficheiro ZIP não foi criado.\n");
|
|
}
|
|
|
|
// --- Resultado --------------------------------------------------------------
|
|
$bytes = filesize($zipPath);
|
|
$size = $bytes >= 1024 * 1024
|
|
? round($bytes / 1024 / 1024, 2) . ' MB'
|
|
: round($bytes / 1024, 1) . ' KB';
|
|
|
|
echo "Backup da BD concluído!\n";
|
|
echo "Ficheiro : $zipPath\n";
|
|
echo "Tamanho : $size\n";
|
|
|
|
cleanOldBackups($backupDir, 'db_backup_*.zip');
|
|
// Varre também .sql órfãos de execuções antigas interrompidas a meio.
|
|
cleanOldBackups($backupDir, 'db_backup_*.sql');
|