Some more avatar decorations work

This commit is contained in:
Rory&
2026-09-23 13:29:15 +02:00
parent 404da008ba
commit 5de5934b65
13 changed files with 116 additions and 40 deletions
Binary file not shown.
Binary file not shown.
+17 -2
View File
@@ -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();
@@ -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);
@@ -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 },
+24 -2
View File
@@ -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<boolean> {
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;
}
}
+22 -2
View File
@@ -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) (<PublicUser>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) (<UserPrivate>user).avatar_decoration_data = this.avatar_decoration.toJSON();
return user as UserPrivate;
}
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AvatarDecorationsRelation1790152636577 implements MigrationInterface {
name = "AvatarDecorationsRelation1790152636577";
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
+25 -1
View File
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
}
@@ -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;
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
export interface UserProfileModifySchema {
bio?: string;
accent_color?: number | null;
banner?: string | null;
pronouns?: string;
/**
* @items.type integer
*/
theme_colors?: [number, number];
}
-1
View File
@@ -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";
+3
View File
@@ -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);
},