updated media api and added tests, fixed body limit

This commit is contained in:
dswbx
2025-02-14 10:42:36 +01:00
parent cc938db4b8
commit c4e505582b
18 changed files with 769 additions and 251 deletions

View File

@@ -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<string, string> {
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<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
// biome-ignore lint/suspicious/noConstEnum: <explanation>
export const enum HttpStatus {
// Informational responses (100199)
CONTINUE = 100,
SWITCHING_PROTOCOLS = 101,
PROCESSING = 102,
EARLY_HINTS = 103,
// Successful responses (200299)
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 (300399)
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 (400499)
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 (500599)
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
}

View File

@@ -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
};
}

View File

@@ -14,8 +14,28 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
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<Blob> {
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<ReadableStream<Uint8Array>> {
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<MediaApiOptions> {
});
}
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) {

View File

@@ -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);
}
);

View File

@@ -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<FileBody> {
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;
}
}

View File

@@ -13,12 +13,6 @@ export const cloudinaryAdapterConfig = Type.Object(
);
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
/*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<boolean> {
//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<Response> {
//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) {

View File

@@ -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<string> {
const hashBuffer = await crypto.subtle.digest("SHA-256", content);
private async computeEtag(body: FileBody): Promise<string> {
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<string> {
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
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<void> {

View File

@@ -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<PutObjectRequest, "Bucket" | "Key"> = {}
) {
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;

View File

@@ -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 "";
}

View File

@@ -23,13 +23,13 @@ export type ApiResponse<Data = any> = {
export type TInput = string | (string | number | PrimaryFieldType)[];
export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModuleApiOptions> {
protected fetcher: typeof fetch;
constructor(
protected readonly _options: Partial<Options> = {},
protected fetcher?: typeof fetch
fetcher?: typeof fetch
) {
if (!fetcher) {
this.fetcher = fetch;
}
this.fetcher = fetcher ?? fetch;
}
protected getDefaultOptions(): Partial<Options> {
@@ -80,7 +80,9 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
headers.set(key, value as string);
}
headers.set("Accept", "application/json");
if (!headers.has("Accept")) {
headers.set("Accept", "application/json");
}
// only add token if initial headers not provided
if (this.options.token && this.options.token_transport === "header") {
@@ -170,7 +172,11 @@ export function createResponseProxy<Body = any, Data = any>(
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<T = ApiResponse<any>> implements Promise<T> {
}
} else if (contentType.startsWith("text")) {
resBody = await res.text();
} else {
resBody = res.body;
}
return createResponseProxy<T>(res, resBody, resData);

View File

@@ -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);
});
}