From 2e9885915370bbb26c82d686e4ad8cf757388cc2 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 12 Jun 2024 15:03:57 -1000 Subject: [PATCH] refactor(database): :recycle: Move Attachment into its own class --- database/entities/Attachment.ts | 111 +---------- packages/database-interface/attachment.ts | 213 ++++++++++++++++++++++ packages/database-interface/note.ts | 12 +- packages/database-interface/role.ts | 4 - packages/database-interface/user.ts | 6 +- server/api/api/v1/media/:id/index.ts | 43 ++--- server/api/api/v1/media/index.ts | 38 ++-- server/api/api/v2/media/index.ts | 39 ++-- 8 files changed, 270 insertions(+), 196 deletions(-) create mode 100644 packages/database-interface/attachment.ts diff --git a/database/entities/Attachment.ts b/database/entities/Attachment.ts index 3e5a0c22..7bb82912 100644 --- a/database/entities/Attachment.ts +++ b/database/entities/Attachment.ts @@ -1,114 +1,5 @@ -import { proxyUrl } from "@/response"; -import type { EntityValidator } from "@lysand-org/federation"; -import type { Config } from "config-manager"; -import type { InferSelectModel } from "drizzle-orm"; import { MediaBackendType } from "media-manager"; -import { db } from "~/drizzle/db"; -import { Attachments } from "~/drizzle/schema"; -import type { AsyncAttachment as APIAsyncAttachment } from "~/types/mastodon/async_attachment"; -import type { Attachment as APIAttachment } from "~/types/mastodon/attachment"; - -export type Attachment = InferSelectModel; - -export const attachmentToAPI = ( - attachment: Attachment, -): APIAsyncAttachment | APIAttachment => { - let type = "unknown"; - - if (attachment.mimeType.startsWith("image/")) { - type = "image"; - } else if (attachment.mimeType.startsWith("video/")) { - type = "video"; - } else if (attachment.mimeType.startsWith("audio/")) { - type = "audio"; - } - - return { - id: attachment.id, - type: type as "image" | "video" | "audio" | "unknown", - url: proxyUrl(attachment.url) ?? "", - remote_url: proxyUrl(attachment.remoteUrl), - preview_url: proxyUrl(attachment.thumbnailUrl || attachment.url), - text_url: null, - meta: { - width: attachment.width || undefined, - height: attachment.height || undefined, - fps: attachment.fps || undefined, - size: - attachment.width && attachment.height - ? `${attachment.width}x${attachment.height}` - : undefined, - duration: attachment.duration || undefined, - length: undefined, - aspect: - attachment.width && attachment.height - ? attachment.width / attachment.height - : undefined, - original: { - width: attachment.width || undefined, - height: attachment.height || undefined, - size: - attachment.width && attachment.height - ? `${attachment.width}x${attachment.height}` - : undefined, - aspect: - attachment.width && attachment.height - ? attachment.width / attachment.height - : undefined, - }, - // Idk whether size or length is the right value - }, - description: attachment.description, - blurhash: attachment.blurhash, - }; -}; - -export const attachmentToLysand = ( - attachment: Attachment, -): typeof EntityValidator.$ContentFormat => { - return { - [attachment.mimeType]: { - content: attachment.url, - blurhash: attachment.blurhash ?? undefined, - description: attachment.description ?? undefined, - duration: attachment.duration ?? undefined, - fps: attachment.fps ?? undefined, - height: attachment.height ?? undefined, - size: attachment.size ?? undefined, - hash: attachment.sha256 - ? { - sha256: attachment.sha256, - } - : undefined, - width: attachment.width ?? undefined, - }, - }; -}; - -export const attachmentFromLysand = async ( - attachmentToConvert: typeof EntityValidator.$ContentFormat, -): Promise> => { - const key = Object.keys(attachmentToConvert)[0]; - const value = attachmentToConvert[key]; - - const result = await db - .insert(Attachments) - .values({ - mimeType: key, - url: value.content, - description: value.description || undefined, - duration: value.duration || undefined, - fps: value.fps || undefined, - height: value.height || undefined, - size: value.size || undefined, - width: value.width || undefined, - sha256: value.hash?.sha256 || undefined, - blurhash: value.blurhash || undefined, - }) - .returning(); - - return result[0]; -}; +import type { Config } from "~/packages/config-manager"; export const getUrl = (name: string, config: Config) => { if (config.media.backend === MediaBackendType.LOCAL) { diff --git a/packages/database-interface/attachment.ts b/packages/database-interface/attachment.ts new file mode 100644 index 00000000..25fc8872 --- /dev/null +++ b/packages/database-interface/attachment.ts @@ -0,0 +1,213 @@ +import { proxyUrl } from "@/response"; +import type { EntityValidator } from "@lysand-org/federation"; +import { + type InferInsertModel, + type InferSelectModel, + type SQL, + desc, + eq, + inArray, +} from "drizzle-orm"; +import { db } from "~/drizzle/db"; +import { Attachments } from "~/drizzle/schema"; +import type { AsyncAttachment as APIAsyncAttachment } from "~/types/mastodon/async_attachment"; +import type { Attachment as APIAttachment } from "~/types/mastodon/attachment"; +import { BaseInterface } from "./base"; + +export type AttachmentType = InferSelectModel; + +export class Attachment extends BaseInterface { + async reload(): Promise { + const reloaded = await Attachment.fromId(this.data.id); + + if (!reloaded) { + throw new Error("Failed to reload role"); + } + + this.data = reloaded.data; + } + + public static async fromId(id: string | null): Promise { + if (!id) return null; + + return await Attachment.fromSql(eq(Attachments.id, id)); + } + + public static async fromIds(ids: string[]): Promise { + return await Attachment.manyFromSql(inArray(Attachments.id, ids)); + } + + public static async fromSql( + sql: SQL | undefined, + orderBy: SQL | undefined = desc(Attachments.id), + ): Promise { + const found = await db.query.Attachments.findFirst({ + where: sql, + orderBy, + }); + + if (!found) return null; + return new Attachment(found); + } + + public static async manyFromSql( + sql: SQL | undefined, + orderBy: SQL | undefined = desc(Attachments.id), + limit?: number, + offset?: number, + extra?: Parameters[0], + ): Promise { + const found = await db.query.Attachments.findMany({ + where: sql, + orderBy, + limit, + offset, + with: extra?.with, + }); + + return found.map((s) => new Attachment(s)); + } + + async update( + newAttachment: Partial, + ): Promise { + await db + .update(Attachments) + .set(newAttachment) + .where(eq(Attachments.id, this.id)); + + const updated = await Attachment.fromId(this.data.id); + + if (!updated) { + throw new Error("Failed to update role"); + } + + this.data = updated.data; + return updated.data; + } + + async save(): Promise { + return this.update(this.data); + } + + async delete(ids: string[]): Promise; + async delete(): Promise; + async delete(ids?: unknown): Promise { + if (Array.isArray(ids)) { + await db.delete(Attachments).where(inArray(Attachments.id, ids)); + } else { + await db.delete(Attachments).where(eq(Attachments.id, this.id)); + } + } + + public static async insert( + data: InferInsertModel, + ): Promise { + const inserted = ( + await db.insert(Attachments).values(data).returning() + )[0]; + + const role = await Attachment.fromId(inserted.id); + + if (!role) { + throw new Error("Failed to insert role"); + } + + return role; + } + + get id() { + return this.data.id; + } + + public toAPI(): APIAttachment | APIAsyncAttachment { + let type = "unknown"; + + if (this.data.mimeType.startsWith("image/")) { + type = "image"; + } else if (this.data.mimeType.startsWith("video/")) { + type = "video"; + } else if (this.data.mimeType.startsWith("audio/")) { + type = "audio"; + } + + return { + id: this.data.id, + type: type as "image" | "video" | "audio" | "unknown", + url: proxyUrl(this.data.url) ?? "", + remote_url: proxyUrl(this.data.remoteUrl), + preview_url: proxyUrl(this.data.thumbnailUrl || this.data.url), + text_url: null, + meta: { + width: this.data.width || undefined, + height: this.data.height || undefined, + fps: this.data.fps || undefined, + size: + this.data.width && this.data.height + ? `${this.data.width}x${this.data.height}` + : undefined, + duration: this.data.duration || undefined, + length: undefined, + aspect: + this.data.width && this.data.height + ? this.data.width / this.data.height + : undefined, + original: { + width: this.data.width || undefined, + height: this.data.height || undefined, + size: + this.data.width && this.data.height + ? `${this.data.width}x${this.data.height}` + : undefined, + aspect: + this.data.width && this.data.height + ? this.data.width / this.data.height + : undefined, + }, + // Idk whether size or length is the right value + }, + description: this.data.description, + blurhash: this.data.blurhash, + }; + } + + public toLysand(): typeof EntityValidator.$ContentFormat { + return { + [this.data.mimeType]: { + content: this.data.url, + blurhash: this.data.blurhash ?? undefined, + description: this.data.description ?? undefined, + duration: this.data.duration ?? undefined, + fps: this.data.fps ?? undefined, + height: this.data.height ?? undefined, + size: this.data.size ?? undefined, + hash: this.data.sha256 + ? { + sha256: this.data.sha256, + } + : undefined, + width: this.data.width ?? undefined, + }, + }; + } + + public static fromLysand( + attachmentToConvert: typeof EntityValidator.$ContentFormat, + ): Promise { + const key = Object.keys(attachmentToConvert)[0]; + const value = attachmentToConvert[key]; + + return Attachment.insert({ + mimeType: key, + url: value.content, + description: value.description || undefined, + duration: value.duration || undefined, + fps: value.fps || undefined, + height: value.height || undefined, + size: value.size || undefined, + width: value.width || undefined, + sha256: value.hash?.sha256 || undefined, + blurhash: value.blurhash || undefined, + }); + } +} diff --git a/packages/database-interface/note.ts b/packages/database-interface/note.ts index cb537602..6091add5 100644 --- a/packages/database-interface/note.ts +++ b/packages/database-interface/note.ts @@ -21,11 +21,6 @@ import { type Application, applicationToAPI, } from "~/database/entities/Application"; -import { - attachmentFromLysand, - attachmentToAPI, - attachmentToLysand, -} from "~/database/entities/Attachment"; import { type EmojiWithInstance, emojiToAPI, @@ -51,6 +46,7 @@ import { import { config } from "~/packages/config-manager"; import type { Attachment as APIAttachment } from "~/types/mastodon/attachment"; import type { Status as APIStatus } from "~/types/mastodon/status"; +import { Attachment } from "./attachment"; import { BaseInterface } from "./base"; import { User } from "./user"; @@ -515,7 +511,7 @@ export class Note extends BaseInterface { const attachments = []; for (const attachment of note.attachments ?? []) { - const resolvedAttachment = await attachmentFromLysand( + const resolvedAttachment = await Attachment.fromLysand( attachment, ).catch((e) => { dualLogger.logError( @@ -711,7 +707,7 @@ export class Note extends BaseInterface { favourited: data.liked, favourites_count: data.likeCount, media_attachments: (data.attachments ?? []).map( - (a) => attachmentToAPI(a) as APIAttachment, + (a) => new Attachment(a).toAPI() as APIAttachment, ), mentions: data.mentions.map((mention) => ({ id: mention.id, @@ -786,7 +782,7 @@ export class Note extends BaseInterface { }, }, attachments: (status.attachments ?? []).map((attachment) => - attachmentToLysand(attachment), + new Attachment(attachment).toLysand(), ), is_sensitive: status.sensitive, mentions: status.mentions.map((mention) => mention.uri || ""), diff --git a/packages/database-interface/role.ts b/packages/database-interface/role.ts index d50a8c00..340e02f7 100644 --- a/packages/database-interface/role.ts +++ b/packages/database-interface/role.ts @@ -26,10 +26,6 @@ export class Role extends BaseInterface { this.data = reloaded.data; } - public static fromRole(role: InferSelectModel) { - return new Role(role); - } - public static async fromId(id: string | null): Promise { if (!id) return null; diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index 997aaacc..5da5e248 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -597,9 +597,9 @@ export class User extends BaseInterface { discoverable: undefined, mute_expires_at: undefined, roles: user.roles - .map((role) => Role.fromRole(role)) + .map((role) => new Role(role)) .concat( - Role.fromRole({ + new Role({ id: "default", name: "Default", permissions: config.permissions.default, @@ -612,7 +612,7 @@ export class User extends BaseInterface { .concat( user.isAdmin ? [ - Role.fromRole({ + new Role({ id: "admin", name: "Admin", permissions: config.permissions.admin, diff --git a/server/api/api/v1/media/:id/index.ts b/server/api/api/v1/media/:id/index.ts index 5389da6c..c83d0897 100644 --- a/server/api/api/v1/media/:id/index.ts +++ b/server/api/api/v1/media/:id/index.ts @@ -2,15 +2,14 @@ import { applyConfig, auth, handleZodError, idValidator } from "@/api"; import { errorResponse, jsonResponse, response } from "@/response"; import { zValidator } from "@hono/zod-validator"; import { config } from "config-manager"; -import { eq } from "drizzle-orm"; import type { Hono } from "hono"; import type { MediaBackend } from "media-manager"; import { MediaBackendType } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import { z } from "zod"; -import { attachmentToAPI, getUrl } from "~/database/entities/Attachment"; -import { db } from "~/drizzle/db"; -import { Attachments, RolePermissions } from "~/drizzle/schema"; +import { getUrl } from "~/database/entities/Attachment"; +import { RolePermissions } from "~/drizzle/schema"; +import { Attachment } from "~/packages/database-interface/attachment"; export const meta = applyConfig({ allowedMethods: ["GET", "PUT"], @@ -56,18 +55,16 @@ export default (app: Hono) => return errorResponse("Invalid ID, must be of type UUIDv7", 404); } - const foundAttachment = await db.query.Attachments.findFirst({ - where: (attachment, { eq }) => eq(attachment.id, id), - }); + const attachment = await Attachment.fromId(id); - if (!foundAttachment) { + if (!attachment) { return errorResponse("Media not found", 404); } switch (context.req.method) { case "GET": { - if (foundAttachment.url) { - return jsonResponse(attachmentToAPI(foundAttachment)); + if (attachment.data.url) { + return jsonResponse(attachment.toAPI()); } return response(null, 206); } @@ -75,7 +72,7 @@ export default (app: Hono) => const { description, thumbnail } = context.req.valid("form"); - let thumbnailUrl = foundAttachment.thumbnailUrl; + let thumbnailUrl = attachment.data.thumbnailUrl; let mediaManager: MediaBackend; @@ -97,27 +94,21 @@ export default (app: Hono) => } const descriptionText = - description || foundAttachment.description; + description || attachment.data.description; if ( - descriptionText !== foundAttachment.description || - thumbnailUrl !== foundAttachment.thumbnailUrl + descriptionText !== attachment.data.description || + thumbnailUrl !== attachment.data.thumbnailUrl ) { - const newAttachment = ( - await db - .update(Attachments) - .set({ - description: descriptionText, - thumbnailUrl, - }) - .where(eq(Attachments.id, id)) - .returning() - )[0]; + await attachment.update({ + description: descriptionText, + thumbnailUrl, + }); - return jsonResponse(attachmentToAPI(newAttachment)); + return jsonResponse(attachment.toAPI()); } - return jsonResponse(attachmentToAPI(foundAttachment)); + return jsonResponse(attachment.toAPI()); } } diff --git a/server/api/api/v1/media/index.ts b/server/api/api/v1/media/index.ts index e5c58a29..30c4fd81 100644 --- a/server/api/api/v1/media/index.ts +++ b/server/api/api/v1/media/index.ts @@ -9,9 +9,9 @@ import type { MediaBackend } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import sharp from "sharp"; import { z } from "zod"; -import { attachmentToAPI, getUrl } from "~/database/entities/Attachment"; -import { db } from "~/drizzle/db"; -import { Attachments, RolePermissions } from "~/drizzle/schema"; +import { getUrl } from "~/database/entities/Attachment"; +import { RolePermissions } from "~/drizzle/schema"; +import { Attachment } from "~/packages/database-interface/attachment"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -127,26 +127,20 @@ export default (app: Hono) => thumbnailUrl = getUrl(path, config); } - const newAttachment = ( - await db - .insert(Attachments) - .values({ - url, - thumbnailUrl, - sha256: sha256 - .update(await file.arrayBuffer()) - .digest("hex"), - mimeType: file.type, - description: description ?? "", - size: file.size, - blurhash: blurhash ?? undefined, - width: metadata?.width ?? undefined, - height: metadata?.height ?? undefined, - }) - .returning() - )[0]; + const newAttachment = await Attachment.insert({ + url, + thumbnailUrl, + sha256: sha256.update(await file.arrayBuffer()).digest("hex"), + mimeType: file.type, + description: description ?? "", + size: file.size, + blurhash: blurhash ?? undefined, + width: metadata?.width ?? undefined, + height: metadata?.height ?? undefined, + }); + // TODO: Add job to process videos and other media - return jsonResponse(attachmentToAPI(newAttachment)); + return jsonResponse(newAttachment.toAPI()); }, ); diff --git a/server/api/api/v2/media/index.ts b/server/api/api/v2/media/index.ts index 5a8e2b19..19cbc164 100644 --- a/server/api/api/v2/media/index.ts +++ b/server/api/api/v2/media/index.ts @@ -9,9 +9,9 @@ import { MediaBackendType } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import sharp from "sharp"; import { z } from "zod"; -import { attachmentToAPI, getUrl } from "~/database/entities/Attachment"; -import { db } from "~/drizzle/db"; -import { Attachments, RolePermissions } from "~/drizzle/schema"; +import { getUrl } from "~/database/entities/Attachment"; +import { RolePermissions } from "~/drizzle/schema"; +import { Attachment } from "~/packages/database-interface/attachment"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -127,34 +127,27 @@ export default (app: Hono) => thumbnailUrl = getUrl(path, config); } - const newAttachment = ( - await db - .insert(Attachments) - .values({ - url, - thumbnailUrl, - sha256: sha256 - .update(await file.arrayBuffer()) - .digest("hex"), - mimeType: file.type, - description: description ?? "", - size: file.size, - blurhash: blurhash ?? undefined, - width: metadata?.width ?? undefined, - height: metadata?.height ?? undefined, - }) - .returning() - )[0]; + const newAttachment = await Attachment.insert({ + url, + thumbnailUrl, + sha256: sha256.update(await file.arrayBuffer()).digest("hex"), + mimeType: file.type, + description: description ?? "", + size: file.size, + blurhash: blurhash ?? undefined, + width: metadata?.width ?? undefined, + height: metadata?.height ?? undefined, + }); // TODO: Add job to process videos and other media if (isImage) { - return jsonResponse(attachmentToAPI(newAttachment)); + return jsonResponse(newAttachment.toAPI()); } return jsonResponse( { - ...attachmentToAPI(newAttachment), + ...newAttachment.toAPI(), url: null, }, 202,