From 5de5934b65b932afc6adac409aac38ea24c33d8c Mon Sep 17 00:00:00 2001 From: Rory& Date: Wed, 23 Sep 2026 13:29:15 +0200 Subject: [PATCH] Some more avatar decorations work --- assets/openapi.json | Bin 1105194 -> 1108521 bytes assets/schemas.json | Bin 517511 -> 520478 bytes src/api/routes/users/@me/index.ts | 19 ++++++++++-- .../api/v1/avatar_decorations/#id/index.ts | 4 +-- .../api/v1/avatar_decorations/index.ts | 4 +-- src/database/entities/AvatarDecoration.ts | 26 ++++++++++++++-- src/database/entities/User.ts | 24 +++++++++++++-- ...1790152636577-AvatarDecorationsRelation.ts | 19 ++++++++++++ src/schemas/api/users/User.ts | 26 +++++++++++++++- src/schemas/uncategorised/UserModifySchema.ts | 2 ++ .../uncategorised/UserProfileModifySchema.ts | 28 ------------------ src/schemas/uncategorised/index.ts | 1 - src/util/util/Constants.ts | 3 ++ 13 files changed, 116 insertions(+), 40 deletions(-) create mode 100644 src/database/migration/postgres/1790152636577-AvatarDecorationsRelation.ts delete mode 100644 src/schemas/uncategorised/UserProfileModifySchema.ts diff --git a/assets/openapi.json b/assets/openapi.json index eb5234e6db7254e2c92fe96ce106387879d15b22..bb0f22b8a5344a72fb5e5096a2303ea5624ba964 100644 GIT binary patch delta 484 zcmYMuJ!n%=7zSX@Urv(#*PsJE{W~B}})&k7H z!XQha)GMzdx8of^bDci2^6zwLe&TlQ>$FC#mHnty;~Fl1lDE;yP$|-xOxggsDDGRS zZU4#1#|^X4Fp1MJY5hI4G7Z5#MMd$6=tQ(ihyE8_)mefR(lMrRCmj=xfzmLOxX?$v z_&LjT#1Hs6oLO|8KY8fDL60kfd|8g+&lZa#^;dkr=dV18sfQxqOg_& zRQ6iom!YGi;cSpkV{Vt9Mu~d*+yB1=`5^W-wIF^SZ7;XP5T*x+z+Q;9OBC#?P+W>s W+=@r>DjiCv(xr4OKF6+V^Wq;Z+PI$p delta 221 zcmZ2^#d*~(=M8VOnlBW$UnpkWexaD@$)?ExtA(ataA%g?Ua!t<#5kEbr+E6^CZ^cw z29Zn?r_bl&V4Hs75R2~g15vCI(E>fd=v07_%?urEQ--qFBD!Vk7ISoqsPB9GV>F&H9dw{RgE1gtcki7qaSAYd zeU~!g&ibzKEkBRq)4X40IF8J=d+%`L}l0q?)h zur$Zt<6E^TK`O+H9~yxuFO!R=fzBw`uzJk~qYXkiz~ni70Xvh@S#t6OCYH$;(yb7j z2TRqa2fSodoW5Wav-b2qLQK5VC+INMwlnQy0%GRvOgmYmIHtcdVNsYIz@a*QLMChX b_O*vt?%PjK;9z8J=i&fiw(VRT>>@7#9PLCN diff --git a/src/api/routes/users/@me/index.ts b/src/api/routes/users/@me/index.ts index f58f87ff2..341dbf1f3 100644 --- a/src/api/routes/users/@me/index.ts +++ b/src/api/routes/users/@me/index.ts @@ -19,8 +19,8 @@ import bcrypt from "bcrypt"; import { Request, Response, Router } from "express"; import { route } from "@spacebar/api/middlewares"; -import { User } from "@spacebar/database"; -import { Config, emitEvent, FieldErrors, generateToken, handleFile, UserUpdateEvent } from "@spacebar/util"; +import { AvatarDecoration, User } from "@spacebar/database"; +import { ApiError, Config, DiscordApiErrors, emitEvent, FieldErrors, generateToken, handleFile, UserUpdateEvent } from "@spacebar/util"; import { DisplayNameStyle, PrivateUserProjection, UserModifySchema } from "@spacebar/schemas"; const router: Router = Router({ mergeParams: true }); @@ -216,6 +216,21 @@ router.patch( } } + if ("avatar_decoration_sku_id" in body) { + if (!body.avatar_decoration_sku_id) { + user.avatar_decoration_data = undefined; + user.avatar_decoration_id = undefined; + } else { + const avatarDecoration = await AvatarDecoration.findOne({ where: { id: body.avatar_decoration_sku_id } }); + if (!avatarDecoration) throw FieldErrors({ avatar_decoration_sku_id: { code: "50057", message: "Invalid SKU" } }); + + if (!(await avatarDecoration.canUseAvatarDecoration(req.user_id))) + throw FieldErrors({ avatar_decoration_sku_id: { code: "40018", message: "You do not have access to this avatar decoration" } }); // TODO: find a better code + + user.avatar_decoration_id = body.avatar_decoration_sku_id; + } + } + user.assign(body); user.validate(); await user.save(); diff --git a/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/#id/index.ts b/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/#id/index.ts index bf36ea792..0750c53b4 100644 --- a/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/#id/index.ts +++ b/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/#id/index.ts @@ -18,7 +18,7 @@ import { Router, Response, Request } from "express"; import { route } from "@spacebar/api/middlewares"; -import { AvatarDecorations } from "@spacebar/database"; +import { AvatarDecoration } from "@spacebar/database"; import { PublicAvatarDecorationResponse, UpdateAvatarDecorationSchema } from "@spacebar/schemas/api/spacebar/AvatarDecorations"; import { ApiError } from "@spacebar/util"; @@ -38,7 +38,7 @@ router.patch( }), async (req: Request, res: Response) => { const changes = req.body as UpdateAvatarDecorationSchema; - const deco = await AvatarDecorations.findOneOrFail({ where: { id: req.params.id as string } }); + const deco = await AvatarDecoration.findOneOrFail({ where: { id: req.params.id as string } }); if (deco.uploader_id !== req.user_id) throw new ApiError("You do not have permission to update this avatar decoration", 0, 403); diff --git a/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/index.ts b/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/index.ts index e403ef6e9..87db77b3d 100644 --- a/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/index.ts +++ b/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/index.ts @@ -19,7 +19,7 @@ import { Router, Response, Request } from "express"; import { Raw } from "typeorm"; import { route } from "@spacebar/api/middlewares"; -import { AvatarDecorations, Member } from "@spacebar/database"; +import { AvatarDecoration, Member } from "@spacebar/database"; import { PublicAvatarDecorationListResponse } from "@spacebar/schemas/api/spacebar/AvatarDecorations"; import { arrayDistinctBy } from "@spacebar/extensions"; @@ -40,7 +40,7 @@ router.get( const memberships = await Member.find({ select: { guild_id: true, roles: { id: true } }, relations: { roles: true }, where: { id: req.user_id } }); const decos = ( - await AvatarDecorations.find({ + await AvatarDecoration.find({ where: [ { approved: true, public: true }, { approved: true, uploader_id: req.user_id }, diff --git a/src/database/entities/AvatarDecoration.ts b/src/database/entities/AvatarDecoration.ts index 97bd13415..761421438 100644 --- a/src/database/entities/AvatarDecoration.ts +++ b/src/database/entities/AvatarDecoration.ts @@ -20,11 +20,12 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeor import { AvatarDecorationData, PublicAvatarDecorationResponse } from "@spacebar/schemas"; import { BaseClass } from "./BaseClass"; import { User } from "./User"; +import { Member } from "./Member"; @Entity({ name: "avatar_decorations", }) -export class AvatarDecorations extends BaseClass { +export class AvatarDecoration extends BaseClass { @Column({}) asset: string; @@ -32,7 +33,7 @@ export class AvatarDecorations extends BaseClass { approved: boolean; @Column({ nullable: true }) - @RelationId((deco: AvatarDecorations) => deco.uploader) + @RelationId((deco: AvatarDecoration) => deco.uploader) @Index("IDX_avatar_decoration_uploader_id") uploader_id: string; @@ -70,4 +71,25 @@ export class AvatarDecorations extends BaseClass { available: opts?.available ?? this.public, } satisfies PublicAvatarDecorationResponse; } + + async canUseAvatarDecoration(user_id: string): Promise { + if (!this.approved) return false; + if (this.uploader_id == user_id) return true; + if (this.allowed_user_ids.includes(user_id)) return true; + + let memberships: Member[]; + if (this.allowed_guild_ids.length > 0) { + memberships ??= await Member.find({ select: { guild_id: true, roles: { id: true } }, where: { id: user_id }, relations: { roles: true } }); + const guildIds = memberships.map((x) => x.guild_id); + for (const allowedGuildId of this.allowed_guild_ids) if (guildIds.includes(allowedGuildId)) return true; + } + + if (this.allowed_role_ids.length > 0) { + memberships ??= await Member.find({ select: { guild_id: true, roles: true }, where: { id: user_id } }); + const roleIds = memberships.flatMap((x) => x.roles.map((x) => x.id)); + for (const allowedRoleId of this.allowed_role_ids) if (roleIds.includes(allowedRoleId)) return true; + } + + return false; + } } diff --git a/src/database/entities/User.ts b/src/database/entities/User.ts index 140fed2a3..2cd8b3cd7 100644 --- a/src/database/entities/User.ts +++ b/src/database/entities/User.ts @@ -17,10 +17,11 @@ */ import { Request } from "express"; -import { Column, Entity, JoinColumn, OneToMany, OneToOne } from "typeorm"; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne, RelationId } from "typeorm"; import { Config, Email, FieldErrors, Snowflake } from "@spacebar/util"; import { Stopwatch, trimSpecial, Random } from "@spacebar/extensions"; import { BaseClass } from "./BaseClass"; +import { AvatarDecoration } from "./AvatarDecoration"; import { Channel } from "./Channel"; import { ConnectedAccount } from "./ConnectedAccount"; import { Member } from "./Member"; @@ -197,6 +198,14 @@ export class User extends BaseClass { @Column({ type: "jsonb", nullable: true }) primary_guild?: PrimaryGuild; + @JoinColumn({ name: "avatar_decoration_id", foreignKeyConstraintName: "FK_user_avatar_decoration_id" }) + @OneToOne(() => AvatarDecoration, { onDelete: "SET NULL", nullable: true }) + avatar_decoration?: AvatarDecoration; + + @Column({ type: "int8", nullable: true }) + @RelationId((user: User) => user.avatar_decoration) + avatar_decoration_id?: string; + // TODO: I don't like this method? validate() { if (this.discriminator) { @@ -220,6 +229,9 @@ export class User extends BaseClass { PublicUserProjection.forEach((x) => { user[x] = this[x]; }); + + if (this.avatar_decoration) (user).avatar_decoration_data = this.avatar_decoration.toJSON(); + return user as PublicUser; } @@ -230,7 +242,12 @@ export class User extends BaseClass { discriminator: this.discriminator, global_name: undefined, // TODO when pomelo avatar: this.avatar ?? null, - avatar_decoration_data: this.avatar_decoration_data, + avatar_decoration_data: this.avatar_decoration + ? { + ...this.avatar_decoration?.toJSON(), + ...this.avatar_decoration_data, + } + : null, bot: this.bot, system: this.system, banner: this.banner, @@ -246,6 +263,9 @@ export class User extends BaseClass { [...PrivateUserProjection, ...extraFields].forEach((x) => { user[x] = this[x]; }); + + if (this.avatar_decoration) (user).avatar_decoration_data = this.avatar_decoration.toJSON(); + return user as UserPrivate; } diff --git a/src/database/migration/postgres/1790152636577-AvatarDecorationsRelation.ts b/src/database/migration/postgres/1790152636577-AvatarDecorationsRelation.ts new file mode 100644 index 000000000..3e0f91e90 --- /dev/null +++ b/src/database/migration/postgres/1790152636577-AvatarDecorationsRelation.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AvatarDecorationsRelation1790152636577 implements MigrationInterface { + name = "AvatarDecorationsRelation1790152636577"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "avatar_decoration_id" bigint`); + await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_119117c066ad70abbe777d34f40" UNIQUE ("avatar_decoration_id")`); + await queryRunner.query( + `ALTER TABLE "users" ADD CONSTRAINT "FK_user_avatar_decoration_id" FOREIGN KEY ("avatar_decoration_id") REFERENCES "avatar_decorations"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_user_avatar_decoration_id"`); + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_119117c066ad70abbe777d34f40"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "avatar_decoration_id"`); + } +} diff --git a/src/schemas/api/users/User.ts b/src/schemas/api/users/User.ts index 8a3449ba3..426363271 100644 --- a/src/schemas/api/users/User.ts +++ b/src/schemas/api/users/User.ts @@ -16,7 +16,7 @@ along with this program. If not, see . */ -import { ConnectedAccountSchema, Snowflake, UserSettingsSchema } from "@spacebar/schemas"; +import { base64str, ConnectedAccountSchema, Snowflake, UserSettingsSchema } from "@spacebar/schemas"; // TODO: maybe move the one thing this depends on to import { BitField } from "@spacebar/util/util"; // TODO: remove entity import @@ -212,3 +212,27 @@ export class UserFlags extends BitField { RESTRICTED_COLLABORATOR: 1n << 51n, }; } + +// OAuth2 only (account.global_name.update) - why can this be null? +export class UserAccountModifySchema { + global_name?: string | null; +} + +export class UserProfileModifySchema { + pronouns?: string | null; + bio?: string | null; + banner?: base64str | null; + /** + * @type integer + */ + accent_color?: number | null; + /** + * @items.type integer + */ + theme_colors?: [number, number] | null; + //@deprecated - what even was this + popout_animation_particle_type?: Snowflake | null; + //@deprecated - what even was this + emoji_id?: Snowflake | null; + profile_effect_id?: Snowflake | null; +} diff --git a/src/schemas/uncategorised/UserModifySchema.ts b/src/schemas/uncategorised/UserModifySchema.ts index 006be9246..a409952b4 100644 --- a/src/schemas/uncategorised/UserModifySchema.ts +++ b/src/schemas/uncategorised/UserModifySchema.ts @@ -57,4 +57,6 @@ export interface UserModifySchema { display_name_colors?: number[]; display_name_effect_id?: User_DisplayNameEffect; display_name_font_id?: User_DisplayNameFont; + + avatar_decoration_sku_id?: string | null; } diff --git a/src/schemas/uncategorised/UserProfileModifySchema.ts b/src/schemas/uncategorised/UserProfileModifySchema.ts deleted file mode 100644 index 5ba1dcad9..000000000 --- a/src/schemas/uncategorised/UserProfileModifySchema.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - Spacebar: A FOSS re-implementation and extension of the Discord.com backend. - Copyright (C) 2023 Spacebar and Spacebar Contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -export interface UserProfileModifySchema { - bio?: string; - accent_color?: number | null; - banner?: string | null; - pronouns?: string; - /** - * @items.type integer - */ - theme_colors?: [number, number]; -} diff --git a/src/schemas/uncategorised/index.ts b/src/schemas/uncategorised/index.ts index a4ee4eab3..ee1b01f57 100644 --- a/src/schemas/uncategorised/index.ts +++ b/src/schemas/uncategorised/index.ts @@ -79,7 +79,6 @@ export * from "./UserDeleteSchema"; export * from "./UserGuildSettingsSchema"; export * from "./UserModifySchema"; export * from "./UserNoteUpdateSchema"; -export * from "./UserProfileModifySchema"; export * from "./VanityUrlSchema"; export * from "./VerifyEmailSchema"; export * from "./VoiceStateUpdateSchema"; diff --git a/src/util/util/Constants.ts b/src/util/util/Constants.ts index cdd0ce773..d34495774 100644 --- a/src/util/util/Constants.ts +++ b/src/util/util/Constants.ts @@ -864,6 +864,9 @@ export const DiscordApiErrors = { get CANNOT_SELF_REDEEM_GIFT() { return new ApiError("Cannot self-redeem this gift", 50054); }, + get INVALID_SKU() { + return new ApiError("Invalid SKU", 50057); + }, get PAYMENT_SOURCE_REQUIRED() { return new ApiError("Payment source required to redeem gift", 50070); },