diff --git a/assets/openapi.json b/assets/openapi.json index eb5234e6d..bb0f22b8a 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json index e08fc88b1..7afc0e440 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ 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); },