From 570536132ba6035da4626583fdbcbe5379ba402d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 7 Mar 2026 19:52:33 -0300 Subject: [PATCH] feat: implement unified 1-click full backup (ZIP with data + media) --- frontend/src/app/admin/backup/page.tsx | 365 +++++++----------- .../src/app/api/admin/backup/full/route.ts | 96 +++++ 2 files changed, 229 insertions(+), 232 deletions(-) create mode 100644 frontend/src/app/api/admin/backup/full/route.ts diff --git a/frontend/src/app/admin/backup/page.tsx b/frontend/src/app/admin/backup/page.tsx index 6c4688f..bd4feea 100644 --- a/frontend/src/app/admin/backup/page.tsx +++ b/frontend/src/app/admin/backup/page.tsx @@ -8,130 +8,45 @@ import JSZip from 'jszip'; export default function BackupPage() { const [isExporting, setIsExporting] = useState(false); const [isImporting, setIsImporting] = useState(false); - const [isExportingMedia, setIsExportingMedia] = useState(false); - const [isImportingMedia, setIsImportingMedia] = useState(false); - const [mediaProgress, setMediaProgress] = useState({ current: 0, total: 0 }); + const [importProgress, setImportProgress] = useState({ current: 0, total: 0, status: '' }); const { success, error, info } = useToast(); const { confirm } = useConfirm(); - const handleExport = async () => { + const handleFullExport = async () => { setIsExporting(true); + info('Preparando backup completo. Isso pode levar alguns minutos se houver muitas mídias.'); try { - const response = await fetch('/api/admin/backup'); - if (!response.ok) throw new Error('Falha ao exportar backup'); - - 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 response = await fetch('/api/admin/backup/full'); + if (!response.ok) throw new Error('Falha ao exportar backup completo'); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); 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); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); - success('Mídias exportadas com sucesso!'); + success('Backup completo (Dados + Mídias) exportado com sucesso!'); } catch (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 { - setIsExportingMedia(false); + setIsExporting(false); } }; - const handleImportMedia = async (e: React.ChangeEvent) => { + const handleFullImport = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const confirmed = await confirm({ - title: 'Confirmar Importação de Mídias', - message: 'Você está prestes a importar um pacote de mídias. Isso pode substituir arquivos com o mesmo nome. Deseja continuar?', - confirmText: 'Importar Mídias', - 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) => { - 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', + title: 'Confirmar Restauração Completa', + 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: 'Sim, Restaurar Tudo', cancelText: 'Cancelar', type: 'warning' }); @@ -142,158 +57,144 @@ export default function BackupPage() { } setIsImporting(true); - try { - const reader = new FileReader(); - reader.onload = async (event) => { - try { - const content = event.target?.result as string; - const backupData = JSON.parse(content); + setImportProgress({ current: 0, total: 0, status: 'Lendo pacote ZIP...' }); - 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', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(backupData), + }); + + if (!dbResponse.ok) throw new Error('Erro ao restaurar banco de dados'); + } + + // 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', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(backupData), + body: formData, }); - if (response.ok) { - 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); - } + if (!mediaResponse.ok) console.warn(`Falha ao restaurar mídia individual: ${fileName}`); - e.target.value = ''; + 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 = ''; + } }; return (
-
-

Central de Migração e Backup

-

- Gerencie a portabilidade do seu CMS. Recomendamos baixar tanto os Dados quanto as Mídias ao migrar de servidor. +

+

Central de Imigração

+

+ 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.

-
- {/* Section 1: Database Data */} -
-
-
- -
-

1. Dados do CMS (JSON)

+
+ {/* Export Section */} +
+
+
+

Backup Completo

+

+ Gera um arquivo ZIP com TUDO: projetos, serviços, mensagens, usuários e todas as imagens do repositório. +

+ +
-
-
-

Exportar Dados

-

- Projetos, serviços, textos e configurações. -

- -
- -
-

Importar Dados

-

- Substitui informações atuais pelos dados do arquivo. -

- -
+ {/* Import Section */} +
+
+
-
- - {/* Section 2: Media Files */} -
-
-
- -
-

2. Mídias e Imagens (ZIP)

-
- -
-
-

Exportar Imagens

-

- Gera um ZIP com todas as fotos hospedadas no S3/MinIO. -

- -
- -
-

Importar Imagens

-

- Traga arquivos ZIP para o novo servidor automaticamente. -

- -
-
-
+

Restaurar Sistema

+

+ Selecione o arquivo ZIP de backup para restaurar todo o conteúdo no novo servidor/ambiente. +

+ +
- {/* Info Section */} -
-

- - Dicas para migração de servidor + {/* Migration Checklist */} +
+

+ + Como usar para Migração (Novo Servidor)

-
-

- Ao mudar para um servidor com S3 unificado e Postgres separado: -

-
    -
  1. Faça o download dos Dados (JSON) e das Mídias (ZIP) neste servidor antigo.
  2. -
  3. Configure as novas credenciais (Host, Bucket, DB) no seu novo ambiente.
  4. -
  5. No novo site, acesse esta mesma página de Backup.
  6. -
  7. Primeiro, faça o upload do ZIP de Mídias para popular o novo S3.
  8. -
  9. Em seguida, faça o upload do JSON de Dados para restaurar os textos e vínculos.
  10. -
+
+ {[ + { step: '01', title: 'Exportar', desc: 'No site antigo, clique em "Baixar Tudo" para obter o ZIP único.' }, + { step: '02', title: 'Configurar', desc: 'No novo Dokploy, configure o Postgres e o RustFS no .env.' }, + { step: '03', title: 'Importar', desc: 'No novo site, acesse esta página e escolha o ZIP. Pronto!' } + ].map((item, i) => ( +
+ {item.step} +

{item.title}

+

{item.desc}

+
+ ))}
diff --git a/frontend/src/app/api/admin/backup/full/route.ts b/frontend/src/app/api/admin/backup/full/route.ts new file mode 100644 index 0000000..d934b72 --- /dev/null +++ b/frontend/src/app/api/admin/backup/full/route.ts @@ -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 => { + 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 }); + } +}