Skip to content

Commit f7a020d

Browse files
committed
fix(background): accept a blank MIME type by extension, and say so when a file is refused
`handleFileSelected` gated custom wallpaper uploads on `file.type.startsWith("image/")`, so a file the browser reports no MIME type for — which Windows does for some files and some locales — was dropped on the floor, with no toast, no message, nothing. The user picked a PNG and the pane simply did not react. That exact case had been fixed once, in "Allow PNG custom background uploads": `isSupportedBackgroundImageType` fell back to the file extension when `type` was blank, and its test named a real offender (`生成画像1.png`). The module was never imported by the upload path though — only by its own test — so the 2026-07-26 reorg removed it as dead code, correctly, and the repo lost the fallback while keeping the weaker check that needed it. So restore the fallback where the upload actually happens, and derive the `accept` filter from the same two lists the validation uses, since those had already drifted apart once (the accept string was an inline copy of the deleted module's constant). An explicit non-image type is still refused — only a blank one earns the extension fallback, so `notes.txt` renamed to `notes.png` does not get through. Rejections and unreadable files now raise a toast, localized across all 13 locales. Tests are the recovered cases plus a component test asserting the toast actually reaches the user, which is the half that was really missing.
1 parent 67a51e1 commit f7a020d

15 files changed

Lines changed: 145 additions & 3 deletions

File tree

src/components/ai-edition/RightPanes.tsx

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
useRef,
2929
useState,
3030
} from "react";
31+
import { toast } from "sonner";
3132
import defaultCursorPreviewUrl from "@/assets/cursors/Cursor=Default.svg";
3233
import GradientEditor, { type GradientEditorState } from "@/components/ui/gradient-editor";
3334
import { useScopedT } from "@/contexts/I18nContext";
@@ -158,7 +159,35 @@ const COLOR_PALETTE: readonly string[] = [
158159
"#1e293b",
159160
];
160161

161-
const IMAGE_ACCEPT = ".jpg,.jpeg,.png,image/jpeg,image/png";
162+
// One source for the file dialog's filter AND the post-pick validation. They were separate
163+
// before — the accept string was an inline copy of a constant living in a module whose
164+
// extension fallback never got wired up, so the dialog offered files the handler then
165+
// dropped on the floor.
166+
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png"];
167+
// `image/jpg` is not the registered type but real systems emit it, so accept it too.
168+
const IMAGE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png"];
169+
const IMAGE_ACCEPT = [...IMAGE_EXTENSIONS, ...IMAGE_MIME_TYPES].join(",");
170+
171+
/**
172+
* Whether a picked file is a background image we can use.
173+
*
174+
* A blank `type` falls back to the extension: the browser reports no MIME type for some
175+
* files and some locales on Windows, and a bare `file.type.startsWith("image/")` then
176+
* rejected perfectly good PNGs — silently, since the handler just returned. That is the
177+
* case "Allow PNG custom background uploads" fixed once already (its test named a real
178+
* one: `生成画像1.png`, arriving with no MIME type at all).
179+
*
180+
* An explicit non-image type is still a rejection. Only a blank one earns the fallback,
181+
* so `notes.txt` renamed to `notes.png` does not sneak through on its extension.
182+
*/
183+
export function isSupportedBackgroundImage(type: string, fileName: string): boolean {
184+
const mime = type.trim().toLowerCase();
185+
if (mime) {
186+
return IMAGE_MIME_TYPES.includes(mime);
187+
}
188+
const name = fileName.trim().toLowerCase();
189+
return IMAGE_EXTENSIONS.some((extension) => name.endsWith(extension));
190+
}
162191

163192
// Wallpaper picker — image / solid color / gradient tabs.
164193
//
@@ -214,13 +243,20 @@ export function BackgroundPane() {
214243
const file = e.target.files?.[0];
215244
e.target.value = "";
216245
if (!file) return;
217-
if (!file.type.startsWith("image/")) return;
246+
if (!isSupportedBackgroundImage(file.type, file.name)) {
247+
toast.error(ts("background.unsupportedImage"));
248+
return;
249+
}
218250
const reader = new FileReader();
219251
reader.onload = () => {
220252
const dataUrl = typeof reader.result === "string" ? reader.result : "";
221-
if (!dataUrl) return;
253+
if (!dataUrl) {
254+
toast.error(ts("background.imageReadFailed"));
255+
return;
256+
}
222257
void set({ wallpaper: dataUrl });
223258
};
259+
reader.onerror = () => toast.error(ts("background.imageReadFailed"));
224260
reader.readAsDataURL(file);
225261
};
226262

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Cases recovered from the deleted `video-editor/backgroundImageUpload.test.ts`, whose
2+
// module was dropped as dead code in the 2026-07-26 reorg — correctly, since only its own
3+
// test imported it, but the empty-MIME fallback it encoded had never been wired into the
4+
// pane that actually handles the upload. These pin the behaviour to the live code path.
5+
import { cleanup, fireEvent, render } from "@testing-library/react";
6+
import { afterEach, describe, expect, it, vi } from "vitest";
7+
import { I18nProvider } from "@/contexts/I18nContext";
8+
import { BackgroundPane, isSupportedBackgroundImage } from "./RightPanes";
9+
10+
const toastError = vi.hoisted(() => vi.fn());
11+
vi.mock("sonner", () => ({ toast: { error: toastError, success: vi.fn(), info: vi.fn() } }));
12+
13+
afterEach(() => {
14+
cleanup();
15+
toastError.mockClear();
16+
});
17+
18+
describe("background image upload validation", () => {
19+
it("accepts PNG images for custom backgrounds", () => {
20+
expect(isSupportedBackgroundImage("image/png", "生成画像1.png")).toBe(true);
21+
});
22+
23+
it("accepts PNG images by extension when the browser does not provide a MIME type", () => {
24+
// The regression this guards: Windows reports no MIME type for some files and
25+
// locales, and the pane used to drop them silently.
26+
expect(isSupportedBackgroundImage("", "生成画像1.png")).toBe(true);
27+
});
28+
29+
it("keeps rejecting non-image uploads", () => {
30+
expect(isSupportedBackgroundImage("text/plain", "notes.txt")).toBe(false);
31+
});
32+
33+
it("does not allow extension fallback for explicit unsupported MIME types", () => {
34+
expect(isSupportedBackgroundImage("text/plain", "notes.png")).toBe(false);
35+
});
36+
37+
it("accepts jpeg and the non-standard image/jpg some systems emit", () => {
38+
expect(isSupportedBackgroundImage("image/jpeg", "shot.jpeg")).toBe(true);
39+
expect(isSupportedBackgroundImage("image/jpg", "shot.jpg")).toBe(true);
40+
});
41+
42+
it("ignores case and stray whitespace in either field", () => {
43+
expect(isSupportedBackgroundImage(" IMAGE/PNG ", "shot.png")).toBe(true);
44+
expect(isSupportedBackgroundImage("", " SHOT.PNG ")).toBe(true);
45+
});
46+
47+
it("rejects a blank type with an extension we do not support", () => {
48+
expect(isSupportedBackgroundImage("", "clip.webp")).toBe(false);
49+
expect(isSupportedBackgroundImage("", "noextension")).toBe(false);
50+
});
51+
});
52+
53+
describe("a rejected upload tells the user", () => {
54+
function pick(file: File) {
55+
const { container } = render(
56+
<I18nProvider>
57+
<BackgroundPane />
58+
</I18nProvider>,
59+
);
60+
const input = container.querySelector('input[type="file"]');
61+
if (!(input instanceof HTMLInputElement)) throw new Error("no file input rendered");
62+
// jsdom leaves `files` unwritable, so define it the way a real pick would.
63+
Object.defineProperty(input, "files", { value: [file], configurable: true });
64+
fireEvent.change(input);
65+
}
66+
67+
it("surfaces an error instead of silently dropping the file", () => {
68+
// The whole point: this used to `return` with no feedback at all.
69+
pick(new File(["nope"], "notes.txt", { type: "text/plain" }));
70+
71+
expect(toastError).toHaveBeenCalledTimes(1);
72+
expect(toastError.mock.calls[0]?.[0]).toBe("Unsupported image. Use a JPG or PNG file.");
73+
});
74+
75+
it("stays quiet for a file it accepts", () => {
76+
pick(new File(["x"], "生成画像1.png", { type: "" }));
77+
78+
expect(toastError).not.toHaveBeenCalled();
79+
});
80+
});

src/i18n/locales/ar/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@
8484
"presets": "إعدادات مسبقة",
8585
"help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
8686
"customWallpaper": "خلفية مخصصة",
87+
"unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
88+
"imageReadFailed": "تعذّر قراءة ملف الصورة.",
8789
"imageLabel": "الخلفية {{index}}",
8890
"colorLabel": "اللون {{color}}"
8991
},

src/i18n/locales/en/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@
8484
"presets": "Presets",
8585
"help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk.",
8686
"customWallpaper": "Custom wallpaper",
87+
"unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
88+
"imageReadFailed": "Could not read that image file.",
8789
"imageLabel": "Background {{index}}",
8890
"colorLabel": "Color {{color}}"
8991
},

src/i18n/locales/es/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@
8484
"presets": "Ajustes preestablecidos",
8585
"help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco.",
8686
"customWallpaper": "Fondo personalizado",
87+
"unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
88+
"imageReadFailed": "No se pudo leer ese archivo de imagen.",
8789
"imageLabel": "Fondo {{index}}",
8890
"colorLabel": "Color {{color}}"
8991
},

src/i18n/locales/fr/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@
8484
"presets": "Préréglages",
8585
"help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.",
8686
"customWallpaper": "Fond personnalisé",
87+
"unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
88+
"imageReadFailed": "Impossible de lire ce fichier image.",
8789
"imageLabel": "Fond {{index}}",
8890
"colorLabel": "Couleur {{color}}"
8991
},

src/i18n/locales/it/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@
8484
"presets": "Predefiniti",
8585
"help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco.",
8686
"customWallpaper": "Sfondo personalizzato",
87+
"unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
88+
"imageReadFailed": "Impossibile leggere quel file immagine.",
8789
"imageLabel": "Sfondo {{index}}",
8890
"colorLabel": "Colore {{color}}"
8991
},

src/i18n/locales/ja-JP/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@
8484
"presets": "プリセット",
8585
"help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。",
8686
"customWallpaper": "カスタム壁紙",
87+
"unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
88+
"imageReadFailed": "この画像ファイルを読み込めませんでした。",
8789
"imageLabel": "背景 {{index}}",
8890
"colorLabel": "色 {{color}}"
8991
},

src/i18n/locales/ko-KR/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@
8484
"presets": "프리셋",
8585
"help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
8686
"customWallpaper": "사용자 배경",
87+
"unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
88+
"imageReadFailed": "이미지 파일을 읽을 수 없습니다.",
8789
"imageLabel": "배경 {{index}}",
8890
"colorLabel": "색상 {{color}}"
8991
},

src/i18n/locales/pt-BR/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@
8484
"presets": "Predefinições",
8585
"help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco.",
8686
"customWallpaper": "Papel de parede personalizado",
87+
"unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
88+
"imageReadFailed": "Não foi possível ler esse arquivo de imagem.",
8789
"imageLabel": "Fundo {{index}}",
8890
"colorLabel": "Cor {{color}}"
8991
},

0 commit comments

Comments
 (0)