refactor(database): 🔥 Simplify media management code

This commit is contained in:
Jesse Wierzbinski 2025-01-28 18:06:33 +01:00
parent cf1104d762
commit bc961b70bb
No known key found for this signature in database
7 changed files with 173 additions and 109 deletions

View file

@ -61,6 +61,7 @@ const schemas = {
.min(1) .min(1)
.max(2000) .max(2000)
.url() .url()
.transform((a) => new URL(a))
.or( .or(
z z
.instanceof(File) .instanceof(File)
@ -76,6 +77,7 @@ const schemas = {
.min(1) .min(1)
.max(2000) .max(2000)
.url() .url()
.transform((v) => new URL(v))
.or( .or(
z z
.instanceof(File) .instanceof(File)
@ -256,7 +258,7 @@ export default apiRoute((app) =>
content_type: contentType, content_type: contentType,
}; };
} else { } else {
self.avatar = avatar; self.avatar = avatar.toString();
self.source.avatar = { self.source.avatar = {
content_type: await mimeLookup(avatar), content_type: await mimeLookup(avatar),
}; };
@ -274,7 +276,7 @@ export default apiRoute((app) =>
content_type: contentType, content_type: contentType,
}; };
} else { } else {
self.header = header; self.header = header.toString();
self.source.header = { self.source.header = {
content_type: await mimeLookup(header), content_type: await mimeLookup(header),
}; };

View file

@ -1,8 +1,7 @@
import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api"; import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api";
import { mimeLookup } from "@/content_types"; import { mimeLookup } from "@/content_types";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import type { ContentFormat } from "@versia/federation/types"; import { Emoji, db } from "@versia/kit/db";
import { Emoji, Media, db } from "@versia/kit/db";
import { Emojis, RolePermissions } from "@versia/kit/tables"; import { Emojis, RolePermissions } from "@versia/kit/tables";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
@ -32,6 +31,7 @@ const schemas = {
.min(1) .min(1)
.max(2000) .max(2000)
.url() .url()
.transform((a) => new URL(a))
.or( .or(
z z
.instanceof(File) .instanceof(File)
@ -247,9 +247,6 @@ export default apiRoute((app) => {
); );
} }
const modifiedMedia = structuredClone(emoji.data.media);
const modified = structuredClone(emoji.data);
if (element) { if (element) {
// Check of emoji is an image // Check of emoji is an image
const contentType = const contentType =
@ -265,40 +262,24 @@ export default apiRoute((app) => {
); );
} }
let contentFormat: ContentFormat | undefined;
if (element instanceof File) { if (element instanceof File) {
const mediaManager = new MediaManager(config); await emoji.media.updateFromFile(element);
const { uploadedFile, path } =
await mediaManager.addFile(element);
contentFormat = await Media.fileToContentFormat(
uploadedFile,
Media.getUrl(path),
{ description: alt },
);
} else { } else {
contentFormat = { await emoji.media.updateFromUrl(element);
[contentType]: { }
content: element, }
remote: true,
if (alt) {
await emoji.media.updateMetadata({
description: alt, description: alt,
}, });
};
} }
modifiedMedia.content = contentFormat; await emoji.update({
} shortcode,
ownerId: emojiGlobal ? null : user.data.id,
modified.shortcode = shortcode ?? modified.shortcode; category,
modified.category = category ?? modified.category; });
if (emojiGlobal !== undefined) {
modified.ownerId = emojiGlobal ? null : user.data.id;
}
await emoji.update(modified);
return context.json(emoji.toApi(), 200); return context.json(emoji.toApi(), 200);
}); });

View file

@ -1,13 +1,11 @@
import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api"; import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api";
import { mimeLookup } from "@/content_types"; import { mimeLookup } from "@/content_types";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import type { ContentFormat } from "@versia/federation/types";
import { Emoji, Media } from "@versia/kit/db"; import { Emoji, Media } from "@versia/kit/db";
import { Emojis, RolePermissions } from "@versia/kit/tables"; import { Emojis, RolePermissions } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { MediaManager } from "~/classes/media/media-manager";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
@ -28,6 +26,7 @@ const schemas = {
.min(1) .min(1)
.max(2000) .max(2000)
.url() .url()
.transform((a) => new URL(a))
.or( .or(
z z
.instanceof(File) .instanceof(File)
@ -143,30 +142,13 @@ export default apiRoute((app) =>
); );
} }
let contentFormat: ContentFormat | undefined; const media =
element instanceof File
if (element instanceof File) { ? await Media.fromFile(element, {
const mediaManager = new MediaManager(config); description: alt,
})
const { uploadedFile, path } = await mediaManager.addFile(element); : await Media.fromUrl(element, {
contentFormat = await Media.fileToContentFormat(
uploadedFile,
Media.getUrl(path),
{ description: alt },
);
} else {
contentFormat = {
[contentType]: {
content: element,
remote: true,
description: alt, description: alt,
},
};
}
const media = await Media.insert({
content: contentFormat,
}); });
const emoji = await Emoji.insert({ const emoji = await Emoji.insert({

View file

@ -4,7 +4,6 @@ import { Media } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod"; import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { MediaManager } from "~/classes/media/media-manager";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/packages/config-manager/index.ts";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
@ -101,47 +100,26 @@ export default apiRoute((app) => {
app.openapi(routePut, async (context) => { app.openapi(routePut, async (context) => {
const { id } = context.req.valid("param"); 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"); throw new ApiError(404, "Media not found");
} }
const { description, thumbnail: thumbnailFile } = const { description, thumbnail: thumbnailFile } =
context.req.valid("form"); context.req.valid("form");
const mediaManager = new MediaManager(config);
// TODO: Generate thumbnail if not provided
if (thumbnailFile) { if (thumbnailFile) {
const { path } = await mediaManager.addFile(thumbnailFile); await media.updateThumbnail(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;
} }
if (description) { if (description) {
for (const type of Object.keys(attachment.data.content)) { await media.updateMetadata({
attachment.data.content[type].description = description; description,
}
}
if (description || thumbnailFile) {
await attachment.update({
content: attachment.data.content,
thumbnail: attachment.data.thumbnail,
}); });
return context.json(attachment.toApi(), 200);
} }
return context.json(attachment.toApi(), 200); return context.json(media.toApi(), 200);
}); });
app.openapi(routeGet, async (context) => { app.openapi(routeGet, async (context) => {

View file

@ -1,3 +1,4 @@
import { mimeLookup } from "@/content_types.ts";
import { proxyUrl } from "@/response"; import { proxyUrl } from "@/response";
import type { Attachment as ApiAttachment } from "@versia/client/types"; import type { Attachment as ApiAttachment } from "@versia/client/types";
import type { ContentFormat } from "@versia/federation/types"; import type { ContentFormat } from "@versia/federation/types";
@ -158,23 +159,7 @@ export class Media extends BaseInterface<typeof Medias> {
thumbnail?: File; thumbnail?: File;
}, },
): Promise<Media> { ): Promise<Media> {
if (file.size > config.validation.max_media_size) { Media.checkFile(file);
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(", ")}`,
);
}
const mediaManager = new MediaManager(config); const mediaManager = new MediaManager(config);
@ -219,6 +204,137 @@ export class Media extends BaseInterface<typeof Medias> {
return newAttachment; return newAttachment;
} }
public static async fromUrl(
uri: URL,
options?: {
description?: string;
},
): Promise<Media> {
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<void> {
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<void> {
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<void> {
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<Omit<ContentFormat[keyof ContentFormat], "content">>,
): Promise<void> {
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 { public get id(): string {
return this.data.id; return this.data.id;
} }

View file

@ -250,7 +250,9 @@ export default (plugin: PluginType): void => {
avatar: picture avatar: picture
? { ? {
url: picture, url: picture,
content_type: await mimeLookup(picture), content_type: await mimeLookup(
new URL(picture),
),
} }
: undefined, : undefined,
password: undefined, password: undefined,

View file

@ -57,8 +57,11 @@ export const urlToContentFormat = (
}; };
}; };
export const mimeLookup = (url: string): Promise<string> => { export const mimeLookup = (url: URL): Promise<string> => {
const naiveLookup = lookup(url.replace(new URL(url).search, "")); const urlWithoutSearch = url.toString().replace(url.search, "");
// Strip query params from URL to get the proper file extension
const naiveLookup = lookup(urlWithoutSearch);
if (naiveLookup) { if (naiveLookup) {
return Promise.resolve(naiveLookup); return Promise.resolve(naiveLookup);