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:
2026-10-15 13:20:12 -04:00
co-authored by Claude Opus 5.5
parent b705bfcce4
commit 31aed8dec4
6 changed files with 49 additions and 10 deletions
+7 -4
View File
@@ -3,7 +3,8 @@ import { env } from '../env';
import { HttpError } from './http'; import { HttpError } from './http';
export const AVATAR_SIZE = 512; 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 * 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 * 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. * (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 { 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 .rotate() // apply the EXIF orientation before it's stripped
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' }) .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
.webp({ quality: 82 }) .webp({ quality: 82 })
.toBuffer(); .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."); throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC.");
} }
} }
+4 -2
View File
@@ -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); 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'); if (!file) throw new HttpError(400, 'Choose a picture to upload');
const input = await file.toBuffer().catch(() => { 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() }; const values = { image, contentType: 'image/webp', updatedAt: new Date() };
await db await db
+8
View File
@@ -22,6 +22,14 @@ const photo = () =>
.toBuffer(); .toBuffer();
describe('avatars', () => { 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 () => { it('resizes, re-encodes and serves an uploaded picture', async () => {
const { token, user } = await createUser(app(), 'alice'); const { token, user } = await createUser(app(), 'alice');
const { payload, headers } = multipart(await photo()); const { payload, headers } = multipart(await photo());
+2
View File
@@ -12,10 +12,12 @@
"expo-camera": "~57.0.6", "expo-camera": "~57.0.6",
"expo-constants": "~57.0.20", "expo-constants": "~57.0.20",
"expo-device": "~57.0.2", "expo-device": "~57.0.2",
"expo-file-system": "~57.0.7",
"expo-font": "~57.0.4", "expo-font": "~57.0.4",
"expo-glass-effect": "~57.0.4", "expo-glass-effect": "~57.0.4",
"expo-haptics": "~57.0.3", "expo-haptics": "~57.0.3",
"expo-image": "~57.0.5", "expo-image": "~57.0.5",
"expo-image-manipulator": "~57.0.21",
"expo-image-picker": "~57.0.20", "expo-image-picker": "~57.0.20",
"expo-linear-gradient": "~57.0.2", "expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.11", "expo-linking": "~57.0.11",
+14 -4
View File
@@ -1,3 +1,5 @@
import { File } from 'expo-file-system';
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
@@ -103,10 +105,18 @@ function AvatarPicker() {
}); });
const asset = result.canceled ? null : result.assets[0]; const asset = result.canceled ? null : result.assets[0];
if (!asset) return; if (!asset) return;
const form = new FormData(); run(async () => {
// React Native's FormData takes a file as { uri, name, type }. // Shrink first: photos from 50-200 MP cameras are huge, and the server keeps 512 px anyway.
form.append('file', { uri: asset.uri, name: asset.fileName ?? 'avatar.jpg', type: asset.mimeType ?? 'image/jpeg' } as unknown as Blob); const side = Math.min(1024, asset.width || 1024);
run(() => api.uploadAvatar(form)); const image = await (await ImageManipulator.manipulate(asset.uri).resize({ width: side }).renderAsync()).saveAsync({
compress: 0.85,
format: SaveFormat.JPEG,
});
const form = new FormData();
// Expo's fetch needs a real file object (it rejects React Native's { uri, name, type } parts).
form.append('file', new File(image.uri) as unknown as Blob);
return api.uploadAvatar(form);
});
}; };
const remove = () => const remove = () =>
+14
View File
@@ -820,10 +820,12 @@
"expo-camera": "~57.0.6", "expo-camera": "~57.0.6",
"expo-constants": "~57.0.20", "expo-constants": "~57.0.20",
"expo-device": "~57.0.2", "expo-device": "~57.0.2",
"expo-file-system": "~57.0.7",
"expo-font": "~57.0.4", "expo-font": "~57.0.4",
"expo-glass-effect": "~57.0.4", "expo-glass-effect": "~57.0.4",
"expo-haptics": "~57.0.3", "expo-haptics": "~57.0.3",
"expo-image": "~57.0.5", "expo-image": "~57.0.5",
"expo-image-manipulator": "~57.0.21",
"expo-image-picker": "~57.0.20", "expo-image-picker": "~57.0.20",
"expo-linear-gradient": "~57.0.2", "expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.11", "expo-linking": "~57.0.11",
@@ -11288,6 +11290,18 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-image-manipulator": {
"version": "57.0.21",
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.21.tgz",
"integrity": "sha512-NZeNvZxBNf/PK97Df5zORmtqRR/LovkA+lHdRLm1IJFiVKjs4xr+Ib6/jWhDxn04VOX3TJswCQyu4SCxkgMe4Q==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~57.0.1"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": { "node_modules/expo-image-picker": {
"version": "57.0.20", "version": "57.0.20",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz", "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz",