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
This commit is contained in:
@@ -3,7 +3,8 @@ import { env } from '../env';
|
||||
import { HttpError } from './http';
|
||||
|
||||
export const AVATAR_SIZE = 512;
|
||||
export const AVATAR_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
/** Full-size photos from 50-200 MP phone cameras can be well over 10 MB (the app shrinks them first since 1.3.0). */
|
||||
export const AVATAR_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Uploaded avatars are stored as a path (`/avatars/<id>?v=<version>`) so they keep
|
||||
@@ -16,14 +17,16 @@ export const publicAvatarUrl = (stored: string | null): string | null =>
|
||||
* Square-crops, resizes and re-encodes to WebP. Re-encoding also drops EXIF
|
||||
* (phone photos carry GPS location) and anything that isn't image data.
|
||||
*/
|
||||
export async function processAvatar(input: Buffer): Promise<Buffer> {
|
||||
export async function processAvatar(input: Buffer, log?: (err: unknown) => void): Promise<Buffer> {
|
||||
try {
|
||||
return await sharp(input, { limitInputPixels: 50_000_000, animated: false })
|
||||
// sharp's default pixel limit (~268 MP) fits a 200 MP camera; JPEGs are shrunk while decoding, so it stays cheap.
|
||||
return await sharp(input, { animated: false })
|
||||
.rotate() // apply the EXIF orientation before it's stripped
|
||||
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer();
|
||||
} catch {
|
||||
} catch (err) {
|
||||
log?.(err);
|
||||
throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ export async function avatarRoutes(app: FastifyInstance) {
|
||||
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 (10 MB max)');
|
||||
throw new HttpError(413, `That picture is too big (${AVATAR_MAX_UPLOAD_BYTES / 1024 / 1024} MB max)`);
|
||||
});
|
||||
const image = await processAvatar(input);
|
||||
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
|
||||
|
||||
@@ -22,6 +22,14 @@ const photo = () =>
|
||||
.toBuffer();
|
||||
|
||||
describe('avatars', () => {
|
||||
it('takes a full-size photo from a high-resolution phone camera (64 MP)', async () => {
|
||||
const { token } = await createUser(app(), 'hires');
|
||||
const big = await sharp({ create: { width: 8000, height: 8000, channels: 3, background: '#203040' } }).jpeg().toBuffer();
|
||||
const { payload, headers } = multipart(big);
|
||||
const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('resizes, re-encodes and serves an uploaded picture', async () => {
|
||||
const { token, user } = await createUser(app(), 'alice');
|
||||
const { payload, headers } = multipart(await photo());
|
||||
|
||||
Reference in New Issue
Block a user