fix(api): 🐛 Fetch media content-type from data, instead of doing naive guesses

This commit is contained in:
Jesse Wierzbinski 2024-12-16 23:57:21 +01:00
parent 6f67881d96
commit 4fdb96930f
No known key found for this signature in database
8 changed files with 111 additions and 23 deletions

View file

@ -48,6 +48,7 @@ max_emoji_description_size = 1000
- Correctly proxy all URIs in custom Markdown text. - 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 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. - 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 # `0.7.0` • The Auth and APIs Update

View file

@ -1,4 +1,5 @@
import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api"; import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
import { mimeLookup } from "@/content_types";
import { mergeAndDeduplicate } from "@/lib"; import { mergeAndDeduplicate } from "@/lib";
import { sanitizedHtmlStrip } from "@/sanitization"; import { sanitizedHtmlStrip } from "@/sanitization";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
@ -267,21 +268,37 @@ export default apiRoute((app) =>
if (avatar) { if (avatar) {
if (avatar instanceof File) { 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.avatar = Attachment.getUrl(path);
self.source.avatar = {
content_type: contentType,
};
} else { } else {
self.avatar = avatar; self.avatar = avatar;
self.source.avatar = {
content_type: await mimeLookup(avatar),
};
} }
} }
if (header) { if (header) {
if (header instanceof File) { 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.header = Attachment.getUrl(path);
self.source.header = {
content_type: contentType,
};
} else { } else {
self.header = header; self.header = header;
self.source.header = {
content_type: await mimeLookup(header),
};
} }
} }

View file

@ -61,8 +61,12 @@ export default apiRoute((app) =>
name: "Versia Server", name: "Versia Server",
version: pkg.version, version: pkg.version,
}, },
banner: urlToContentFormat(config.instance.banner), banner: config.instance.banner
logo: urlToContentFormat(config.instance.logo), ? urlToContentFormat(config.instance.banner)
: undefined,
logo: config.instance.logo
? urlToContentFormat(config.instance.logo)
: undefined,
shared_inbox: new URL( shared_inbox: new URL(
"/inbox", "/inbox",
config.http.base_url, config.http.base_url,

View file

@ -6,7 +6,6 @@ import { WebFinger } from "@versia/federation/schemas";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { lookup } from "mime-types";
import { z } from "zod"; import { z } from "zod";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
@ -137,9 +136,8 @@ export default apiRoute((app) =>
}, },
{ {
rel: "avatar", rel: "avatar",
// TODO: don't... don't use the mime type from the file extension
type: type:
lookup(user.getAvatarUrl(config)) || user.data.source.avatar?.content_type ||
"application/octet-stream", "application/octet-stream",
href: user.getAvatarUrl(config), href: user.getAvatarUrl(config),
}, },

View file

@ -125,6 +125,16 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
value: z.string(), value: z.string(),
}), }),
), ),
avatar: z
.object({
content_type: z.string(),
})
.optional(),
header: z
.object({
content_type: z.string(),
})
.optional(),
}) })
.optional(), .optional(),
role: z role: z
@ -691,6 +701,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
user: VersiaUser, user: VersiaUser,
instance: Instance, instance: Instance,
): Promise<User> { ): Promise<User> {
const avatar = user.avatar ? Object.entries(user.avatar)[0] : null;
const header = user.header ? Object.entries(user.header)[0] : null;
const data = { const data = {
username: user.username, username: user.username,
uri: user.uri, uri: user.uri,
@ -708,12 +721,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
fields: user.fields ?? [], fields: user.fields ?? [],
updatedAt: new Date(user.created_at).toISOString(), updatedAt: new Date(user.created_at).toISOString(),
instanceId: instance.id, instanceId: instance.id,
avatar: user.avatar avatar: avatar?.[1].content || "",
? Object.entries(user.avatar)[0][1].content header: header?.[1].content || "",
: "",
header: user.header
? Object.entries(user.header)[0][1].content
: "",
displayName: user.display_name ?? "", displayName: user.display_name ?? "",
note: getBestContentType(user.bio).content, note: getBestContentType(user.bio).content,
publicKey: user.public_key.key, publicKey: user.public_key.key,
@ -723,6 +732,16 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
privacy: "public", privacy: "public",
sensitive: false, sensitive: false,
fields: [], fields: [],
avatar: avatar
? {
content_type: avatar[0],
}
: undefined,
header: header
? {
content_type: header[0],
}
: undefined,
}, },
}; };
@ -840,8 +859,14 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
password: string | undefined; password: string | undefined;
email: string | undefined; email: string | undefined;
bio?: string; bio?: string;
avatar?: string; avatar?: {
header?: string; url: string;
content_type: string;
};
header?: {
url: string;
content_type: string;
};
admin?: boolean; admin?: boolean;
skipPasswordHash?: boolean; skipPasswordHash?: boolean;
}): Promise<User> { }): Promise<User> {
@ -859,8 +884,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
: await Bun.password.hash(data.password), : await Bun.password.hash(data.password),
email: data.email, email: data.email,
note: data.bio ?? "", note: data.bio ?? "",
avatar: data.avatar ?? config.defaults.avatar ?? "", avatar: data.avatar?.url ?? config.defaults.avatar ?? "",
header: data.header ?? config.defaults.avatar ?? "", header: data.header?.url ?? config.defaults.avatar ?? "",
isAdmin: data.admin ?? false, isAdmin: data.admin ?? false,
publicKey: keys.public_key, publicKey: keys.public_key,
fields: [], fields: [],
@ -872,6 +897,16 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
privacy: "public", privacy: "public",
sensitive: false, sensitive: false,
fields: [], fields: [],
avatar: data.avatar
? {
content_type: data.avatar.content_type,
}
: undefined,
header: data.header
? {
content_type: data.header.content_type,
}
: undefined,
}, },
}) })
.returning() .returning()
@ -1209,8 +1244,16 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
indexable: false, indexable: false,
username: user.username, username: user.username,
manually_approves_followers: this.data.isLocked, manually_approves_followers: this.data.isLocked,
avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined, avatar:
header: urlToContentFormat(this.getHeaderUrl(config)) ?? undefined, 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, display_name: user.displayName,
fields: user.fields, fields: user.fields,
public_key: { public_key: {

View file

@ -394,7 +394,16 @@ export const Users = pgTable(
inbox: string; inbox: string;
outbox: string; outbox: string;
}> | null>(), }> | null>(),
source: jsonb("source").notNull().$type<ApiSource>(), source: jsonb("source").notNull().$type<
ApiSource & {
avatar?: {
content_type: string;
};
header?: {
content_type: string;
};
}
>(),
avatar: text("avatar").notNull(), avatar: text("avatar").notNull(),
header: text("header").notNull(), header: text("header").notNull(),
createdAt: timestamp("created_at", { precision: 3, mode: "string" }) createdAt: timestamp("created_at", { precision: 3, mode: "string" })

View file

@ -1,3 +1,4 @@
import { mimeLookup } from "@/content_types.ts";
import { randomString } from "@/math.ts"; import { randomString } from "@/math.ts";
import { setCookie } from "@hono/hono/cookie"; import { setCookie } from "@hono/hono/cookie";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
@ -245,7 +246,12 @@ export default (plugin: PluginType): void => {
const user = await User.fromDataLocal({ const user = await User.fromDataLocal({
email: doesEmailExist ? undefined : email, email: doesEmailExist ? undefined : email,
username, username,
avatar: picture, avatar: picture
? {
url: picture,
content_type: await mimeLookup(picture),
}
: undefined,
password: undefined, password: undefined,
}); });

View file

@ -28,7 +28,10 @@ export const getBestContentType = (
return { content: "", format: "text/plain" }; return { content: "", format: "text/plain" };
}; };
export const urlToContentFormat = (url?: string): ContentFormat | null => { export const urlToContentFormat = (
url: string,
contentType?: string,
): ContentFormat | null => {
if (!url) { if (!url) {
return null; return null;
} }
@ -41,6 +44,7 @@ export const urlToContentFormat = (url?: string): ContentFormat | null => {
}; };
} }
const mimeType = const mimeType =
contentType ||
lookup(url.replace(new URL(url).search, "")) || lookup(url.replace(new URL(url).search, "")) ||
"application/octet-stream"; "application/octet-stream";
@ -63,7 +67,13 @@ export const mimeLookup = (url: string): Promise<string> => {
method: "HEAD", method: "HEAD",
// @ts-expect-error Proxy is a Bun-specific feature // @ts-expect-error Proxy is a Bun-specific feature
proxy: config.http.proxy.address, 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; return fetchLookup;
}; };