mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
add image dimension detection for most common formats
This commit is contained in:
BIN
app/__test__/_assets/image.jpg
Normal file
BIN
app/__test__/_assets/image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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 * as utils from "../../src/core/utils";
|
||||||
|
import { assetsPath } from "../helper";
|
||||||
|
|
||||||
async function wait(ms: number) {
|
async function wait(ms: number) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -75,57 +76,6 @@ describe("Core Utils", async () => {
|
|||||||
const result3 = utils.encodeSearch(obj3, { encode: true });
|
const result3 = utils.encodeSearch(obj3, { encode: true });
|
||||||
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
|
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 () => {
|
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", () => {
|
describe("dates", () => {
|
||||||
test.only("formats local time", () => {
|
test.only("formats local time", () => {
|
||||||
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
|
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { getRandomizedFilename } from "../../src/media/utils";
|
|||||||
|
|
||||||
describe("media/mime-types", () => {
|
describe("media/mime-types", () => {
|
||||||
test("tiny resolves", () => {
|
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) {
|
for (const [ext, mime] of tests) {
|
||||||
expect(tiny.guess(ext)).toBe(mime);
|
expect(tiny.guess(ext)).toBe(mime);
|
||||||
@@ -69,7 +71,7 @@ describe("media/mime-types", () => {
|
|||||||
["application/zip", "zip"],
|
["application/zip", "zip"],
|
||||||
["text/tab-separated-values", "tsv"],
|
["text/tab-separated-values", "tsv"],
|
||||||
["application/zip", "zip"],
|
["application/zip", "zip"],
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
for (const [mime, ext] of tests) {
|
for (const [mime, ext] of tests) {
|
||||||
expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext);
|
expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext);
|
||||||
@@ -86,7 +88,7 @@ describe("media/mime-types", () => {
|
|||||||
["image.jpeg", "jpeg"],
|
["image.jpeg", "jpeg"],
|
||||||
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
||||||
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
for (const [filename, ext] of tests) {
|
for (const [filename, ext] of tests) {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export class Api {
|
|||||||
this.buildApis();
|
this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get fetcher() {
|
||||||
|
return this.options.fetcher ?? fetch;
|
||||||
|
}
|
||||||
|
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return this.options.host ?? "http://localhost";
|
return this.options.host ?? "http://localhost";
|
||||||
}
|
}
|
||||||
|
|||||||
239
app/src/core/utils/file.ts
Normal file
239
app/src/core/utils/file.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
"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<string | undefined> {
|
||||||
|
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<any>): Promise<File> {
|
||||||
|
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<any>): Promise<ReadableStream | File> {
|
||||||
|
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<ImageDim>;
|
||||||
|
export async function detectImageDimensions(input: File): Promise<ImageDim>;
|
||||||
|
export async function detectImageDimensions(
|
||||||
|
input: File | ArrayBuffer,
|
||||||
|
_type?: `image/${string}`,
|
||||||
|
): Promise<ImageDim> {
|
||||||
|
// 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<File> {
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -97,186 +97,6 @@ export function decodeSearch(str) {
|
|||||||
return out;
|
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<string, string> = {
|
|
||||||
"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<string | undefined> {
|
|
||||||
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<File> {
|
|
||||||
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<any>): Promise<File> {
|
|
||||||
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<any>): Promise<ReadableStream | File> {
|
|
||||||
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
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
|
||||||
// biome-ignore lint/suspicious/noConstEnum: <explanation>
|
// biome-ignore lint/suspicious/noConstEnum: <explanation>
|
||||||
export const enum HttpStatus {
|
export const enum HttpStatus {
|
||||||
|
|||||||
@@ -47,3 +47,9 @@ export function isNode() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invariant(condition: boolean | any, message: string) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,13 +75,20 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
|||||||
return this._storage!;
|
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 {
|
return {
|
||||||
path: info.name,
|
path: info.name,
|
||||||
mime_type: info.meta.type,
|
mime_type: info.meta.type,
|
||||||
size: info.meta.size,
|
size: info.meta.size,
|
||||||
etag: info.etag,
|
etag: info.etag,
|
||||||
modified_at: new Date(),
|
modified_at: new Date(),
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { type EmitsEvents, EventManager } from "core/events";
|
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 { isMimeType } from "media/storage/mime-types-tiny";
|
||||||
import * as StorageEvents from "./events";
|
import * as StorageEvents from "./events";
|
||||||
import type { FileUploadedEventData } from "./events";
|
import type { FileUploadedEventData } from "./events";
|
||||||
|
import { $console } from "core";
|
||||||
|
|
||||||
export type FileListObject = {
|
export type FileListObject = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -10,7 +11,7 @@ export type FileListObject = {
|
|||||||
size: number;
|
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 FileBody = ReadableStream | File;
|
||||||
export type FileUploadPayload = {
|
export type FileUploadPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -102,7 +103,7 @@ export class Storage implements EmitsEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// try to get better meta info
|
// 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);
|
const meta = await this.#adapter.getObjectMeta(name);
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
throw new Error("Failed to get object meta");
|
throw new Error("Failed to get object meta");
|
||||||
@@ -110,6 +111,19 @@ export class Storage implements EmitsEvents {
|
|||||||
info.meta = meta;
|
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 = {
|
const eventData = {
|
||||||
file,
|
file,
|
||||||
...info,
|
...info,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
PutObjectRequest,
|
PutObjectRequest,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { AwsClient, isDebug } from "core";
|
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 { transform } from "lodash-es";
|
||||||
import type { FileBody, FileListObject, StorageAdapter } from "../Storage";
|
import type { FileBody, FileListObject, StorageAdapter } from "../Storage";
|
||||||
|
|
||||||
@@ -178,7 +178,6 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
|||||||
const res = await this.fetch(url, {
|
const res = await this.fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: pickHeaders2(headers, [
|
headers: pickHeaders2(headers, [
|
||||||
"range",
|
|
||||||
"if-none-match",
|
"if-none-match",
|
||||||
"accept-encoding",
|
"accept-encoding",
|
||||||
"accept",
|
"accept",
|
||||||
|
|||||||
Reference in New Issue
Block a user