diff --git a/CHANGELOG.md b/CHANGELOG.md index 3423c2ec..77915905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ max_emoji_description_size = 1000 - Correctly proxy all URIs in custom Markdown text. - Fixed several issues with the [ActivityPub Federation Bridge](https://github.com/versia-pub/activitypub) preventing it from operating properly. - Fixed incorrect content-type on some media when using S3. +- All media content-type is now correctly fetched, instead of guessed from the file extension. # `0.7.0` • The Auth and APIs Update diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index 6b5fb51e..4316374a 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -1,4 +1,5 @@ import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api"; +import { mimeLookup } from "@/content_types"; import { mergeAndDeduplicate } from "@/lib"; import { sanitizedHtmlStrip } from "@/sanitization"; import { createRoute } from "@hono/zod-openapi"; @@ -267,21 +268,37 @@ export default apiRoute((app) => if (avatar) { if (avatar instanceof File) { - const { path } = await mediaManager.addFile(avatar); + const { path, uploadedFile } = + await mediaManager.addFile(avatar); + const contentType = uploadedFile.type; self.avatar = Attachment.getUrl(path); + self.source.avatar = { + content_type: contentType, + }; } else { self.avatar = avatar; + self.source.avatar = { + content_type: await mimeLookup(avatar), + }; } } if (header) { if (header instanceof File) { - const { path } = await mediaManager.addFile(header); + const { path, uploadedFile } = + await mediaManager.addFile(header); + const contentType = uploadedFile.type; self.header = Attachment.getUrl(path); + self.source.header = { + content_type: contentType, + }; } else { self.header = header; + self.source.header = { + content_type: await mimeLookup(header), + }; } } diff --git a/api/well-known/versia.ts b/api/well-known/versia.ts index 2d765354..84befc6c 100644 --- a/api/well-known/versia.ts +++ b/api/well-known/versia.ts @@ -61,8 +61,12 @@ export default apiRoute((app) => name: "Versia Server", version: pkg.version, }, - banner: urlToContentFormat(config.instance.banner), - logo: urlToContentFormat(config.instance.logo), + banner: config.instance.banner + ? urlToContentFormat(config.instance.banner) + : undefined, + logo: config.instance.logo + ? urlToContentFormat(config.instance.logo) + : undefined, shared_inbox: new URL( "/inbox", config.http.base_url, diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts index 2aae8e6d..dae633c2 100644 --- a/api/well-known/webfinger/index.ts +++ b/api/well-known/webfinger/index.ts @@ -6,7 +6,6 @@ import { WebFinger } from "@versia/federation/schemas"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; -import { lookup } from "mime-types"; import { z } from "zod"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -137,9 +136,8 @@ export default apiRoute((app) => }, { rel: "avatar", - // TODO: don't... don't use the mime type from the file extension type: - lookup(user.getAvatarUrl(config)) || + user.data.source.avatar?.content_type || "application/octet-stream", href: user.getAvatarUrl(config), }, diff --git a/classes/database/user.ts b/classes/database/user.ts index d55b07f2..9a92c178 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -125,6 +125,16 @@ export class User extends BaseInterface { value: z.string(), }), ), + avatar: z + .object({ + content_type: z.string(), + }) + .optional(), + header: z + .object({ + content_type: z.string(), + }) + .optional(), }) .optional(), role: z @@ -691,6 +701,9 @@ export class User extends BaseInterface { user: VersiaUser, instance: Instance, ): Promise { + const avatar = user.avatar ? Object.entries(user.avatar)[0] : null; + const header = user.header ? Object.entries(user.header)[0] : null; + const data = { username: user.username, uri: user.uri, @@ -708,12 +721,8 @@ export class User extends BaseInterface { fields: user.fields ?? [], updatedAt: new Date(user.created_at).toISOString(), instanceId: instance.id, - avatar: user.avatar - ? Object.entries(user.avatar)[0][1].content - : "", - header: user.header - ? Object.entries(user.header)[0][1].content - : "", + avatar: avatar?.[1].content || "", + header: header?.[1].content || "", displayName: user.display_name ?? "", note: getBestContentType(user.bio).content, publicKey: user.public_key.key, @@ -723,6 +732,16 @@ export class User extends BaseInterface { privacy: "public", sensitive: false, fields: [], + avatar: avatar + ? { + content_type: avatar[0], + } + : undefined, + header: header + ? { + content_type: header[0], + } + : undefined, }, }; @@ -840,8 +859,14 @@ export class User extends BaseInterface { password: string | undefined; email: string | undefined; bio?: string; - avatar?: string; - header?: string; + avatar?: { + url: string; + content_type: string; + }; + header?: { + url: string; + content_type: string; + }; admin?: boolean; skipPasswordHash?: boolean; }): Promise { @@ -859,8 +884,8 @@ export class User extends BaseInterface { : await Bun.password.hash(data.password), email: data.email, note: data.bio ?? "", - avatar: data.avatar ?? config.defaults.avatar ?? "", - header: data.header ?? config.defaults.avatar ?? "", + avatar: data.avatar?.url ?? config.defaults.avatar ?? "", + header: data.header?.url ?? config.defaults.avatar ?? "", isAdmin: data.admin ?? false, publicKey: keys.public_key, fields: [], @@ -872,6 +897,16 @@ export class User extends BaseInterface { privacy: "public", sensitive: false, fields: [], + avatar: data.avatar + ? { + content_type: data.avatar.content_type, + } + : undefined, + header: data.header + ? { + content_type: data.header.content_type, + } + : undefined, }, }) .returning() @@ -1209,8 +1244,16 @@ export class User extends BaseInterface { indexable: false, username: user.username, manually_approves_followers: this.data.isLocked, - avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined, - header: urlToContentFormat(this.getHeaderUrl(config)) ?? undefined, + avatar: + urlToContentFormat( + this.getAvatarUrl(config), + this.data.source.avatar?.content_type, + ) ?? undefined, + header: + urlToContentFormat( + this.getHeaderUrl(config), + this.data.source.header?.content_type, + ) ?? undefined, display_name: user.displayName, fields: user.fields, public_key: { diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 65c53bea..907a8a04 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -394,7 +394,16 @@ export const Users = pgTable( inbox: string; outbox: string; }> | null>(), - source: jsonb("source").notNull().$type(), + source: jsonb("source").notNull().$type< + ApiSource & { + avatar?: { + content_type: string; + }; + header?: { + content_type: string; + }; + } + >(), avatar: text("avatar").notNull(), header: text("header").notNull(), createdAt: timestamp("created_at", { precision: 3, mode: "string" }) diff --git a/plugins/openid/routes/oauth/callback.ts b/plugins/openid/routes/oauth/callback.ts index 6864403e..9014fba0 100644 --- a/plugins/openid/routes/oauth/callback.ts +++ b/plugins/openid/routes/oauth/callback.ts @@ -1,3 +1,4 @@ +import { mimeLookup } from "@/content_types.ts"; import { randomString } from "@/math.ts"; import { setCookie } from "@hono/hono/cookie"; import { createRoute, z } from "@hono/zod-openapi"; @@ -245,7 +246,12 @@ export default (plugin: PluginType): void => { const user = await User.fromDataLocal({ email: doesEmailExist ? undefined : email, username, - avatar: picture, + avatar: picture + ? { + url: picture, + content_type: await mimeLookup(picture), + } + : undefined, password: undefined, }); diff --git a/utils/content_types.ts b/utils/content_types.ts index 86c31fc2..e6f936b7 100644 --- a/utils/content_types.ts +++ b/utils/content_types.ts @@ -28,7 +28,10 @@ export const getBestContentType = ( return { content: "", format: "text/plain" }; }; -export const urlToContentFormat = (url?: string): ContentFormat | null => { +export const urlToContentFormat = ( + url: string, + contentType?: string, +): ContentFormat | null => { if (!url) { return null; } @@ -41,6 +44,7 @@ export const urlToContentFormat = (url?: string): ContentFormat | null => { }; } const mimeType = + contentType || lookup(url.replace(new URL(url).search, "")) || "application/octet-stream"; @@ -63,7 +67,13 @@ export const mimeLookup = (url: string): Promise => { method: "HEAD", // @ts-expect-error Proxy is a Bun-specific feature proxy: config.http.proxy.address, - }).then((response) => response.headers.get("content-type") || ""); + }) + .then( + (response) => + response.headers.get("content-type") || + "application/octet-stream", + ) + .catch(() => "application/octet-stream"); return fetchLookup; };