/shared/platform-tools/backups/db_backup.php * → $backupDir = /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"; // Só se chega aqui com a cópia nova no disco: a poda nunca corre depois de uma // cópia falhada, que é quando as antigas são as únicas que há. backup_prune_report();