From 1ab4326778ccb02b85bfcc6ad9c6632c48b8f830 Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Tue, 21 Jul 2026 20:16:01 -0300 Subject: [PATCH] feat: add MinIO S3 integration for avatar upload and editable profile --- docker-stack.yml | 39 ++++++ package.json | 3 + server.ts | 116 +++++++++++++++++ src/App.tsx | 1 + src/components/ProfileView.tsx | 228 +++++++++++++++++++++++++++------ src/types.ts | 1 + 6 files changed, 352 insertions(+), 36 deletions(-) diff --git a/docker-stack.yml b/docker-stack.yml index 359238a..3b4bb8a 100644 --- a/docker-stack.yml +++ b/docker-stack.yml @@ -73,9 +73,48 @@ services: - "traefik.http.services.microtecflix-admin.loadbalancer.server.port=3000" - "traefik.docker.network=network_public" + # ============================================================= + # MINIO S3 — Storage de Arquivos e Mídias (Fotos/Imagens) + # ============================================================= + minio-microtecflix: + image: minio/minio:latest + restart: always + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: microtecflix_admin + MINIO_ROOT_PASSWORD: MicrotecFlixS3SecurePass2026! + MINIO_BROWSER_REDIRECT_URL: https://minio-estudo.microtecinformaticacurso.com.br + volumes: + - miniodata-microtecflix:/data + networks: + - microtecflix-net + - network_public + deploy: + replicas: 1 + restart_policy: + condition: on-failure + labels: + - "traefik.enable=true" + # Rota para a API do S3 (Acesso aos arquivos/bucket público) + - "traefik.http.routers.minio-s3.rule=Host(`s3-estudo.microtecinformaticacurso.com.br`)" + - "traefik.http.routers.minio-s3.entrypoints=websecure" + - "traefik.http.routers.minio-s3.tls=true" + - "traefik.http.routers.minio-s3.tls.certresolver=leresolver" + - "traefik.http.routers.minio-s3.service=minio-s3" + - "traefik.http.services.minio-s3.loadbalancer.server.port=9000" + # Rota para o Painel Web do MinIO + - "traefik.http.routers.minio-console.rule=Host(`minio-estudo.microtecinformaticacurso.com.br`)" + - "traefik.http.routers.minio-console.entrypoints=websecure" + - "traefik.http.routers.minio-console.tls=true" + - "traefik.http.routers.minio-console.tls.certresolver=leresolver" + - "traefik.http.routers.minio-console.service=minio-console" + - "traefik.http.services.minio-console.loadbalancer.server.port=9001" + - "traefik.docker.network=network_public" + volumes: pgdata-microtecflix: appdata-microtecflix: + miniodata-microtecflix: networks: microtecflix-net: diff --git a/package.json b/package.json index e55627f..6e7662e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@aws-sdk/client-s3": "^3.700.0", "@google/genai": "^2.4.0", + "multer": "^1.4.5-lts.1", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "bcryptjs": "^3.0.3", @@ -28,6 +30,7 @@ "@types/bcryptjs": "^3.0.0", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^1.4.12", "@types/node": "^22.14.0", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", diff --git a/server.ts b/server.ts index 455cb8d..21491dc 100644 --- a/server.ts +++ b/server.ts @@ -2,6 +2,8 @@ import express, { Request, Response, NextFunction } from 'express'; import path from 'path'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; +import multer from 'multer'; +import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3'; import { createServer as createViteServer } from 'vite'; import { loadDb, saveDb } from './src/db/db'; import { AsaasService } from './src/services/asaas'; @@ -15,6 +17,60 @@ const PORT = 3000; app.use(express.json()); +// --- MINIO / S3 CONFIGURATION --- +const s3 = new S3Client({ + region: 'us-east-1', + endpoint: 'http://minio-microtecflix:9000', + credentials: { + accessKeyId: process.env.MINIO_ROOT_USER || 'microtecflix_admin', + secretAccessKey: process.env.MINIO_ROOT_PASSWORD || 'MicrotecFlixS3SecurePass2026!' + }, + forcePathStyle: true +}); + +const upload = multer({ storage: multer.memoryStorage() }); + +async function initMinio() { + const bucketName = 'microtecflix'; + try { + await s3.send(new HeadBucketCommand({ Bucket: bucketName })); + console.log('✅ MinIO bucket already exists.'); + } catch (err: any) { + if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404 || err.message.includes('404')) { + console.log('⚠️ Creating MinIO bucket...'); + try { + await s3.send(new CreateBucketCommand({ Bucket: bucketName })); + + const policy = { + Version: "2012-10-17", + Statement: [ + { + Sid: "PublicReadGetObject", + Effect: "Allow", + Principal: "*", + Action: "s3:GetObject", + Resource: `arn:aws:s3:::${bucketName}/*` + } + ] + }; + + await s3.send(new PutBucketPolicyCommand({ + Bucket: bucketName, + Policy: JSON.stringify(policy) + })); + console.log('✅ MinIO bucket created and set to public read.'); + } catch (createErr) { + console.error('❌ Failed to create or configure MinIO bucket:', createErr); + } + } else { + console.error('❌ Error checking MinIO bucket:', err); + } + } +} +// Start initialization async, don't block server start +initMinio(); + + // --- JWT AUTH MIDDLEWARE --- interface AuthenticatedRequest extends Request { user?: { @@ -121,6 +177,7 @@ app.post('/api/auth/register', async (req: Request, res: Response) => { cpf: newUser.cpf, whatsapp: newUser.whatsapp, birthDate: newUser.birthDate, + avatarUrl: newUser.avatarUrl, createdAt: newUser.createdAt } }); @@ -170,6 +227,7 @@ app.post('/api/auth/login', (req: Request, res: Response) => { cpf: user.cpf, whatsapp: user.whatsapp, birthDate: user.birthDate, + avatarUrl: user.avatarUrl, createdAt: user.createdAt } }); @@ -200,10 +258,68 @@ app.get('/api/auth/me', authenticateToken, (req: AuthenticatedRequest, res: Resp cpf: user.cpf, whatsapp: user.whatsapp, birthDate: user.birthDate, + avatarUrl: user.avatarUrl, createdAt: user.createdAt }); }); +// 3.1. Profile: Update Data +app.put('/api/users/profile', authenticateToken, (req: AuthenticatedRequest, res: Response) => { + const { name, whatsapp, birthDate } = req.body; + const userId = req.user!.id; + const db = loadDb(); + + const user = db.users.find(u => u.id === userId); + if (!user) { + res.status(404).json({ error: 'Usuário não encontrado' }); + return; + } + + if (name) user.name = name; + if (whatsapp !== undefined) user.whatsapp = whatsapp; + if (birthDate !== undefined) user.birthDate = birthDate; + + saveDb(db); + + res.json({ success: true, user }); +}); + +// 3.2. Profile: Avatar Upload +app.post('/api/users/avatar', authenticateToken, upload.single('avatar'), async (req: AuthenticatedRequest, res: Response) => { + const userId = req.user!.id; + if (!req.file) { + res.status(400).json({ error: 'Nenhum arquivo enviado' }); + return; + } + + const ext = req.file.originalname.split('.').pop(); + const filename = `avatars/${userId}_${Date.now()}.${ext}`; + + try { + await s3.send(new PutObjectCommand({ + Bucket: 'microtecflix', + Key: filename, + Body: req.file.buffer, + ContentType: req.file.mimetype + })); + + // URL Pública configurada via Traefik + const avatarUrl = `https://s3-estudo.microtecinformaticacurso.com.br/microtecflix/${filename}`; + + const db = loadDb(); + const user = db.users.find(u => u.id === userId); + if (user) { + user.avatarUrl = avatarUrl; + saveDb(db); + } + + res.json({ success: true, avatarUrl }); + } catch (err: any) { + console.error('Erro no upload da foto:', err); + res.status(500).json({ error: 'Erro ao salvar foto no MinIO' }); + } +}); + // 4. Courses List (With Progress) app.get('/api/courses', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); diff --git a/src/App.tsx b/src/App.tsx index b63c4eb..8d3bc16 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -269,6 +269,7 @@ export default function App() { setView('home')} + onUpdateUser={(updated) => setUser(updated)} /> )} diff --git a/src/components/ProfileView.tsx b/src/components/ProfileView.tsx index 1bfe7eb..64a9997 100644 --- a/src/components/ProfileView.tsx +++ b/src/components/ProfileView.tsx @@ -1,13 +1,23 @@ -import React from 'react'; -import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft } from 'lucide-react'; +import React, { useState, useRef } from 'react'; +import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft, Camera, Edit2, Save, X, Loader2 } from 'lucide-react'; import { User } from '../types'; interface ProfileViewProps { user: User; onBack: () => void; + onUpdateUser?: (updated: User) => void; } -export default function ProfileView({ user, onBack }: ProfileViewProps) { +export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewProps) { + const [isEditing, setIsEditing] = useState(false); + const [name, setName] = useState(user.name); + const [whatsapp, setWhatsapp] = useState(user.whatsapp || ''); + const [birthDate, setBirthDate] = useState(user.birthDate || ''); + const [isSaving, setIsSaving] = useState(false); + const [isUploading, setIsUploading] = useState(false); + + const fileInputRef = useRef(null); + const getStatusColor = (status: string) => { switch (status) { case 'ACTIVE': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/50'; @@ -28,23 +38,132 @@ export default function ProfileView({ user, onBack }: ProfileViewProps) { } }; - return ( -
- + const handleSave = async () => { + setIsSaving(true); + try { + const token = localStorage.getItem('token'); + const res = await fetch('/api/users/profile', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ name, whatsapp, birthDate }) + }); + const data = await res.json(); + if (data.success && onUpdateUser) { + onUpdateUser(data.user); + setIsEditing(false); + } else { + alert(data.error || 'Erro ao atualizar perfil'); + } + } catch (err) { + alert('Erro de conexão ao salvar perfil'); + } finally { + setIsSaving(false); + } + }; -
+ const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsUploading(true); + const formData = new FormData(); + formData.append('avatar', file); + + try { + const token = localStorage.getItem('token'); + const res = await fetch('/api/users/avatar', { + method: 'POST', + headers: { + ...(token && { Authorization: `Bearer ${token}` }) + }, + body: formData + }); + const data = await res.json(); + if (data.success && onUpdateUser) { + onUpdateUser({ ...user, avatarUrl: data.avatarUrl }); + } else { + alert(data.error || 'Erro ao fazer upload da foto'); + } + } catch (err) { + alert('Erro de conexão ao enviar foto'); + } finally { + setIsUploading(false); + } + }; + + return ( +
+
+ + + {!isEditing ? ( + + ) : ( +
+ + +
+ )} +
+ +
{/* Avatar Area */}
-
- +
fileInputRef.current?.click()}> +
+ {isUploading ? ( + + ) : user.avatarUrl ? ( + Avatar + ) : ( + + )} +
+
+ +
+
{getStatusLabel(user.subscriptionStatus)} @@ -54,45 +173,82 @@ export default function ProfileView({ user, onBack }: ProfileViewProps) { {/* User Info */}
-

{user.name}

-

{user.role === 'admin' ? 'Administrador do Sistema' : 'Aluno'}

+ {isEditing ? ( +
+ + setName(e.target.value)} + className="w-full glass-input rounded-lg p-2 text-xl font-bold text-white focus:outline-none focus:border-[#E50914]" + /> +
+ ) : ( + <> +

{user.name}

+

{user.role === 'admin' ? 'Administrador do Sistema' : 'Aluno'}

+ + )}
-
- -
+
+
+

Email

-

{user.email}

+

{user.email}

-
- -
+
+
+

WhatsApp

-

{user.whatsapp || 'Não informado'}

+ {isEditing ? ( +
+ setWhatsapp(e.target.value)} + placeholder="(11) 99999-9999" + className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]" + /> +
+ ) : ( +

{user.whatsapp || 'Não informado'}

+ )}
-
- -
+
+
+

Data de Nascimento

-

+

+ {isEditing ? ( +
+ setBirthDate(e.target.value)} + className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914] [color-scheme:dark]" + /> +
+ ) : ( +

{user.birthDate ? new Date(user.birthDate).toLocaleDateString('pt-BR') : 'Não informado'}

-
+ )}
-
- -
+
+
+

Membro desde

-

- {user.createdAt ? new Date(user.createdAt).toLocaleDateString('pt-BR') : 'Não disponível'} -

+

+ {user.createdAt ? new Date(user.createdAt).toLocaleDateString('pt-BR') : 'Não disponível'} +

diff --git a/src/types.ts b/src/types.ts index 9e5e0a9..3d395c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export interface User { cpf?: string; birthDate?: string; whatsapp?: string; + avatarUrl?: string; trialEndsAt?: string | null; createdAt: string; }