refactor(database): ♻️ Move Attachment into its own class

This commit is contained in:
Jesse Wierzbinski 2024-06-12 15:03:57 -10:00
parent 5565bf00de
commit 2e98859153
No known key found for this signature in database
8 changed files with 270 additions and 196 deletions

View file

@ -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 { MediaBackendType } from "media-manager";
import { db } from "~/drizzle/db"; import type { Config } from "~/packages/config-manager";
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<typeof Attachments>;
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<InferSelectModel<typeof Attachments>> => {
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];
};
export const getUrl = (name: string, config: Config) => { export const getUrl = (name: string, config: Config) => {
if (config.media.backend === MediaBackendType.LOCAL) { if (config.media.backend === MediaBackendType.LOCAL) {

View file

@ -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<typeof Attachments>;
export class Attachment extends BaseInterface<typeof Attachments> {
async reload(): Promise<void> {
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<Attachment | null> {
if (!id) return null;
return await Attachment.fromSql(eq(Attachments.id, id));
}
public static async fromIds(ids: string[]): Promise<Attachment[]> {
return await Attachment.manyFromSql(inArray(Attachments.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Attachments.id),
): Promise<Attachment | null> {
const found = await db.query.Attachments.findFirst({
where: sql,
orderBy,
});
if (!found) return null;
return new Attachment(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Attachments.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Attachments.findMany>[0],
): Promise<Attachment[]> {
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<AttachmentType>,
): Promise<AttachmentType> {
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<AttachmentType> {
return this.update(this.data);
}
async delete(ids: string[]): Promise<void>;
async delete(): Promise<void>;
async delete(ids?: unknown): Promise<void> {
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<typeof Attachments>,
): Promise<Attachment> {
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<Attachment> {
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,
});
}
}

View file

@ -21,11 +21,6 @@ import {
type Application, type Application,
applicationToAPI, applicationToAPI,
} from "~/database/entities/Application"; } from "~/database/entities/Application";
import {
attachmentFromLysand,
attachmentToAPI,
attachmentToLysand,
} from "~/database/entities/Attachment";
import { import {
type EmojiWithInstance, type EmojiWithInstance,
emojiToAPI, emojiToAPI,
@ -51,6 +46,7 @@ import {
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import type { Attachment as APIAttachment } from "~/types/mastodon/attachment"; import type { Attachment as APIAttachment } from "~/types/mastodon/attachment";
import type { Status as APIStatus } from "~/types/mastodon/status"; import type { Status as APIStatus } from "~/types/mastodon/status";
import { Attachment } from "./attachment";
import { BaseInterface } from "./base"; import { BaseInterface } from "./base";
import { User } from "./user"; import { User } from "./user";
@ -515,7 +511,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
const attachments = []; const attachments = [];
for (const attachment of note.attachments ?? []) { for (const attachment of note.attachments ?? []) {
const resolvedAttachment = await attachmentFromLysand( const resolvedAttachment = await Attachment.fromLysand(
attachment, attachment,
).catch((e) => { ).catch((e) => {
dualLogger.logError( dualLogger.logError(
@ -711,7 +707,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
favourited: data.liked, favourited: data.liked,
favourites_count: data.likeCount, favourites_count: data.likeCount,
media_attachments: (data.attachments ?? []).map( media_attachments: (data.attachments ?? []).map(
(a) => attachmentToAPI(a) as APIAttachment, (a) => new Attachment(a).toAPI() as APIAttachment,
), ),
mentions: data.mentions.map((mention) => ({ mentions: data.mentions.map((mention) => ({
id: mention.id, id: mention.id,
@ -786,7 +782,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
}, },
}, },
attachments: (status.attachments ?? []).map((attachment) => attachments: (status.attachments ?? []).map((attachment) =>
attachmentToLysand(attachment), new Attachment(attachment).toLysand(),
), ),
is_sensitive: status.sensitive, is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) => mention.uri || ""), mentions: status.mentions.map((mention) => mention.uri || ""),

View file

@ -26,10 +26,6 @@ export class Role extends BaseInterface<typeof Roles> {
this.data = reloaded.data; this.data = reloaded.data;
} }
public static fromRole(role: InferSelectModel<typeof Roles>) {
return new Role(role);
}
public static async fromId(id: string | null): Promise<Role | null> { public static async fromId(id: string | null): Promise<Role | null> {
if (!id) return null; if (!id) return null;

View file

@ -597,9 +597,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
discoverable: undefined, discoverable: undefined,
mute_expires_at: undefined, mute_expires_at: undefined,
roles: user.roles roles: user.roles
.map((role) => Role.fromRole(role)) .map((role) => new Role(role))
.concat( .concat(
Role.fromRole({ new Role({
id: "default", id: "default",
name: "Default", name: "Default",
permissions: config.permissions.default, permissions: config.permissions.default,
@ -612,7 +612,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
.concat( .concat(
user.isAdmin user.isAdmin
? [ ? [
Role.fromRole({ new Role({
id: "admin", id: "admin",
name: "Admin", name: "Admin",
permissions: config.permissions.admin, permissions: config.permissions.admin,

View file

@ -2,15 +2,14 @@ import { applyConfig, auth, handleZodError, idValidator } from "@/api";
import { errorResponse, jsonResponse, response } from "@/response"; import { errorResponse, jsonResponse, response } from "@/response";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { config } from "config-manager"; import { config } from "config-manager";
import { eq } from "drizzle-orm";
import type { Hono } from "hono"; import type { Hono } from "hono";
import type { MediaBackend } from "media-manager"; import type { MediaBackend } from "media-manager";
import { MediaBackendType } from "media-manager"; import { MediaBackendType } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import { z } from "zod"; import { z } from "zod";
import { attachmentToAPI, getUrl } from "~/database/entities/Attachment"; import { getUrl } from "~/database/entities/Attachment";
import { db } from "~/drizzle/db"; import { RolePermissions } from "~/drizzle/schema";
import { Attachments, RolePermissions } from "~/drizzle/schema"; import { Attachment } from "~/packages/database-interface/attachment";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET", "PUT"], allowedMethods: ["GET", "PUT"],
@ -56,18 +55,16 @@ export default (app: Hono) =>
return errorResponse("Invalid ID, must be of type UUIDv7", 404); return errorResponse("Invalid ID, must be of type UUIDv7", 404);
} }
const foundAttachment = await db.query.Attachments.findFirst({ const attachment = await Attachment.fromId(id);
where: (attachment, { eq }) => eq(attachment.id, id),
});
if (!foundAttachment) { if (!attachment) {
return errorResponse("Media not found", 404); return errorResponse("Media not found", 404);
} }
switch (context.req.method) { switch (context.req.method) {
case "GET": { case "GET": {
if (foundAttachment.url) { if (attachment.data.url) {
return jsonResponse(attachmentToAPI(foundAttachment)); return jsonResponse(attachment.toAPI());
} }
return response(null, 206); return response(null, 206);
} }
@ -75,7 +72,7 @@ export default (app: Hono) =>
const { description, thumbnail } = const { description, thumbnail } =
context.req.valid("form"); context.req.valid("form");
let thumbnailUrl = foundAttachment.thumbnailUrl; let thumbnailUrl = attachment.data.thumbnailUrl;
let mediaManager: MediaBackend; let mediaManager: MediaBackend;
@ -97,27 +94,21 @@ export default (app: Hono) =>
} }
const descriptionText = const descriptionText =
description || foundAttachment.description; description || attachment.data.description;
if ( if (
descriptionText !== foundAttachment.description || descriptionText !== attachment.data.description ||
thumbnailUrl !== foundAttachment.thumbnailUrl thumbnailUrl !== attachment.data.thumbnailUrl
) { ) {
const newAttachment = ( await attachment.update({
await db
.update(Attachments)
.set({
description: descriptionText, description: descriptionText,
thumbnailUrl, thumbnailUrl,
}) });
.where(eq(Attachments.id, id))
.returning()
)[0];
return jsonResponse(attachmentToAPI(newAttachment)); return jsonResponse(attachment.toAPI());
} }
return jsonResponse(attachmentToAPI(foundAttachment)); return jsonResponse(attachment.toAPI());
} }
} }

View file

@ -9,9 +9,9 @@ import type { MediaBackend } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import sharp from "sharp"; import sharp from "sharp";
import { z } from "zod"; import { z } from "zod";
import { attachmentToAPI, getUrl } from "~/database/entities/Attachment"; import { getUrl } from "~/database/entities/Attachment";
import { db } from "~/drizzle/db"; import { RolePermissions } from "~/drizzle/schema";
import { Attachments, RolePermissions } from "~/drizzle/schema"; import { Attachment } from "~/packages/database-interface/attachment";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -127,26 +127,20 @@ export default (app: Hono) =>
thumbnailUrl = getUrl(path, config); thumbnailUrl = getUrl(path, config);
} }
const newAttachment = ( const newAttachment = await Attachment.insert({
await db
.insert(Attachments)
.values({
url, url,
thumbnailUrl, thumbnailUrl,
sha256: sha256 sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
.update(await file.arrayBuffer())
.digest("hex"),
mimeType: file.type, mimeType: file.type,
description: description ?? "", description: description ?? "",
size: file.size, size: file.size,
blurhash: blurhash ?? undefined, blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined, width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined, height: metadata?.height ?? undefined,
}) });
.returning()
)[0];
// TODO: Add job to process videos and other media // TODO: Add job to process videos and other media
return jsonResponse(attachmentToAPI(newAttachment)); return jsonResponse(newAttachment.toAPI());
}, },
); );

View file

@ -9,9 +9,9 @@ import { MediaBackendType } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import sharp from "sharp"; import sharp from "sharp";
import { z } from "zod"; import { z } from "zod";
import { attachmentToAPI, getUrl } from "~/database/entities/Attachment"; import { getUrl } from "~/database/entities/Attachment";
import { db } from "~/drizzle/db"; import { RolePermissions } from "~/drizzle/schema";
import { Attachments, RolePermissions } from "~/drizzle/schema"; import { Attachment } from "~/packages/database-interface/attachment";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -127,34 +127,27 @@ export default (app: Hono) =>
thumbnailUrl = getUrl(path, config); thumbnailUrl = getUrl(path, config);
} }
const newAttachment = ( const newAttachment = await Attachment.insert({
await db
.insert(Attachments)
.values({
url, url,
thumbnailUrl, thumbnailUrl,
sha256: sha256 sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
.update(await file.arrayBuffer())
.digest("hex"),
mimeType: file.type, mimeType: file.type,
description: description ?? "", description: description ?? "",
size: file.size, size: file.size,
blurhash: blurhash ?? undefined, blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined, width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined, height: metadata?.height ?? undefined,
}) });
.returning()
)[0];
// TODO: Add job to process videos and other media // TODO: Add job to process videos and other media
if (isImage) { if (isImage) {
return jsonResponse(attachmentToAPI(newAttachment)); return jsonResponse(newAttachment.toAPI());
} }
return jsonResponse( return jsonResponse(
{ {
...attachmentToAPI(newAttachment), ...newAttachment.toAPI(),
url: null, url: null,
}, },
202, 202,