fix(upload): let Android guests reach the camera without breaking video (#1248)
* fix(upload): let Android guests reach the camera without breaking video Stable twin of #1244 (which replaces #1117). The reporter is on 3.46.1, so this branch is where the bug is actually being hit. Recent Android versions route an <input> 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. Gated on the Android UA: iOS and desktop pickers behave correctly and would only gain a selectable PDF that addFiles then rejects. No image-only guard — #1117 added one that broke video uploads outright on any install configured for them, and it was redundant anyway, since extensionsToMimeTypes only emits types it has a mapping for and the existing allowlist check already rejects a picked PDF. The premise — that this actually surfaces the camera option on Android — is taken at the reporter's description level and still needs confirmation on a device. Co-authored-by: Zszywany <[email protected]> * 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 <[email protected]> Co-authored-by: Zszywany <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
Zszywany
parent
fed99ac03d
commit
ccc725f36e
@@ -5,7 +5,7 @@ import { toast } from 'react-toastify';
|
|||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
|
import { extensionsToMimeTypes, buildUploadAcceptString } from '../../utils/fileTypes';
|
||||||
|
|
||||||
interface UserPhotoUploadProps {
|
interface UserPhotoUploadProps {
|
||||||
eventId: number;
|
eventId: number;
|
||||||
@@ -48,8 +48,10 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
[publicSettings?.allowed_file_types]
|
[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(
|
const acceptString = useMemo(
|
||||||
() => extensionsToAcceptString(publicSettings?.allowed_file_types),
|
() => buildUploadAcceptString(publicSettings?.allowed_file_types),
|
||||||
[publicSettings?.allowed_file_types]
|
[publicSettings?.allowed_file_types]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildUploadAcceptString } from '../fileTypes';
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,3 +46,40 @@ export function extensionsToMimeTypes(extString?: string | null): string[] {
|
|||||||
export function extensionsToAcceptString(extString?: string | null): string {
|
export function extensionsToAcceptString(extString?: string | null): string {
|
||||||
return extensionsToMimeTypes(extString).join(',');
|
return extensionsToMimeTypes(extString).join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `accept` for the guest upload input (#1117).
|
||||||
|
*
|
||||||
|
* Chrome and Edge on Android 14/15 route an `<input>` 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user