diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 675f993..da43bfb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" - } } } } diff --git a/frontend/package.json b/frontend/package.json index f35fc98..fe09258 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/admin/backup/page.tsx b/frontend/src/app/admin/backup/page.tsx index 07b9cfc..6c4688f 100644 --- a/frontend/src/app/admin/backup/page.tsx +++ b/frontend/src/app/admin/backup/page.tsx @@ -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) => { + 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; @@ -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 (
-
-

Backup do Sistema

+
+

Central de Migração e Backup

- 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 Dados quanto as Mídias ao migrar de servidor.

-
- {/* Export Card */} -
-
- +
+ {/* Section 1: Database Data */} +
+
+
+ +
+

1. Dados do CMS (JSON)

-

Exportar Dados

-

- Cria um arquivo JSON contendo todos os projetos, serviços, textos de páginas e configurações globais. Ideal para salvar o progresso atual. -

- -
- {/* Import Card */} -
-
- +
+
+

Exportar Dados

+

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

+ +
+ +
+

Importar Dados

+

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

+ +
-

Restaurar Backup

-

- Importe um arquivo de backup previamente exportado. Atenção: Isso irá substituir ou atualizar os dados existentes. -

- -
+ + + {/* 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. +

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

- O que está incluído no backup? + Dicas para migração de servidor

-
    - {[ - '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) => ( -
  • - - {item} -
  • - ))} -
-
-

- - - Nota importante: O backup contém os links das imagens. Se as imagens originais forem deletadas do servidor, elas não aparecerão mesmo restaurando o backup. - +

+

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