diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts index ae3ede62..f9425459 100644 --- a/src/app/api/metrics/route.ts +++ b/src/app/api/metrics/route.ts @@ -4,9 +4,12 @@ import { checkAuth } from '@/lib/check-auth'; import { ROLES } from '@/lib/roles'; export async function GET() { - const session = await checkAuth({ requireAdmin: true }); - if (!session) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + // `checkAuth` sempre devolve um objeto — `{ authorized: false, response }` ao + // negar e `{ authorized: true, session }` ao permitir. Testar a existência do + // objeto (`if (!auth)`) nunca é falso, e a rota ficava aberta a qualquer + // requisição, inclusive anônima. + const auth = await checkAuth({ requireAdmin: true }); + if (!auth.authorized) return auth.response; // Skills mais frequentes (users) const userSkills = await prisma.userSkill.groupBy({ diff --git a/src/app/api/upload/avatar/route.ts b/src/app/api/upload/avatar/route.ts index fb4d895b..afec5616 100644 --- a/src/app/api/upload/avatar/route.ts +++ b/src/app/api/upload/avatar/route.ts @@ -3,49 +3,98 @@ import { UploadApiResponse, UploadApiErrorResponse, } from 'cloudinary'; -import { NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; +import { checkAuth } from '@/lib/check-auth'; +import { ROLE_GROUPS } from '@/lib/roles'; +import { buildResponse } from '@/constants/messages'; +import { logger } from '@/lib/logger'; -// Configura Cloudinary com as variáveis de ambiente cloudinary.config({ cloud_name: process.env.CLOUDINARY_CLOUD_NAME!, api_key: process.env.CLOUDINARY_API_KEY!, api_secret: process.env.CLOUDINARY_API_SECRET!, }); -export async function POST(req: Request) { - const formData = await req.formData(); - const file = formData.get('file') as File; +const TAMANHO_MAXIMO = 2 * 1024 * 1024; // 2 MB +const TIPOS_ACEITOS = ['image/png', 'image/jpeg', 'image/webp']; - if (!file) { - return NextResponse.json( - { error: 'Nenhum arquivo enviado' }, - { status: 400 } - ); - } - - // Converte para buffer - const buffer = Buffer.from(await file.arrayBuffer()); - - // Faz upload para Cloudinary - const result = await new Promise((resolve, reject) => { - cloudinary.uploader - .upload_stream( - { - folder: 'avatars', // organiza em uma pasta "avatars" na Cloudinary - transformation: [ - { width: 300, height: 300, crop: 'thumb', gravity: 'face' }, // centraliza no rosto - ], - }, - ( - error: UploadApiErrorResponse | undefined, - result: UploadApiResponse | undefined - ) => { - if (error || !result) reject(error || new Error('Upload failed')); - else resolve(result); - } - ) - .end(buffer); +export async function POST(req: NextRequest) { + const { authorized, response, session } = await checkAuth({ + allowedRoles: ROLE_GROUPS.ALL, }); + if (!authorized || !session) return response; + + try { + const formData = await req.formData(); + const file = formData.get('file') as File | null; + + if (!file) { + return buildResponse({ + success: false, + message: 'Nenhum arquivo enviado.', + status: 400, + }); + } + + // Primeira barreira. O `type` vem do cliente e pode ser forjado — a + // validação real de conteúdo fica com o Cloudinary, via `resource_type`. + if (!TIPOS_ACEITOS.includes(file.type)) { + return buildResponse({ + success: false, + message: 'Formato não suportado. Envie PNG, JPEG ou WebP.', + status: 400, + }); + } - return NextResponse.json({ url: result.secure_url }); + if (file.size > TAMANHO_MAXIMO) { + return buildResponse({ + success: false, + message: 'A imagem deve ter no máximo 2 MB.', + status: 400, + }); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + + const result = await new Promise((resolve, reject) => { + cloudinary.uploader + .upload_stream( + { + folder: 'avatars', + resource_type: 'image', + // Amarra o arquivo à conta: cada pessoa só sobrescreve o próprio + // avatar, em vez de poder gravar em qualquer caminho da pasta. + public_id: session.user.id, + overwrite: true, + transformation: [ + { width: 300, height: 300, crop: 'thumb', gravity: 'face' }, + ], + }, + ( + error: UploadApiErrorResponse | undefined, + result: UploadApiResponse | undefined + ) => { + if (error || !result) reject(error || new Error('Upload failed')); + else resolve(result); + } + ) + .end(buffer); + }); + + return buildResponse({ + success: true, + message: 'Imagem enviada com sucesso.', + data: { url: result.secure_url }, + status: 200, + }); + } catch (error) { + logger.error('Erro ao enviar avatar:', 'POST /api/upload/avatar', { + error: error instanceof Error ? error.message : String(error), + }); + return buildResponse({ + success: false, + message: 'Erro interno ao enviar a imagem.', + status: 500, + }); + } } diff --git a/src/components/Dashboard/User/UserEdit.tsx b/src/components/Dashboard/User/UserEdit.tsx index 696ea1b2..f4af52d6 100644 --- a/src/components/Dashboard/User/UserEdit.tsx +++ b/src/components/Dashboard/User/UserEdit.tsx @@ -75,8 +75,9 @@ export function UserEdit({ user }: UserEditProps) { if (!res.ok) throw new Error('Falha no upload do avatar'); const json = await res.json(); - if (json.url) { - setValue('avatar', json.url, { shouldValidate: true }); + const urlEnviada = json?.data?.url; + if (urlEnviada) { + setValue('avatar', urlEnviada, { shouldValidate: true }); toast.success('Avatar atualizado com sucesso!'); } else { throw new Error('Servidor não retornou a URL do avatar'); diff --git a/src/components/Dashboard/UserAdmin/EditUserAdminForm.tsx b/src/components/Dashboard/UserAdmin/EditUserAdminForm.tsx index 87e77de4..76e341de 100644 --- a/src/components/Dashboard/UserAdmin/EditUserAdminForm.tsx +++ b/src/components/Dashboard/UserAdmin/EditUserAdminForm.tsx @@ -83,8 +83,9 @@ export function EditUserAdminForm({ user, onSuccess, onClose }: Props) { if (!res.ok) throw new Error('Falha no upload do avatar'); const json = await res.json(); - if (json.url) { - setValue('avatar', json.url); + const urlEnviada = json?.data?.url; + if (urlEnviada) { + setValue('avatar', urlEnviada); toast.success('Avatar atualizado com sucesso!'); } else { throw new Error('URL do avatar não retornada'); diff --git a/src/components/Dashboard/UserAdmin/UserAdminForm.tsx b/src/components/Dashboard/UserAdmin/UserAdminForm.tsx index 870b7c25..d71d4cfb 100644 --- a/src/components/Dashboard/UserAdmin/UserAdminForm.tsx +++ b/src/components/Dashboard/UserAdmin/UserAdminForm.tsx @@ -106,8 +106,9 @@ export function UserAdminForm() { if (!res.ok) throw new Error('Falha no upload do avatar'); const json = await res.json(); - if (json.url) { - setValue('avatar', json.url); // Atualiza o campo avatar no hook form + const urlEnviada = json?.data?.url; + if (urlEnviada) { + setValue('avatar', urlEnviada); // Atualiza o campo avatar no hook form } else { toast.error('URL do avatar não retornada'); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index adafb39d..7025a85f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -16,6 +16,9 @@ export const authOptions: NextAuthOptions = { try { const user = await prisma.user.findUnique({ where: { email: credentials?.email }, + // Única exceção ao `omit` global definido em src/lib/prisma.ts: + // aqui o hash é necessário para validar a senha. + omit: { password: false }, }); if (!user) throw new Error('Email não encontrado.'); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 1330420e..0c97ecca 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -4,14 +4,33 @@ import { PrismaClient } from '@prisma/client'; function createPrismaClient() { const connectionString = process.env.DATABASE_URL!; const adapter = new PrismaPg({ connectionString }); + return new PrismaClient({ adapter, - log: ['query'], + + // O hash da senha nunca sai do banco por padrão. Sem isto, qualquer rota + // que devolva um User devolve o hash junto — e basta uma esquecer o + // `select` para vazar. Quem realmente precisa do campo pede explicitamente + // com `omit: { password: false }`, como faz a validação de login. + omit: { + user: { password: true }, + }, + + // Em produção, registrar toda query gera custo de I/O, polui os logs e + // espalha dado de usuário pelo provedor. + log: + process.env.NODE_ENV === 'development' + ? ['query', 'warn', 'error'] + : ['error'], }); } +// O tipo vem da própria fábrica: com `omit` configurado, o cliente tem um tipo +// mais específico que o PrismaClient padrão. +type ClientePrisma = ReturnType; + const globalForPrisma = global as unknown as { - prisma: PrismaClient | undefined; + prisma: ClientePrisma | undefined; }; export const prisma = globalForPrisma.prisma ?? createPrismaClient();