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
59 lines
2.6 KiB
TypeScript
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);
|
|
});
|
|
}
|