import { eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { db, schema } from '../db'; import { requireUser } from '../lib/auth'; import { AVATAR_MAX_UPLOAD_BYTES, processAvatar } from '../lib/avatars'; import { HttpError } from '../lib/http'; import { toMeDTO } from './me'; export async function avatarRoutes(app: FastifyInstance) { /** Multipart upload with one file field. Returns the updated profile. */ app.post('/me/avatar', { config: { rateLimit: { max: 20, timeWindow: '1 hour' } } }, async (req) => { const user = requireUser(req); const file = await req.file({ limits: { fileSize: AVATAR_MAX_UPLOAD_BYTES, files: 1 } }).catch(() => undefined); if (!file) throw new HttpError(400, 'Choose a picture to upload'); const input = await file.toBuffer().catch(() => { throw new HttpError(413, `That picture is too big (${AVATAR_MAX_UPLOAD_BYTES / 1024 / 1024} MB max)`); }); const image = await processAvatar(input, (err) => req.log.warn({ err, bytes: input.length, mimetype: file.mimetype, filename: file.filename }, 'avatar not readable'), ); const values = { image, contentType: 'image/webp', updatedAt: new Date() }; await db .insert(schema.avatars) .values({ userId: user.id, ...values }) .onConflictDoUpdate({ target: schema.avatars.userId, set: values }); // The version busts caches, since the image itself is served as immutable. const [updated] = await db .update(schema.users) .set({ avatarUrl: `/avatars/${user.id}?v=${values.updatedAt.getTime()}` }) .where(eq(schema.users.id, user.id)) .returning(); return toMeDTO(updated!); }); app.delete('/me/avatar', async (req) => { const user = requireUser(req); await db.delete(schema.avatars).where(eq(schema.avatars.userId, user.id)); const [updated] = await db.update(schema.users).set({ avatarUrl: null }).where(eq(schema.users.id, user.id)).returning(); return toMeDTO(updated!); }); app.get('/avatars/:userId', async (req, reply) => { const { userId } = z.object({ userId: z.uuid() }).parse(req.params); const [row] = await db .select({ image: schema.avatars.image, contentType: schema.avatars.contentType }) .from(schema.avatars) .where(eq(schema.avatars.userId, userId)); if (!row) throw new HttpError(404, 'No picture'); return reply .header('Content-Type', row.contentType) .header('Cache-Control', 'public, max-age=31536000, immutable') .header('Content-Security-Policy', "default-src 'none'") .header('Cross-Origin-Resource-Policy', 'cross-origin') .send(row.image); }); }