feat: implement media backup (ZIP) and migration tools

This commit is contained in:
Erik
2026-03-07 18:29:01 -03:00
parent 0c40dbadfc
commit c3d0676c4f
5 changed files with 407 additions and 92 deletions

View File

@@ -13,6 +13,7 @@
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"next": "15.1.0",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
@@ -4066,6 +4067,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -5390,6 +5397,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -5417,6 +5430,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5974,6 +5993,18 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@@ -6039,6 +6070,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -6797,6 +6837,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7054,6 +7100,12 @@
"fsevents": "2.3.3"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7131,6 +7183,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -7402,6 +7481,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
@@ -7598,6 +7683,21 @@
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -8131,6 +8231,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
@@ -8297,21 +8403,6 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.0.tgz",
"integrity": "sha512-J1YdKuJv9xcixzXR24Dv+4SaDKc2jj31IVUEMdO5xJivMTXuE6MAdIi4qPjSymHuFG8O5wbfWKnhJUcHHpj5CA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}

View File

@@ -18,6 +18,7 @@
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"next": "15.1.0",
"next-themes": "^0.4.6",
"pg": "^8.16.3",

View File

@@ -3,11 +3,16 @@
import { useState } from 'react';
import { useToast } from '@/contexts/ToastContext';
import { useConfirm } from '@/contexts/ConfirmContext';
import JSZip from 'jszip';
export default function BackupPage() {
const [isExporting, setIsExporting] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const { success, error } = useToast();
const [isExportingMedia, setIsExportingMedia] = useState(false);
const [isImportingMedia, setIsImportingMedia] = useState(false);
const [mediaProgress, setMediaProgress] = useState({ current: 0, total: 0 });
const { success, error, info } = useToast();
const { confirm } = useConfirm();
const handleExport = async () => {
@@ -27,7 +32,7 @@ export default function BackupPage() {
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
success('Backup exportado com sucesso!');
success('Backup de dados exportado com sucesso!');
} catch (err) {
console.error(err);
error('Erro ao exportar backup. Tente novamente.');
@@ -36,6 +41,89 @@ export default function BackupPage() {
}
};
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 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`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
success('Mídias exportadas com sucesso!');
} catch (err) {
console.error(err);
error('Erro ao exportar mídias. Verifique a conexão do servidor S3/MinIO.');
} finally {
setIsExportingMedia(false);
}
};
const handleImportMedia = 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;
@@ -69,7 +157,6 @@ export default function BackupPage() {
if (response.ok) {
success('Backup importado com sucesso!');
// Opcional: recarregar a página para ver mudanças
setTimeout(() => window.location.reload(), 1500);
} else {
const data = await response.json();
@@ -92,92 +179,121 @@ export default function BackupPage() {
return (
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-secondary dark:text-white mb-2">Backup do Sistema</h1>
<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 segurança dos seus dados. Você pode exportar todos os conteúdos do CMS para um arquivo local ou restaurar um backup anterior.
Gerencie a portabilidade do seu CMS. Recomendamos baixar tanto os <strong>Dados</strong> quanto as <strong>Mídias</strong> ao migrar de servidor.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Export Card */}
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-2xl p-6 shadow-sm flex flex-col">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center text-primary mb-4">
<i className="ri-download-cloud-2-line text-2xl"></i>
<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>
<h2 className="text-xl font-bold text-secondary dark:text-white mb-2">Exportar Dados</h2>
<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">
Cria um arquivo JSON contendo todos os projetos, serviços, textos de páginas e configurações globais. Ideal para salvar o progresso atual.
Projetos, serviços, textos e configurações.
</p>
<button
onClick={handleExport}
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"
>
{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 ? 'Exportando...' : 'Baixar Backup'}
{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'}
</button>
</div>
{/* Import Card */}
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-2xl p-6 shadow-sm flex flex-col">
<div className="w-12 h-12 bg-amber-500/10 rounded-xl flex items-center justify-center text-amber-500 mb-4">
<i className="ri-upload-cloud-2-line text-2xl"></i>
</div>
<h2 className="text-xl font-bold text-secondary dark:text-white mb-2">Restaurar Backup</h2>
<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">
Importe um arquivo de backup previamente exportado. <span className="text-amber-500 font-medium font-bold">Atenção:</span> Isso irá substituir ou atualizar os dados existentes.
Substitui informações atuais pelos dados do arquivo.
</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 ? 'Importando...' : 'Selecionar Arquivo'}
<input
type="file"
accept=".json"
onChange={handleImport}
disabled={isImporting}
className="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 ? 'Enviando...' : 'Selecionar JSON'}
<input type="file" accept=".json" onChange={handleImport} disabled={isImporting} className="hidden" />
</label>
</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>
</div>
) : (
<>
<i className="ri-upload-2-line text-xl"></i>
<span>Selecionar ZIP</span>
</>
)}
<input type="file" accept=".zip" onChange={handleImportMedia} disabled={isImportingMedia} className="hidden" />
</label>
</div>
</div>
</section>
</div>
{/* Info Section */}
<div className="mt-8 bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-900/20 rounded-2xl p-6">
<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>
O que está incluído no backup?
Dicas para migração de servidor
</h3>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
'Todos os Projetos Detalhados',
'Lista de Serviços e Descrições',
'Textos e Traduções de Todas as Páginas',
'Configurações Globais (Redes Sociais, Contato)',
'Estrutura de Categorias e Status',
'Referências de Imagens (URLs)'
].map((item, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-blue-700/80 dark:text-blue-400/80">
<i className="ri-check-line text-blue-500"></i>
{item}
</li>
))}
</ul>
<div className="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-900/30 rounded-lg">
<p className="text-xs text-amber-700 dark:text-amber-400 flex gap-2">
<i className="ri-error-warning-line"></i>
<span>
<strong>Nota importante:</strong> O backup contém os <strong>links</strong> das imagens. Se as imagens originais forem deletadas do servidor, elas não aparecerão mesmo restaurando o backup.
</span>
<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>
</div>
</div>

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server';
import { minioClient, bucketName, ensureBucketExists } from '@/lib/minio';
import { cookies } from 'next/headers';
import jwt from 'jsonwebtoken';
import prisma from '@/lib/prisma';
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/media - Exportar todas as mídias em um 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();
const objects = await minioClient.listObjects(bucketName);
if (objects.Contents) {
for (const obj of objects.Contents) {
if (!obj.Key) continue;
const data = await minioClient.getObject(bucketName, obj.Key);
if (data.Body) {
// Converter stream para buffer
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(data.Body);
zip.file(obj.Key, buffer);
}
}
}
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
return new Response(zipBuffer, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="media-backup-${new Date().toISOString().split('T')[0]}.zip"`,
},
});
} catch (error) {
console.error('Erro ao exportar mídias:', error);
return NextResponse.json({ error: 'Erro ao processar exportação de mídias' }, { status: 500 });
}
}
// POST /api/admin/backup/media - Importar uma mídia individual (preservando o nome)
export async function POST(request: NextRequest) {
try {
const user = await authenticate();
if (!user) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
}
await ensureBucketExists();
const formData = await request.formData();
const file = formData.get('file') as File;
const filename = formData.get('filename') as string;
if (!file || !filename) {
return NextResponse.json({ error: 'Arquivo ou nome não fornecido' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
await minioClient.putObject(bucketName, filename, buffer, file.size, {
'Content-Type': file.type,
});
return NextResponse.json({ success: true, path: filename });
} catch (error) {
console.error('Erro ao importar mídia:', error);
return NextResponse.json({ error: 'Erro ao importar arquivo' }, { status: 500 });
}
}

View File

@@ -1,4 +1,4 @@
import { S3Client, HeadBucketCommand, CreateBucketCommand, PutBucketPolicyCommand, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { S3Client, HeadBucketCommand, CreateBucketCommand, PutBucketPolicyCommand, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
const endpointHost = process.env.MINIO_ENDPOINT || 'localhost';
const port = Number.parseInt(process.env.MINIO_PORT || '9000', 10);
@@ -10,7 +10,7 @@ console.log(`[MinIO] Configurando cliente: ${endpointHost}:${port} (SSL: ${useSS
export const bucketName = process.env.MINIO_BUCKET_NAME || 'occto-images';
const s3Client = new S3Client({
export const s3Client = new S3Client({
region: 'us-east-1',
endpoint: endpointUrl,
forcePathStyle: true,
@@ -84,6 +84,11 @@ export const minioClient = {
async removeObject(bucket: string, key: string) {
await s3Client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
},
async listObjects(bucket: string) {
const command = new ListObjectsV2Command({ Bucket: bucket });
return s3Client.send(command);
},
};
// Garante que o bucket exista antes de qualquer upload