From f801d3a5561399b482411d6af3a5eb9d49f66aa9 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 22 Feb 2025 13:14:32 +0100 Subject: [PATCH] media: added more mime types, added mime type check on dropzone --- app/__test__/media/mime-types.spec.ts | 66 +++++++++++++++---- app/src/media/storage/mime-types-tiny.ts | 9 ++- app/src/media/utils/index.ts | 2 +- app/src/ui/elements/media/Dropzone.tsx | 25 +++++++ .../ui/elements/media/DropzoneContainer.tsx | 4 +- app/src/ui/elements/media/file-selector.ts | 2 - app/src/ui/elements/media/use-dropzone.ts | 12 ++-- .../test/tests/dropzone-element-test.tsx | 3 + 8 files changed, 99 insertions(+), 24 deletions(-) diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts index 55469ff..cdc0ea7 100644 --- a/app/__test__/media/mime-types.spec.ts +++ b/app/__test__/media/mime-types.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import * as large from "../../src/media/storage/mime-types"; import * as tiny from "../../src/media/storage/mime-types-tiny"; +import { getRandomizedFilename } from "../../src/media/utils"; describe("media/mime-types", () => { test("tiny resolves", () => { @@ -36,19 +37,62 @@ describe("media/mime-types", () => { }); test("isMimeType", () => { - expect(tiny.isMimeType("image/jpeg")).toBe(true); - expect(tiny.isMimeType("image/jpeg", ["image/png"])).toBe(true); - expect(tiny.isMimeType("image/png", ["image/png"])).toBe(false); - expect(tiny.isMimeType("image/png")).toBe(true); - expect(tiny.isMimeType("whatever")).toBe(false); - expect(tiny.isMimeType("text/tab-separated-values")).toBe(true); + const tests = [ + ["image/avif", true], + ["image/AVIF", true], + ["image/jpeg", true], + ["image/jpeg", true, ["image/png"]], + ["image/png", false, ["image/png"]], + ["image/png", true], + ["image/heif", true], + ["image/heic", true], + ["image/gif", true], + ["whatever", false], + ["text/tab-separated-values", true], + ["application/zip", true] + ]; + + for (const [mime, expected, exclude] of tests) { + expect( + tiny.isMimeType(mime, exclude as any), + `isMimeType(): ${mime} should be ${expected}` + ).toBe(expected as any); + } }); test("extension", () => { - expect(tiny.extension("image/png")).toBe("png"); - expect(tiny.extension("image/jpeg")).toBe("jpeg"); - expect(tiny.extension("application/zip")).toBe("zip"); - expect(tiny.extension("text/tab-separated-values")).toBe("tsv"); - expect(tiny.extension("application/zip")).toBe("zip"); + const tests = [ + ["image/avif", "avif"], + ["image/png", "png"], + ["image/PNG", "png"], + ["image/jpeg", "jpeg"], + ["application/zip", "zip"], + ["text/tab-separated-values", "tsv"], + ["application/zip", "zip"] + ]; + + for (const [mime, ext] of tests) { + expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext); + } + }); + + test("getRandomizedFilename", () => { + const tests = [ + ["file.txt", "txt"], + ["file.TXT", "txt"], + ["image.jpg", "jpg"], + ["image.avif", "avif"], + ["image.heic", "heic"], + ["image.jpeg", "jpeg"], + ["-473Wx593H-466453554-black-MODEL.jpg", "jpg"], + ["-473Wx593H-466453554-black-MODEL.avif", "avif"] + ]; + + for (const [filename, ext] of tests) { + expect( + getRandomizedFilename(filename).split(".").pop(), + `getRandomizedFilename(): ${filename} should end with ${ext}` + ).toBe(ext); + } }); }); diff --git a/app/src/media/storage/mime-types-tiny.ts b/app/src/media/storage/mime-types-tiny.ts index 1f90f77..6936e43 100644 --- a/app/src/media/storage/mime-types-tiny.ts +++ b/app/src/media/storage/mime-types-tiny.ts @@ -1,7 +1,7 @@ export const Q = { video: ["mp4", "webm"], audio: ["ogg"], - image: ["jpeg", "png", "gif", "webp", "bmp", "tiff"], + image: ["jpeg", "png", "gif", "webp", "bmp", "tiff", "avif", "heic", "heif"], text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"], application: ["zip", "xml", "toml", "json", "json5"], font: ["woff", "woff2", "ttf", "otf"] @@ -76,7 +76,8 @@ export function guess(f: string): string { } } -export function isMimeType(mime: any, exclude: string[] = []) { +export function isMimeType(_mime: any, exclude: string[] = []) { + const mime = _mime.toLowerCase(); if (exclude.includes(mime)) return false; // try quick first @@ -96,7 +97,9 @@ export function isMimeType(mime: any, exclude: string[] = []) { return false; } -export function extension(mime: string) { +export function extension(_mime: string) { + const mime = _mime.toLowerCase(); + for (const [t, e] of Object.entries(Q)) { for (const _e of e) { if (mime === `${t}/${_e}`) { diff --git a/app/src/media/utils/index.ts b/app/src/media/utils/index.ts index e71e2b7..575fd1a 100644 --- a/app/src/media/utils/index.ts +++ b/app/src/media/utils/index.ts @@ -5,7 +5,7 @@ export function getExtensionFromName(filename: string): string | undefined { if (!filename.includes(".")) return; const parts = filename.split("."); - return parts[parts.length - 1]; + return parts[parts.length - 1]?.toLowerCase(); } export function getRandomizedFilename(file: File, length?: number): string; diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 2c2b39d..295c111 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -49,6 +49,7 @@ export type DropzoneProps = { initialItems?: FileState[]; flow?: "start" | "end"; maxItems?: number; + allowedMimeTypes?: string[]; overwrite?: boolean; autoUpload?: boolean; onRejected?: (files: FileWithPath[]) => void; @@ -75,6 +76,7 @@ export function Dropzone({ handleDelete, initialItems = [], flow = "start", + allowedMimeTypes, maxItems, overwrite, autoUpload, @@ -109,8 +111,26 @@ export function Dropzone({ return added > remaining; } + function isAllowed(i: DataTransferItem | DataTransferItem[] | File | File[]): boolean { + const items = Array.isArray(i) ? i : [i]; + const specs = items.map((item) => ({ + kind: "kind" in item ? item.kind : "file", + type: item.type, + size: "size" in item ? item.size : 0 + })); + + return specs.every((spec) => { + if (spec.kind !== "file") { + return false; + } + return !(allowedMimeTypes && !allowedMimeTypes.includes(spec.type)); + }); + } + const { isOver, handleFileInputChange, ref } = useDropzone({ onDropped: (newFiles: FileWithPath[]) => { + if (!isAllowed(newFiles)) return; + let to_drop = 0; const added = newFiles.length; @@ -155,6 +175,11 @@ export function Dropzone({ } }, onOver: (items) => { + if (!isAllowed(items)) { + setIsOverAccepted(false); + return; + } + const max_reached = isMaxReached(items.length); setIsOverAccepted(!max_reached); }, diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index ebb4a83..4ae789f 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -81,7 +81,9 @@ export function DropzoneContainer({ return api.media.deleteFile(file.path); }); - const actualItems = (initialItems || $q.data || []) as MediaFieldSchema[]; + const actualItems = (initialItems ?? + (Array.isArray($q.data) ? $q.data : []) ?? + []) as MediaFieldSchema[]; const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl }); const key = id + JSON.stringify(_initialItems); diff --git a/app/src/ui/elements/media/file-selector.ts b/app/src/ui/elements/media/file-selector.ts index d718893..cb88a16 100644 --- a/app/src/ui/elements/media/file-selector.ts +++ b/app/src/ui/elements/media/file-selector.ts @@ -80,10 +80,8 @@ export interface FileWithPath extends File { export async function fromEvent(evt: Event | any): Promise<(FileWithPath | DataTransferItem)[]> { if (isObject(evt) && isDataTransfer(evt.dataTransfer)) { return getDataTransferFiles(evt.dataTransfer, evt.type); - // biome-ignore lint/style/noUselessElse: not useless } else if (isChangeEvt(evt)) { return getInputFiles(evt); - // biome-ignore lint/style/noUselessElse: not useless } else if ( Array.isArray(evt) && evt.every((item) => "getFile" in item && typeof item.getFile === "function") diff --git a/app/src/ui/elements/media/use-dropzone.ts b/app/src/ui/elements/media/use-dropzone.ts index b2a0607..f48af65 100644 --- a/app/src/ui/elements/media/use-dropzone.ts +++ b/app/src/ui/elements/media/use-dropzone.ts @@ -3,13 +3,13 @@ import { type FileWithPath, fromEvent } from "./file-selector"; type DropzoneProps = { onDropped: (files: FileWithPath[]) => void; - onOver?: (items: DataTransferItem[]) => void; + onOver?: (items: DataTransferItem[], event: DragEvent) => void; onLeave?: () => void; }; const events = { - enter: ["dragenter", "dragover", "dragstart"], - leave: ["dragleave", "drop"] + enter: ["dragenter", "dragover", "dragstart"] as const, + leave: ["dragleave", "drop"] as const }; const allEvents = [...events.enter, ...events.leave]; @@ -24,10 +24,10 @@ export function useDropzone({ onDropped, onOver, onLeave }: DropzoneProps) { e.stopPropagation(); }, []); - const toggleHighlight = useCallback(async (e: Event) => { - const _isOver = events.enter.includes(e.type); + const toggleHighlight = useCallback(async (e: DragEvent) => { + const _isOver = events.enter.includes(e.type as any); if (onOver && _isOver !== isOver && !onOverCalled.current) { - onOver((await fromEvent(e)) as DataTransferItem[]); + onOver((await fromEvent(e)) as DataTransferItem[], e); onOverCalled.current = true; } diff --git a/app/src/ui/routes/test/tests/dropzone-element-test.tsx b/app/src/ui/routes/test/tests/dropzone-element-test.tsx index 6e53d95..286b262 100644 --- a/app/src/ui/routes/test/tests/dropzone-element-test.tsx +++ b/app/src/ui/routes/test/tests/dropzone-element-test.tsx @@ -6,6 +6,9 @@ export default function DropzoneElementTest() {
+ Dropzone no auto avif only + + Dropzone User Avatar 1 (fully customized)