mirror of
https://github.com/versia-pub/server.git
synced 2025-12-08 17:28:19 +01:00
refactor(database): 🔥 Simplify media management code
This commit is contained in:
parent
cf1104d762
commit
bc961b70bb
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue