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"; import type { AppMedia } from "../AppMedia"; import { MediaField } from "../MediaField"; const booleanLike = Type.Transform(Type.String()) .Decode((v) => v === "1") .Encode((v) => (v ? "1" : "0")); export class MediaController extends Controller { constructor(private readonly media: AppMedia) { super(); } private getStorageAdapter(): StorageAdapter { return this.getStorage().getAdapter(); } private getStorage() { return this.media.storage; } override getController() { // @todo: multiple providers? // @todo: implement range requests const { auth } = this.middlewares; const hono = this.create().use(auth()); // get files list (temporary) hono.get("/files", async (c) => { const files = await this.getStorageAdapter().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"); } await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename })); return await this.getStorageAdapter().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.getStorage().deleteFile(filename); return c.json({ message: "File deleted" }); }); 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", async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); } 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 // @todo: add required type for "upload endpoints" hono.post( "/entity/:entity/:id/:field", tb( "query", Type.Object({ overwrite: Type.Optional(booleanLike) }) ), 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")); // check if entity exists const entity = this.media.em.entity(entity_name); if (!entity) { 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}"` }, HttpStatus.BAD_REQUEST); } const media_entity = this.media.getMediaEntity().name as "media"; const reference = `${entity_name}.${field_name}`; const mediaRef = { scope: field_name, reference, entity_id: entity_id }; // check max items const max_items = field.getMaxItems(); const paths_to_delete: string[] = []; if (max_items) { const { overwrite } = c.req.valid("query"); const { count } = await this.media.em.repository(media_entity).count(mediaRef); // if there are more than or equal to max items if (count >= max_items) { // if overwrite not set, abort early if (!overwrite) { return c.json( { error: `Max items (${max_items}) reached` }, HttpStatus.BAD_REQUEST ); } // if already more in database than allowed, abort early // because we don't know if we can delete multiple items if (count > max_items) { return c.json( { error: `Max items (${max_items}) exceeded already with ${count} items.` }, HttpStatus.UNPROCESSABLE_ENTITY ); } // collect items to delete const deleteRes = await this.media.em.repo(media_entity).findMany({ select: ["path"], where: mediaRef, sort: { by: "id", dir: "asc" }, limit: count - max_items + 1 }); if (deleteRes.data && deleteRes.data.length > 0) { deleteRes.data.map((item) => paths_to_delete.push(item.path)); } } } // check if entity exists in database const { exists } = await this.media.em.repository(entity).exists({ id: entity_id }); if (!exists) { return c.json( { error: `Entity "${entity_name}" with ID "${entity_id}" doesn't exist found` }, 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_name = getRandomizedFilename(file as File); const info = await this.getStorage().uploadFile(file, file_name, true); const mutator = this.media.em.mutator(media_entity); mutator.__unstable_toggleSystemEntityCreation(false); const result = await mutator.insertOne({ ...this.media.uploadedEventDataToMediaPayload(info), ...mediaRef } as any); mutator.__unstable_toggleSystemEntityCreation(true); // delete items if needed if (paths_to_delete.length > 0) { // delete files from db & adapter for (const path of paths_to_delete) { await this.getStorage().deleteFile(path); } } return c.json({ ok: true, result: result.data, ...info }, HttpStatus.CREATED); } ); return hono; } }