diff --git a/assets/openapi.json b/assets/openapi.json index 9e9513185..e9c892ee2 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json index 5a8563684..2ac0c2d16 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ 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 new file mode 100644 index 000000000..bf36ea792 --- /dev/null +++ b/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/#id/index.ts @@ -0,0 +1,65 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 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 . +*/ + +import { Router, Response, Request } from "express"; +import { route } from "@spacebar/api/middlewares"; +import { AvatarDecorations } from "@spacebar/database"; +import { PublicAvatarDecorationResponse, UpdateAvatarDecorationSchema } from "@spacebar/schemas/api/spacebar/AvatarDecorations"; +import { ApiError } from "@spacebar/util"; + +const router = Router({ mergeParams: true }); + +router.patch( + "/", + route({ + spacebarOnly: true, + description: "Get available avatar decorations", + requestBody: "UpdateAvatarDecorationSchema", + responses: { + 200: { + body: "PublicAvatarDecorationListResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const changes = req.body as UpdateAvatarDecorationSchema; + const deco = await AvatarDecorations.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); + + if (changes.public !== undefined) deco.public = changes.public; + if (changes.allowed_user_ids) { + if (changes.allowed_user_ids.add) deco.allowed_user_ids.push(...changes.allowed_user_ids.add); + if (changes.allowed_user_ids.remove) deco.allowed_user_ids = deco.allowed_user_ids.filter((x) => !changes.allowed_user_ids!.remove!.includes(x)); + } + if (changes.allowed_guild_ids) { + if (changes.allowed_guild_ids.add) deco.allowed_user_ids.push(...changes.allowed_guild_ids.add); + if (changes.allowed_guild_ids.remove) deco.allowed_user_ids = deco.allowed_user_ids.filter((x) => !changes.allowed_guild_ids!.remove!.includes(x)); + } + if (changes.allowed_role_ids) { + if (changes.allowed_role_ids.add) deco.allowed_user_ids.push(...changes.allowed_role_ids.add); + if (changes.allowed_role_ids.remove) deco.allowed_user_ids = deco.allowed_user_ids.filter((x) => !changes.allowed_role_ids!.remove!.includes(x)); + } + + // TODO: remove avatar from users that no longer have access to them + + res.json(deco.toPublicAvatarDecoration() satisfies PublicAvatarDecorationResponse); + }, +); + +export default router; 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 new file mode 100644 index 000000000..e403ef6e9 --- /dev/null +++ b/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/index.ts @@ -0,0 +1,64 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 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 . +*/ + +import { Router, Response, Request } from "express"; +import { Raw } from "typeorm"; +import { route } from "@spacebar/api/middlewares"; +import { AvatarDecorations, Member } from "@spacebar/database"; +import { PublicAvatarDecorationListResponse } from "@spacebar/schemas/api/spacebar/AvatarDecorations"; +import { arrayDistinctBy } from "@spacebar/extensions"; + +const router = Router({ mergeParams: true }); + +router.get( + "/", + route({ + spacebarOnly: true, + description: "Get available avatar decorations", + responses: { + 200: { + body: "PublicAvatarDecorationListResponse", + }, + }, + }), + async (req: Request, res: Response) => { + 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({ + where: [ + { approved: true, public: true }, + { approved: true, uploader_id: req.user_id }, + { approved: true, allowed_user_ids: Raw((columnAlias) => `${columnAlias} && ARRAY[:req_uid]::int8[]`, { req_uid: req.user_id }) }, + { approved: true, allowed_guild_ids: Raw((columnAlias) => `${columnAlias} && :guild_ids`, { guild_ids: memberships.map((x) => x.guild_id) }) }, + { approved: true, allowed_role_ids: Raw((columnAlias) => `${columnAlias} && :role_ids`, { role_ids: memberships.flatMap((x) => x.roles.map((r) => r.id)) }) }, + ], + relations: { + uploader: true, + }, + order: { + id: "DESC", + }, + }) + ).map((x) => x.toPublicAvatarDecoration({ available: true })); + + res.json(arrayDistinctBy(decos, (d) => d.id) satisfies PublicAvatarDecorationListResponse); + }, +); + +export default router; diff --git a/src/database/entities/AvatarDecoration.ts b/src/database/entities/AvatarDecoration.ts index dedc8f3e6..695de8850 100644 --- a/src/database/entities/AvatarDecoration.ts +++ b/src/database/entities/AvatarDecoration.ts @@ -18,6 +18,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeorm"; import { AvatarDecorationData } from "@spacebar/schemas"; +import { PublicAvatarDecoration } from "@spacebar/schemas/api/spacebar/AvatarDecorations"; import { BaseClass } from "./BaseClass"; import { User } from "./User"; @@ -60,4 +61,14 @@ export class AvatarDecorations extends BaseClass { expires_at: null, } satisfies AvatarDecorationData; } + + toPublicAvatarDecoration(opts?: {available: boolean}): PublicAvatarDecoration { + return { + id: this.id, + approved: this.approved, + uploader: this.uploader.toPartialUser(), + public: this.public, + available: opts?.available ?? this.public + } satisfies PublicAvatarDecoration; + } } diff --git a/src/database/entities/index.ts b/src/database/entities/index.ts index 79fceecca..429efb0a3 100644 --- a/src/database/entities/index.ts +++ b/src/database/entities/index.ts @@ -21,6 +21,7 @@ export * from "./ApplicationCommand"; export * from "./Attachment"; export * from "./AuditLog"; export * from "./AutomodRule"; +export * from "./AvatarDecoration"; export * from "./BackupCodes"; export * from "./Badge"; export * from "./Ban"; diff --git a/src/schemas/api/spacebar/AvatarDecorations.ts b/src/schemas/api/spacebar/AvatarDecorations.ts new file mode 100644 index 000000000..5c3992744 --- /dev/null +++ b/src/schemas/api/spacebar/AvatarDecorations.ts @@ -0,0 +1,42 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 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 . +*/ + +import { PartialUser, Snowflake } from "@spacebar/schemas"; + +export type PublicAvatarDecorationListResponse = PublicAvatarDecorationResponse[]; +export type PrivateAvatarDecorationListResponse = PrivateAvatarDecorationResponse[]; +export interface PublicAvatarDecorationResponse { + id: Snowflake; + approved: boolean; + uploader: PartialUser; + public: boolean; + available: boolean; +} + +export interface PrivateAvatarDecorationResponse extends PublicAvatarDecorationResponse { + allowed_user_ids: Snowflake[]; + allowed_guild_ids: Snowflake[]; + allowed_role_ids: Snowflake[]; +} + +export interface UpdateAvatarDecorationSchema { + public?: boolean; + allowed_user_ids?: { add?: Snowflake[]; remove?: Snowflake[] }; + allowed_guild_ids?: { add?: Snowflake[]; remove?: Snowflake[] }; + allowed_role_ids?: { add?: Snowflake[]; remove?: Snowflake[] }; +}