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.
- 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

View file

@ -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),
};
}
}

View file

@ -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,

View file

@ -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),
},

View file

@ -125,6 +125,16 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
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<typeof Users, UserWithRelations> {
user: VersiaUser,
instance: Instance,
): Promise<User> {
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<typeof Users, UserWithRelations> {
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<typeof Users, UserWithRelations> {
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<typeof Users, UserWithRelations> {
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<User> {
@ -859,8 +884,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
: 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<typeof Users, UserWithRelations> {
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<typeof Users, UserWithRelations> {
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: {

View file

@ -394,7 +394,16 @@ export const Users = pgTable(
inbox: string;
outbox: string;
}> | 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(),
header: text("header").notNull(),
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 { 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,
});

View file

@ -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<string> => {
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;
};