diff --git a/app/__test__/_assets/test.mp3 b/app/__test__/_assets/test.mp3 new file mode 100644 index 0000000..ab94045 Binary files /dev/null and b/app/__test__/_assets/test.mp3 differ diff --git a/app/__test__/_assets/test.txt b/app/__test__/_assets/test.txt new file mode 100644 index 0000000..b6fc4c6 --- /dev/null +++ b/app/__test__/_assets/test.txt @@ -0,0 +1 @@ +hello \ No newline at end of file diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index 8ce6a0b..ec2aa1e 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -10,7 +10,7 @@ import { assetsPath, assetsTmpPath } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; beforeAll(() => { - disableConsoleLog(); + //disableConsoleLog(); registries.media.register("local", StorageLocalAdapter); }); afterAll(enableConsoleLog); @@ -94,4 +94,38 @@ describe("MediaController", () => { expect(res.status).toBe(413); expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false); }); + + test("audio files", async () => { + const app = await makeApp(); + const file = Bun.file(`${assetsPath}/test.mp3`); + const name = makeName("mp3"); + const res = await app.server.request("/api/media/upload/" + name, { + method: "POST", + body: file, + }); + const result = (await res.json()) as any; + expect(result.data.mime_type).toBe("audio/mpeg"); + expect(result.name).toBe(name); + + const destFile = Bun.file(assetsTmpPath + "/" + name); + expect(destFile.exists()).resolves.toBe(true); + await destFile.delete(); + }); + + test("text files", async () => { + const app = await makeApp(); + const file = Bun.file(`${assetsPath}/test.txt`); + const name = makeName("txt"); + const res = await app.server.request("/api/media/upload/" + name, { + method: "POST", + body: file, + }); + const result = (await res.json()) as any; + expect(result.data.mime_type).toBe("text/plain"); + expect(result.name).toBe(name); + + const destFile = Bun.file(assetsTmpPath + "/" + name); + expect(destFile.exists()).resolves.toBe(true); + await destFile.delete(); + }); }); diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts index d326aca..1435dd6 100644 --- a/app/__test__/media/mime-types.spec.ts +++ b/app/__test__/media/mime-types.spec.ts @@ -72,6 +72,7 @@ describe("media/mime-types", () => { ["text/tab-separated-values", "tsv"], ["application/zip", "zip"], ["application/pdf", "pdf"], + ["audio/mpeg", "mp3"], ] as const; for (const [mime, ext] of tests) { @@ -90,6 +91,8 @@ describe("media/mime-types", () => { ["-473Wx593H-466453554-black-MODEL.jpg", "jpg"], ["-473Wx593H-466453554-black-MODEL.avif", "avif"], ["file.pdf", "pdf"], + ["file.mp3", "mp3"], + ["robots.txt", "txt"], ] as const; for (const [filename, ext] of tests) { @@ -127,5 +130,13 @@ describe("media/mime-types", () => { ).split("."); expect(ext).toBe("what"); } + + { + // txt + const [, ext] = getRandomizedFilename( + new File([""], "file.txt", { type: "text/plain" }), + ).split("."); + expect(ext).toBe("txt"); + } }); }); diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.vitest.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.vitest.ts index 8ad1d90..6ecd8df 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.vitest.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.vitest.ts @@ -28,7 +28,8 @@ describe("StorageR2Adapter", async () => { const buffer = readFileSync(path.join(basePath, "image.png")); const file = new File([buffer], "image.png", { type: "image/png" }); - await adapterTestSuite(viTestRunner, adapter, file); + // miniflare doesn't support range requests + await adapterTestSuite(viTestRunner, adapter, file, { testRange: false }); }); afterAll(async () => { diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.ts b/app/src/adapter/node/storage/StorageLocalAdapter.ts index fa3e336..46db8fd 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.ts @@ -80,18 +80,79 @@ export class StorageLocalAdapter extends StorageAdapter { } } + private parseRangeHeader( + rangeHeader: string, + fileSize: number, + ): { start: number; end: number } | null { + // Parse "bytes=start-end" format + const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/); + if (!match) return null; + + const [, startStr, endStr] = match; + let start = startStr ? Number.parseInt(startStr, 10) : 0; + let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1; + + // Handle suffix-byte-range-spec (e.g., "bytes=-500") + if (!startStr && endStr) { + start = Math.max(0, fileSize - Number.parseInt(endStr, 10)); + end = fileSize - 1; + } + + // Validate range + if (start < 0 || end >= fileSize || start > end) { + return null; + } + + return { start, end }; + } + async getObject(key: string, headers: Headers): Promise { try { - const content = await readFile(`${this.config.path}/${key}`); + const filePath = `${this.config.path}/${key}`; + const stats = await stat(filePath); + const fileSize = stats.size; const mimeType = guessMimeType(key); - return new Response(content, { - status: 200, - headers: { - "Content-Type": mimeType || "application/octet-stream", - "Content-Length": content.length.toString(), - }, + const responseHeaders = new Headers({ + "Accept-Ranges": "bytes", + "Content-Type": mimeType || "application/octet-stream", }); + + const rangeHeader = headers.get("range"); + + if (rangeHeader) { + const range = this.parseRangeHeader(rangeHeader, fileSize); + + if (!range) { + // Invalid range - return 416 Range Not Satisfiable + responseHeaders.set("Content-Range", `bytes */${fileSize}`); + return new Response("", { + status: 416, + headers: responseHeaders, + }); + } + + const { start, end } = range; + const content = await readFile(filePath, { encoding: null }); + const chunk = content.slice(start, end + 1); + + responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`); + responseHeaders.set("Content-Length", chunk.length.toString()); + + return new Response(chunk, { + status: 206, // Partial Content + headers: responseHeaders, + }); + } else { + // Normal request - return entire file + const content = await readFile(filePath); + responseHeaders.set("Content-Length", content.length.toString()); + + return new Response(content, { + status: 200, + headers: responseHeaders, + }); + } } catch (error) { // Handle file reading errors return new Response("", { status: 404 }); diff --git a/app/src/media/MediaField.ts b/app/src/media/MediaField.ts index 5f005bc..6ff8cd3 100644 --- a/app/src/media/MediaField.ts +++ b/app/src/media/MediaField.ts @@ -43,6 +43,10 @@ export class MediaField< return this.config.max_items; } + getAllowedMimeTypes(): string[] | undefined { + return this.config.mime_types; + } + getMinItems(): number | undefined { return this.config.min_items; } diff --git a/app/src/media/storage/adapters/adapter-test-suite.ts b/app/src/media/storage/adapters/adapter-test-suite.ts index 1a92d34..a6d1917 100644 --- a/app/src/media/storage/adapters/adapter-test-suite.ts +++ b/app/src/media/storage/adapters/adapter-test-suite.ts @@ -11,12 +11,14 @@ export async function adapterTestSuite( retries?: number; retryTimeout?: number; skipExistsAfterDelete?: boolean; + testRange?: boolean; }, ) { const { test, expect } = testRunner; const options = { retries: opts?.retries ?? 1, retryTimeout: opts?.retryTimeout ?? 1000, + testRange: opts?.testRange ?? true, }; let objects = 0; @@ -53,9 +55,34 @@ export async function adapterTestSuite( await test("gets an object", async () => { const res = await adapter.getObject(filename, new Headers()); expect(res.ok).toBe(true); + expect(res.headers.get("Accept-Ranges")).toBe("bytes"); // @todo: check the content }); + if (options.testRange) { + await test("handles range request - partial content", async () => { + const headers = new Headers({ Range: "bytes=0-99" }); + const res = await adapter.getObject(filename, headers); + expect(res.status).toBe(206); // Partial Content + expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); + expect(res.headers.get("Accept-Ranges")).toBe("bytes"); + }); + + await test("handles range request - suffix range", async () => { + const headers = new Headers({ Range: "bytes=-100" }); + const res = await adapter.getObject(filename, headers); + expect(res.status).toBe(206); // Partial Content + expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); + }); + + await test("handles invalid range request", async () => { + const headers = new Headers({ Range: "bytes=invalid" }); + const res = await adapter.getObject(filename, headers); + expect(res.status).toBe(416); // Range Not Satisfiable + expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); + }); + } + await test("gets object meta", async () => { expect(await adapter.getObjectMeta(filename)).toEqual({ type: file.type, // image/png diff --git a/app/src/media/storage/mime-types-tiny.ts b/app/src/media/storage/mime-types-tiny.ts index 6a3e241..16598b6 100644 --- a/app/src/media/storage/mime-types-tiny.ts +++ b/app/src/media/storage/mime-types-tiny.ts @@ -15,10 +15,12 @@ const c = { a: (w = "octet-stream") => `application/${w}`, i: (w) => `image/${w}`, v: (w) => `video/${w}`, + au: (w) => `audio/${w}`, } as const; export const M = new Map([ ["7z", c.z], ["7zip", c.z], + ["txt", c.t()], ["ai", c.a("postscript")], ["apk", c.a("vnd.android.package-archive")], ["doc", c.a("msword")], @@ -32,12 +34,12 @@ export const M = new Map([ ["jpg", c.i("jpeg")], ["js", c.t("javascript")], ["log", c.t()], - ["m3u", c.t()], + ["m3u", c.au("x-mpegurl")], ["m3u8", c.a("vnd.apple.mpegurl")], ["manifest", c.t("cache-manifest")], ["md", c.t("markdown")], ["mkv", c.v("x-matroska")], - ["mp3", c.a("mpeg")], + ["mp3", c.au("mpeg")], ["mobi", c.a("x-mobipocket-ebook")], ["ppt", c.a("powerpoint")], ["pptx", `${c.vnd}.presentationml.presentation`], @@ -46,11 +48,10 @@ export const M = new Map([ ["tif", c.i("tiff")], ["tsv", c.t("tab-separated-values")], ["tgz", c.a("x-tar")], - ["txt", c.t()], ["text", c.t()], ["vcd", c.a("x-cdlink")], ["vcs", c.t("x-vcalendar")], - ["wav", c.a("x-wav")], + ["wav", c.au("vnd.wav")], ["webmanifest", c.a("manifest+json")], ["xls", c.a("vnd.ms-excel")], ["xlsx", `${c.vnd}.spreadsheetml.sheet`], diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 908c49a..ce5a385 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -42,7 +42,10 @@ export type DropzoneRenderProps = { showPlaceholder: boolean; onClick?: (file: { path: string }) => void; footer?: ReactNode; - dropzoneProps: Pick; + dropzoneProps: Pick< + DropzoneProps, + "maxItems" | "placeholder" | "autoUpload" | "flow" | "allowedMimeTypes" + >; }; export type DropzoneProps = { @@ -151,6 +154,7 @@ export function Dropzone({ const setIsOver = useStore(store, (state) => state.setIsOver); const uploading = useStore(store, (state) => state.uploading); const setFileState = useStore(store, (state) => state.setFileState); + const overrideFile = useStore(store, (state) => state.overrideFile); const removeFile = useStore(store, (state) => state.removeFile); const inputRef = useRef(null); @@ -359,7 +363,7 @@ export function Dropzone({ state: "uploaded", }; - setFileState(file.path, newState.state); + overrideFile(file.path, newState); resolve({ ...response, ...file, ...newState }); } catch (e) { setFileState(file.path, "uploaded", 1); @@ -382,7 +386,9 @@ export function Dropzone({ }; xhr.setRequestHeader("Accept", "application/json"); - xhr.send(file.body); + const formData = new FormData(); + formData.append("file", file.body); + xhr.send(formData); }); } @@ -411,7 +417,9 @@ export function Dropzone({ const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]); const showPlaceholder = useMemo( () => - Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)), + Boolean( + placeholder?.show !== false && (!maxItems || (maxItems && files.length < maxItems)), + ), [placeholder, maxItems, files.length], ); @@ -437,11 +445,12 @@ export function Dropzone({ placeholder, autoUpload, flow, + allowedMimeTypes, }, onClick, footer, }), - [maxItems, flow, placeholder, autoUpload, footer], + [maxItems, files.length, flow, placeholder, autoUpload, footer, allowedMimeTypes], ) as unknown as DropzoneRenderProps; return ( diff --git a/app/src/ui/elements/media/DropzoneInner.tsx b/app/src/ui/elements/media/DropzoneInner.tsx index dfbdf8f..db3402e 100644 --- a/app/src/ui/elements/media/DropzoneInner.tsx +++ b/app/src/ui/elements/media/DropzoneInner.tsx @@ -22,7 +22,7 @@ export const DropzoneInner = ({ inputProps, showPlaceholder, actions: { uploadFile, deleteFile, openFileInput }, - dropzoneProps: { placeholder, flow }, + dropzoneProps: { placeholder, flow, maxItems, allowedMimeTypes }, onClick, footer, }: DropzoneRenderProps) => { @@ -52,7 +52,7 @@ export const DropzoneInner = ({ )} >
- +
@@ -159,9 +159,9 @@ const Preview = memo(

{file.name}

-
+
{file.type} - {formatNumber.fileSize(file.size)} + {formatNumber.fileSize(file.size)}
@@ -272,5 +272,7 @@ const VideoPreview = ({ }; const FallbackPreview = ({ file }: { file: ReducedFile }) => { - return
{file.type}
; + return ( +
{file.type}
+ ); }; diff --git a/app/src/ui/elements/media/dropzone-state.ts b/app/src/ui/elements/media/dropzone-state.ts index 11212be..d1f0b70 100644 --- a/app/src/ui/elements/media/dropzone-state.ts +++ b/app/src/ui/elements/media/dropzone-state.ts @@ -36,6 +36,10 @@ export const createDropzoneStore = () => { : f, ), })), + overrideFile: (path: string, newState: Partial) => + set((state) => ({ + files: state.files.map((f) => (f.path === path ? { ...f, ...newState } : f)), + })), }), ), ); diff --git a/app/src/ui/modals/media/MediaInfoModal.tsx b/app/src/ui/modals/media/MediaInfoModal.tsx index 43f0f1c..9b8bdea 100644 --- a/app/src/ui/modals/media/MediaInfoModal.tsx +++ b/app/src/ui/modals/media/MediaInfoModal.tsx @@ -1,5 +1,5 @@ import type { ContextModalProps } from "@mantine/modals"; -import type { ReactNode } from "react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useEntityQuery } from "ui/client"; import { type FileState, Media } from "ui/elements"; import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils"; @@ -157,11 +157,43 @@ const Item = ({ }; const FilePreview = ({ file }: { file: FileState }) => { + const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); + if (file.type.startsWith("image/") || file.type.startsWith("video/")) { // @ts-ignore return ; } + if (file.type === "application/pdf") { + // use browser preview + return ( +