feat: implement unified 1-click full backup (ZIP with data + media)

This commit is contained in:
Erik
2026-03-07 19:52:33 -03:00
parent f8f7c3765c
commit 570536132b
2 changed files with 229 additions and 232 deletions

View File

@@ -8,130 +8,45 @@ import JSZip from 'jszip';
export default function BackupPage() { export default function BackupPage() {
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [isExportingMedia, setIsExportingMedia] = useState(false); const [importProgress, setImportProgress] = useState({ current: 0, total: 0, status: '' });
const [isImportingMedia, setIsImportingMedia] = useState(false);
const [mediaProgress, setMediaProgress] = useState({ current: 0, total: 0 });
const { success, error, info } = useToast(); const { success, error, info } = useToast();
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const handleExport = async () => { const handleFullExport = async () => {
setIsExporting(true); setIsExporting(true);
info('Preparando backup completo. Isso pode levar alguns minutos se houver muitas mídias.');
try { try {
const response = await fetch('/api/admin/backup'); const response = await fetch('/api/admin/backup/full');
if (!response.ok) throw new Error('Falha ao exportar backup'); if (!response.ok) throw new Error('Falha ao exportar backup completo');
const data = await response.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `backup-occto-cms-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
success('Backup de dados exportado com sucesso!');
} catch (err) {
console.error(err);
error('Erro ao exportar backup. Tente novamente.');
} finally {
setIsExporting(false);
}
};
const handleExportMedia = async () => {
setIsExportingMedia(true);
info('Preparando download das mídias... Isso pode levar alguns instantes.');
try {
const response = await fetch('/api/admin/backup/media');
if (!response.ok) throw new Error('Falha ao exportar mídias');
const blob = await response.blob(); const blob = await response.blob();
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `backup-occto-midias-${new Date().toISOString().split('T')[0]}.zip`; a.download = `backup-completo-occto-${new Date().toISOString().split('T')[0]}.zip`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
success('Mídias exportadas com sucesso!'); success('Backup completo (Dados + Mídias) exportado com sucesso!');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
error('Erro ao exportar mídias. Verifique a conexão do servidor S3/MinIO.'); error('Erro ao gerar backup completo. Verifique a conexão do servidor.');
} finally { } finally {
setIsExportingMedia(false); setIsExporting(false);
} }
}; };
const handleImportMedia = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFullImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const confirmed = await confirm({ const confirmed = await confirm({
title: 'Confirmar Importação de Mídias', title: 'Confirmar Restauração Completa',
message: 'Você está prestes a importar um pacote de mídias. Isso pode substituir arquivos com o mesmo nome. Deseja continuar?', message: 'Você está prestes a restaurar TODO o sistema (Projetos, Usuários, Mídias, etc.). Os dados atuais serão substituídos. Deseja continuar?',
confirmText: 'Importar Mídias', confirmText: 'Sim, Restaurar Tudo',
cancelText: 'Cancelar',
type: 'warning'
});
if (!confirmed) {
e.target.value = '';
return;
}
setIsImportingMedia(true);
try {
const zip = new JSZip();
const zipContent = await zip.loadAsync(file);
const files = Object.keys(zipContent.files).filter(name => !zipContent.files[name].dir);
setMediaProgress({ current: 0, total: files.length });
for (let i = 0; i < files.length; i++) {
const fileName = files[i];
const fileData = await zipContent.files[fileName].async('blob');
const formData = new FormData();
formData.append('file', fileData);
formData.append('filename', fileName);
const response = await fetch('/api/admin/backup/media', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Falha ao importar o arquivo ${fileName}`);
}
setMediaProgress(prev => ({ ...prev, current: i + 1 }));
}
success(`${files.length} mídias importadas com sucesso!`);
} catch (err) {
console.error(err);
error('Erro ao importar mídias. O arquivo ZIP pode estar corrompido ou houve falha na conexão.');
} finally {
setIsImportingMedia(false);
setMediaProgress({ current: 0, total: 0 });
}
e.target.value = '';
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const confirmed = await confirm({
title: 'Confirmar Importação',
message: 'Isso irá substituir ou atualizar os dados existentes (Projetos, Serviços, Páginas e Configurações). Deseja continuar?',
confirmText: 'Importar',
cancelText: 'Cancelar', cancelText: 'Cancelar',
type: 'warning' type: 'warning'
}); });
@@ -142,158 +57,144 @@ export default function BackupPage() {
} }
setIsImporting(true); setIsImporting(true);
try { setImportProgress({ current: 0, total: 0, status: 'Lendo pacote ZIP...' });
const reader = new FileReader();
reader.onload = async (event) => {
try {
const content = event.target?.result as string;
const backupData = JSON.parse(content);
const response = await fetch('/api/admin/backup', { try {
const zip = new JSZip();
const zipContent = await zip.loadAsync(file);
// 1. Restaurar Dados (data.json)
setImportProgress(p => ({ ...p, status: 'Restaurando banco de dados...' }));
const dataFile = zipContent.file('data.json');
if (dataFile) {
const dataJson = await dataFile.async('string');
const backupData = JSON.parse(dataJson);
const dbResponse = await fetch('/api/admin/backup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(backupData), body: JSON.stringify(backupData),
}); });
if (response.ok) { if (!dbResponse.ok) throw new Error('Erro ao restaurar banco de dados');
success('Backup importado com sucesso!');
setTimeout(() => window.location.reload(), 1500);
} else {
const data = await response.json();
error(data.error || 'Erro ao importar backup');
}
} catch (err) {
error('Arquivo de backup inválido.');
} finally {
setIsImporting(false);
}
};
reader.readAsText(file);
} catch (err) {
error('Erro ao ler arquivo.');
setIsImporting(false);
} }
// 2. Restaurar Mídias (media/)
const mediaFiles = Object.keys(zipContent.files).filter(name => name.startsWith('media/') && !zipContent.files[name].dir);
if (mediaFiles.length > 0) {
setImportProgress({ current: 0, total: mediaFiles.length, status: 'Restaurando mídias...' });
for (let i = 0; i < mediaFiles.length; i++) {
const filePath = mediaFiles[i];
const fileName = filePath.replace('media/', '');
const fileBlob = await zipContent.files[filePath].async('blob');
const formData = new FormData();
formData.append('file', fileBlob);
formData.append('filename', fileName);
const mediaResponse = await fetch('/api/admin/backup/media', {
method: 'POST',
body: formData,
});
if (!mediaResponse.ok) console.warn(`Falha ao restaurar mídia individual: ${fileName}`);
setImportProgress(prev => ({ ...prev, current: i + 1 }));
}
}
success('Restauração completa concluída com sucesso!');
setTimeout(() => window.location.reload(), 2000);
} catch (err: any) {
console.error(err);
error(`Erro na restauração: ${err.message || 'Verifique o formato do arquivo'}`);
} finally {
setIsImporting(false);
setImportProgress({ current: 0, total: 0, status: '' });
e.target.value = ''; e.target.value = '';
}
}; };
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="mb-10"> <div className="mb-12 text-center">
<h1 className="text-3xl font-bold text-secondary dark:text-white mb-2">Central de Migração e Backup</h1> <h1 className="text-4xl font-bold text-secondary dark:text-white mb-4">Central de Imigração</h1>
<p className="text-gray-500 dark:text-gray-400"> <p className="text-lg text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
Gerencie a portabilidade do seu CMS. Recomendamos baixar tanto os <strong>Dados</strong> quanto as <strong>Mídias</strong> ao migrar de servidor. Migre todo o seu sistema de um servidor para outro com apenas um clique.
Gere um arquivo único contendo banco de dados, usuários e todas as imagens.
</p> </p>
</div> </div>
<div className="space-y-12"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
{/* Section 1: Database Data */} {/* Export Section */}
<section> <div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-3xl p-8 shadow-xl flex flex-col items-center text-center">
<div className="flex items-center gap-3 mb-6"> <div className="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center text-primary mb-6">
<div className="w-10 h-10 bg-primary/20 rounded-lg flex items-center justify-center text-primary"> <i className="ri-folder-zip-line text-3xl"></i>
<i className="ri-database-2-line text-xl"></i>
</div> </div>
<h2 className="text-xl font-bold text-secondary dark:text-white">1. Dados do CMS (JSON)</h2> <h2 className="text-2xl font-bold text-secondary dark:text-white mb-3">Backup Completo</h2>
</div> <p className="text-gray-500 dark:text-gray-400 mb-8 flex-1">
Gera um arquivo ZIP com <strong>TUDO</strong>: projetos, serviços, mensagens, usuários e todas as imagens do repositório.
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-2xl p-6 shadow-sm flex flex-col">
<h3 className="font-bold text-secondary dark:text-white mb-2">Exportar Dados</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 flex-1">
Projetos, serviços, textos e configurações.
</p> </p>
<button <button
onClick={handleExport} onClick={handleFullExport}
disabled={isExporting} disabled={isExporting}
className="w-full py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 active:scale-[0.98] transition-all flex items-center justify-center gap-2 disabled:opacity-50" className="w-full py-4 bg-primary text-white rounded-2xl font-bold text-lg hover-primary active:scale-[0.98] transition-all flex items-center justify-center gap-3 disabled:opacity-50"
> >
{isExporting ? <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> : <i className="ri-file-download-line text-xl"></i>} {isExporting ? <div className="w-6 h-6 border-3 border-white/30 border-t-white rounded-full animate-spin"></div> : <i className="ri-download-cloud-2-line text-2xl"></i>}
{isExporting ? 'Processando...' : 'Baixar Dados'} {isExporting ? 'Processando...' : 'Baixar Tudo em 1 Clique'}
</button> </button>
</div> </div>
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-2xl p-6 shadow-sm flex flex-col"> {/* Import Section */}
<h3 className="font-bold text-secondary dark:text-white mb-2">Importar Dados</h3> <div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-3xl p-8 shadow-xl flex flex-col items-center text-center">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 flex-1"> <div className="w-16 h-16 bg-emerald-500/10 rounded-2xl flex items-center justify-center text-emerald-500 mb-6">
Substitui informações atuais pelos dados do arquivo. <i className="ri-upload-cloud-2-line text-3xl"></i>
</div>
<h2 className="text-2xl font-bold text-secondary dark:text-white mb-3">Restaurar Sistema</h2>
<p className="text-gray-500 dark:text-gray-400 mb-8 flex-1">
Selecione o arquivo ZIP de backup para restaurar todo o conteúdo no novo servidor/ambiente.
</p> </p>
<label className="w-full py-3 border-2 border-dashed border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-400 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-all flex items-center justify-center gap-2 cursor-pointer"> <label className="w-full py-4 border-2 border-dashed border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-400 rounded-2xl font-bold text-lg hover:bg-gray-50 dark:hover:bg-white/5 transition-all flex items-center justify-center gap-3 cursor-pointer relative overflow-hidden">
{isImporting ? <div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin"></div> : <i className="ri-file-upload-line text-xl"></i>} {isImporting ? (
{isImporting ? 'Enviando...' : 'Selecionar JSON'} <div className="flex flex-col items-center w-full px-4">
<input type="file" accept=".json" onChange={handleImport} disabled={isImporting} className="hidden" /> <div className="w-full bg-gray-100 dark:bg-white/5 h-2 rounded-full overflow-hidden mb-2">
</label> <div className="bg-primary h-full transition-all duration-300" style={{ width: importProgress.total > 0 ? `${(importProgress.current / importProgress.total) * 100}%` : '50%' }}></div>
</div> </div>
</div> <span className="text-xs text-primary font-bold uppercase tracking-wider">{importProgress.status}</span>
</section> {importProgress.total > 0 && <span className="text-[10px] mt-1 text-gray-400">{importProgress.current} de {importProgress.total} arquivos</span>}
{/* Section 2: Media Files */}
<section>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-emerald-500/20 rounded-lg flex items-center justify-center text-emerald-500">
<i className="ri-image-2-line text-xl"></i>
</div>
<h2 className="text-xl font-bold text-secondary dark:text-white">2. Mídias e Imagens (ZIP)</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-2xl p-6 shadow-sm flex flex-col">
<h3 className="font-bold text-secondary dark:text-white mb-2">Exportar Imagens</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 flex-1">
Gera um ZIP com todas as fotos hospedadas no S3/MinIO.
</p>
<button
onClick={handleExportMedia}
disabled={isExportingMedia}
className="w-full py-3 bg-emerald-600 text-white rounded-xl font-bold hover:bg-emerald-700 active:scale-[0.98] transition-all flex items-center justify-center gap-2 disabled:opacity-50"
>
{isExportingMedia ? <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> : <i className="ri-folder-zip-line text-xl"></i>}
{isExportingMedia ? 'Compactando...' : 'Baixar Imagens'}
</button>
</div>
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-2xl p-6 shadow-sm flex flex-col">
<h3 className="font-bold text-secondary dark:text-white mb-2">Importar Imagens</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 flex-1">
Traga arquivos ZIP para o novo servidor automaticamente.
</p>
<label className="w-full py-3 border-2 border-dashed border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-400 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-all flex items-center justify-center gap-2 cursor-pointer relative overflow-hidden text-center justify-center items-center">
{isImportingMedia ? (
<div className="flex flex-col items-center w-full">
<div className="w-full bg-gray-200 h-1 absolute top-0 left-0">
<div className="bg-primary h-full transition-all duration-300" style={{ width: `${(mediaProgress.current / mediaProgress.total) * 100}%` }}></div>
</div>
<span className="text-xs mt-1 text-primary animate-pulse">Enviando {mediaProgress.current}/{mediaProgress.total}...</span>
</div> </div>
) : ( ) : (
<> <>
<i className="ri-upload-2-line text-xl"></i> <i className="ri-folder-open-line text-2xl"></i>
<span>Selecionar ZIP</span> <span>Restaurar Backup</span>
</> </>
)} )}
<input type="file" accept=".zip" onChange={handleImportMedia} disabled={isImportingMedia} className="hidden" /> <input type="file" accept=".zip" onChange={handleFullImport} disabled={isImporting} className="hidden" />
</label> </label>
</div> </div>
</div> </div>
</section>
</div>
{/* Info Section */} {/* Migration Checklist */}
<div className="mt-12 bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-900/20 rounded-2xl p-6"> <div className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/10 dark:to-indigo-900/10 border border-blue-100 dark:border-blue-800/20 rounded-3xl p-8">
<h3 className="text-blue-800 dark:text-blue-300 font-bold flex items-center gap-2 mb-3"> <h3 className="text-blue-900 dark:text-blue-300 font-bold text-xl flex items-center gap-3 mb-6">
<i className="ri-information-line text-xl"></i> <i className="ri-flashlight-line text-2xl text-blue-500"></i>
Dicas para migração de servidor Como usar para Migração (Novo Servidor)
</h3> </h3>
<div className="space-y-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<p className="text-sm text-blue-700/80 dark:text-blue-400/80"> {[
Ao mudar para um servidor com S3 unificado e Postgres separado: { step: '01', title: 'Exportar', desc: 'No site antigo, clique em "Baixar Tudo" para obter o ZIP único.' },
</p> { step: '02', title: 'Configurar', desc: 'No novo Dokploy, configure o Postgres e o RustFS no .env.' },
<ol className="list-decimal list-inside text-sm text-blue-700/80 dark:text-blue-400/80 space-y-2"> { step: '03', title: 'Importar', desc: 'No novo site, acesse esta página e escolha o ZIP. Pronto!' }
<li>Faça o download dos <strong>Dados (JSON)</strong> e das <strong>Mídias (ZIP)</strong> neste servidor antigo.</li> ].map((item, i) => (
<li>Configure as novas credenciais (Host, Bucket, DB) no seu novo ambiente.</li> <div key={i} className="relative p-5 bg-white/50 dark:bg-white/5 rounded-2xl border border-white dark:border-white/5">
<li>No novo site, acesse esta mesma página de Backup.</li> <span className="absolute -top-3 -left-3 w-8 h-8 bg-blue-600 text-white text-xs font-bold rounded-lg flex items-center justify-center shadow-lg">{item.step}</span>
<li>Primeiro, faça o upload do <strong>ZIP de Mídias</strong> para popular o novo S3.</li> <h4 className="font-bold text-secondary dark:text-white mb-2">{item.title}</h4>
<li>Em seguida, faça o upload do <strong>JSON de Dados</strong> para restaurar os textos e vínculos.</li> <p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">{item.desc}</p>
</ol> </div>
))}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { minioClient, bucketName, ensureBucketExists } from '@/lib/minio';
import { cookies } from 'next/headers';
import jwt from 'jsonwebtoken';
import JSZip from 'jszip';
async function authenticate() {
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
if (!token) return null;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true }
});
return user;
} catch {
return null;
}
}
// GET /api/admin/backup/full - Exportar TUDO (Dados + Mídias) em um único ZIP
export async function GET() {
try {
const user = await authenticate();
if (!user) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
}
const zip = new JSZip();
// 1. Coletar Dados do Banco
const projects = await prisma.project.findMany();
const services = await prisma.service.findMany();
const pageContents = await prisma.pageContent.findMany();
const settings = await prisma.settings.findFirst();
const users = await prisma.user.findMany();
const messages = await prisma.message.findMany();
const data = {
version: '2.0',
timestamp: new Date().toISOString(),
projects,
services,
pageContents,
settings,
users,
messages
};
zip.file('data.json', JSON.stringify(data, null, 2));
// 2. Coletar Mídias do S3/MinIO
const objects = await minioClient.listObjects(bucketName);
if (objects.Contents) {
const mediaFolder = zip.folder('media');
if (mediaFolder) {
for (const obj of objects.Contents) {
if (!obj.Key) continue;
try {
const fileData = await minioClient.getObject(bucketName, obj.Key);
if (fileData.Body) {
const streamToBuffer = async (stream: any): Promise<Buffer> => {
const chunks: any[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
};
const buffer = await streamToBuffer(fileData.Body);
mediaFolder.file(obj.Key, buffer);
}
} catch (e) {
console.error(`Erro ao incluir arquivo ${obj.Key} no backup:`, e);
}
}
}
}
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
return new Response(zipBuffer, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="backup-completo-occto-${new Date().toISOString().split('T')[0]}.zip"`,
},
});
} catch (error) {
console.error('Erro ao gerar backup completo:', error);
return NextResponse.json({ error: 'Erro ao processar backup completo' }, { status: 500 });
}
}