Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/app/api/metrics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
119 changes: 84 additions & 35 deletions src/app/api/upload/avatar/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UploadApiResponse>((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<UploadApiResponse>((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,
});
}
}
5 changes: 3 additions & 2 deletions src/components/Dashboard/User/UserEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
5 changes: 3 additions & 2 deletions src/components/Dashboard/UserAdmin/EditUserAdminForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
5 changes: 3 additions & 2 deletions src/components/Dashboard/UserAdmin/UserAdminForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
23 changes: 21 additions & 2 deletions src/lib/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createPrismaClient>;

const globalForPrisma = global as unknown as {
prisma: PrismaClient | undefined;
prisma: ClientePrisma | undefined;
};

export const prisma = globalForPrisma.prisma ?? createPrismaClient();
Expand Down
Loading