From 31aed8dec417fb38f4747cd8dc72228476c526e1 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 15 Oct 2026 13:20:12 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP --- apps/api/src/lib/avatars.ts | 11 +++++++---- apps/api/src/routes/avatars.ts | 6 ++++-- apps/api/test/avatars.test.ts | 8 ++++++++ apps/mobile/package.json | 2 ++ apps/mobile/src/app/(tabs)/settings.tsx | 18 ++++++++++++++---- package-lock.json | 14 ++++++++++++++ 6 files changed, 49 insertions(+), 10 deletions(-) diff --git a/apps/api/src/lib/avatars.ts b/apps/api/src/lib/avatars.ts index 95c4095..7790c4a 100644 --- a/apps/api/src/lib/avatars.ts +++ b/apps/api/src/lib/avatars.ts @@ -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/?v=`) 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 { +export async function processAvatar(input: Buffer, log?: (err: unknown) => void): Promise { 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."); } } diff --git a/apps/api/src/routes/avatars.ts b/apps/api/src/routes/avatars.ts index 828a5a8..b42c82a 100644 --- a/apps/api/src/routes/avatars.ts +++ b/apps/api/src/routes/avatars.ts @@ -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 diff --git a/apps/api/test/avatars.test.ts b/apps/api/test/avatars.test.ts index 20ccd69..fea5bba 100644 --- a/apps/api/test/avatars.test.ts +++ b/apps/api/test/avatars.test.ts @@ -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()); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f0cc2fc..bde0d4a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -12,10 +12,12 @@ "expo-camera": "~57.0.6", "expo-constants": "~57.0.20", "expo-device": "~57.0.2", + "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-glass-effect": "~57.0.4", "expo-haptics": "~57.0.3", "expo-image": "~57.0.5", + "expo-image-manipulator": "~57.0.21", "expo-image-picker": "~57.0.20", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.11", diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index f013590..ffbab8a 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -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 { router } from 'expo-router'; import { useState } from 'react'; @@ -103,10 +105,18 @@ function AvatarPicker() { }); const asset = result.canceled ? null : result.assets[0]; if (!asset) return; - const form = new FormData(); - // React Native's FormData takes a file as { uri, name, type }. - form.append('file', { uri: asset.uri, name: asset.fileName ?? 'avatar.jpg', type: asset.mimeType ?? 'image/jpeg' } as unknown as Blob); - run(() => api.uploadAvatar(form)); + run(async () => { + // Shrink first: photos from 50-200 MP cameras are huge, and the server keeps 512 px anyway. + const side = Math.min(1024, asset.width || 1024); + 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 = () => diff --git a/package-lock.json b/package-lock.json index bfd89e4..874824c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -820,10 +820,12 @@ "expo-camera": "~57.0.6", "expo-constants": "~57.0.20", "expo-device": "~57.0.2", + "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-glass-effect": "~57.0.4", "expo-haptics": "~57.0.3", "expo-image": "~57.0.5", + "expo-image-manipulator": "~57.0.21", "expo-image-picker": "~57.0.20", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.11", @@ -11288,6 +11290,18 @@ "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": { "version": "57.0.20", "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz",