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 0000000..2ebf8fc
Binary files /dev/null and b/app/__test__/_assets/image.png differ
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);
});
}