From cc938db4b8fdd0965397d4fb74c61da22e78232b Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 13 Feb 2025 11:24:35 +0100 Subject: [PATCH 1/5] add default fetcher fallback for ModuleApi --- app/src/modules/ModuleApi.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index a088170..039b249 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -26,7 +26,11 @@ export abstract class ModuleApi = {}, protected fetcher?: typeof fetch - ) {} + ) { + if (!fetcher) { + this.fetcher = fetch; + } + } protected getDefaultOptions(): Partial { return {}; From c4e505582bf4e41ff7dc6fdda14157b15288f94c Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 14 Feb 2025 10:42:36 +0100 Subject: [PATCH 2/5] updated media api and added tests, fixed body limit --- app/__test__/_assets/.gitignore | 1 + app/__test__/_assets/image.png | Bin 0 -> 67485 bytes app/__test__/api/MediaApi.spec.ts | 149 ++++++++++ app/__test__/core/utils.spec.ts | 57 +++- app/__test__/helper.ts | 3 + app/__test__/media/MediaController.spec.ts | 130 ++++++--- .../adapters/StorageLocalAdapter.spec.ts | 2 +- app/src/core/utils/reqres.ts | 260 ++++++++++++++++++ app/src/core/utils/test.ts | 18 ++ app/src/media/api/MediaApi.ts | 68 ++++- app/src/media/api/MediaController.ts | 70 +++-- app/src/media/storage/Storage.ts | 132 ++------- .../adapters/StorageCloudinaryAdapter.ts | 35 +-- .../StorageLocalAdapter.ts | 26 +- .../storage/adapters/StorageS3Adapter.ts | 27 +- app/src/media/storage/mime-types-tiny.ts | 18 ++ app/src/modules/ModuleApi.ts | 20 +- app/src/ui/elements/media/Dropzone.tsx | 4 +- 18 files changed, 769 insertions(+), 251 deletions(-) create mode 100644 app/__test__/_assets/.gitignore create mode 100644 app/__test__/_assets/image.png create mode 100644 app/__test__/api/MediaApi.spec.ts diff --git a/app/__test__/_assets/.gitignore b/app/__test__/_assets/.gitignore new file mode 100644 index 0000000..e540f5b --- /dev/null +++ b/app/__test__/_assets/.gitignore @@ -0,0 +1 @@ +tmp/* \ No newline at end of file diff --git a/app/__test__/_assets/image.png b/app/__test__/_assets/image.png new file mode 100644 index 0000000000000000000000000000000000000000..2ebf8fc8ccfeb9b7b780bbe42e2c4760147c914b GIT binary patch literal 67485 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 literal 0 HcmV?d00001 diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts new file mode 100644 index 0000000..196d711 --- /dev/null +++ b/app/__test__/api/MediaApi.spec.ts @@ -0,0 +1,149 @@ +/// +import { describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils"; +import { MediaApi } from "../../src/media/api/MediaApi"; +import { assetsPath, assetsTmpPath } from "../helper"; + +const mockedBackend = new Hono() + .basePath("/api/media") + .post("/upload/:name", async (c) => { + const { name } = c.req.param(); + const body = await getFileFromContext(c); + return c.json({ name, is_file: isFile(body), size: body.size }); + }) + .get("/file/:name", async (c) => { + const { name } = c.req.param(); + const file = Bun.file(`${assetsPath}/${name}`); + return new Response(file, { + headers: { + "Content-Type": file.type, + "Content-Length": file.size.toString() + } + }); + }); + +describe("MediaApi", () => { + it("should give correct file upload url", () => { + const host = "http://localhost"; + const basepath = "/api/media"; + // @ts-ignore tests + const api = new MediaApi({ + host, + basepath + }); + expect(api.getFileUploadUrl({ path: "path" })).toBe(`${host}${basepath}/upload/path`); + }); + + it("should have correct upload headers", () => { + // @ts-ignore tests + const api = new MediaApi({ + token: "token" + }); + expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token"); + }); + + it("should upload file directly", async () => { + const name = "image.png"; + const file = await Bun.file(`${assetsPath}/${name}`); + + // @ts-ignore tests + const api = new MediaApi({}, mockedBackend.request); + const result = await api.uploadFile(file as any, name); + expect(result.name).toBe(name); + expect(result.is_file).toBe(true); + expect(result.size).toBe(file.size); + }); + + it("should get file: native", async () => { + const name = "image.png"; + const path = `${assetsTmpPath}/${name}`; + const res = await mockedBackend.request("/api/media/file/" + name); + await Bun.write(path, res); + + const file = await Bun.file(path); + expect(file.size).toBeGreaterThan(0); + expect(file.type).toBe("image/png"); + await file.delete(); + }); + + it("getFile", async () => { + // @ts-ignore tests + const api = new MediaApi({}, mockedBackend.request); + + const name = "image.png"; + const file = await api.getFile(name); + expect(isFile(file)).toBe(true); + expect(file.size).toBeGreaterThan(0); + expect(file.type).toBe("image/png"); + expect(file.name).toContain(name); + }); + + it("getFileResponse", async () => { + // @ts-ignore tests + const api = new MediaApi({}, mockedBackend.request); + + const name = "image.png"; + const res = await api.getFileResponse(name); + expect(res.ok).toBe(true); + // make sure it's a normal api request as usual + expect(res.res.ok).toBe(true); + expect(isReadableStream(res)).toBe(true); + expect(isReadableStream(res.body)).toBe(true); + expect(isReadableStream(res.res.body)).toBe(true); + + const blob = await res.res.blob(); + expect(isFile(blob)).toBe(true); + expect(blob.size).toBeGreaterThan(0); + expect(blob.type).toBe("image/png"); + expect(blob.name).toContain(name); + }); + + it("getFileStream", async () => { + // @ts-ignore tests + const api = new MediaApi({}, mockedBackend.request); + + const name = "image.png"; + const res = await api.getFileStream(name); + expect(isReadableStream(res)).toBe(true); + + const blob = await new Response(res).blob(); + expect(isFile(blob)).toBe(true); + expect(blob.size).toBeGreaterThan(0); + expect(blob.type).toBe("image/png"); + expect(blob.name).toContain(name); + }); + + it("should upload file in various ways", async () => { + // @ts-ignore tests + const api = new MediaApi({}, mockedBackend.request); + const file = Bun.file(`${assetsPath}/image.png`); + + async function matches(req: Promise, filename: string) { + const res: any = await req; + expect(res.name).toBe(filename); + expect(res.is_file).toBe(true); + expect(res.size).toBe(file.size); + } + + const url = "http://localhost/api/media/file/image.png"; + + // upload bun file + await matches(api.upload(file as any, "bunfile.png"), "bunfile.png"); + + // upload via request + await matches(api.upload(new Request(url), "request.png"), "request.png"); + + // upload via url + await matches(api.upload(url, "url.png"), "url.png"); + + // upload via response + { + const response = await mockedBackend.request(url); + await matches(api.upload(response, "response.png"), "response.png"); + } + + // upload via readable + await matches(await api.upload(file.stream(), "readable.png"), "readable.png"); + }); +}); diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index b484be8..3de8a48 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { Perf } from "../../src/core/utils"; +import { Perf, isBlob, ucFirst } from "../../src/core/utils"; import * as utils from "../../src/core/utils"; async function wait(ms: number) { @@ -75,6 +75,57 @@ describe("Core Utils", async () => { const result3 = utils.encodeSearch(obj3, { encode: true }); expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D"); }); + + describe("guards", () => { + const types = { + blob: new Blob(), + file: new File([""], "file.txt"), + stream: new ReadableStream(), + arrayBuffer: new ArrayBuffer(10), + arrayBufferView: new Uint8Array(new ArrayBuffer(10)) + }; + + const fns = [ + [utils.isReadableStream, "stream"], + [utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]], + [utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]], + [utils.isArrayBuffer, "arrayBuffer"], + [utils.isArrayBufferView, "arrayBufferView"] + ] as const; + + const additional = [0, 0.0, "", null, undefined, {}, []]; + + for (const [fn, type, _to_test] of fns) { + test(`is${ucFirst(type)}`, () => { + const to_test = _to_test ?? (Object.keys(types) as string[]); + for (const key of to_test) { + const value = types[key as keyof typeof types]; + const result = fn(value); + expect(result).toBe(key === type); + } + + for (const value of additional) { + const result = fn(value); + expect(result).toBe(false); + } + }); + } + }); + + test("getContentName", () => { + const name = "test.json"; + const text = "attachment; filename=" + name; + const headers = new Headers({ + "Content-Disposition": text + }); + const request = new Request("http://example.com", { + headers + }); + + expect(utils.getContentName(text)).toBe(name); + expect(utils.getContentName(headers)).toBe(name); + expect(utils.getContentName(request)).toBe(name); + }); }); describe("perf", async () => { @@ -134,8 +185,8 @@ describe("Core Utils", async () => { [true, true, true], [true, false, false], [false, false, true], - [1, NaN, false], - [NaN, NaN, true], + [1, Number.NaN, false], + [Number.NaN, Number.NaN, true], [null, null, true], [null, undefined, false], [undefined, undefined, true], diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index f07cd34..8cafff2 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -68,3 +68,6 @@ export function schemaToEm(s: ReturnType, conn?: Connection): En const connection = conn ? conn : getDummyConnection().dummyConnection; return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices); } + +export const assetsPath = `${import.meta.dir}/_assets`; +export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`; diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index 4816620..49b0900 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -1,56 +1,96 @@ -import { describe, test } from "bun:test"; -import { Hono } from "hono"; -import { Guard } from "../../src/auth"; -import { EventManager } from "../../src/core/events"; -import { EntityManager } from "../../src/data"; -import { AppMedia } from "../../src/media/AppMedia"; -import { MediaController } from "../../src/media/api/MediaController"; -import { getDummyConnection } from "../helper"; +/// -const { dummyConnection, afterAllCleanup } = getDummyConnection(); +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createApp, registries } from "../../src"; +import { StorageLocalAdapter } from "../../src/adapter/node"; +import { mergeObject, randomString } from "../../src/core/utils"; +import type { TAppMediaConfig } from "../../src/media/media-schema"; +import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper"; -/** - * R2 - * value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, - * Node writefile - * data: string | NodeJS.ArrayBufferView | Iterable | AsyncIterable | Stream, - */ -const ALL_TESTS = !!process.env.ALL_TESTS; -describe.skipIf(ALL_TESTS)("MediaController", () => { - test("..", async () => { - const ctx: any = { - em: new EntityManager([], dummyConnection, []), - guard: new Guard(), - emgr: new EventManager(), - server: new Hono() - }; +beforeAll(() => { + registries.media.register("local", StorageLocalAdapter); +}); - const media = new AppMedia( - // @ts-ignore - { - enabled: true, - adapter: { - type: "s3", - config: { - access_key: process.env.R2_ACCESS_KEY as string, - secret_access_key: process.env.R2_SECRET_ACCESS_KEY as string, - url: process.env.R2_URL as string +const path = `${assetsPath}/image.png`; + +async function makeApp(mediaOverride: Partial = {}) { + const app = createApp({ + initialConfig: { + media: mergeObject( + { + enabled: true, + adapter: { + type: "local", + config: { + path: assetsTmpPath + } } - } - }, - ctx - ); - await media.build(); - const app = new MediaController(media).getController(); + }, + mediaOverride + ) + } + }); - const file = Bun.file(`${import.meta.dir}/adapters/icon.png`); - console.log("file", file); - const form = new FormData(); - form.append("file", file); + await app.build(); + return app; +} - await app.request("/upload/test.png", { +function makeName(ext: string) { + return randomString(10) + "." + ext; +} + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("MediaController", () => { + test("accepts direct", async () => { + const app = await makeApp(); + + const file = Bun.file(path); + const name = makeName("png"); + const res = await app.server.request("/api/media/upload/" + name, { method: "POST", body: file }); + const result = (await res.json()) as any; + expect(result.name).toBe(name); + + const destFile = Bun.file(assetsTmpPath + "/" + name); + expect(destFile.exists()).resolves.toBe(true); + await destFile.delete(); + }); + + test("accepts form data", async () => { + const app = await makeApp(); + + const file = Bun.file(path); + const name = makeName("png"); + const form = new FormData(); + form.append("file", file); + + const res = await app.server.request("/api/media/upload/" + name, { + method: "POST", + body: form + }); + const result = (await res.json()) as any; + expect(result.name).toBe(name); + + const destFile = Bun.file(assetsTmpPath + "/" + name); + expect(destFile.exists()).resolves.toBe(true); + await destFile.delete(); + }); + + test("limits body", async () => { + const app = await makeApp({ storage: { body_max_size: 1 } }); + + const file = await Bun.file(path); + const name = makeName("png"); + const res = await app.server.request("/api/media/upload/" + name, { + method: "POST", + body: file + }); + + expect(res.status).toBe(413); + expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false); }); }); diff --git a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts index a7c6d79..2240aeb 100644 --- a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts +++ b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts @@ -15,7 +15,7 @@ describe("StorageLocalAdapter", () => { test("puts an object", async () => { objects = (await adapter.listObjects()).length; - expect(await adapter.putObject(filename, file)).toBeString(); + expect(await adapter.putObject(filename, file as unknown as File)).toBeString(); }); test("lists objects", async () => { diff --git a/app/src/core/utils/reqres.ts b/app/src/core/utils/reqres.ts index 21e0e28..e8fa4d4 100644 --- a/app/src/core/utils/reqres.ts +++ b/app/src/core/utils/reqres.ts @@ -1,3 +1,7 @@ +import { randomString } from "core/utils/strings"; +import type { Context } from "hono"; +import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; + export function headersToObject(headers: Headers): Record { if (!headers) return {}; return { ...Object.fromEntries(headers.entries()) }; @@ -82,3 +86,259 @@ export function decodeSearch(str) { return out; } + +export function isReadableStream(value: unknown): value is ReadableStream { + return ( + typeof value === "object" && + value !== null && + typeof (value as ReadableStream).getReader === "function" + ); +} + +export function isBlob(value: unknown): value is Blob { + return ( + typeof value === "object" && + value !== null && + typeof (value as Blob).arrayBuffer === "function" && + typeof (value as Blob).type === "string" + ); +} + +export function isFile(value: unknown): value is File { + return ( + isBlob(value) && + typeof (value as File).name === "string" && + typeof (value as File).lastModified === "number" + ); +} + +export function isArrayBuffer(value: unknown): value is ArrayBuffer { + return ( + typeof value === "object" && + value !== null && + Object.prototype.toString.call(value) === "[object ArrayBuffer]" + ); +} + +export function isArrayBufferView(value: unknown): value is ArrayBufferView { + return typeof value === "object" && value !== null && ArrayBuffer.isView(value); +} + +export function getContentName(request: Request): string | undefined; +export function getContentName(contentDisposition: string): string | undefined; +export function getContentName(headers: Headers): string | undefined; +export function getContentName(ctx: Headers | Request | string): string | undefined { + let c: string = ""; + + if (typeof ctx === "string") { + c = ctx; + } else if (ctx instanceof Headers) { + c = ctx.get("Content-Disposition") || ""; + } else if (ctx instanceof Request) { + c = ctx.headers.get("Content-Disposition") || ""; + } + + const match = c.match(/filename\*?=(?:UTF-8'')?("?)([^";]+)\1/); + return match ? match[2] : undefined; +} + +const FILE_SIGNATURES: Record = { + "89504E47": "image/png", + FFD8FF: "image/jpeg", + "47494638": "image/gif", + "49492A00": "image/tiff", // Little Endian TIFF + "4D4D002A": "image/tiff", // Big Endian TIFF + "52494646????57454250": "image/webp", // WEBP (RIFF....WEBP) + "504B0304": "application/zip", + "25504446": "application/pdf", + "00000020667479706D70": "video/mp4", + "000001BA": "video/mpeg", + "000001B3": "video/mpeg", + "1A45DFA3": "video/webm", + "4F676753": "audio/ogg", + "494433": "audio/mpeg", // MP3 with ID3 header + FFF1: "audio/aac", + FFF9: "audio/aac", + "52494646????41564920": "audio/wav", + "52494646????57415645": "audio/wave", + "52494646????415550": "audio/aiff" +}; + +async function detectMimeType( + input: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | File | null +): Promise { + if (!input) return; + + let buffer: Uint8Array; + + if (isReadableStream(input)) { + const reader = input.getReader(); + const { value } = await reader.read(); + if (!value) return; + buffer = new Uint8Array(value); + } else if (isBlob(input) || isFile(input)) { + buffer = new Uint8Array(await input.slice(0, 12).arrayBuffer()); + } else if (isArrayBuffer(input)) { + buffer = new Uint8Array(input); + } else if (isArrayBufferView(input)) { + buffer = new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } else if (typeof input === "string") { + buffer = new TextEncoder().encode(input); + } else { + return; + } + + const hex = Array.from(buffer.slice(0, 12)) + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join(""); + + for (const [signature, mime] of Object.entries(FILE_SIGNATURES)) { + const regex = new RegExp("^" + signature.replace(/\?\?/g, "..")); + if (regex.test(hex)) return mime; + } + + return; +} + +export async function blobToFile( + blob: Blob | File | unknown, + overrides: FilePropertyBag & { name?: string } = {} +): Promise { + if (isFile(blob)) return blob; + if (!isBlob(blob)) throw new Error("Not a Blob"); + + const type = !isMimeType(overrides.type, ["application/octet-stream"]) + ? overrides.type + : await detectMimeType(blob); + const ext = type ? extension(type) : ""; + const name = overrides.name || [randomString(16), ext].filter(Boolean).join("."); + + return new File([blob], name, { + type: type || guess(name), + lastModified: Date.now() + }); +} + +export async function getFileFromContext(c: Context): Promise { + const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; + + if ( + contentType?.startsWith("multipart/form-data") || + contentType?.startsWith("application/x-www-form-urlencoded") + ) { + try { + const f = await c.req.formData(); + if ([...f.values()].length > 0) { + const v = [...f.values()][0]; + return await blobToFile(v); + } + } catch (e) { + console.warn("Error parsing form data", e); + } + } else { + try { + const blob = await c.req.blob(); + if (isFile(blob)) { + return blob; + } else if (isBlob(blob)) { + return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType }); + } + } catch (e) { + console.warn("Error parsing blob", e); + } + } + + throw new Error("No file found in request"); +} + +export async function getBodyFromContext(c: Context): Promise { + const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; + + if ( + !contentType?.startsWith("multipart/form-data") && + !contentType?.startsWith("application/x-www-form-urlencoded") + ) { + const body = c.req.raw.body; + if (body) { + return body; + } + } + + return getFileFromContext(c); +} + +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status +// biome-ignore lint/suspicious/noConstEnum: +export const enum HttpStatus { + // Informational responses (100–199) + CONTINUE = 100, + SWITCHING_PROTOCOLS = 101, + PROCESSING = 102, + EARLY_HINTS = 103, + + // Successful responses (200–299) + OK = 200, + CREATED = 201, + ACCEPTED = 202, + NON_AUTHORITATIVE_INFORMATION = 203, + NO_CONTENT = 204, + RESET_CONTENT = 205, + PARTIAL_CONTENT = 206, + MULTI_STATUS = 207, + ALREADY_REPORTED = 208, + IM_USED = 226, + + // Redirection messages (300–399) + MULTIPLE_CHOICES = 300, + MOVED_PERMANENTLY = 301, + FOUND = 302, + SEE_OTHER = 303, + NOT_MODIFIED = 304, + USE_PROXY = 305, + TEMPORARY_REDIRECT = 307, + PERMANENT_REDIRECT = 308, + + // Client error responses (400–499) + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + PAYMENT_REQUIRED = 402, + FORBIDDEN = 403, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + NOT_ACCEPTABLE = 406, + PROXY_AUTHENTICATION_REQUIRED = 407, + REQUEST_TIMEOUT = 408, + CONFLICT = 409, + GONE = 410, + LENGTH_REQUIRED = 411, + PRECONDITION_FAILED = 412, + PAYLOAD_TOO_LARGE = 413, + URI_TOO_LONG = 414, + UNSUPPORTED_MEDIA_TYPE = 415, + RANGE_NOT_SATISFIABLE = 416, + EXPECTATION_FAILED = 417, + IM_A_TEAPOT = 418, + MISDIRECTED_REQUEST = 421, + UNPROCESSABLE_ENTITY = 422, + LOCKED = 423, + FAILED_DEPENDENCY = 424, + TOO_EARLY = 425, + UPGRADE_REQUIRED = 426, + PRECONDITION_REQUIRED = 428, + TOO_MANY_REQUESTS = 429, + REQUEST_HEADER_FIELDS_TOO_LARGE = 431, + UNAVAILABLE_FOR_LEGAL_REASONS = 451, + + // Server error responses (500–599) + INTERNAL_SERVER_ERROR = 500, + NOT_IMPLEMENTED = 501, + BAD_GATEWAY = 502, + SERVICE_UNAVAILABLE = 503, + GATEWAY_TIMEOUT = 504, + HTTP_VERSION_NOT_SUPPORTED = 505, + VARIANT_ALSO_NEGOTIATES = 506, + INSUFFICIENT_STORAGE = 507, + LOOP_DETECTED = 508, + NOT_EXTENDED = 510, + NETWORK_AUTHENTICATION_REQUIRED = 511 +} diff --git a/app/src/core/utils/test.ts b/app/src/core/utils/test.ts index 662b33c..0b9b69a 100644 --- a/app/src/core/utils/test.ts +++ b/app/src/core/utils/test.ts @@ -42,3 +42,21 @@ export function enableConsoleLog() { console[severity as ConsoleSeverity] = fn; }); } + +export function tryit(fn: () => void, fallback?: any) { + try { + return fn(); + } catch (e) { + return fallback || e; + } +} + +export function formatMemoryUsage() { + const usage = process.memoryUsage(); + return { + rss: usage.rss / 1024 / 1024, + heapUsed: usage.heapUsed / 1024 / 1024, + external: usage.external / 1024 / 1024, + arrayBuffers: usage.arrayBuffers / 1024 / 1024 + }; +} diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index 722f94d..02eda71 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -14,8 +14,28 @@ export class MediaApi extends ModuleApi { return this.get(["files"]); } - getFile(filename: string) { - return this.get(["file", filename]); + getFileResponse(filename: string) { + return this.get(["file", filename], undefined, { + headers: { + Accept: "*/*" + } + }); + } + + async getFile(filename: string): Promise { + const { res } = await this.getFileResponse(filename); + if (!res.ok || !res.body) { + throw new Error("Failed to fetch file"); + } + return await res.blob(); + } + + async getFileStream(filename: string): Promise> { + const { res } = await this.getFileResponse(filename); + if (!res.ok || !res.body) { + throw new Error("Failed to fetch file"); + } + return res.body; } getFileUploadUrl(file: FileWithPath): string { @@ -32,10 +52,46 @@ export class MediaApi extends ModuleApi { }); } - uploadFile(file: File) { - const formData = new FormData(); - formData.append("file", file); - return this.post(["upload"], formData); + uploadFile(body: File | ReadableStream, filename?: string) { + let type: string = "application/octet-stream"; + let name: string = filename || ""; + try { + type = (body as File).type; + if (!filename) { + name = (body as File).name; + } + } catch (e) {} + + if (name && name.length > 0 && name.includes("/")) { + name = name.split("/").pop() || ""; + } + + if (!name || name.length === 0) { + throw new Error("Invalid filename"); + } + + return this.post(["upload", name], body, { + headers: { + "Content-Type": type + } + }); + } + + async upload(item: Request | Response | string | File | ReadableStream, filename?: string) { + if (item instanceof Request || typeof item === "string") { + const res = await this.fetcher(item); + if (!res.ok || !res.body) { + throw new Error("Failed to fetch file"); + } + return this.uploadFile(res.body, filename); + } else if (item instanceof Response) { + if (!item.body) { + throw new Error("Invalid response"); + } + return this.uploadFile(item.body, filename); + } + + return this.uploadFile(item, filename); } deleteFile(filename: string) { diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index e469830..7622c9c 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -1,6 +1,5 @@ -import { tbValidator as tb } from "core"; -import { Type } from "core/utils"; -import { bodyLimit } from "hono/body-limit"; +import { isDebug, tbValidator as tb } from "core"; +import { HttpStatus, Type, getFileFromContext } from "core/utils"; import type { StorageAdapter } from "media"; import { StorageEvents, getRandomizedFilename } from "media"; import { Controller } from "modules/Controller"; @@ -42,7 +41,6 @@ export class MediaController extends Controller { if (!filename) { throw new Error("No file name provided"); } - //console.log("getting file", filename, headersToObject(c.req.raw.headers)); await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename })); return await this.getStorageAdapter().getObject(filename, c.req.raw.headers); @@ -59,24 +57,39 @@ export class MediaController extends Controller { return c.json({ message: "File deleted" }); }); - const uploadSizeMiddleware = bodyLimit({ - maxSize: this.getStorage().getConfig().body_max_size, - onError: (c: any) => { - return c.text(`Payload exceeds ${this.getStorage().getConfig().body_max_size}`, 413); - } - }); + const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY; + + if (isDebug()) { + hono.post("/inspect", async (c) => { + const file = await getFileFromContext(c); + return c.json({ + type: file?.type, + name: file?.name, + size: file?.size + }); + }); + } // upload file // @todo: add required type for "upload endpoints" - hono.post("/upload/:filename", uploadSizeMiddleware, async (c) => { + hono.post("/upload/:filename", async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); } - const file = await this.getStorage().getFileFromRequest(c); - console.log("----file", file); - return c.json(await this.getStorage().uploadFile(file, filename)); + const body = await getFileFromContext(c); + if (!body) { + return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST); + } + if (body.size > maxSize) { + return c.json( + { error: `Max size (${maxSize} bytes) exceeded` }, + HttpStatus.PAYLOAD_TOO_LARGE + ); + } + + return c.json(await this.getStorage().uploadFile(body, filename), HttpStatus.CREATED); }); // add upload file to entity @@ -89,23 +102,21 @@ export class MediaController extends Controller { overwrite: Type.Optional(booleanLike) }) ), - uploadSizeMiddleware, async (c) => { const entity_name = c.req.param("entity"); const field_name = c.req.param("field"); const entity_id = Number.parseInt(c.req.param("id")); - console.log("params", { entity_name, field_name, entity_id }); // check if entity exists const entity = this.media.em.entity(entity_name); if (!entity) { - return c.json({ error: `Entity "${entity_name}" not found` }, 404); + return c.json({ error: `Entity "${entity_name}" not found` }, HttpStatus.NOT_FOUND); } // check if field exists and is of type MediaField const field = entity.field(field_name); if (!field || !(field instanceof MediaField)) { - return c.json({ error: `Invalid field "${field_name}"` }, 400); + return c.json({ error: `Invalid field "${field_name}"` }, HttpStatus.BAD_REQUEST); } const media_entity = this.media.getMediaEntity().name as "media"; @@ -127,7 +138,10 @@ export class MediaController extends Controller { if (count >= max_items) { // if overwrite not set, abort early if (!overwrite) { - return c.json({ error: `Max items (${max_items}) reached` }, 400); + return c.json( + { error: `Max items (${max_items}) reached` }, + HttpStatus.BAD_REQUEST + ); } // if already more in database than allowed, abort early @@ -135,7 +149,7 @@ export class MediaController extends Controller { if (count > max_items) { return c.json( { error: `Max items (${max_items}) exceeded already with ${count} items.` }, - 400 + HttpStatus.UNPROCESSABLE_ENTITY ); } @@ -161,11 +175,21 @@ export class MediaController extends Controller { if (!exists) { return c.json( { error: `Entity "${entity_name}" with ID "${entity_id}" doesn't exist found` }, - 404 + HttpStatus.NOT_FOUND + ); + } + + const file = await getFileFromContext(c); + if (!file) { + return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST); + } + if (file.size > maxSize) { + return c.json( + { error: `Max size (${maxSize} bytes) exceeded` }, + HttpStatus.PAYLOAD_TOO_LARGE ); } - const file = await this.getStorage().getFileFromRequest(c); const file_name = getRandomizedFilename(file as File); const info = await this.getStorage().uploadFile(file, file_name, true); @@ -185,7 +209,7 @@ export class MediaController extends Controller { } } - return c.json({ ok: true, result: result.data, ...info }); + return c.json({ ok: true, result: result.data, ...info }, HttpStatus.CREATED); } ); diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index 701319a..51f6ae5 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -1,7 +1,5 @@ import { type EmitsEvents, EventManager } from "core/events"; -import type { TSchema } from "core/utils"; -import { type Context, Hono } from "hono"; -import { bodyLimit } from "hono/body-limit"; +import { type TSchema, isFile } from "core/utils"; import * as StorageEvents from "./events"; import type { FileUploadedEventData } from "./events"; @@ -12,7 +10,7 @@ export type FileListObject = { }; export type FileMeta = { type: string; size: number }; -export type FileBody = ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob | File; +export type FileBody = ReadableStream | File; export type FileUploadPayload = { name: string; meta: FileMeta; @@ -38,7 +36,7 @@ export interface StorageAdapter { } export type StorageConfig = { - body_max_size: number; + body_max_size?: number; }; export class Storage implements EmitsEvents { @@ -55,7 +53,7 @@ export class Storage implements EmitsEvents { this.#adapter = adapter; this.config = { ...config, - body_max_size: config.body_max_size ?? 20 * 1024 * 1024 + body_max_size: config.body_max_size }; this.emgr = emgr ?? new EventManager(); @@ -90,13 +88,25 @@ export class Storage implements EmitsEvents { case "undefined": throw new Error("Failed to upload file"); case "string": { - // get object meta - const meta = await this.#adapter.getObjectMeta(name); - if (!meta) { - throw new Error("Failed to get object meta"); - } + if (isFile(file)) { + info = { + name, + meta: { + size: file.size, + type: file.type + }, + etag: result + }; + break; + } else { + // get object meta + const meta = await this.#adapter.getObjectMeta(name); + if (!meta) { + throw new Error("Failed to get object meta"); + } - info = { name, meta, etag: result }; + info = { name, meta, etag: result }; + } break; } case "object": @@ -127,102 +137,4 @@ export class Storage implements EmitsEvents { async fileExists(name: string) { return await this.#adapter.objectExists(name); } - - getController(): any { - // @todo: multiple providers? - // @todo: implement range requests - - const hono = new Hono(); - - // get files list (temporary) - hono.get("/files", async (c) => { - const files = await this.#adapter.listObjects(); - return c.json(files); - }); - - // get file by name - hono.get("/file/:filename", async (c) => { - const { filename } = c.req.param(); - if (!filename) { - throw new Error("No file name provided"); - } - //console.log("getting file", filename, headersToObject(c.req.raw.headers)); - - await this.emgr.emit(new StorageEvents.FileAccessEvent({ name: filename })); - return await this.#adapter.getObject(filename, c.req.raw.headers); - }); - - // delete a file by name - hono.delete("/file/:filename", async (c) => { - const { filename } = c.req.param(); - if (!filename) { - throw new Error("No file name provided"); - } - await this.deleteFile(filename); - - return c.json({ message: "File deleted" }); - }); - - // upload file - hono.post( - "/upload/:filename", - bodyLimit({ - maxSize: this.config.body_max_size, - onError: (c: any) => { - return c.text(`Payload exceeds ${this.config.body_max_size}`, 413); - } - }), - async (c) => { - const { filename } = c.req.param(); - if (!filename) { - throw new Error("No file name provided"); - } - - const file = await this.getFileFromRequest(c); - return c.json(await this.uploadFile(file, filename)); - } - ); - - return hono; - } - - /** - * If uploaded through HttpPie -> ReadableStream - * If uploaded in tests -> file == ReadableStream - * If uploaded in FE -> content_type:body multipart/form-data; boundary=----WebKitFormBoundary7euoBFF12B0AHWLn - * file File { - * size: 223052, - * type: 'image/png', - * name: 'noise_white.png', - * lastModified: 1731743671176 - * } - * @param c - */ - async getFileFromRequest(c: Context): Promise { - const content_type = c.req.header("Content-Type") ?? "application/octet-stream"; - console.log("content_type:body", content_type); - const body = c.req.raw.body; - if (!body) { - throw new Error("No body"); - } - - let file: FileBody | undefined; - if (content_type?.startsWith("multipart/form-data")) { - file = (await c.req.formData()).get("file") as File; - // @todo: check nextjs, it's not *that* [File] type (but it's uploadable) - if (typeof file === "undefined") { - throw new Error("No file given at form data 'file'"); - } - /*console.log("file", file); - if (!(file instanceof File)) { - throw new Error("No file given at form data 'file'"); - }*/ - } else if (content_type?.startsWith("application/octet-stream")) { - file = body; - } else { - throw new Error(`Unsupported content type: ${content_type}`); - } - - return file; - } } diff --git a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts index cfb4100..7f2de8c 100644 --- a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts +++ b/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts @@ -13,12 +13,6 @@ export const cloudinaryAdapterConfig = Type.Object( ); export type CloudinaryConfig = Static; -/*export type CloudinaryConfig = { - cloud_name: string; - api_key: string; - api_secret: string; - upload_preset?: string; -};*/ type CloudinaryObject = { asset_id: string; @@ -91,10 +85,8 @@ export class StorageCloudinaryAdapter implements StorageAdapter { } async putObject(_key: string, body: FileBody) { - //console.log("_key", _key); // remove extension, as it is added by cloudinary const key = _key.replace(/\.[a-z0-9]{2,5}$/, ""); - //console.log("key", key); const formData = new FormData(); formData.append("file", body as any); @@ -117,21 +109,12 @@ export class StorageCloudinaryAdapter implements StorageAdapter { body: formData } ); - //console.log("putObject:cloudinary", formData); if (!result.ok) { - /*console.log( - "failed to upload using cloudinary", - Object.fromEntries(formData.entries()), - result - );*/ return undefined; } - //console.log("putObject:result", result); - const data = (await result.json()) as CloudinaryPutObjectResponse; - //console.log("putObject:result:json", data); return { name: data.public_id + "." + data.format, @@ -154,7 +137,6 @@ export class StorageCloudinaryAdapter implements StorageAdapter { } } ); - //console.log("result", result); if (!result.ok) { throw new Error("Failed to list objects"); @@ -179,10 +161,7 @@ export class StorageCloudinaryAdapter implements StorageAdapter { } async objectExists(key: string): Promise { - //console.log("--object exists?", key); const result = await this.headObject(key); - //console.log("object exists", result); - return result.ok; } @@ -214,12 +193,10 @@ export class StorageCloudinaryAdapter implements StorageAdapter { const type = this.guessType(key) ?? "image"; const objectUrl = `https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/${key}`; - //console.log("objectUrl", objectUrl); return objectUrl; } async getObject(key: string, headers: Headers): Promise { - //console.log("url", this.getObjectUrl(key)); const res = await fetch(this.getObjectUrl(key), { method: "GET", headers: pickHeaders(headers, ["range"]) @@ -237,14 +214,10 @@ export class StorageCloudinaryAdapter implements StorageAdapter { const formData = new FormData(); formData.append("public_ids[]", key); - const result = await fetch( - `https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`, - { - method: "DELETE", - body: formData - } - ); - //console.log("deleteObject:result", result); + await fetch(`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`, { + method: "DELETE", + body: formData + }); } toJSON(secrets?: boolean) { diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts index 2c142ff..8b2f9ba 100644 --- a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts +++ b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts @@ -1,6 +1,12 @@ import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; -import { type Static, Type, parse } from "core/utils"; -import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage"; +import { type Static, Type, isFile, parse } from "core/utils"; +import type { + FileBody, + FileListObject, + FileMeta, + FileUploadPayload, + StorageAdapter +} from "../../Storage"; import { guess } from "../../mime-types-tiny"; export const localAdapterConfig = Type.Object( @@ -43,8 +49,9 @@ export class StorageLocalAdapter implements StorageAdapter { return fileStats; } - private async computeEtag(content: BufferSource): Promise { - const hashBuffer = await crypto.subtle.digest("SHA-256", content); + private async computeEtag(body: FileBody): Promise { + const content = isFile(body) ? body : new Response(body); + const hashBuffer = await crypto.subtle.digest("SHA-256", await content.arrayBuffer()); const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join(""); @@ -52,17 +59,16 @@ export class StorageLocalAdapter implements StorageAdapter { return `"${hashHex}"`; } - async putObject(key: string, body: FileBody): Promise { + async putObject(key: string, body: FileBody): Promise { if (body === null) { throw new Error("Body is empty"); } - // @todo: this is too hacky - const file = body as File; - const filePath = `${this.config.path}/${key}`; - await writeFile(filePath, file.stream()); - return await this.computeEtag(await file.arrayBuffer()); + const is_file = isFile(body); + await writeFile(filePath, is_file ? body.stream() : body); + + return await this.computeEtag(body); } async deleteObject(key: string): Promise { diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/StorageS3Adapter.ts index b330d64..4e9ac94 100644 --- a/app/src/media/storage/adapters/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/StorageS3Adapter.ts @@ -7,7 +7,7 @@ import type { PutObjectRequest } from "@aws-sdk/client-s3"; import { AwsClient, isDebug } from "core"; -import { type Static, Type, parse, pickHeaders } from "core/utils"; +import { type Static, Type, isFile, parse, pickHeaders } from "core/utils"; import { transform } from "lodash-es"; import type { FileBody, FileListObject, StorageAdapter } from "../Storage"; @@ -82,17 +82,14 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { }; const url = this.getUrl("", params); - //console.log("url", url); const res = await this.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, { method: "GET" }); - //console.log("res", res); // absolutely weird, but if only one object is there, it's an object, not an array const { Contents } = res.ListBucketResult; const objects = !Contents ? [] : Array.isArray(Contents) ? Contents : [Contents]; - //console.log(JSON.stringify(res.ListBucketResult, null, 2), objects); const transformed = transform( objects, (acc, obj) => { @@ -107,32 +104,36 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { }, [] as FileListObject[] ); - //console.log(transformed); return transformed; } async putObject( key: string, - body: FileBody | null, + body: FileBody, // @todo: params must be added as headers, skipping for now params: Omit = {} ) { const url = this.getUrl(key, {}); - //console.log("url", url); const res = await this.fetch(url, { method: "PUT", body }); - /*console.log("putObject:raw:res", { - ok: res.ok, - status: res.status, - statusText: res.statusText, - });*/ if (res.ok) { // "df20fcb574dba1446cf5ec997940492b" - return String(res.headers.get("etag")); + const etag = String(res.headers.get("etag")); + if (isFile(body)) { + return { + etag, + name: body.name, + meta: { + size: body.size, + type: body.type + } + }; + } + return etag; } return undefined; diff --git a/app/src/media/storage/mime-types-tiny.ts b/app/src/media/storage/mime-types-tiny.ts index a231734..f54b51e 100644 --- a/app/src/media/storage/mime-types-tiny.ts +++ b/app/src/media/storage/mime-types-tiny.ts @@ -75,3 +75,21 @@ export function guess(f: string): string { return c.a(); } } + +export function isMimeType(mime: any, exclude: string[] = []) { + for (const [k, v] of M.entries()) { + if (v === mime && !exclude.includes(k)) { + return true; + } + } + return false; +} + +export function extension(mime: string) { + for (const [k, v] of M.entries()) { + if (v === mime) { + return k; + } + } + return ""; +} diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 039b249..3ba0552 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -23,13 +23,13 @@ export type ApiResponse = { export type TInput = string | (string | number | PrimaryFieldType)[]; export abstract class ModuleApi { + protected fetcher: typeof fetch; + constructor( protected readonly _options: Partial = {}, - protected fetcher?: typeof fetch + fetcher?: typeof fetch ) { - if (!fetcher) { - this.fetcher = fetch; - } + this.fetcher = fetcher ?? fetch; } protected getDefaultOptions(): Partial { @@ -80,7 +80,9 @@ export abstract class ModuleApi( const actualData = data ?? (body as unknown as Data); const _props = ["raw", "body", "ok", "status", "res", "data", "toJSON"]; - return new Proxy(actualData as any, { + if (typeof actualData !== "object") { + throw new Error(`Response data must be an object, "${typeof actualData}" given.`); + } + + return new Proxy(actualData ?? ({} as any), { get(target, prop, receiver) { if (prop === "raw" || prop === "res") return raw; if (prop === "body") return body; @@ -232,6 +238,8 @@ export class FetchPromise> implements Promise { } } else if (contentType.startsWith("text")) { resBody = await res.text(); + } else { + resBody = res.body; } return createResponseProxy(res, resBody, resData); diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 1a0e2d6..04e3071 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -226,8 +226,6 @@ export function Dropzone({ const uploadInfo = getUploadInfo(file.body); console.log("dropzone:uploadInfo", uploadInfo); const { url, headers, method = "POST" } = uploadInfo; - const formData = new FormData(); - formData.append("file", file.body); const xhr = new XMLHttpRequest(); console.log("xhr:url", url); @@ -295,7 +293,7 @@ export function Dropzone({ }; xhr.setRequestHeader("Accept", "application/json"); - xhr.send(formData); + xhr.send(file.body); }); } From f0bdb3a75eeedec264fafc59fc5d4456ee3a02dd Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 14 Feb 2025 11:04:50 +0100 Subject: [PATCH 3/5] improved typing, renamed functions for clarity --- app/__test__/api/MediaApi.spec.ts | 28 +++++++++++----------------- app/src/media/api/MediaApi.ts | 29 +++++++++++++++-------------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index 196d711..7387792 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -43,18 +43,6 @@ describe("MediaApi", () => { expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token"); }); - it("should upload file directly", async () => { - const name = "image.png"; - const file = await Bun.file(`${assetsPath}/${name}`); - - // @ts-ignore tests - const api = new MediaApi({}, mockedBackend.request); - const result = await api.uploadFile(file as any, name); - expect(result.name).toBe(name); - expect(result.is_file).toBe(true); - expect(result.size).toBe(file.size); - }); - it("should get file: native", async () => { const name = "image.png"; const path = `${assetsTmpPath}/${name}`; @@ -67,24 +55,24 @@ describe("MediaApi", () => { await file.delete(); }); - it("getFile", async () => { + it("download", async () => { // @ts-ignore tests const api = new MediaApi({}, mockedBackend.request); const name = "image.png"; - const file = await api.getFile(name); + const file = await api.download(name); expect(isFile(file)).toBe(true); expect(file.size).toBeGreaterThan(0); expect(file.type).toBe("image/png"); expect(file.name).toContain(name); }); - it("getFileResponse", async () => { + it("getFile", async () => { // @ts-ignore tests const api = new MediaApi({}, mockedBackend.request); const name = "image.png"; - const res = await api.getFileResponse(name); + const res = await api.getFile(name); expect(res.ok).toBe(true); // make sure it's a normal api request as usual expect(res.res.ok).toBe(true); @@ -143,7 +131,13 @@ describe("MediaApi", () => { await matches(api.upload(response, "response.png"), "response.png"); } - // upload via readable + // upload via readable from bun await matches(await api.upload(file.stream(), "readable.png"), "readable.png"); + + // upload via readable from response + { + const response = (await mockedBackend.request(url)) as Response; + await matches(await api.upload(response.body!, "readable.png"), "readable.png"); + } }); }); diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index 02eda71..83dd504 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -1,3 +1,4 @@ +import type { FileListObject } from "media"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules/ModuleApi"; import type { FileWithPath } from "ui/elements/media/file-selector"; @@ -10,34 +11,34 @@ export class MediaApi extends ModuleApi { }; } - getFiles() { - return this.get(["files"]); + listFiles() { + return this.get(["files"]); } - getFileResponse(filename: string) { - return this.get(["file", filename], undefined, { + getFile(filename: string) { + return this.get>(["file", filename], undefined, { headers: { Accept: "*/*" } }); } - async getFile(filename: string): Promise { - const { res } = await this.getFileResponse(filename); - if (!res.ok || !res.body) { - throw new Error("Failed to fetch file"); - } - return await res.blob(); - } - async getFileStream(filename: string): Promise> { - const { res } = await this.getFileResponse(filename); + const { res } = await this.getFile(filename); if (!res.ok || !res.body) { throw new Error("Failed to fetch file"); } return res.body; } + async download(filename: string): Promise { + const { res } = await this.getFile(filename); + if (!res.ok || !res.body) { + throw new Error("Failed to fetch file"); + } + return (await res.blob()) as File; + } + getFileUploadUrl(file: FileWithPath): string { return this.getUrl(`/upload/${file.path}`); } @@ -52,7 +53,7 @@ export class MediaApi extends ModuleApi { }); } - uploadFile(body: File | ReadableStream, filename?: string) { + protected uploadFile(body: File | ReadableStream, filename?: string) { let type: string = "application/octet-stream"; let name: string = filename || ""; try { From 9c38c8f2adfe368b59823f29a3abde0eb5acc19a Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 14 Feb 2025 11:21:11 +0100 Subject: [PATCH 4/5] improved Dropzone error handling --- app/src/ui/elements/media/Dropzone.tsx | 32 ++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 04e3071..537cfe0 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -58,6 +58,15 @@ export type DropzoneProps = { children?: (props: DropzoneRenderProps) => JSX.Element; }; +function handleUploadError(e: unknown) { + if (e && e instanceof XMLHttpRequest) { + const res = JSON.parse(e.responseText) as any; + alert(`Upload failed with code ${e.status}: ${res.error}`); + } else { + alert("Upload failed"); + } +} + export function Dropzone({ getUploadInfo, handleDelete, @@ -164,7 +173,11 @@ export function Dropzone({ return; } else { for (const file of pendingFiles) { - await uploadFileProgress(file); + try { + await uploadFileProgress(file); + } catch (e) { + handleUploadError(e); + } } setUploading(false); onUploaded?.(files); @@ -258,7 +271,7 @@ export function Dropzone({ xhr.onload = () => { console.log("onload", file.path, xhr.status); - if (xhr.status === 200) { + if (xhr.status >= 200 && xhr.status < 300) { //setFileState(file.path, "uploaded", 1); console.log("Upload complete"); @@ -279,8 +292,8 @@ export function Dropzone({ resolve(); } else { setFileState(file.path, "failed", 1); - console.error("Upload failed with status: ", xhr.status); - reject(); + console.error("Upload failed with status: ", xhr.status, xhr.statusText); + reject(xhr); } }; @@ -364,6 +377,14 @@ const DropzoneInner = ({ ); + async function uploadHandler(file: FileState) { + try { + return await uploadFile(file); + } catch (e) { + handleUploadError(e); + } + } + return (
))} @@ -450,6 +471,7 @@ const Preview: React.FC = ({ file, handleUpload, handleDelete }) =
From 24e69eec9019246b1ee50307cd928a58c95aa4ce Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 14 Feb 2025 11:42:26 +0100 Subject: [PATCH 5/5] fix s3 adapter --- app/src/media/AppMedia.ts | 3 ++- app/src/media/api/MediaController.ts | 3 ++- app/src/media/storage/Storage.ts | 1 - app/src/media/storage/adapters/StorageS3Adapter.ts | 13 +------------ .../ui/components/form/json-schema-form/Form.tsx | 10 +++++----- app/src/ui/routes/test/tests/json-schema-form3.tsx | 11 ++++++----- 6 files changed, 16 insertions(+), 25 deletions(-) diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index 564b008..779d02a 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -124,7 +124,8 @@ export class AppMedia extends Module { async (e) => { const mutator = em.mutator(media); mutator.__unstable_toggleSystemEntityCreation(false); - await mutator.insertOne(this.uploadedEventDataToMediaPayload(e.params)); + const payload = this.uploadedEventDataToMediaPayload(e.params); + await mutator.insertOne(payload); mutator.__unstable_toggleSystemEntityCreation(true); console.log("App:storage:file uploaded", e); }, diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 7622c9c..fc70cce 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -89,7 +89,8 @@ export class MediaController extends Controller { ); } - return c.json(await this.getStorage().uploadFile(body, filename), HttpStatus.CREATED); + const res = await this.getStorage().uploadFile(body, filename); + return c.json(res, HttpStatus.CREATED); }); // add upload file to entity diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index 51f6ae5..1a17c7b 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -80,7 +80,6 @@ export class Storage implements EmitsEvents { noEmit?: boolean ): Promise { const result = await this.#adapter.putObject(name, file); - console.log("result", result); let info: FileUploadPayload; diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/StorageS3Adapter.ts index 4e9ac94..cd051d7 100644 --- a/app/src/media/storage/adapters/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/StorageS3Adapter.ts @@ -122,18 +122,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { if (res.ok) { // "df20fcb574dba1446cf5ec997940492b" - const etag = String(res.headers.get("etag")); - if (isFile(body)) { - return { - etag, - name: body.name, - meta: { - size: body.size, - type: body.type - } - }; - } - return etag; + return String(res.headers.get("etag")); } return undefined; diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx index c26cb01..0cc5e97 100644 --- a/app/src/ui/components/form/json-schema-form/Form.tsx +++ b/app/src/ui/components/form/json-schema-form/Form.tsx @@ -225,12 +225,12 @@ export function FormContextOverride({ // especially useful for AnyOf, since it doesn't need to fully validate (e.g. pattern) if (prefix) { additional.root = prefix; - additional.setValue = (pointer: string, value: any) => { - ctx.setValue(prefixPointer(pointer, prefix), value); - }; - additional.deleteValue = (pointer: string) => { - ctx.deleteValue(prefixPointer(pointer, prefix)); + /*additional.setValue = (path: string, value: any) => { + ctx.setValue(prefixPath(path, prefix), value); }; + additional.deleteValue = (path: string) => { + ctx.deleteValue(prefixPath(path, prefix)); + };*/ } const context = { diff --git a/app/src/ui/routes/test/tests/json-schema-form3.tsx b/app/src/ui/routes/test/tests/json-schema-form3.tsx index 0640872..eb0dc65 100644 --- a/app/src/ui/routes/test/tests/json-schema-form3.tsx +++ b/app/src/ui/routes/test/tests/json-schema-form3.tsx @@ -46,7 +46,7 @@ export default function JsonSchemaForm3() { return (
-
console.log("change", data)} onSubmit={(data) => console.log("submit", data)} schema={{ @@ -68,7 +68,7 @@ export default function JsonSchemaForm3() { className="flex flex-col gap-3" validateOn="change" options={{ debug: true }} - /> + />*/} {/**/} {/**/} - {/**/} + options={{ debug: true }} + /*validateOn="change"*/ + /> {/*