From 66989d70f143fa5d86bdc95b4f1b3d8c0ff0dac5 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:24:24 +0200 Subject: [PATCH] fix(upload): let Android guests reach the camera without breaking video (#1244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(upload): let Android guests reach the camera without breaking video Recent Android versions route an whose accept list is entirely image/video types to the system photo picker, which has no camera entry — so a guest standing at the event can only pick an existing photo, not take one. Including a type that picker can't handle forces the general chooser, which does offer the camera. Two corrections to the original approach in #1117: - the .pdf is gated on the Android UA. It was appended unconditionally, so desktop and iOS pickers — which behave correctly — gained a selectable PDF that only produces an error when chosen. - no image-only guard. #1117 rejected every non-image file before the existing allowlist check, which breaks video uploads outright on any install configured for them (fileTypes.ts maps mp4/m4v/webm/mov/avi and general_allowed_file_types is admin-editable). The guard was also redundant: extensionsToMimeTypes only emits types it has a mapping for, so application/pdf can never be in allowedMimeTypes and the existing "Invalid file type" check already rejects a picked PDF. The empty-string fallback to 'image/*, .pdf' goes too — extensionsToMimeTypes already falls back to the configured default set, and image/* was broader than the admin's allowlist. Lives in fileTypes.ts as a pure function so the UA behaviour is testable; the component keeps a one-line useMemo. Co-authored-by: Zszywany * fix(upload): use android/allowCamera instead of .pdf for the chooser fallback Same mechanism, better token. Chrome on Android 14/15 sends an input whose accept list is all media types to the photo picker, which has no camera tile; adding a value that picker cannot satisfy makes it fall back to the general chooser, which does offer the camera. `.pdf` achieves that but advertises PDFs as selectable — pick one and the existing allowlist check answers "Invalid file type", which is a dead end we put in front of the guest ourselves. `android/allowCamera` is the token the workaround converged on: not a real MIME type, matches no file, so it flips the picker without offering anything. Neither token ever widened what is accepted — addFiles validates against extensionsToMimeTypes, which only emits types it has a mapping for — but not showing the guest a choice that cannot work is worth the one-line change. Verified in a browser rather than asserted: the real component rendered under an Android UA emits image/jpeg,image/png,image/webp,android/allowCamera and under a desktop UA image/jpeg,image/png,image/webp with the visible modal identical in both, and the format hint still reading "JPG, JPEG, PNG, WEBP" — the token does not leak into anything a guest sees. * fix(upload): keep the camera token off Firefox for Android External review round. The gate was a bare /Android/i, which Firefox for Android matches — so it received a token invented to reroute Chromium's photo picker, a picker it does not use. The doc comment two lines up already said Firefox behaves correctly; the code did not agree with it. Inert at best, and at worst it perturbs a chooser that was working. Narrowed to Android minus Firefox, which is the Chromium-family set the behaviour was actually observed on (Chrome and Edge, Android 14/15), with a UA test to pin it. --------- Co-authored-by: Paul Nothaft Co-authored-by: Zszywany --- .../components/gallery/UserPhotoUpload.tsx | 6 ++- .../src/utils/__tests__/fileTypes.test.ts | 44 ++++++++++++++++++- frontend/src/utils/fileTypes.ts | 37 ++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx index 5aef8b43..e909526b 100644 --- a/frontend/src/components/gallery/UserPhotoUpload.tsx +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -5,7 +5,7 @@ import { toast } from 'react-toastify'; import { Button } from '../common'; import { api } from '../../config/api'; import { usePublicSettings } from '../../hooks/usePublicSettings'; -import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes'; +import { extensionsToMimeTypes, buildUploadAcceptString, extensionsToLabel } from '../../utils/fileTypes'; interface UserPhotoUploadProps { eventId: number; @@ -56,8 +56,10 @@ export const UserPhotoUpload: React.FC = ({ [publicSettings?.allowed_file_types] ); + // #1117 — on Android this appends a type the photo picker can't handle, so + // the system falls back to the chooser that actually offers the camera. const acceptString = useMemo( - () => extensionsToAcceptString(publicSettings?.allowed_file_types), + () => buildUploadAcceptString(publicSettings?.allowed_file_types), [publicSettings?.allowed_file_types] ); diff --git a/frontend/src/utils/__tests__/fileTypes.test.ts b/frontend/src/utils/__tests__/fileTypes.test.ts index 551f2e14..07fd8a56 100644 --- a/frontend/src/utils/__tests__/fileTypes.test.ts +++ b/frontend/src/utils/__tests__/fileTypes.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../fileTypes'; +import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel, buildUploadAcceptString } from '../fileTypes'; describe('fileTypes', () => { describe('extensionsToMimeTypes', () => { @@ -35,4 +35,46 @@ describe('fileTypes', () => { expect(extensionsToAcceptString('jpg,heic')).toBe('image/jpeg,image/heic'); }); }); + + describe('buildUploadAcceptString (#1117)', () => { + const ANDROID = 'Mozilla/5.0 (Linux; Android 16; Pixel 9) AppleWebKit/537.36 Chrome/151.0.0.0 Mobile Safari/537.36'; + const IOS = 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_6 like Mac OS X) AppleWebKit/605.1.15 Version/26.0 Mobile Safari/604.1'; + const DESKTOP = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/151.0.0.0 Safari/537.36'; + const FIREFOX_ANDROID = 'Mozilla/5.0 (Android 16; Mobile; rv:140.0) Gecko/140.0 Firefox/140.0'; + + it('appends the camera token on Android so the chooser offers the camera', () => { + expect(buildUploadAcceptString('jpg,png', ANDROID)).toBe('image/jpeg,image/png,android/allowCamera'); + }); + + it('adds nothing a guest could actually select', () => { + // The token exists to flip Chrome out of the photo picker, not to widen + // the allowlist. An earlier revision used .pdf, which does flip it but + // also offers PDFs — pick one and you get "Invalid file type". + const accept = buildUploadAcceptString('jpg,png', ANDROID); + expect(accept).not.toMatch(/\.pdf|application\/pdf/); + expect(accept.split(',').filter((t) => t.startsWith('image/') || t.startsWith('video/'))) + .toEqual(['image/jpeg', 'image/png']); + }); + + + it('leaves Firefox for Android alone — its chooser already offers the camera', () => { + // The UA says Android, but the behaviour this works around is Chromium's. + expect(buildUploadAcceptString('jpg,png', FIREFOX_ANDROID)).toBe('image/jpeg,image/png'); + }); + it('leaves iOS and desktop untouched — their pickers already work', () => { + expect(buildUploadAcceptString('jpg,png', IOS)).toBe('image/jpeg,image/png'); + expect(buildUploadAcceptString('jpg,png', DESKTOP)).toBe('image/jpeg,image/png'); + }); + + it('keeps offering video when the admin configured it', () => { + // The workaround must not narrow the accept list to images: an install + // with video enabled still has to offer mp4/mov in the chooser. + expect(buildUploadAcceptString('jpg,mp4,mov', ANDROID)).toBe('image/jpeg,video/mp4,video/quicktime,android/allowCamera'); + }); + + it('falls back to the configured default set, not a wider image/*', () => { + expect(buildUploadAcceptString('', DESKTOP)).toBe('image/jpeg,image/png,image/webp'); + expect(buildUploadAcceptString('', ANDROID)).toBe('image/jpeg,image/png,image/webp,android/allowCamera'); + }); + }); }); diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts index 34e42680..d7ce848a 100644 --- a/frontend/src/utils/fileTypes.ts +++ b/frontend/src/utils/fileTypes.ts @@ -52,6 +52,43 @@ export function extensionsToAcceptString(extString?: string | null): string { return extensionsToMimeTypes(extString).join(','); } +/** + * `accept` for the guest upload input (#1117). + * + * Chrome and Edge on Android 14/15 route an `` whose accept list is + * entirely image and video types to the system *photo picker*, which has no + * camera tile — so a guest standing at the event can only pick a photo already + * in their gallery, never take one. Adding a value that picker cannot satisfy + * makes Chrome fall back to the general document chooser, which does offer the + * camera. + * + * `android/allowCamera` is the token the workaround converged on. It is not a + * real MIME type and matches no file, which is the point: it flips the picker + * without advertising anything extra as selectable. An earlier revision used + * `.pdf`, which works by the same mechanism but offers PDFs in the chooser — + * pick one and you get "Invalid file type" for your trouble. + * + * Gated to Android MINUS Firefox. The behaviour is Chromium's — Chrome and + * Edge on Android 14/15 — and Firefox for Android, whose UA also says + * `Android`, opens a chooser that already offers the camera. Handing it a + * token invented to reroute a picker it does not use is at best inert and at + * worst changes a chooser that was working. + * + * UA sniffing is the wrong tool in general, but there is no feature query for + * "which picker will this open", and the failure mode of a wrong guess is an + * accept token the browser ignores. + * + * Neither token widens what is actually accepted: `addFiles` validates every + * file against `extensionsToMimeTypes`, which only ever emits types it has a + * mapping for, so nothing new can get past it. + */ +export function buildUploadAcceptString(extString?: string | null, userAgent?: string): string { + const accept = extensionsToAcceptString(extString); + const ua = userAgent ?? (typeof navigator !== 'undefined' ? navigator.userAgent : ''); + const needsCameraToken = /Android/i.test(ua) && !/Firefox/i.test(ua); + return needsCameraToken ? `${accept},android/allowCamera` : accept; +} + /** * Human-readable, de-duplicated list of the configured extensions for the * upload requirements hint, e.g. "JPG, PNG, WEBP, MOV". Only extensions the