From bc961b70bbfeffc7a8a061a59b2ce6ac1779f107 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 28 Jan 2025 18:06:33 +0100 Subject: [PATCH] refactor(database): :fire: Simplify media management code --- .../v1/accounts/update_credentials/index.ts | 6 +- api/api/v1/emojis/:id/index.ts | 45 ++---- api/api/v1/emojis/index.ts | 36 ++--- api/api/v1/media/:id/index.ts | 34 +--- classes/database/media.ts | 150 ++++++++++++++++-- plugins/openid/routes/oauth/callback.ts | 4 +- utils/content_types.ts | 7 +- 7 files changed, 173 insertions(+), 109 deletions(-) diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index 1888114d..bb5a23f6 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -61,6 +61,7 @@ const schemas = { .min(1) .max(2000) .url() + .transform((a) => new URL(a)) .or( z .instanceof(File) @@ -76,6 +77,7 @@ const schemas = { .min(1) .max(2000) .url() + .transform((v) => new URL(v)) .or( z .instanceof(File) @@ -256,7 +258,7 @@ export default apiRoute((app) => content_type: contentType, }; } else { - self.avatar = avatar; + self.avatar = avatar.toString(); self.source.avatar = { content_type: await mimeLookup(avatar), }; @@ -274,7 +276,7 @@ export default apiRoute((app) => content_type: contentType, }; } else { - self.header = header; + self.header = header.toString(); self.source.header = { content_type: await mimeLookup(header), }; diff --git a/api/api/v1/emojis/:id/index.ts b/api/api/v1/emojis/:id/index.ts index 87f5fc87..9930336a 100644 --- a/api/api/v1/emojis/:id/index.ts +++ b/api/api/v1/emojis/:id/index.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api"; import { mimeLookup } from "@/content_types"; import { createRoute } from "@hono/zod-openapi"; -import type { ContentFormat } from "@versia/federation/types"; -import { Emoji, Media, db } from "@versia/kit/db"; +import { Emoji, db } from "@versia/kit/db"; import { Emojis, RolePermissions } from "@versia/kit/tables"; import { eq } from "drizzle-orm"; import { z } from "zod"; @@ -32,6 +31,7 @@ const schemas = { .min(1) .max(2000) .url() + .transform((a) => new URL(a)) .or( z .instanceof(File) @@ -247,9 +247,6 @@ export default apiRoute((app) => { ); } - const modifiedMedia = structuredClone(emoji.data.media); - const modified = structuredClone(emoji.data); - if (element) { // Check of emoji is an image const contentType = @@ -265,40 +262,24 @@ export default apiRoute((app) => { ); } - let contentFormat: ContentFormat | undefined; - if (element instanceof File) { - const mediaManager = new MediaManager(config); - - const { uploadedFile, path } = - await mediaManager.addFile(element); - - contentFormat = await Media.fileToContentFormat( - uploadedFile, - Media.getUrl(path), - { description: alt }, - ); + await emoji.media.updateFromFile(element); } else { - contentFormat = { - [contentType]: { - content: element, - remote: true, - description: alt, - }, - }; + await emoji.media.updateFromUrl(element); } - - modifiedMedia.content = contentFormat; } - modified.shortcode = shortcode ?? modified.shortcode; - modified.category = category ?? modified.category; - - if (emojiGlobal !== undefined) { - modified.ownerId = emojiGlobal ? null : user.data.id; + if (alt) { + await emoji.media.updateMetadata({ + description: alt, + }); } - await emoji.update(modified); + await emoji.update({ + shortcode, + ownerId: emojiGlobal ? null : user.data.id, + category, + }); return context.json(emoji.toApi(), 200); }); diff --git a/api/api/v1/emojis/index.ts b/api/api/v1/emojis/index.ts index 8d3d8c58..b198d65d 100644 --- a/api/api/v1/emojis/index.ts +++ b/api/api/v1/emojis/index.ts @@ -1,13 +1,11 @@ import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api"; import { mimeLookup } from "@/content_types"; import { createRoute } from "@hono/zod-openapi"; -import type { ContentFormat } from "@versia/federation/types"; import { Emoji, Media } from "@versia/kit/db"; import { Emojis, RolePermissions } from "@versia/kit/tables"; import { and, eq, isNull, or } from "drizzle-orm"; import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; -import { MediaManager } from "~/classes/media/media-manager"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -28,6 +26,7 @@ const schemas = { .min(1) .max(2000) .url() + .transform((a) => new URL(a)) .or( z .instanceof(File) @@ -143,31 +142,14 @@ export default apiRoute((app) => ); } - let contentFormat: ContentFormat | undefined; - - if (element instanceof File) { - const mediaManager = new MediaManager(config); - - const { uploadedFile, path } = await mediaManager.addFile(element); - - contentFormat = await Media.fileToContentFormat( - uploadedFile, - Media.getUrl(path), - { description: alt }, - ); - } else { - contentFormat = { - [contentType]: { - content: element, - remote: true, - description: alt, - }, - }; - } - - const media = await Media.insert({ - content: contentFormat, - }); + const media = + element instanceof File + ? await Media.fromFile(element, { + description: alt, + }) + : await Media.fromUrl(element, { + description: alt, + }); const emoji = await Emoji.insert({ shortcode, diff --git a/api/api/v1/media/:id/index.ts b/api/api/v1/media/:id/index.ts index 65fc11aa..a7ce3e91 100644 --- a/api/api/v1/media/:id/index.ts +++ b/api/api/v1/media/:id/index.ts @@ -4,7 +4,6 @@ import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; -import { MediaManager } from "~/classes/media/media-manager"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -101,47 +100,26 @@ export default apiRoute((app) => { app.openapi(routePut, async (context) => { const { id } = context.req.valid("param"); - const attachment = await Media.fromId(id); + const media = await Media.fromId(id); - if (!attachment) { + if (!media) { throw new ApiError(404, "Media not found"); } const { description, thumbnail: thumbnailFile } = context.req.valid("form"); - const mediaManager = new MediaManager(config); - - // TODO: Generate thumbnail if not provided if (thumbnailFile) { - const { path } = await mediaManager.addFile(thumbnailFile); - const thumbnail = attachment.data.thumbnail; - - // FIXME: Also update thumbnail if it hasn't been set - if (thumbnail) { - thumbnail[Object.keys(thumbnail)[0]].content = - Media.getUrl(path); - } - - attachment.data.thumbnail = thumbnail; + await media.updateThumbnail(thumbnailFile); } if (description) { - for (const type of Object.keys(attachment.data.content)) { - attachment.data.content[type].description = description; - } - } - - if (description || thumbnailFile) { - await attachment.update({ - content: attachment.data.content, - thumbnail: attachment.data.thumbnail, + await media.updateMetadata({ + description, }); - - return context.json(attachment.toApi(), 200); } - return context.json(attachment.toApi(), 200); + return context.json(media.toApi(), 200); }); app.openapi(routeGet, async (context) => { diff --git a/classes/database/media.ts b/classes/database/media.ts index 4c5003ae..a9207f89 100644 --- a/classes/database/media.ts +++ b/classes/database/media.ts @@ -1,3 +1,4 @@ +import { mimeLookup } from "@/content_types.ts"; import { proxyUrl } from "@/response"; import type { Attachment as ApiAttachment } from "@versia/client/types"; import type { ContentFormat } from "@versia/federation/types"; @@ -158,23 +159,7 @@ export class Media extends BaseInterface { thumbnail?: File; }, ): Promise { - if (file.size > config.validation.max_media_size) { - throw new ApiError( - 413, - `File too large, max size is ${config.validation.max_media_size} bytes`, - ); - } - - if ( - config.validation.enforce_mime_types && - !config.validation.allowed_mime_types.includes(file.type) - ) { - throw new ApiError( - 415, - `File type ${file.type} is not allowed`, - `Allowed types: ${config.validation.allowed_mime_types.join(", ")}`, - ); - } + Media.checkFile(file); const mediaManager = new MediaManager(config); @@ -219,6 +204,137 @@ export class Media extends BaseInterface { return newAttachment; } + public static async fromUrl( + uri: URL, + options?: { + description?: string; + }, + ): Promise { + const mimeType = await mimeLookup(uri); + + const content: ContentFormat = { + [mimeType]: { + content: uri.toString(), + remote: true, + description: options?.description, + }, + }; + + const newAttachment = await Media.insert({ + content, + }); + + await mediaQueue.add(MediaJobType.CalculateMetadata, { + attachmentId: newAttachment.id, + // CalculateMetadata doesn't use the filename, but the type is annoying + // and requires it anyway + filename: "blank", + }); + + return newAttachment; + } + + private static checkFile(file: File): void { + if (file.size > config.validation.max_media_size) { + throw new ApiError( + 413, + `File too large, max size is ${config.validation.max_media_size} bytes`, + ); + } + + if ( + config.validation.enforce_mime_types && + !config.validation.allowed_mime_types.includes(file.type) + ) { + throw new ApiError( + 415, + `File type ${file.type} is not allowed`, + `Allowed types: ${config.validation.allowed_mime_types.join(", ")}`, + ); + } + } + + public async updateFromFile(file: File): Promise { + Media.checkFile(file); + + const mediaManager = new MediaManager(config); + + const { path } = await mediaManager.addFile(file); + + const url = Media.getUrl(path); + + const content = await Media.fileToContentFormat(file, url, { + description: + this.data.content[Object.keys(this.data.content)[0]] + .description || undefined, + }); + + await this.update({ + content, + }); + + await mediaQueue.add(MediaJobType.CalculateMetadata, { + attachmentId: this.id, + filename: file.name, + }); + } + + public async updateFromUrl(uri: URL): Promise { + const mimeType = await mimeLookup(uri); + + const content: ContentFormat = { + [mimeType]: { + content: uri.toString(), + remote: true, + description: + this.data.content[Object.keys(this.data.content)[0]] + .description || undefined, + }, + }; + + await this.update({ + content, + }); + + await mediaQueue.add(MediaJobType.CalculateMetadata, { + attachmentId: this.id, + filename: "blank", + }); + } + + public async updateThumbnail(file: File): Promise { + Media.checkFile(file); + + const mediaManager = new MediaManager(config); + + const { path } = await mediaManager.addFile(file); + + const url = Media.getUrl(path); + + const content = await Media.fileToContentFormat(file, url); + + await this.update({ + thumbnail: content, + }); + } + + public async updateMetadata( + metadata: Partial>, + ): Promise { + const content = this.data.content; + + for (const type of Object.keys(content)) { + content[type] = { + ...content[type], + ...metadata, + }; + } + + await this.update({ + content, + }); + } + public get id(): string { return this.data.id; } diff --git a/plugins/openid/routes/oauth/callback.ts b/plugins/openid/routes/oauth/callback.ts index 3af10035..586153ce 100644 --- a/plugins/openid/routes/oauth/callback.ts +++ b/plugins/openid/routes/oauth/callback.ts @@ -250,7 +250,9 @@ export default (plugin: PluginType): void => { avatar: picture ? { url: picture, - content_type: await mimeLookup(picture), + content_type: await mimeLookup( + new URL(picture), + ), } : undefined, password: undefined, diff --git a/utils/content_types.ts b/utils/content_types.ts index 5dece767..b67b67a0 100644 --- a/utils/content_types.ts +++ b/utils/content_types.ts @@ -57,8 +57,11 @@ export const urlToContentFormat = ( }; }; -export const mimeLookup = (url: string): Promise => { - const naiveLookup = lookup(url.replace(new URL(url).search, "")); +export const mimeLookup = (url: URL): Promise => { + const urlWithoutSearch = url.toString().replace(url.search, ""); + + // Strip query params from URL to get the proper file extension + const naiveLookup = lookup(urlWithoutSearch); if (naiveLookup) { return Promise.resolve(naiveLookup);