Files
musicshare/apps/api/src/routes/avatars.ts
T
chrisandClaude Opus 5.5 31aed8dec4 Fix profile picture uploads from the app
Expo's fetch rejects React Native's { uri, name, type } FormData parts ("Unsupported FormDataPart
implementation"), so every upload failed. Upload an expo-file-system File instead, shrunk to 1024 px
first. The server also takes bigger photos (25 MB, sharp's default pixel limit) and logs why a
picture couldn't be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 13:20:12 -04:00

59 lines
2.6 KiB
TypeScript

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);
});
}