Fix image uploads using the wrong format (#2380)

* fix(app): preserve image attachment MIME types

Image intake discarded clipboard and desktop path metadata, then guessed JPEG when a later file object had no type. Resolve MIME once at the platform boundary and require it downstream.

* fix(app): honor native picker MIME metadata
This commit is contained in:
Mohamed Boudra
2026-07-24 21:11:41 +08:00
committed by GitHub
parent 967edab497
commit 21597bdc1b
13 changed files with 198 additions and 54 deletions
@@ -6,6 +6,7 @@ import {
isRasterImageMimeType, isRasterImageMimeType,
isRasterImagePath, isRasterImagePath,
RASTER_IMAGE_FILE_EXTENSIONS, RASTER_IMAGE_FILE_EXTENSIONS,
resolveRasterImageMimeType,
} from "./file-types"; } from "./file-types";
describe("attachment file types", () => { describe("attachment file types", () => {
@@ -37,4 +38,28 @@ describe("attachment file types", () => {
new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]), new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]),
); );
}); });
it("uses explicit raster MIME metadata before the filename", () => {
expect(
resolveRasterImageMimeType({ mimeType: "image/jpeg", path: "/tmp/screenshot.png" }),
).toBe("image/jpeg");
expect(
resolveRasterImageMimeType({
mimeType: "image/png; charset=binary",
path: "/tmp/screenshot.jpg",
}),
).toBe("image/png");
});
it("uses the filename only when MIME metadata is absent", () => {
expect(resolveRasterImageMimeType({ mimeType: "", path: "/tmp/screenshot.png" })).toBe(
"image/png",
);
expect(
resolveRasterImageMimeType({
mimeType: "application/octet-stream",
path: "/tmp/screenshot.png",
}),
).toBeNull();
});
}); });
+19 -6
View File
@@ -41,18 +41,31 @@ export function getRasterImageMimeTypeFromPath(path: string): string | null {
return RASTER_IMAGE_MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? null; return RASTER_IMAGE_MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? null;
} }
export function resolveRasterImageMimeType(input: {
mimeType?: string | null;
path?: string | null;
}): string | null {
const suppliedMimeType = input.mimeType?.trim();
if (suppliedMimeType) {
const normalizedMimeType = suppliedMimeType.split(";", 1)[0]?.trim().toLowerCase();
if (normalizedMimeType === "image/jpg") {
return "image/jpeg";
}
return normalizedMimeType && RASTER_IMAGE_MIME_TYPES.has(normalizedMimeType)
? normalizedMimeType
: null;
}
return input.path ? getRasterImageMimeTypeFromPath(input.path) : null;
}
export function isRasterImagePath(path: string): boolean { export function isRasterImagePath(path: string): boolean {
return getRasterImageMimeTypeFromPath(path) !== null; return getRasterImageMimeTypeFromPath(path) !== null;
} }
export function isRasterImageMimeType(mimeType: string | null | undefined): boolean { export function isRasterImageMimeType(mimeType: string | null | undefined): boolean {
const normalized = mimeType?.split(";", 1)[0]?.trim().toLowerCase(); return resolveRasterImageMimeType({ mimeType }) !== null;
return Boolean(normalized && RASTER_IMAGE_MIME_TYPES.has(normalized));
} }
export function isRasterImageFile(file: Pick<File, "name" | "type">): boolean { export function isRasterImageFile(file: Pick<File, "name" | "type">): boolean {
if (isRasterImageMimeType(file.type)) { return resolveRasterImageMimeType({ mimeType: file.type, path: file.name }) !== null;
return true;
}
return file.type.trim().length === 0 && isRasterImagePath(file.name);
} }
@@ -5,9 +5,9 @@ import type { ImageAttachment } from "@/composer/types";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import { import {
getRasterImageMimeTypeFromPath,
isRasterImageFile, isRasterImageFile,
isRasterImagePath, isRasterImagePath,
resolveRasterImageMimeType,
} from "@/attachments/file-types"; } from "@/attachments/file-types";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types"; import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
@@ -27,14 +27,21 @@ interface DesktopDragDropEvent {
} }
async function filePathToImageAttachment(path: string): Promise<ImageAttachment> { async function filePathToImageAttachment(path: string): Promise<ImageAttachment> {
const mimeType = getRasterImageMimeTypeFromPath(path) ?? "image/jpeg"; const mimeType = resolveRasterImageMimeType({ path });
if (!mimeType) {
throw new Error(`Unsupported image type for '${path}'.`);
}
return await persistAttachmentFromFileUri({ uri: path, mimeType }); return await persistAttachmentFromFileUri({ uri: path, mimeType });
} }
async function fileToImageAttachment(file: File): Promise<ImageAttachment> { async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
const mimeType = resolveRasterImageMimeType({ mimeType: file.type, path: file.name });
if (!mimeType) {
throw new Error(`Unsupported image type for '${file.name}'.`);
}
return await persistAttachmentFromBlob({ return await persistAttachmentFromBlob({
blob: file, blob: file,
mimeType: file.type || "image/jpeg", mimeType,
fileName: file.name, fileName: file.name,
}); });
} }
+5 -1
View File
@@ -321,7 +321,11 @@ describe("pickAndPersistImages", () => {
const persister = createFakePersister(); const persister = createFakePersister();
const result = await pickAndPersistImages({ const result = await pickAndPersistImages({
pickImages: async () => [ pickImages: async () => [
{ source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null }, {
source: { kind: "file_uri", uri: "/tmp/x.jpg" },
mimeType: "image/jpeg",
fileName: null,
},
], ],
persister, persister,
}); });
+1 -1
View File
@@ -93,7 +93,7 @@ export async function pickAndPersistImages(input: {
return await Promise.all( return await Promise.all(
result.map(async (picked) => { result.map(async (picked) => {
const fileName = picked.fileName ?? null; const fileName = picked.fileName ?? null;
const mimeType = picked.mimeType || "image/jpeg"; const mimeType = picked.mimeType;
if (picked.source.kind === "blob") { if (picked.source.kind === "blob") {
return await input.persister.persistFromBlob({ return await input.persister.persistFromBlob({
blob: picked.source.blob, blob: picked.source.blob,
@@ -34,6 +34,8 @@ export async function normalizePickedImageAssets(
return normalizePickedImageAssetsWith(assets, exportPickedImageAsPng); return normalizePickedImageAssetsWith(assets, exportPickedImageAsPng);
} }
export async function openImagePathsWithDesktopDialog(_dialog?: unknown): Promise<string[]> { export async function pickImagesWithDesktopDialog(
_dialog?: unknown,
): Promise<PickedImageAttachmentInput[]> {
throw new Error("Desktop dialog API is not available on native."); throw new Error("Desktop dialog API is not available on native.");
} }
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "@/desktop/host"; import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "@/desktop/host";
import { import { normalizePickedImageAssets, pickImagesWithDesktopDialog } from "./image-attachment-picker";
normalizePickedImageAssets,
openImagePathsWithDesktopDialog,
} from "./image-attachment-picker";
function fakeDialogReturning(selection: string | string[] | null): { function fakeDialogReturning(selection: string | string[] | null): {
dialog: DesktopDialogBridge; dialog: DesktopDialogBridge;
@@ -42,6 +39,27 @@ describe("image-attachment-picker", () => {
expect(result[0]?.mimeType).toBe("image/png"); expect(result[0]?.mimeType).toBe("image/png");
}); });
it("derives the type of a type-less picked File from its name", async () => {
const file = new File(["image"], "picked.png");
const result = await normalizePickedImageAssets([
{
uri: "blob:test",
mimeType: null,
fileName: null,
file,
},
]);
expect(result).toEqual([
{
source: { kind: "blob", blob: file },
mimeType: "image/png",
fileName: "picked.png",
},
]);
});
it("keeps filesystem picker results as file uris", async () => { it("keeps filesystem picker results as file uris", async () => {
const result = await normalizePickedImageAssets([ const result = await normalizePickedImageAssets([
{ {
@@ -78,7 +96,7 @@ describe("image-attachment-picker", () => {
it("uses the desktop dialog api when available", async () => { it("uses the desktop dialog api when available", async () => {
const { dialog, recordedOptions } = fakeDialogReturning(["/tmp/one.png", "/tmp/two.jpg"]); const { dialog, recordedOptions } = fakeDialogReturning(["/tmp/one.png", "/tmp/two.jpg"]);
const result = await openImagePathsWithDesktopDialog(dialog); const result = await pickImagesWithDesktopDialog(dialog);
expect(recordedOptions).toHaveLength(1); expect(recordedOptions).toHaveLength(1);
expect(recordedOptions[0]).toMatchObject({ expect(recordedOptions[0]).toMatchObject({
@@ -86,14 +104,25 @@ describe("image-attachment-picker", () => {
directory: false, directory: false,
title: "Attach images", title: "Attach images",
}); });
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]); expect(result).toEqual([
{
source: { kind: "file_uri", uri: "/tmp/one.png" },
mimeType: "image/png",
fileName: "one.png",
},
{
source: { kind: "file_uri", uri: "/tmp/two.jpg" },
mimeType: "image/jpeg",
fileName: "two.jpg",
},
]);
}); });
it("throws when desktop dialog API is not available", async () => { it("throws when desktop dialog API is not available", async () => {
await expect(openImagePathsWithDesktopDialog(null)).rejects.toThrow( await expect(pickImagesWithDesktopDialog(null)).rejects.toThrow(
"Desktop dialog API is not available.", "Desktop dialog API is not available.",
); );
await expect(openImagePathsWithDesktopDialog({})).rejects.toThrow( await expect(pickImagesWithDesktopDialog({})).rejects.toThrow(
"Desktop dialog API is not available.", "Desktop dialog API is not available.",
); );
}); });
@@ -1,5 +1,6 @@
import type { DesktopDialogBridge } from "@/desktop/host"; import type { DesktopDialogBridge } from "@/desktop/host";
import { RASTER_IMAGE_FILE_EXTENSIONS } from "@/attachments/file-types"; import { RASTER_IMAGE_FILE_EXTENSIONS, resolveRasterImageMimeType } from "@/attachments/file-types";
import { getFileNameFromPath } from "@/attachments/utils";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
@@ -7,7 +8,7 @@ export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blo
export interface PickedImageAttachmentInput { export interface PickedImageAttachmentInput {
source: PickedImageSource; source: PickedImageSource;
mimeType?: string | null; mimeType: string;
fileName?: string | null; fileName?: string | null;
} }
@@ -30,30 +31,52 @@ async function blobFromUri(uri: string): Promise<Blob> {
return await response.blob(); return await response.blob();
} }
function requirePickedImageMimeType(input: {
mimeType?: string | null;
path?: string | null;
}): string {
const mimeType = resolveRasterImageMimeType(input);
if (!mimeType) {
throw new Error(`Unsupported image type for '${input.path ?? "selected image"}'.`);
}
return mimeType;
}
export async function normalizePickedImageAssets( export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[], assets: readonly ExpoImagePickerAssetLike[],
): Promise<PickedImageAttachmentInput[]> { ): Promise<PickedImageAttachmentInput[]> {
return await Promise.all( return await Promise.all(
assets.map(async (asset) => { assets.map(async (asset) => {
if (asset.file instanceof Blob) { if (asset.file instanceof Blob) {
const fileName = asset.fileName ?? asset.file.name ?? null;
return { return {
source: { kind: "blob", blob: asset.file }, source: { kind: "blob", blob: asset.file },
mimeType: asset.mimeType ?? asset.file.type ?? null, mimeType: requirePickedImageMimeType({
fileName: asset.fileName ?? asset.file.name ?? null, mimeType: asset.mimeType || asset.file.type,
path: fileName ?? asset.uri,
}),
fileName,
}; };
} }
if (shouldTreatAsFileUri(asset.uri)) { if (shouldTreatAsFileUri(asset.uri)) {
return { return {
source: { kind: "file_uri", uri: asset.uri }, source: { kind: "file_uri", uri: asset.uri },
mimeType: asset.mimeType ?? null, mimeType: requirePickedImageMimeType({
mimeType: asset.mimeType,
path: asset.fileName ?? asset.uri,
}),
fileName: asset.fileName ?? null, fileName: asset.fileName ?? null,
}; };
} }
const blob = await blobFromUri(asset.uri);
return { return {
source: { kind: "blob", blob: await blobFromUri(asset.uri) }, source: { kind: "blob", blob },
mimeType: asset.mimeType ?? null, mimeType: requirePickedImageMimeType({
mimeType: asset.mimeType || blob.type,
path: asset.fileName ?? asset.uri,
}),
fileName: asset.fileName ?? null, fileName: asset.fileName ?? null,
}; };
}), }),
@@ -67,9 +90,9 @@ function normalizeDesktopDialogSelection(selection: string | string[] | null): s
return Array.isArray(selection) ? selection : [selection]; return Array.isArray(selection) ? selection : [selection];
} }
export async function openImagePathsWithDesktopDialog( export async function pickImagesWithDesktopDialog(
dialog: DesktopDialogBridge | null | undefined, dialog: DesktopDialogBridge | null | undefined,
): Promise<string[]> { ): Promise<PickedImageAttachmentInput[]> {
const options = { const options = {
directory: false, directory: false,
multiple: true, multiple: true,
@@ -87,5 +110,15 @@ export async function openImagePathsWithDesktopDialog(
throw new Error("Desktop dialog API is not available."); throw new Error("Desktop dialog API is not available.");
} }
return normalizeDesktopDialogSelection(await dialogOpen(options)); return normalizeDesktopDialogSelection(await dialogOpen(options)).map((path) => {
const mimeType = resolveRasterImageMimeType({ path });
if (!mimeType) {
throw new Error(`Unsupported image type for '${path}'.`);
}
return {
source: { kind: "file_uri" as const, uri: path },
mimeType,
fileName: getFileNameFromPath(path),
};
});
} }
@@ -53,6 +53,30 @@ describe("native image attachment picker", () => {
expect(recordedUris).toEqual([]); expect(recordedUris).toEqual([]);
}); });
it("uses explicit native MIME metadata before the URI extension", async () => {
const { exportAsPng, recordedUris } = fakeExportAsPng();
const result = await normalizePickedImageAssetsWith(
[
{
uri: "file:///photos/screenshot.jpg",
mimeType: "image/png",
fileName: "screenshot.png",
},
],
exportAsPng,
);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///photos/screenshot.jpg" },
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
expect(recordedUris).toEqual([]);
});
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => { it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
const { exportAsPng, recordedUris } = fakeExportAsPng(); const { exportAsPng, recordedUris } = fakeExportAsPng();
@@ -2,7 +2,7 @@ export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blo
export interface PickedImageAttachmentInput { export interface PickedImageAttachmentInput {
source: PickedImageSource; source: PickedImageSource;
mimeType?: string | null; mimeType: string;
fileName?: string | null; fileName?: string | null;
} }
@@ -62,13 +62,15 @@ function pickedAssetSupportedFormat(
asset: ExpoImagePickerAssetLike, asset: ExpoImagePickerAssetLike,
): SupportedPickedImageFormat | null { ): SupportedPickedImageFormat | null {
const uriExtension = extensionFromPath(asset.uri); const uriExtension = extensionFromPath(asset.uri);
if (uriExtension) { const uriFormat = supportedFormatForExtension(uriExtension);
return supportedFormatForExtension(uriExtension); if (uriExtension && !uriFormat) {
return null;
} }
return ( return (
supportedFormatForMimeType(asset.mimeType) ??
supportedFormatForExtension(extensionFromPath(asset.fileName)) ?? supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
supportedFormatForMimeType(asset.mimeType) uriFormat
); );
} }
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { import {
normalizePickedImageAssets, normalizePickedImageAssets,
openImagePathsWithDesktopDialog, pickImagesWithDesktopDialog,
type PickedImageAttachmentInput, type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker"; } from "@/hooks/image-attachment-picker";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -51,15 +51,11 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
try { try {
if (isWeb && isElectronRuntime()) { if (isWeb && isElectronRuntime()) {
const selectedPaths = await openImagePathsWithDesktopDialog(getDesktopHost()?.dialog); const selectedImages = await pickImagesWithDesktopDialog(getDesktopHost()?.dialog);
if (selectedPaths.length === 0) { if (selectedImages.length === 0) {
return null; return null;
} }
return selectedPaths.map((path) => ({ return selectedImages;
source: { kind: "file_uri" as const, uri: path },
mimeType: null,
fileName: null,
}));
} }
const hasPermission = await ensurePermission(); const hasPermission = await ensurePermission();
@@ -92,7 +92,7 @@ describe("collectImageFilesFromClipboardData", () => {
], ],
}); });
expect(files).toEqual([imagePng]); expect(files).toEqual([{ file: imagePng, mimeType: "image/png" }]);
}); });
it("ignores SVG clipboard files", () => { it("ignores SVG clipboard files", () => {
@@ -127,7 +127,10 @@ describe("filesToImageAttachments", () => {
type: "", type: "",
}); });
const attachments = await filesToImageAttachments([pngFile, typeLessFile]); const attachments = await filesToImageAttachments([
{ file: pngFile, mimeType: "image/png" },
{ file: typeLessFile, mimeType: "image/png" },
]);
expect(attachments).toEqual([ expect(attachments).toEqual([
{ {
@@ -141,7 +144,7 @@ describe("filesToImageAttachments", () => {
}, },
{ {
id: "att-2", id: "att-2",
mimeType: "image/jpeg", mimeType: "image/png",
storageType: "web-indexeddb", storageType: "web-indexeddb",
storageKey: "att-2", storageKey: "att-2",
fileName: "second", fileName: "second",
@@ -156,7 +159,7 @@ describe("filesToImageAttachments", () => {
type: "image/png", type: "image/png",
}); });
const [attachment] = await filesToImageAttachments([large]); const [attachment] = await filesToImageAttachments([{ file: large, mimeType: "image/png" }]);
expect(attachment?.storageType).toBe("web-indexeddb"); expect(attachment?.storageType).toBe("web-indexeddb");
expect(attachment?.byteSize).toBe(4 * 1024 * 1024); expect(attachment?.byteSize).toBe(4 * 1024 * 1024);
@@ -1,6 +1,6 @@
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { persistAttachmentFromBlob } from "@/attachments/service"; import { persistAttachmentFromBlob } from "@/attachments/service";
import { isRasterImageMimeType } from "@/attachments/file-types"; import { resolveRasterImageMimeType } from "@/attachments/file-types";
export interface ClipboardItemLike { export interface ClipboardItemLike {
kind?: string; kind?: string;
@@ -14,40 +14,46 @@ export interface ClipboardDataLike {
export type ImageAttachmentFromFile = AttachmentMetadata; export type ImageAttachmentFromFile = AttachmentMetadata;
export interface ClipboardImageFile {
file: File;
mimeType: string;
}
export function collectImageFilesFromClipboardData( export function collectImageFilesFromClipboardData(
clipboardData?: ClipboardDataLike | null, clipboardData?: ClipboardDataLike | null,
): File[] { ): ClipboardImageFile[] {
if (!clipboardData?.items) { if (!clipboardData?.items) {
return []; return [];
} }
const files: File[] = []; const files: ClipboardImageFile[] = [];
for (const item of Array.from(clipboardData.items)) { for (const item of Array.from(clipboardData.items)) {
if (item?.kind !== "file") { if (item?.kind !== "file") {
continue; continue;
} }
if (!isRasterImageMimeType(item.type)) { const mimeType = resolveRasterImageMimeType({ mimeType: item.type });
if (!mimeType) {
continue; continue;
} }
const file = item.getAsFile?.(); const file = item.getAsFile?.();
if (!file) { if (!file) {
continue; continue;
} }
files.push(file); files.push({ file, mimeType });
} }
return files; return files;
} }
export async function filesToImageAttachments( export async function filesToImageAttachments(
files: readonly File[], files: readonly ClipboardImageFile[],
): Promise<ImageAttachmentFromFile[]> { ): Promise<ImageAttachmentFromFile[]> {
const attachments = await Promise.all( const attachments = await Promise.all(
files.map(async (file) => { files.map(async ({ file, mimeType }) => {
try { try {
return await persistAttachmentFromBlob({ return await persistAttachmentFromBlob({
blob: file, blob: file,
mimeType: file.type || "image/jpeg", mimeType,
fileName: file.name, fileName: file.name,
}); });
} catch (error) { } catch (error) {