diff --git a/app/__test__/_assets/image.jpg b/app/__test__/_assets/image.jpg new file mode 100644 index 0000000..0f7890d Binary files /dev/null and b/app/__test__/_assets/image.jpg differ diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index db3c967..15428bf 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { Perf, datetimeStringUTC, isBlob, ucFirst } from "../../src/core/utils"; +import { Perf, ucFirst } from "../../src/core/utils"; import * as utils from "../../src/core/utils"; +import { assetsPath } from "../helper"; async function wait(ms: number) { return new Promise((resolve) => { @@ -75,57 +76,6 @@ 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 () => { @@ -246,6 +196,76 @@ describe("Core Utils", async () => { }); }); + describe("file", async () => { + describe("type 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); + }); + + test.only("detectImageDimensions", async () => { + // wrong + // @ts-expect-error + expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow(); + + // successful ones + const getFile = (name: string): File => Bun.file(`${assetsPath}/${name}`) as any; + expect(await utils.detectImageDimensions(getFile("image.png"))).toEqual({ + width: 362, + height: 387, + }); + expect(await utils.detectImageDimensions(getFile("image.jpg"))).toEqual({ + width: 453, + height: 512, + }); + }); + }); + describe("dates", () => { test.only("formats local time", () => { expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25"); diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts index 6c51fab..37f29a8 100644 --- a/app/__test__/media/mime-types.spec.ts +++ b/app/__test__/media/mime-types.spec.ts @@ -5,7 +5,9 @@ import { getRandomizedFilename } from "../../src/media/utils"; describe("media/mime-types", () => { test("tiny resolves", () => { - const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]]; + const tests = [ + [".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"], + ] as const; for (const [ext, mime] of tests) { expect(tiny.guess(ext)).toBe(mime); @@ -69,7 +71,7 @@ describe("media/mime-types", () => { ["application/zip", "zip"], ["text/tab-separated-values", "tsv"], ["application/zip", "zip"], - ]; + ] as const; for (const [mime, ext] of tests) { expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext); @@ -86,7 +88,7 @@ describe("media/mime-types", () => { ["image.jpeg", "jpeg"], ["-473Wx593H-466453554-black-MODEL.jpg", "jpg"], ["-473Wx593H-466453554-black-MODEL.avif", "avif"], - ]; + ] as const; for (const [filename, ext] of tests) { expect( diff --git a/app/src/Api.ts b/app/src/Api.ts index 70cbd13..593979e 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -78,6 +78,10 @@ export class Api { this.buildApis(); } + get fetcher() { + return this.options.fetcher ?? fetch; + } + get baseUrl() { return this.options.host ?? "http://localhost"; } diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts new file mode 100644 index 0000000..96970a7 --- /dev/null +++ b/app/src/core/utils/file.ts @@ -0,0 +1,239 @@ +import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; +import { randomString } from "core/utils/strings"; +import type { Context } from "hono"; +import { invariant } from "core/utils/runtime"; + +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; +} + +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); +} + +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 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); +} + +type ImageDim = { width: number; height: number }; +export async function detectImageDimensions( + input: ArrayBuffer, + type: `image/${string}`, +): Promise; +export async function detectImageDimensions(input: File): Promise; +export async function detectImageDimensions( + input: File | ArrayBuffer, + _type?: `image/${string}`, +): Promise { + // Only process images + const is_file = isFile(input); + const type = is_file ? input.type : _type!; + + invariant(type && typeof type === "string" && type.startsWith("image/"), "type must be image/*"); + + const buffer = is_file ? await input.arrayBuffer() : input; + invariant(buffer.byteLength >= 128, "Buffer must be at least 128 bytes"); + + const dataView = new DataView(buffer); + + if (type === "image/jpeg") { + let offset = 2; + while (offset < dataView.byteLength) { + const marker = dataView.getUint16(offset); + offset += 2; + if (marker === 0xffc0 || marker === 0xffc2) { + return { + width: dataView.getUint16(offset + 5), + height: dataView.getUint16(offset + 3), + }; + } + offset += dataView.getUint16(offset); + } + } else if (type === "image/png") { + return { + width: dataView.getUint32(16), + height: dataView.getUint32(20), + }; + } else if (type === "image/gif") { + return { + width: dataView.getUint16(6), + height: dataView.getUint16(8), + }; + } else if (type === "image/tiff") { + const isLittleEndian = dataView.getUint16(0) === 0x4949; + const offset = dataView.getUint32(4, isLittleEndian); + const width = dataView.getUint32(offset + 18, isLittleEndian); + const height = dataView.getUint32(offset + 10, isLittleEndian); + return { width, height }; + } + + throw new Error("Unsupported image format"); +} + +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(), + }); +} diff --git a/app/src/core/utils/reqres.ts b/app/src/core/utils/reqres.ts index 9890334..3e9ec12 100644 --- a/app/src/core/utils/reqres.ts +++ b/app/src/core/utils/reqres.ts @@ -97,186 +97,6 @@ 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 { diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 3e1bfa1..b90f670 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -47,3 +47,9 @@ export function isNode() { return false; } } + +export function invariant(condition: boolean | any, message: string) { + if (!condition) { + throw new Error(message); + } +} diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index f71ef6a..4c73b9d 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -75,13 +75,20 @@ export class AppMedia extends Module { return this._storage!; } - uploadedEventDataToMediaPayload(info: FileUploadedEventData) { + uploadedEventDataToMediaPayload(info: FileUploadedEventData): MediaFieldSchema { + const metadata: any = {}; + if (info.meta.width && info.meta.height) { + metadata.width = info.meta.width; + metadata.height = info.meta.height; + } + return { path: info.name, mime_type: info.meta.type, size: info.meta.size, etag: info.etag, modified_at: new Date(), + metadata, }; } diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index 718d8b1..2df3451 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -1,8 +1,9 @@ import { type EmitsEvents, EventManager } from "core/events"; -import { type TSchema, isFile } from "core/utils"; +import { type TSchema, isFile, detectImageDimensions } from "core/utils"; import { isMimeType } from "media/storage/mime-types-tiny"; import * as StorageEvents from "./events"; import type { FileUploadedEventData } from "./events"; +import { $console } from "core"; export type FileListObject = { key: string; @@ -10,7 +11,7 @@ export type FileListObject = { size: number; }; -export type FileMeta = { type: string; size: number }; +export type FileMeta = { type: string; size: number; width?: number; height?: number }; export type FileBody = ReadableStream | File; export type FileUploadPayload = { name: string; @@ -102,7 +103,7 @@ export class Storage implements EmitsEvents { } // try to get better meta info - if (!isMimeType(info?.meta.type, ["application/octet-stream", "application/json"])) { + if (!isMimeType(info.meta.type, ["application/octet-stream", "application/json"])) { const meta = await this.#adapter.getObjectMeta(name); if (!meta) { throw new Error("Failed to get object meta"); @@ -110,6 +111,19 @@ export class Storage implements EmitsEvents { info.meta = meta; } + // try to get width/height for images + if (info.meta.type.startsWith("image") && (!info.meta.width || !info.meta.height)) { + try { + const dim = await detectImageDimensions(file as File); + info.meta = { + ...info.meta, + ...dim, + }; + } catch (e) { + $console.warn("Failed to get image dimensions", e); + } + } + const eventData = { file, ...info, diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/StorageS3Adapter.ts index 37c651d..960b73d 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, isFile, parse, pickHeaders, pickHeaders2 } from "core/utils"; +import { type Static, Type, isFile, parse, pickHeaders2 } from "core/utils"; import { transform } from "lodash-es"; import type { FileBody, FileListObject, StorageAdapter } from "../Storage"; @@ -178,7 +178,6 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { const res = await this.fetch(url, { method: "GET", headers: pickHeaders2(headers, [ - "range", "if-none-match", "accept-encoding", "accept",