refactor(database): ♻️ Make user avatar and header into a Media instead of plaintext

This commit is contained in:
Jesse Wierzbinski 2025-01-28 19:07:55 +01:00
parent bc961b70bb
commit ba431e2b11
No known key found for this signature in database
11 changed files with 2480 additions and 89 deletions

View file

@ -1,16 +1,14 @@
import { apiRoute, auth, jsonOrForm } from "@/api"; import { apiRoute, 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";
import { Emoji, Media, User } from "@versia/kit/db"; import { Emoji, User } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables"; import { RolePermissions, Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { z } from "zod"; import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml } from "~/classes/functions/status"; import { contentToHtml } from "~/classes/functions/status";
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";
@ -206,8 +204,6 @@ export default apiRoute((app) =>
display_name ?? "", display_name ?? "",
); );
const mediaManager = new MediaManager(config);
if (display_name) { if (display_name) {
self.displayName = sanitizedDisplayName; self.displayName = sanitizedDisplayName;
} }
@ -249,37 +245,17 @@ export default apiRoute((app) =>
if (avatar) { if (avatar) {
if (avatar instanceof File) { if (avatar instanceof File) {
const { path, uploadedFile } = await user.avatar?.updateFromFile(avatar);
await mediaManager.addFile(avatar);
const contentType = uploadedFile.type;
self.avatar = Media.getUrl(path);
self.source.avatar = {
content_type: contentType,
};
} else { } else {
self.avatar = avatar.toString(); await user.avatar?.updateFromUrl(avatar);
self.source.avatar = {
content_type: await mimeLookup(avatar),
};
} }
} }
if (header) { if (header) {
if (header instanceof File) { if (header instanceof File) {
const { path, uploadedFile } = await user.header?.updateFromFile(header);
await mediaManager.addFile(header);
const contentType = uploadedFile.type;
self.header = Media.getUrl(path);
self.source.header = {
content_type: contentType,
};
} else { } else {
self.header = header.toString(); await user.header?.updateFromUrl(header);
self.source.header = {
content_type: await mimeLookup(header),
};
} }
} }

View file

@ -30,9 +30,7 @@ export default apiRoute((app) =>
app.openapi(route, async (context) => { app.openapi(route, async (context) => {
const { user } = context.get("auth"); const { user } = context.get("auth");
await user.update({ await user.header?.delete();
avatar: "",
});
return context.json(user.toApi(true), 200); return context.json(user.toApi(true), 200);
}), }),

View file

@ -30,9 +30,7 @@ export default apiRoute((app) =>
app.openapi(route, async (context) => { app.openapi(route, async (context) => {
const { user } = context.get("auth"); const { user } = context.get("auth");
await user.update({ await user.header?.delete();
header: "",
});
return context.json(user.toApi(true), 200); return context.json(user.toApi(true), 200);
}), }),

View file

@ -136,6 +136,8 @@ export class Media extends BaseInterface<typeof Medias> {
} else { } else {
await db.delete(Medias).where(eq(Medias.id, this.id)); await db.delete(Medias).where(eq(Medias.id, this.id));
} }
// TODO: Also delete the file from the media manager
} }
public static async insert( public static async insert(

View file

@ -21,7 +21,7 @@ import type {
FollowReject as VersiaFollowReject, FollowReject as VersiaFollowReject,
User as VersiaUser, User as VersiaUser,
} from "@versia/federation/types"; } from "@versia/federation/types";
import { Notification, PushSubscription, db } from "@versia/kit/db"; import { Media, Notification, PushSubscription, db } from "@versia/kit/db";
import { import {
EmojiToUser, EmojiToUser,
Likes, Likes,
@ -69,6 +69,8 @@ type UserWithInstance = InferSelectModel<typeof Users> & {
type UserWithRelations = UserWithInstance & { type UserWithRelations = UserWithInstance & {
emojis: (typeof Emoji.$type)[]; emojis: (typeof Emoji.$type)[];
avatar: typeof Media.$type | null;
header: typeof Media.$type | null;
followerCount: number; followerCount: number;
followingCount: number; followingCount: number;
statusCount: number; statusCount: number;
@ -149,6 +151,16 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
public static $type: UserWithRelations; public static $type: UserWithRelations;
public avatar: Media | null;
public header: Media | null;
public constructor(data: UserWithRelations) {
super(data);
this.avatar = data.avatar ? new Media(data.avatar) : null;
this.header = data.header ? new Media(data.header) : null;
}
public async reload(): Promise<void> { public async reload(): Promise<void> {
const reloaded = await User.fromId(this.data.id); const reloaded = await User.fromId(this.data.id);
@ -728,9 +740,6 @@ 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,
@ -748,8 +757,6 @@ 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: avatar?.[1].content || "",
header: header?.[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,
@ -759,16 +766,6 @@ 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,
}, },
}; };
@ -784,14 +781,65 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
// If it exists, simply update it // If it exists, simply update it
if (foundUser) { if (foundUser) {
await foundUser.update(data); let avatar: Media | null = null;
let header: Media | null = null;
if (user.avatar) {
if (foundUser.avatar) {
avatar = new Media(
await foundUser.avatar.update({
content: user.avatar,
}),
);
} else {
avatar = await Media.insert({
content: user.avatar,
});
}
}
if (user.header) {
if (foundUser.header) {
header = new Media(
await foundUser.header.update({
content: user.header,
}),
);
} else {
header = await Media.insert({
content: user.header,
});
}
}
await foundUser.update({
...data,
avatarId: avatar?.id,
headerId: header?.id,
});
await foundUser.updateEmojis(emojis); await foundUser.updateEmojis(emojis);
return foundUser; return foundUser;
} }
// Else, create a new user // Else, create a new user
const newUser = await User.insert(data); const avatar = user.avatar
? await Media.insert({
content: user.avatar,
})
: null;
const header = user.header
? await Media.insert({
content: user.header,
})
: null;
const newUser = await User.insert({
...data,
avatarId: avatar?.id,
headerId: header?.id,
});
await newUser.updateEmojis(emojis); await newUser.updateEmojis(emojis);
return newUser; return newUser;
@ -846,13 +894,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @returns The raw URL for the user's avatar * @returns The raw URL for the user's avatar
*/ */
public getAvatarUrl(config: Config): string { public getAvatarUrl(config: Config): string {
if (!this.data.avatar) { if (!this.avatar) {
return ( return (
config.defaults.avatar || config.defaults.avatar ||
`https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${this.data.username}` `https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${this.data.username}`
); );
} }
return this.data.avatar; return this.avatar?.getUrl();
} }
public static async generateKeys(): Promise<{ public static async generateKeys(): Promise<{
@ -886,14 +934,8 @@ 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?: { avatar?: Media;
url: string; header?: Media;
content_type: string;
};
header?: {
url: string;
content_type: string;
};
admin?: boolean; admin?: boolean;
skipPasswordHash?: boolean; skipPasswordHash?: boolean;
}): Promise<User> { }): Promise<User> {
@ -911,8 +953,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?.url ?? config.defaults.avatar ?? "", avatarId: data.avatar?.id,
header: data.header?.url ?? config.defaults.avatar ?? "", headerId: data.header?.id,
isAdmin: data.admin ?? false, isAdmin: data.admin ?? false,
publicKey: keys.public_key, publicKey: keys.public_key,
fields: [], fields: [],
@ -924,16 +966,6 @@ 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()
@ -957,10 +989,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @returns The raw URL for the user's header * @returns The raw URL for the user's header
*/ */
public getHeaderUrl(config: Config): string { public getHeaderUrl(config: Config): string {
if (!this.data.header) { if (!this.header) {
return config.defaults.header || ""; return config.defaults.header || "";
} }
return this.data.header; return this.header.getUrl();
} }
public getAcct(): string { public getAcct(): string {

View file

@ -2,6 +2,7 @@ import {
type Application, type Application,
type Emoji, type Emoji,
type Instance, type Instance,
type Media,
type Role, type Role,
type Token, type Token,
type User, type User,
@ -22,6 +23,8 @@ export const userRelations = {
}, },
}, },
}, },
avatar: true,
header: true,
roles: { roles: {
with: { with: {
role: true, role: true,
@ -76,6 +79,8 @@ export const transformOutputToUserWithRelations = (
followerCount: unknown; followerCount: unknown;
followingCount: unknown; followingCount: unknown;
statusCount: unknown; statusCount: unknown;
avatar: typeof Media.$type | null;
header: typeof Media.$type | null;
emojis: { emojis: {
userId: string; userId: string;
emojiId: string; emojiId: string;

View file

@ -0,0 +1,6 @@
ALTER TABLE "Users" ADD COLUMN "avatarId" uuid;--> statement-breakpoint
ALTER TABLE "Users" ADD COLUMN "headerId" uuid;--> statement-breakpoint
ALTER TABLE "Users" ADD CONSTRAINT "Users_avatarId_Medias_id_fk" FOREIGN KEY ("avatarId") REFERENCES "public"."Medias"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "Users" ADD CONSTRAINT "Users_headerId_Medias_id_fk" FOREIGN KEY ("headerId") REFERENCES "public"."Medias"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "Users" DROP COLUMN "avatar";--> statement-breakpoint
ALTER TABLE "Users" DROP COLUMN "header";

File diff suppressed because it is too large Load diff

View file

@ -316,6 +316,13 @@
"when": 1738082427051, "when": 1738082427051,
"tag": "0044_quiet_jasper_sitwell", "tag": "0044_quiet_jasper_sitwell",
"breakpoints": true "breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1738087527661,
"tag": "0045_polite_mikhail_rasputin",
"breakpoints": true
} }
] ]
} }

View file

@ -365,6 +365,12 @@ export const Medias = pgTable("Medias", {
export const MediasRelations = relations(Medias, ({ many }) => ({ export const MediasRelations = relations(Medias, ({ many }) => ({
notes: many(Notes), notes: many(Notes),
emojis: many(Emojis), emojis: many(Emojis),
avatars: many(Users, {
relationName: "UserToAvatar",
}),
headers: many(Users, {
relationName: "UserToHeader",
}),
})); }));
export const Notifications = pgTable("Notifications", { export const Notifications = pgTable("Notifications", {
@ -557,8 +563,14 @@ export const Users = pgTable(
}; };
} }
>(), >(),
avatar: text("avatar").notNull(), avatarId: uuid("avatarId").references(() => Medias.id, {
header: text("header").notNull(), onDelete: "set null",
onUpdate: "cascade",
}),
headerId: uuid("headerId").references(() => Medias.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
createdAt: createdAt(), createdAt: createdAt(),
updatedAt: updatedAt(), updatedAt: updatedAt(),
isBot: boolean("is_bot").default(false).notNull(), isBot: boolean("is_bot").default(false).notNull(),
@ -588,6 +600,16 @@ export const UsersRelations = relations(Users, ({ many, one }) => ({
notes: many(Notes, { notes: many(Notes, {
relationName: "NoteToAuthor", relationName: "NoteToAuthor",
}), }),
avatar: one(Medias, {
fields: [Users.avatarId],
references: [Medias.id],
relationName: "UserToAvatar",
}),
header: one(Medias, {
fields: [Users.headerId],
references: [Medias.id],
relationName: "UserToHeader",
}),
likes: many(Likes), likes: many(Likes),
relationships: many(Relationships, { relationships: many(Relationships, {
relationName: "RelationshipToOwner", relationName: "RelationshipToOwner",

View file

@ -1,7 +1,6 @@
import { mimeLookup } from "@/content_types.ts";
import { randomString } from "@/math.ts"; import { randomString } from "@/math.ts";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Token, User, db } from "@versia/kit/db"; import { Media, Token, User, db } from "@versia/kit/db";
import { type SQL, and, eq, isNull } from "@versia/kit/drizzle"; import { type SQL, and, eq, isNull } from "@versia/kit/drizzle";
import { OpenIdAccounts, RolePermissions, Users } from "@versia/kit/tables"; import { OpenIdAccounts, RolePermissions, Users } from "@versia/kit/tables";
import { setCookie } from "hono/cookie"; import { setCookie } from "hono/cookie";
@ -243,18 +242,15 @@ export default (plugin: PluginType): void => {
? !!(await User.fromSql(eq(Users.email, email))) ? !!(await User.fromSql(eq(Users.email, email)))
: false; : false;
const avatar = picture
? await Media.fromUrl(new URL(picture))
: null;
// Create new user // Create new user
const user = await User.fromDataLocal({ const user = await User.fromDataLocal({
email: doesEmailExist ? undefined : email, email: doesEmailExist ? undefined : email,
username, username,
avatar: picture avatar: avatar ?? undefined,
? {
url: picture,
content_type: await mimeLookup(
new URL(picture),
),
}
: undefined,
password: undefined, password: undefined,
}); });