feat: implement unified 1-click full backup (ZIP with data + media)
This commit is contained in:
@@ -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<HTMLInputElement>) => {
|
||||
const handleFullImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<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',
|
||||
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 (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 (!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',
|
||||
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 = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold text-secondary dark:text-white mb-2">Central de Migração e Backup</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Gerencie a portabilidade do seu CMS. Recomendamos baixar tanto os <strong>Dados</strong> quanto as <strong>Mídias</strong> ao migrar de servidor.
|
||||
<div className="mb-12 text-center">
|
||||
<h1 className="text-4xl font-bold text-secondary dark:text-white mb-4">Central de Imigração</h1>
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12">
|
||||
{/* Section 1: Database Data */}
|
||||
<section>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-primary/20 rounded-lg flex items-center justify-center text-primary">
|
||||
<i className="ri-database-2-line text-xl"></i>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
|
||||
{/* Export 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="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center text-primary mb-6">
|
||||
<i className="ri-folder-zip-line text-3xl"></i>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white">1. Dados do CMS (JSON)</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 Dados</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 flex-1">
|
||||
Projetos, serviços, textos e configurações.
|
||||
<h2 className="text-2xl font-bold text-secondary dark:text-white mb-3">Backup Completo</h2>
|
||||
<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.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
onClick={handleFullExport}
|
||||
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 ? 'Processando...' : 'Baixar Dados'}
|
||||
{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 Tudo em 1 Clique'}
|
||||
</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 Dados</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 flex-1">
|
||||
Substitui informações atuais pelos dados do arquivo.
|
||||
{/* Import 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="w-16 h-16 bg-emerald-500/10 rounded-2xl flex items-center justify-center text-emerald-500 mb-6">
|
||||
<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>
|
||||
<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">
|
||||
{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 ? 'Enviando...' : 'Selecionar JSON'}
|
||||
<input type="file" accept=".json" onChange={handleImport} disabled={isImporting} className="hidden" />
|
||||
</label>
|
||||
<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="flex flex-col items-center w-full px-4">
|
||||
<div className="w-full bg-gray-100 dark:bg-white/5 h-2 rounded-full overflow-hidden mb-2">
|
||||
<div className="bg-primary h-full transition-all duration-300" style={{ width: importProgress.total > 0 ? `${(importProgress.current / importProgress.total) * 100}%` : '50%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
<span className="text-xs text-primary font-bold uppercase tracking-wider">{importProgress.status}</span>
|
||||
{importProgress.total > 0 && <span className="text-[10px] mt-1 text-gray-400">{importProgress.current} de {importProgress.total} arquivos</span>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<i className="ri-upload-2-line text-xl"></i>
|
||||
<span>Selecionar ZIP</span>
|
||||
<i className="ri-folder-open-line text-2xl"></i>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<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">
|
||||
<h3 className="text-blue-800 dark:text-blue-300 font-bold flex items-center gap-2 mb-3">
|
||||
<i className="ri-information-line text-xl"></i>
|
||||
Dicas para migração de servidor
|
||||
{/* Migration Checklist */}
|
||||
<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-900 dark:text-blue-300 font-bold text-xl flex items-center gap-3 mb-6">
|
||||
<i className="ri-flashlight-line text-2xl text-blue-500"></i>
|
||||
Como usar para Migração (Novo Servidor)
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-blue-700/80 dark:text-blue-400/80">
|
||||
Ao mudar para um servidor com S3 unificado e Postgres separado:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside text-sm text-blue-700/80 dark:text-blue-400/80 space-y-2">
|
||||
<li>Faça o download dos <strong>Dados (JSON)</strong> e das <strong>Mídias (ZIP)</strong> neste servidor antigo.</li>
|
||||
<li>Configure as novas credenciais (Host, Bucket, DB) no seu novo ambiente.</li>
|
||||
<li>No novo site, acesse esta mesma página de Backup.</li>
|
||||
<li>Primeiro, faça o upload do <strong>ZIP de Mídias</strong> para popular o novo S3.</li>
|
||||
<li>Em seguida, faça o upload do <strong>JSON de Dados</strong> para restaurar os textos e vínculos.</li>
|
||||
</ol>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div key={i} className="relative p-5 bg-white/50 dark:bg-white/5 rounded-2xl border border-white dark:border-white/5">
|
||||
<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>
|
||||
<h4 className="font-bold text-secondary dark:text-white mb-2">{item.title}</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
96
frontend/src/app/api/admin/backup/full/route.ts
Normal file
96
frontend/src/app/api/admin/backup/full/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user