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 { mimeLookup } from "@/content_types";
import { mergeAndDeduplicate } from "@/lib";
import { sanitizedHtmlStrip } from "@/sanitization";
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 { and, eq, isNull } from "drizzle-orm";
import ISO6391 from "iso-639-1";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml } from "~/classes/functions/status";
import { MediaManager } from "~/classes/media/media-manager";
import { config } from "~/packages/config-manager/index.ts";
import { ErrorSchema } from "~/types/api";
@ -206,8 +204,6 @@ export default apiRoute((app) =>
display_name ?? "",
);
const mediaManager = new MediaManager(config);
if (display_name) {
self.displayName = sanitizedDisplayName;
}
@ -249,37 +245,17 @@ export default apiRoute((app) =>
if (avatar) {
if (avatar instanceof File) {
const { path, uploadedFile } =
await mediaManager.addFile(avatar);
const contentType = uploadedFile.type;
self.avatar = Media.getUrl(path);
self.source.avatar = {
content_type: contentType,
};
await user.avatar?.updateFromFile(avatar);
} else {
self.avatar = avatar.toString();
self.source.avatar = {
content_type: await mimeLookup(avatar),
};
await user.avatar?.updateFromUrl(avatar);
}
}
if (header) {
if (header instanceof File) {
const { path, uploadedFile } =
await mediaManager.addFile(header);
const contentType = uploadedFile.type;
self.header = Media.getUrl(path);
self.source.header = {
content_type: contentType,
};
await user.header?.updateFromFile(header);
} else {
self.header = header.toString();
self.source.header = {
content_type: await mimeLookup(header),
};
await user.header?.updateFromUrl(header);
}
}

View file

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

View file

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

View file

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

View file

@ -21,7 +21,7 @@ import type {
FollowReject as VersiaFollowReject,
User as VersiaUser,
} from "@versia/federation/types";
import { Notification, PushSubscription, db } from "@versia/kit/db";
import { Media, Notification, PushSubscription, db } from "@versia/kit/db";
import {
EmojiToUser,
Likes,
@ -69,6 +69,8 @@ type UserWithInstance = InferSelectModel<typeof Users> & {
type UserWithRelations = UserWithInstance & {
emojis: (typeof Emoji.$type)[];
avatar: typeof Media.$type | null;
header: typeof Media.$type | null;
followerCount: number;
followingCount: number;
statusCount: number;
@ -149,6 +151,16 @@ export class User extends BaseInterface<typeof Users, 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> {
const reloaded = await User.fromId(this.data.id);
@ -728,9 +740,6 @@ 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,
@ -748,8 +757,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
fields: user.fields ?? [],
updatedAt: new Date(user.created_at).toISOString(),
instanceId: instance.id,
avatar: avatar?.[1].content || "",
header: header?.[1].content || "",
displayName: user.display_name ?? "",
note: getBestContentType(user.bio).content,
publicKey: user.public_key.key,
@ -759,16 +766,6 @@ 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,
},
};
@ -784,14 +781,65 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
// If it exists, simply update it
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);
return foundUser;
}
// 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);
return newUser;
@ -846,13 +894,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @returns The raw URL for the user's avatar
*/
public getAvatarUrl(config: Config): string {
if (!this.data.avatar) {
if (!this.avatar) {
return (
config.defaults.avatar ||
`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<{
@ -886,14 +934,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
password: string | undefined;
email: string | undefined;
bio?: string;
avatar?: {
url: string;
content_type: string;
};
header?: {
url: string;
content_type: string;
};
avatar?: Media;
header?: Media;
admin?: boolean;
skipPasswordHash?: boolean;
}): Promise<User> {
@ -911,8 +953,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
: await Bun.password.hash(data.password),
email: data.email,
note: data.bio ?? "",
avatar: data.avatar?.url ?? config.defaults.avatar ?? "",
header: data.header?.url ?? config.defaults.avatar ?? "",
avatarId: data.avatar?.id,
headerId: data.header?.id,
isAdmin: data.admin ?? false,
publicKey: keys.public_key,
fields: [],
@ -924,16 +966,6 @@ 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()
@ -957,10 +989,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @returns The raw URL for the user's header
*/
public getHeaderUrl(config: Config): string {
if (!this.data.header) {
if (!this.header) {
return config.defaults.header || "";
}
return this.data.header;
return this.header.getUrl();
}
public getAcct(): string {

View file

@ -2,6 +2,7 @@ import {
type Application,
type Emoji,
type Instance,
type Media,
type Role,
type Token,
type User,
@ -22,6 +23,8 @@ export const userRelations = {
},
},
},
avatar: true,
header: true,
roles: {
with: {
role: true,
@ -76,6 +79,8 @@ export const transformOutputToUserWithRelations = (
followerCount: unknown;
followingCount: unknown;
statusCount: unknown;
avatar: typeof Media.$type | null;
header: typeof Media.$type | null;
emojis: {
userId: 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,
"tag": "0044_quiet_jasper_sitwell",
"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 }) => ({
notes: many(Notes),
emojis: many(Emojis),
avatars: many(Users, {
relationName: "UserToAvatar",
}),
headers: many(Users, {
relationName: "UserToHeader",
}),
}));
export const Notifications = pgTable("Notifications", {
@ -557,8 +563,14 @@ export const Users = pgTable(
};
}
>(),
avatar: text("avatar").notNull(),
header: text("header").notNull(),
avatarId: uuid("avatarId").references(() => Medias.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
headerId: uuid("headerId").references(() => Medias.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
createdAt: createdAt(),
updatedAt: updatedAt(),
isBot: boolean("is_bot").default(false).notNull(),
@ -588,6 +600,16 @@ export const UsersRelations = relations(Users, ({ many, one }) => ({
notes: many(Notes, {
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),
relationships: many(Relationships, {
relationName: "RelationshipToOwner",

View file

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