feat: add MinIO S3 integration for avatar upload and editable profile
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 49s Details

This commit is contained in:
Sidney Gomes 2026-07-21 20:16:01 -03:00
parent 647338fe75
commit 1ab4326778
6 changed files with 352 additions and 36 deletions

View File

@ -73,9 +73,48 @@ services:
- "traefik.http.services.microtecflix-admin.loadbalancer.server.port=3000" - "traefik.http.services.microtecflix-admin.loadbalancer.server.port=3000"
- "traefik.docker.network=network_public" - "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: volumes:
pgdata-microtecflix: pgdata-microtecflix:
appdata-microtecflix: appdata-microtecflix:
miniodata-microtecflix:
networks: networks:
microtecflix-net: microtecflix-net:

View File

@ -11,7 +11,9 @@
"lint": "tsc --noEmit" "lint": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@google/genai": "^2.4.0", "@google/genai": "^2.4.0",
"multer": "^1.4.5-lts.1",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@ -28,6 +30,7 @@
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^1.4.12",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",

116
server.ts
View File

@ -2,6 +2,8 @@ import express, { Request, Response, NextFunction } from 'express';
import path from 'path'; import path from 'path';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs'; 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 { createServer as createViteServer } from 'vite';
import { loadDb, saveDb } from './src/db/db'; import { loadDb, saveDb } from './src/db/db';
import { AsaasService } from './src/services/asaas'; import { AsaasService } from './src/services/asaas';
@ -15,6 +17,60 @@ const PORT = 3000;
app.use(express.json()); 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 --- // --- JWT AUTH MIDDLEWARE ---
interface AuthenticatedRequest extends Request { interface AuthenticatedRequest extends Request {
user?: { user?: {
@ -121,6 +177,7 @@ app.post('/api/auth/register', async (req: Request, res: Response) => {
cpf: newUser.cpf, cpf: newUser.cpf,
whatsapp: newUser.whatsapp, whatsapp: newUser.whatsapp,
birthDate: newUser.birthDate, birthDate: newUser.birthDate,
avatarUrl: newUser.avatarUrl,
createdAt: newUser.createdAt createdAt: newUser.createdAt
} }
}); });
@ -170,6 +227,7 @@ app.post('/api/auth/login', (req: Request, res: Response) => {
cpf: user.cpf, cpf: user.cpf,
whatsapp: user.whatsapp, whatsapp: user.whatsapp,
birthDate: user.birthDate, birthDate: user.birthDate,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt createdAt: user.createdAt
} }
}); });
@ -200,10 +258,68 @@ app.get('/api/auth/me', authenticateToken, (req: AuthenticatedRequest, res: Resp
cpf: user.cpf, cpf: user.cpf,
whatsapp: user.whatsapp, whatsapp: user.whatsapp,
birthDate: user.birthDate, birthDate: user.birthDate,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt 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) // 4. Courses List (With Progress)
app.get('/api/courses', authenticateToken, (req: AuthenticatedRequest, res: Response) => { app.get('/api/courses', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb(); const db = loadDb();

View File

@ -269,6 +269,7 @@ export default function App() {
<ProfileView <ProfileView
user={user as any} user={user as any}
onBack={() => setView('home')} onBack={() => setView('home')}
onUpdateUser={(updated) => setUser(updated)}
/> />
)} )}

View File

@ -1,13 +1,23 @@
import React from 'react'; import React, { useState, useRef } from 'react';
import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft } from 'lucide-react'; import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft, Camera, Edit2, Save, X, Loader2 } from 'lucide-react';
import { User } from '../types'; import { User } from '../types';
interface ProfileViewProps { interface ProfileViewProps {
user: User; user: User;
onBack: () => void; 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<HTMLInputElement>(null);
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'ACTIVE': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/50'; case 'ACTIVE': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/50';
@ -28,8 +38,65 @@ export default function ProfileView({ user, onBack }: ProfileViewProps) {
} }
}; };
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<HTMLInputElement>) => {
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 ( return (
<div className="space-y-6 max-w-4xl mx-auto"> <div className="space-y-6 max-w-4xl mx-auto pb-12">
<div className="flex items-center justify-between">
<button <button
onClick={onBack} onClick={onBack}
className="flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors text-sm font-bold" className="flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors text-sm font-bold"
@ -38,13 +105,65 @@ export default function ProfileView({ user, onBack }: ProfileViewProps) {
<span>Voltar</span> <span>Voltar</span>
</button> </button>
<div className="glass-card rounded-2xl overflow-hidden shadow-2xl border border-white/10 p-8"> {!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="flex items-center space-x-2 glass-btn-secondary px-4 py-2 rounded-lg text-sm font-bold text-white hover:text-[#E50914] transition-colors"
>
<Edit2 className="w-4 h-4" />
<span>Editar Perfil</span>
</button>
) : (
<div className="flex items-center space-x-2">
<button
onClick={() => {
setIsEditing(false);
setName(user.name);
setWhatsapp(user.whatsapp || '');
setBirthDate(user.birthDate || '');
}}
className="flex items-center space-x-1 px-4 py-2 rounded-lg text-sm font-bold text-zinc-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
<span>Cancelar</span>
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center space-x-1 glass-btn-primary px-4 py-2 rounded-lg text-sm font-bold text-white transition-colors disabled:opacity-50"
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<span>{isSaving ? 'Salvando...' : 'Salvar Alterações'}</span>
</button>
</div>
)}
</div>
<div className="glass-card rounded-2xl overflow-hidden shadow-2xl border border-white/10 p-8 relative">
<div className="flex flex-col md:flex-row items-start gap-8"> <div className="flex flex-col md:flex-row items-start gap-8">
{/* Avatar Area */} {/* Avatar Area */}
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<div className="w-32 h-32 bg-zinc-800 rounded-full flex items-center justify-center border-4 border-[#E50914]/20"> <div className="relative group cursor-pointer" onClick={() => fileInputRef.current?.click()}>
<div className="w-32 h-32 bg-zinc-800 rounded-full flex items-center justify-center border-4 border-[#E50914]/20 overflow-hidden">
{isUploading ? (
<Loader2 className="w-8 h-8 text-[#E50914] animate-spin" />
) : user.avatarUrl ? (
<img src={user.avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : (
<UserIcon className="w-16 h-16 text-zinc-400" /> <UserIcon className="w-16 h-16 text-zinc-400" />
)}
</div>
<div className="absolute inset-0 bg-black/60 rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Camera className="w-8 h-8 text-white" />
</div>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileChange}
/>
</div> </div>
<div className={`px-3 py-1 rounded-full text-xs font-bold border uppercase tracking-wider ${getStatusColor(user.subscriptionStatus)}`}> <div className={`px-3 py-1 rounded-full text-xs font-bold border uppercase tracking-wider ${getStatusColor(user.subscriptionStatus)}`}>
{getStatusLabel(user.subscriptionStatus)} {getStatusLabel(user.subscriptionStatus)}
@ -54,47 +173,84 @@ export default function ProfileView({ user, onBack }: ProfileViewProps) {
{/* User Info */} {/* User Info */}
<div className="flex-1 space-y-6 w-full"> <div className="flex-1 space-y-6 w-full">
<div> <div>
{isEditing ? (
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-zinc-500">Nome Completo</label>
<input
type="text"
value={name}
onChange={(e) => 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]"
/>
</div>
) : (
<>
<h2 className="text-2xl font-bold text-white">{user.name}</h2> <h2 className="text-2xl font-bold text-white">{user.name}</h2>
<p className="text-zinc-400 text-sm">{user.role === 'admin' ? 'Administrador do Sistema' : 'Aluno'}</p> <p className="text-zinc-400 text-sm">{user.role === 'admin' ? 'Administrador do Sistema' : 'Aluno'}</p>
</>
)}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex items-center space-x-3"> <div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center">
<Mail className="w-5 h-5 text-zinc-500" /> <div className="flex items-center space-x-3 mb-1">
<div> <Mail className="w-4 h-4 text-zinc-500" />
<p className="text-[10px] uppercase font-bold text-zinc-500">Email</p> <p className="text-[10px] uppercase font-bold text-zinc-500">Email</p>
<p className="text-sm font-medium text-white">{user.email}</p>
</div> </div>
<p className="text-sm font-medium text-white opacity-60 cursor-not-allowed pl-7">{user.email}</p>
</div> </div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex items-center space-x-3"> <div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center">
<Phone className="w-5 h-5 text-zinc-500" /> <div className="flex items-center space-x-3 mb-1">
<div> <Phone className="w-4 h-4 text-zinc-500" />
<p className="text-[10px] uppercase font-bold text-zinc-500">WhatsApp</p> <p className="text-[10px] uppercase font-bold text-zinc-500">WhatsApp</p>
<p className="text-sm font-medium text-white">{user.whatsapp || 'Não informado'}</p>
</div> </div>
{isEditing ? (
<div className="pl-7">
<input
type="text"
value={whatsapp}
onChange={(e) => 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]"
/>
</div>
) : (
<p className="text-sm font-medium text-white pl-7">{user.whatsapp || 'Não informado'}</p>
)}
</div> </div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex items-center space-x-3"> <div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center">
<Calendar className="w-5 h-5 text-zinc-500" /> <div className="flex items-center space-x-3 mb-1">
<div> <Calendar className="w-4 h-4 text-zinc-500" />
<p className="text-[10px] uppercase font-bold text-zinc-500">Data de Nascimento</p> <p className="text-[10px] uppercase font-bold text-zinc-500">Data de Nascimento</p>
<p className="text-sm font-medium text-white"> </div>
{isEditing ? (
<div className="pl-7">
<input
type="date"
value={birthDate}
onChange={(e) => 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]"
/>
</div>
) : (
<p className="text-sm font-medium text-white pl-7">
{user.birthDate ? new Date(user.birthDate).toLocaleDateString('pt-BR') : 'Não informado'} {user.birthDate ? new Date(user.birthDate).toLocaleDateString('pt-BR') : 'Não informado'}
</p> </p>
</div> )}
</div> </div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex items-center space-x-3"> <div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center">
<CreditCard className="w-5 h-5 text-zinc-500" /> <div className="flex items-center space-x-3 mb-1">
<div> <CreditCard className="w-4 h-4 text-zinc-500" />
<p className="text-[10px] uppercase font-bold text-zinc-500">Membro desde</p> <p className="text-[10px] uppercase font-bold text-zinc-500">Membro desde</p>
<p className="text-sm font-medium text-white"> </div>
<p className="text-sm font-medium text-white pl-7">
{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'}
</p> </p>
</div> </div>
</div> </div>
</div>
{user.role === 'admin' && ( {user.role === 'admin' && (
<div className="mt-4 p-4 bg-[#E50914]/10 border border-[#E50914]/30 rounded-xl flex items-start space-x-3"> <div className="mt-4 p-4 bg-[#E50914]/10 border border-[#E50914]/30 rounded-xl flex items-start space-x-3">

View File

@ -13,6 +13,7 @@ export interface User {
cpf?: string; cpf?: string;
birthDate?: string; birthDate?: string;
whatsapp?: string; whatsapp?: string;
avatarUrl?: string;
trialEndsAt?: string | null; trialEndsAt?: string | null;
createdAt: string; createdAt: string;
} }