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() {
|
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>
|
||||||
|
|||||||
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