diff --git a/packages/backend-nest/src/app.module.ts b/packages/backend-nest/src/app.module.ts index 5a48ca923..157b48f8e 100644 --- a/packages/backend-nest/src/app.module.ts +++ b/packages/backend-nest/src/app.module.ts @@ -11,6 +11,7 @@ import { DatabaseModule } from "@/common/database/database.module"; import { ZodValidationPipe } from "@/common/zod-validation.pipe"; import { AppConfigModule } from "@/config/config.module"; import { EventModule } from "@/event/event.module"; +import { FrameModule } from "@/frame/frame.module"; import { HistoryModule } from "@/history/history.module"; import { NoticeModule } from "@/notice/notice.module"; import { PaletteModule } from "@/palette/palette.module"; @@ -26,6 +27,7 @@ import { RealtimeModule } from "@/realtime/realtime.module"; RealtimeModule, EventModule, CanvasModule, + FrameModule, PixelModule, NoticeModule, BlocklistModule, diff --git a/packages/backend-nest/src/auth/guards/canvas-admin.guard.spec.ts b/packages/backend-nest/src/auth/guards/canvas-admin.guard.spec.ts index 01cd52286..2544a5cc3 100644 --- a/packages/backend-nest/src/auth/guards/canvas-admin.guard.spec.ts +++ b/packages/backend-nest/src/auth/guards/canvas-admin.guard.spec.ts @@ -1,4 +1,3 @@ -import type { DiscordUserProfile } from "@blurple-canvas-web/types"; import type { ExecutionContext } from "@nestjs/common"; import { Test, type TestingModule } from "@nestjs/testing"; import type { Request } from "express"; @@ -9,12 +8,7 @@ import { ForbiddenError } from "@/common/errors/forbidden.error"; import { UnauthorizedError } from "@/common/errors/unauthorized.error"; import { DiscordGuildService } from "@/discord/discord-guild.service"; import { DiscordTokenService } from "@/discord/discord-token.service"; - -const mockUser: DiscordUserProfile = { - id: "123456789", - username: "user", - profilePictureUrl: "https://example.com/avatar.png", -}; +import { mockDiscordUser as mockUser } from "@/test/fixtures/users"; const mockGuildService = { isCanvasAdmin: vi.fn(), diff --git a/packages/backend-nest/src/auth/guards/canvas-moderator.guard.spec.ts b/packages/backend-nest/src/auth/guards/canvas-moderator.guard.spec.ts index d526abed0..a1fe05503 100644 --- a/packages/backend-nest/src/auth/guards/canvas-moderator.guard.spec.ts +++ b/packages/backend-nest/src/auth/guards/canvas-moderator.guard.spec.ts @@ -1,4 +1,3 @@ -import type { DiscordUserProfile } from "@blurple-canvas-web/types"; import type { ExecutionContext } from "@nestjs/common"; import { Test, type TestingModule } from "@nestjs/testing"; import type { Request } from "express"; @@ -8,12 +7,7 @@ import { ForbiddenError } from "@/common/errors/forbidden.error"; import { UnauthorizedError } from "@/common/errors/unauthorized.error"; import { DiscordGuildService } from "@/discord/discord-guild.service"; import { DiscordTokenService } from "@/discord/discord-token.service"; - -const mockUser: DiscordUserProfile = { - id: "123456789", - username: "user", - profilePictureUrl: "https://example.com/avatar.png", -}; +import { mockDiscordUser as mockUser } from "@/test/fixtures/users"; const mockGuildService = { isCanvasAdmin: vi.fn(), diff --git a/packages/backend-nest/src/auth/guards/logged-in.guard.spec.ts b/packages/backend-nest/src/auth/guards/logged-in.guard.spec.ts index d2372de37..4d86da55e 100644 --- a/packages/backend-nest/src/auth/guards/logged-in.guard.spec.ts +++ b/packages/backend-nest/src/auth/guards/logged-in.guard.spec.ts @@ -1,16 +1,10 @@ -import type { DiscordUserProfile } from "@blurple-canvas-web/types"; import type { ExecutionContext } from "@nestjs/common"; import { Test, type TestingModule } from "@nestjs/testing"; import type { Request } from "express"; import { LoggedInGuard } from "@/auth/guards/logged-in.guard"; import { UnauthorizedError } from "@/common/errors/unauthorized.error"; - -const mockUser: DiscordUserProfile = { - id: "123456789", - username: "user", - profilePictureUrl: "https://example.com/avatar.png", -}; +import { mockDiscordUser as mockUser } from "@/test/fixtures/users"; function makeRequest(overrides: Partial = {}): Request { return { diff --git a/packages/backend-nest/src/canvas/canvas-cache.service.ts b/packages/backend-nest/src/canvas/canvas-cache.service.ts index 82979beed..5ae74b9fe 100644 --- a/packages/backend-nest/src/canvas/canvas-cache.service.ts +++ b/packages/backend-nest/src/canvas/canvas-cache.service.ts @@ -1,10 +1,10 @@ import fs from "node:fs"; import { + type BoundsInput, CANVAS_EXPORT_SCALES, type CanvasExportScale, type CanvasInfo, DEFAULT_CANVAS_EXPORT_SCALE, - type FrameBoundsInput, type PixelColor, type PlacePixelArray, type Point, @@ -76,7 +76,7 @@ export class CanvasCacheService implements OnApplicationBootstrap { canvasId: number, isLocked = false, scale: CanvasExportScale = DEFAULT_CANVAS_EXPORT_SCALE, - bounds?: FrameBoundsInput, + bounds?: BoundsInput, ): string { const scaleSuffix = scale === 1 ? "" : `@${scale}x`; const boundsSuffix = diff --git a/packages/backend-nest/src/canvas/canvas.controller.ts b/packages/backend-nest/src/canvas/canvas.controller.ts index 1a880d787..db8451f56 100644 --- a/packages/backend-nest/src/canvas/canvas.controller.ts +++ b/packages/backend-nest/src/canvas/canvas.controller.ts @@ -1,4 +1,5 @@ import { + type BoundsInput, CanvasExportParamModel, type CanvasExportScale, CanvasIdParamModel, @@ -9,8 +10,7 @@ import { CreateCanvasBodyModel, DEFAULT_CANVAS_EXPORT_SCALE, EditCanvasBodyModel, - type FrameBoundsInput, - OptionalFrameBoundsModel, + OptionalBoundsModel, } from "@blurple-canvas-web/types"; import { Body, @@ -52,9 +52,9 @@ class CanvasExportParamsDto extends createZodDto(CanvasExportParamModel) {} // The schema transforms an absent crop to `undefined`, which a class cannot // extend; the cast narrows the static type while keeping the runtime schema -// (handlers receive `FrameBoundsInput`). +// (handlers receive `BoundsInput`). class CanvasCropQueryDto extends createZodDto( - OptionalFrameBoundsModel as z.ZodType>, + OptionalBoundsModel as z.ZodType>, ) {} class CreateCanvasBodyDto extends createZodDto(CreateCanvasBodyModel) {} @@ -179,7 +179,7 @@ export class CanvasController { cachedCanvas, params.scale, // The schema transforms an absent crop to `undefined`. - bounds as FrameBoundsInput, + bounds as BoundsInput, ); } @@ -322,7 +322,7 @@ export class CanvasController { canvasId: number, cachedCanvas: CachedCanvas, scale: CanvasExportScale = DEFAULT_CANVAS_EXPORT_SCALE, - bounds?: FrameBoundsInput, + bounds?: BoundsInput, ): Promise { if (cachedCanvas.isLocked) { const canvasPath = cachedCanvas.canvasPaths[scale]; diff --git a/packages/backend-nest/src/frame/frame.controller.ts b/packages/backend-nest/src/frame/frame.controller.ts new file mode 100644 index 000000000..4580887d5 --- /dev/null +++ b/packages/backend-nest/src/frame/frame.controller.ts @@ -0,0 +1,231 @@ +import { + CanvasIdParamModel, + CreateFrameBodyModel, + ExportFrameParamModel, + FrameDataParamModel, + FrameGuildIdsQueryModel, + FrameIdParamModel, + UserCanvasParamModel, +} from "@blurple-canvas-web/types"; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Inject, + Logger, + Param, + Post, + Put, + Query, + Req, + Res, +} from "@nestjs/common"; +import { + ApiNoContentResponse, + ApiOkResponse, + ApiOperation, + ApiProduces, +} from "@nestjs/swagger"; +import type { Request, Response } from "express"; +import { createZodDto } from "nestjs-zod"; + +import { CurrentUser, CurrentUserDto } from "@/auth/current-user.decorator"; +import { RequiresLogin } from "@/auth/require-auth.decorator"; +import { ExportService } from "@/canvas/export.service"; +import { type FramesConfig, framesConfig } from "@/config/frames.config"; +import { DiscordTokenService } from "@/discord/discord-token.service"; +import { FrameService } from "./frame.service"; + +class ExportFrameParamsDto extends createZodDto(ExportFrameParamModel) {} + +class FrameIdParamsDto extends createZodDto(FrameIdParamModel) {} + +class UserCanvasParamsDto extends createZodDto(UserCanvasParamModel) {} + +class CanvasIdParamsDto extends createZodDto(CanvasIdParamModel) {} + +class FrameGuildIdsQueryDto extends createZodDto(FrameGuildIdsQueryModel) {} + +class FrameDataBodyDto extends createZodDto(FrameDataParamModel) {} + +class CreateFrameBodyDto extends createZodDto(CreateFrameBodyModel) {} + +@Controller("frame") +export class FrameController { + private readonly logger = new Logger(FrameController.name); + + constructor( + private readonly frameService: FrameService, + private readonly exportService: ExportService, + private readonly discordTokenService: DiscordTokenService, + @Inject(framesConfig.KEY) private readonly frames: FramesConfig, + ) {} + // TODO: rate limit + + @Get(":frameId@:scale.png") + @ApiOperation({ summary: "PNG of a frame's region at scale (1/2/4×)" }) + @ApiProduces("image/png") + @ApiOkResponse({ description: "The frame image" }) + async exportFramePng( + @Param() params: ExportFrameParamsDto, + @Res() res: Response, + ): Promise { + const frame = await this.frameService.getFrameById(params.frameId); + + const stream = await this.exportService.exportCanvasBoundsAsStream({ + canvasId: frame.canvasId, + x0: frame.x0, + y0: frame.y0, + x1: frame.x1, + y1: frame.y1, + scale: params.scale, + }); + + stream.on("error", (error) => { + this.logger.error(`Error streaming frame ${params.frameId} PNG:`, error); + if (res.headersSent) { + res.destroy(error); + } else { + res.sendStatus(500); + } + }); + + stream.pipe( + res + .status(200) + .type("png") + .setHeader("Cache-Control", ["no-cache", "no-store"]) + // Needed to force Safari to not cache the image + .setHeader("Vary", "*") + .setHeader( + "Content-Disposition", + `inline; filename="frame-${params.frameId}.png"`, + ), + ); + } + + @Get(":frameId") + @ApiOperation({ summary: "A frame by ID" }) + async getFrameById(@Param() params: FrameIdParamsDto) { + return await this.frameService.getFrameById(params.frameId); + } + + @Get("user/:userId/:canvasId") + @ApiOperation({ summary: "A user's frames on a canvas" }) + async getFramesByUserId(@Param() params: UserCanvasParamsDto) { + const frames = await this.frameService.getFramesByUserId( + params.userId, + params.canvasId, + ); + + return { + data: frames, + hasReachedMaxFrames: frames.length >= this.frames.maxAllowedUser, + }; + } + + @Get("guilds/:canvasId") + @ApiOperation({ summary: "Frames owned by the given guilds on a canvas" }) + async getFramesByGuildIds( + @Param() params: CanvasIdParamsDto, + @Query() query: FrameGuildIdsQueryDto, + ) { + const { guildIds } = query; + const frames = await this.frameService.getFramesByGuildIds( + guildIds, + params.canvasId, + ); + + const hasReachedMaxFrames: Record = {}; + for (const guildId of guildIds) { + const frameCount = frames.reduce((count, frame) => { + if (frame.owner.guild.guild_id === guildId) count++; + return count; + }, 0); + hasReachedMaxFrames[guildId] = frameCount >= this.frames.maxAllowedGuild; + } + + return { + data: frames, + hasReachedMaxFrames, + }; + } + + @Put(":frameId/edit") + @RequiresLogin() + @ApiOperation({ summary: "Edit a frame (owner or guild manager)" }) + async editFrame( + @Param() params: FrameIdParamsDto, + @Body() body: FrameDataBodyDto, + @CurrentUser() user: CurrentUserDto, + @Req() req: Request, + ) { + return await this.discordTokenService.withDiscordAccessToken( + req.session, + (accessToken) => + this.frameService.editFrame( + user, + accessToken, + params.frameId, + body.name, + body.x0, + body.y0, + body.x1, + body.y1, + ), + ); + } + + @Delete(":frameId/delete") + @RequiresLogin() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Delete a frame (owner or guild manager)" }) + @ApiNoContentResponse({ description: "Frame deleted" }) + async deleteFrame( + @Param() params: FrameIdParamsDto, + @CurrentUser() user: CurrentUserDto, + @Req() req: Request, + ): Promise { + await this.discordTokenService.withDiscordAccessToken( + req.session, + (accessToken) => + this.frameService.deleteFrame(user, accessToken, params.frameId), + ); + } + + @Post() + @RequiresLogin() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a frame" }) + async createFrame( + @Body() body: CreateFrameBodyDto, + @CurrentUser() user: CurrentUserDto, + @Req() req: Request, + ) { + const { canvasId, owner, name, x0, y0, x1, y1 } = body; + + await this.frameService.assertMaxOwnerFramesNotExceeded({ + canvasId, + owner, + }); + + return await this.discordTokenService.withDiscordAccessToken( + req.session, + (accessToken) => + this.frameService.createFrame( + user, + accessToken, + canvasId, + name, + owner, + x0, + y0, + x1, + y1, + ), + ); + } +} diff --git a/packages/backend-nest/src/frame/frame.module.ts b/packages/backend-nest/src/frame/frame.module.ts new file mode 100644 index 000000000..9aeff9f09 --- /dev/null +++ b/packages/backend-nest/src/frame/frame.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; + +import { CanvasModule } from "@/canvas/canvas.module"; +import { DiscordModule } from "@/discord/discord.module"; +import { FrameController } from "./frame.controller"; +import { FrameService } from "./frame.service"; + +@Module({ + imports: [CanvasModule, DiscordModule], + controllers: [FrameController], + providers: [FrameService], + exports: [FrameService], +}) +export class FrameModule {} diff --git a/packages/backend-nest/src/frame/frame.service.spec.ts b/packages/backend-nest/src/frame/frame.service.spec.ts new file mode 100644 index 000000000..340597b3c --- /dev/null +++ b/packages/backend-nest/src/frame/frame.service.spec.ts @@ -0,0 +1,264 @@ +import { FrameOwnerType } from "@blurple-canvas-web/types"; +import { Test, type TestingModule } from "@nestjs/testing"; + +import { DatabaseModule } from "@/common/database/database.module"; +import { ForbiddenError } from "@/common/errors/forbidden.error"; +import { NotFoundError } from "@/common/errors/not-found.error"; +import { UnprocessableError } from "@/common/errors/unprocessable.error"; +import { AppConfigModule } from "@/config/config.module"; +import { framesConfig } from "@/config/frames.config"; +import { DiscordGuildService } from "@/discord/discord-guild.service"; +import { testPrisma as prisma } from "@/test/database"; +import { testUser1 as user } from "@/test/fixtures/users"; +import { seedCanvases } from "@/test/seed/canvases"; +import { seedDiscordProfiles } from "@/test/seed/discord-profiles"; +import { seedEvents } from "@/test/seed/events"; +import { seedGuilds } from "@/test/seed/guilds"; +import { seedUsers } from "@/test/seed/users"; +import { FrameService } from "./frame.service"; + +const ACCESS_TOKEN = "access-token"; + +const discordGuildService = { + getGuildPermissionsForUser: vi.fn(async () => ({ + administrator: true, + manage_guild: false, + })), +}; + +async function createUserFrame(id: string, ownerUserId = 1n, canvasId = 1) { + await prisma.frame.create({ + data: { + id, + canvasId, + ownerUserId, + name: `Frame ${id}`, + x0: 0, + y0: 0, + x1: 2, + y1: 2, + }, + }); +} + +describe("FrameService", () => { + let moduleRef: TestingModule; + let service: FrameService; + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [AppConfigModule, DatabaseModule], + providers: [ + FrameService, + { provide: DiscordGuildService, useValue: discordGuildService }, + // Small limits make the cap assertions tractable. + { + provide: framesConfig.KEY, + useValue: { maxAllowedUser: 1, maxAllowedGuild: 1 }, + }, + ], + }).compile(); + await moduleRef.init(); + + service = moduleRef.get(FrameService); + }); + + afterAll(async () => { + await moduleRef.close(); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + await seedEvents(); + await seedUsers(); + await seedGuilds(); + await seedDiscordProfiles(); + await seedCanvases(); + }); + + describe("getFrameById", () => { + it("returns a user-owned frame, case-insensitively", async () => { + await createUserFrame("abc123"); + + const frame = await service.getFrameById("ABC123"); + expect(frame).toMatchObject({ + id: "abc123", + canvasId: 1, + owner: { + type: FrameOwnerType.User, + user: { id: "1", username: "test_user_1" }, + }, + }); + }); + + it("throws NotFoundError for an unknown frame", async () => { + await expect(service.getFrameById("ffffff")).rejects.toBeInstanceOf( + NotFoundError, + ); + }); + }); + + describe("getFramesByUserId / getFramesByGuildIds", () => { + it("returns a user's frames", async () => { + await createUserFrame("000001"); + await createUserFrame("000002"); + + const frames = await service.getFramesByUserId("1", 1); + expect(frames.map((frame) => frame.id).sort()).toEqual([ + "000001", + "000002", + ]); + }); + + it("returns guild-owned frames", async () => { + await prisma.frame.create({ + data: { + id: "0a0a0a", + canvasId: 1, + ownerGuildId: 1n, + name: "Guild frame", + x0: 0, + y0: 0, + x1: 2, + y1: 2, + }, + }); + + const frames = await service.getFramesByGuildIds(["1"], 1); + expect(frames).toHaveLength(1); + expect(frames[0].owner).toMatchObject({ + type: FrameOwnerType.Guild, + guild: { guild_id: "1", name: "Guild 1" }, + }); + }); + }); + + describe("createFrame", () => { + it("creates a user-owned frame with a 6-hex id", async () => { + const frame = await service.createFrame( + user, + ACCESS_TOKEN, + 1, + "My frame", + { type: FrameOwnerType.User, id: "1" }, + 0, + 0, + 2, + 2, + ); + + expect(frame.id).toMatch(/^[0-9a-f]{6}$/); + expect(frame.ownerUserId).toBe(1n); + }); + + it("rejects creating a user frame for someone else", async () => { + await expect( + service.createFrame( + user, + ACCESS_TOKEN, + 1, + "Nope", + { type: FrameOwnerType.User, id: "9" }, + 0, + 0, + 2, + 2, + ), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + + it("rejects a guild frame when the user lacks permission", async () => { + discordGuildService.getGuildPermissionsForUser.mockResolvedValueOnce({ + administrator: false, + manage_guild: false, + }); + + await expect( + service.createFrame( + user, + ACCESS_TOKEN, + 1, + "Guild frame", + { type: FrameOwnerType.Guild, id: "1" }, + 0, + 0, + 2, + 2, + ), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + + it("rejects out-of-bounds coordinates", async () => { + await expect( + service.createFrame( + user, + ACCESS_TOKEN, + 1, + "Too big", + { type: FrameOwnerType.User, id: "1" }, + 0, + 0, + 99, + 99, + ), + ).rejects.toBeInstanceOf(Error); + }); + }); + + describe("editFrame / deleteFrame", () => { + it("edits a frame the user owns", async () => { + await createUserFrame("aaa111"); + + const updated = await service.editFrame( + user, + ACCESS_TOKEN, + "aaa111", + "Renamed", + 0, + 0, + 1, + 1, + ); + expect(updated).toMatchObject({ name: "Renamed", x1: 1, y1: 1 }); + }); + + it("refuses to edit a frame owned by another user", async () => { + await createUserFrame("bbb222", 9n); + + await expect( + service.editFrame(user, ACCESS_TOKEN, "bbb222", "Hijack", 0, 0, 1, 1), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + + it("deletes a frame the user owns", async () => { + await createUserFrame("ccc333"); + + await service.deleteFrame(user, ACCESS_TOKEN, "ccc333"); + await expect( + prisma.frame.findUnique({ where: { id: "ccc333" } }), + ).resolves.toBeNull(); + }); + }); + + describe("assertMaxOwnerFramesNotExceeded", () => { + it("throws once the per-user limit is reached", async () => { + await createUserFrame("d00d00"); + + await expect( + service.assertMaxOwnerFramesNotExceeded({ + canvasId: 1, + owner: { type: FrameOwnerType.User, id: "1" }, + }), + ).rejects.toBeInstanceOf(UnprocessableError); + }); + + it("passes when below the limit", async () => { + await expect( + service.assertMaxOwnerFramesNotExceeded({ + canvasId: 1, + owner: { type: FrameOwnerType.User, id: "1" }, + }), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/backend-nest/src/frame/frame.service.ts b/packages/backend-nest/src/frame/frame.service.ts new file mode 100644 index 000000000..bae8b3ab1 --- /dev/null +++ b/packages/backend-nest/src/frame/frame.service.ts @@ -0,0 +1,461 @@ +import { + type DiscordUserProfile, + type Frame, + type FrameOwnerInput, + FrameOwnerType, + type GuildOwnedFrame, + type UserOwnedFrame, +} from "@blurple-canvas-web/types"; +import { Inject, Injectable } from "@nestjs/common"; + +import { Prisma } from "@/common/database/prisma.client"; +import { PrismaService } from "@/common/database/prisma.service"; +import { BadRequestError } from "@/common/errors/bad-request.error"; +import { ForbiddenError } from "@/common/errors/forbidden.error"; +import { NotFoundError } from "@/common/errors/not-found.error"; +import { UnprocessableError } from "@/common/errors/unprocessable.error"; +import { type FramesConfig, framesConfig } from "@/config/frames.config"; +import { DiscordGuildService } from "@/discord/discord-guild.service"; + +const MAX_FRAME_ID_ATTEMPTS = 10; + +const frameSelect = { + id: true, + canvasId: true, + ownerUserId: true, + ownerGuildId: true, + name: true, + x0: true, + y0: true, + x1: true, + y1: true, + styleId: true, +} as const satisfies Prisma.FrameSelect; + +type FrameDbRecord = Prisma.FrameGetPayload<{ select: typeof frameSelect }>; + +interface UserOwnerRecord { + userId: bigint; + username: string; + profilePictureUrl: string; +} + +interface GuildOwnerRecord { + guildId: bigint; + name: string; +} + +interface OwnerLookup { + usersById: Map; + guildsById: Map; +} + +export interface GetFrameCountForOwnerParams { + canvasId: number; + owner: FrameOwnerInput; +} + +@Injectable() +export class FrameService { + constructor( + private readonly prisma: PrismaService, + private readonly discordGuildService: DiscordGuildService, + @Inject(framesConfig.KEY) private readonly frames: FramesConfig, + ) {} + + private partitionOwnerIds(frames: FrameDbRecord[]) { + const userIds = new Set(); + const guildIds = new Set(); + + for (const frame of frames) { + if (frame.ownerUserId !== null) { + userIds.add(frame.ownerUserId); + } else if (frame.ownerGuildId !== null) { + guildIds.add(frame.ownerGuildId); + } + } + + return { + userIds: [...userIds], + guildIds: [...guildIds], + }; + } + + private async loadOwnerLookup(frames: FrameDbRecord[]): Promise { + const { userIds, guildIds } = this.partitionOwnerIds(frames); + + const [users, guilds] = await Promise.all([ + userIds.length ? + this.prisma.discordUserProfile.findMany({ + where: { + userId: { + in: userIds, + }, + }, + select: { + userId: true, + username: true, + profilePictureUrl: true, + }, + }) + : [], + guildIds.length ? + this.prisma.discordGuildRecord.findMany({ + where: { + guildId: { + in: guildIds, + }, + }, + select: { + guildId: true, + name: true, + }, + }) + : [], + ]); + + return { + usersById: new Map(users.map((user) => [user.userId, user])), + guildsById: new Map(guilds.map((guild) => [guild.guildId, guild])), + }; + } + + private frameFromDb(frame: FrameDbRecord, owners: OwnerLookup): Frame { + const baseFrame = { + id: frame.id, + canvasId: frame.canvasId, + name: frame.name, + x0: frame.x0, + y0: frame.y0, + x1: frame.x1, + y1: frame.y1, + }; + + if (frame.ownerGuildId !== null) { + const guildData = owners.guildsById.get(frame.ownerGuildId); + + if (!guildData) { + throw new Error( + `Guild owner with ID ${frame.ownerGuildId} not found for frame ${frame.id}`, + ); + } + + return { + ...baseFrame, + owner: { + type: FrameOwnerType.Guild, + guild: { + guild_id: guildData.guildId.toString(), + name: guildData.name, + }, + }, + }; + } + + if (frame.ownerUserId === null) { + throw new Error(`Frame ${frame.id} has no owner set`); + } + + const userData = owners.usersById.get(frame.ownerUserId); + + if (!userData) { + throw new Error( + `User owner with ID ${frame.ownerUserId} not found for frame ${frame.id}`, + ); + } + + return { + ...baseFrame, + owner: { + type: FrameOwnerType.User, + user: { + id: userData.userId.toString(), + username: userData.username, + profilePictureUrl: userData.profilePictureUrl, + }, + }, + }; + } + + private asUserFrame(frame: Frame): asserts frame is UserOwnedFrame { + if (frame.owner.type !== FrameOwnerType.User) { + throw new Error(`Expected user-owned frame, got ${frame.owner.type}`); + } + } + + private asGuildFrame(frame: Frame): asserts frame is GuildOwnedFrame { + if (frame.owner.type !== FrameOwnerType.Guild) { + throw new Error(`Expected guild-owned frame, got ${frame.owner.type}`); + } + } + + async getFrameById(frameId: string): Promise { + const frame = await this.prisma.frame.findFirst({ + where: { + id: { + equals: frameId, + mode: Prisma.QueryMode.insensitive, + }, + }, + select: frameSelect, + }); + + if (!frame) { + throw new NotFoundError("Frame not found"); + } + + const owners = await this.loadOwnerLookup([frame]); + return this.frameFromDb(frame, owners); + } + + async getFramesByUserId( + userId: string, + canvasId: number, + ): Promise { + const frames = await this.prisma.frame.findMany({ + where: { + ownerUserId: BigInt(userId), + canvasId, + }, + select: frameSelect, + }); + + const owners = await this.loadOwnerLookup(frames); + + return frames.map((frame) => { + const mapped = this.frameFromDb(frame, owners); + this.asUserFrame(mapped); + return mapped; + }); + } + + async getFramesByGuildIds( + guildIds: string[], + canvasId: number, + ): Promise { + const frames = await this.prisma.frame.findMany({ + where: { + ownerGuildId: { + in: guildIds.map(BigInt), + }, + canvasId, + }, + select: frameSelect, + }); + + const owners = await this.loadOwnerLookup(frames); + + return frames.map((frame) => { + const mapped = this.frameFromDb(frame, owners); + this.asGuildFrame(mapped); + return mapped; + }); + } + + private async assertUserHasPermissionsForFrame( + user: DiscordUserProfile, + accessToken: string, + owner: FrameOwnerInput, + ) { + if (owner.type === FrameOwnerType.Guild) { + const permissions = + await this.discordGuildService.getGuildPermissionsForUser( + owner.id, + accessToken, + ); + + if (!permissions.administrator && !permissions.manage_guild) { + throw new ForbiddenError( + "You do not have permission to modify frames for this guild", + ); + } + return; + } + + if (owner.id !== user.id) { + throw new ForbiddenError("You are not the owner of this frame"); + } + } + + private async assertUserHasPermissionsForFrameObject( + user: DiscordUserProfile, + accessToken: string, + frame: Frame, + ) { + if (frame.owner.type === FrameOwnerType.System) { + throw new ForbiddenError("System-owned frames cannot be edited"); + } + + const owner: FrameOwnerInput = + frame.owner.type === FrameOwnerType.Guild ? + { type: FrameOwnerType.Guild, id: frame.owner.guild.guild_id } + : { type: FrameOwnerType.User, id: frame.owner.user.id }; + + return this.assertUserHasPermissionsForFrame(user, accessToken, owner); + } + + private async assertCoordsAreWithinCanvas( + canvasId: number, + x0: number, + y0: number, + x1: number, + y1: number, + ) { + const canvas = await this.prisma.canvas.findUnique({ + where: { + id: canvasId, + }, + select: { + width: true, + height: true, + }, + }); + + if (!canvas) { + throw new NotFoundError("Canvas not found"); + } + + if (x0 < 0 || y0 < 0 || x1 > canvas.width || y1 > canvas.height) { + throw new BadRequestError( + "Frame coordinates must be within the bounds of the canvas", + ); + } + + return canvas; + } + + async editFrame( + user: DiscordUserProfile, + accessToken: string, + frameId: string, + name: string, + x0: number, + y0: number, + x1: number, + y1: number, + ) { + const frame = await this.getFrameById(frameId); + + await this.assertUserHasPermissionsForFrameObject(user, accessToken, frame); + + await this.assertCoordsAreWithinCanvas(frame.canvasId, x0, y0, x1, y1); + + return await this.prisma.frame.update({ + where: { + id: frameId, + }, + data: { + name, + x0, + y0, + x1, + y1, + }, + }); + } + + async deleteFrame( + user: DiscordUserProfile, + accessToken: string, + frameId: string, + ) { + const frame = await this.getFrameById(frameId); + + await this.assertUserHasPermissionsForFrameObject(user, accessToken, frame); + + await this.prisma.frame.delete({ + where: { + id: frameId, + }, + }); + } + + async createFrame( + user: DiscordUserProfile, + accessToken: string, + canvasId: number, + name: string, + owner: FrameOwnerInput, + x0: number, + y0: number, + x1: number, + y1: number, + ) { + await this.assertUserHasPermissionsForFrame(user, accessToken, owner); + + await this.assertCoordsAreWithinCanvas(canvasId, x0, y0, x1, y1); + + const ownerColumns = + owner.type === FrameOwnerType.Guild ? + { ownerGuildId: BigInt(owner.id), ownerUserId: null } + : { ownerUserId: BigInt(owner.id), ownerGuildId: null }; + + // Frame IDs are random 6-character hex strings (000000–FFFFFF), so + // collisions are retried. The attempt cap guards against an unexpectedly + // exhausted id space spinning forever. + for (let attempt = 0; attempt < MAX_FRAME_ID_ATTEMPTS; attempt++) { + const id = Math.floor(Math.random() * 0x1000000) + .toString(16) + .padStart(6, "0"); + + try { + return await this.prisma.frame.create({ + data: { + id, + canvasId, + name, + ...ownerColumns, + x0, + y0, + x1, + y1, + }, + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + continue; + } + throw error; + } + } + + throw new Error( + `Failed to allocate a unique frame id after ${MAX_FRAME_ID_ATTEMPTS} attempts`, + ); + } + + private async getFrameCountForOwner({ + canvasId, + owner, + }: GetFrameCountForOwnerParams) { + return this.prisma.frame.count({ + where: { + canvasId, + ...(owner.type === FrameOwnerType.Guild ? + { ownerGuildId: BigInt(owner.id) } + : { ownerUserId: BigInt(owner.id) }), + }, + }); + } + + async assertMaxOwnerFramesNotExceeded({ + canvasId, + owner, + }: GetFrameCountForOwnerParams) { + const frameCount = await this.getFrameCountForOwner({ canvasId, owner }); + const isGuildOwner = owner.type === FrameOwnerType.Guild; + const limit = + isGuildOwner ? this.frames.maxAllowedGuild : this.frames.maxAllowedUser; + + if (frameCount >= limit) { + throw new UnprocessableError( + `Frame limit of ${limit} exceeded for this ${ + isGuildOwner ? "guild" : "user" + } on this canvas`, + ); + } + } +} diff --git a/packages/backend-nest/src/test/fixtures/users.ts b/packages/backend-nest/src/test/fixtures/users.ts new file mode 100644 index 000000000..596b0a821 --- /dev/null +++ b/packages/backend-nest/src/test/fixtures/users.ts @@ -0,0 +1,19 @@ +import type { DiscordUserProfile } from "@blurple-canvas-web/types"; + +export const testUser1: DiscordUserProfile = { + id: "1", + username: "test_user_1", + profilePictureUrl: "https://example.com/avatar1.png", +}; + +export const testUser9: DiscordUserProfile = { + id: "9", + username: "test_user_9", + profilePictureUrl: "https://example.com/avatar9.png", +}; + +export const mockDiscordUser: DiscordUserProfile = { + id: "123456789", + username: "user", + profilePictureUrl: "https://example.com/avatar.png", +}; diff --git a/packages/backend-nest/test/frame.e2e-spec.ts b/packages/backend-nest/test/frame.e2e-spec.ts new file mode 100644 index 000000000..bb9a3e819 --- /dev/null +++ b/packages/backend-nest/test/frame.e2e-spec.ts @@ -0,0 +1,350 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import request from "supertest"; +import type TestAgent from "supertest/lib/agent"; + +import { AppModule } from "@/app.module"; +import { configureApp } from "@/app.setup"; +import { CanvasCacheService } from "@/canvas/canvas-cache.service"; +import { appConfig } from "@/config/app.config"; +import { testPrisma as prisma } from "@/test/database"; +import { seedAll } from "@/test/seed"; +import { + MOCK_DISCORD_USER_ID, + mockDiscordServer, + onUnhandledRequest, + resetMockDiscord, + VALID_OAUTH_CODE, +} from "./mock-discord"; + +// Frames seeded on canvas 1, spanning the whole 2x2 canvas. +const OWNED_FRAME_ID = "aaaaaa"; // owned by the signed-in user +const GUILD_FRAME_ID = "bbbbbb"; // owned by guild 1 +const OTHER_FRAME_ID = "cccccc"; // owned by OTHER_USER_ID + +// A seeded user (polarwolf314) who is not the signed-in user. +const OTHER_USER_ID = "201892070091128832"; + +async function signIn(agent: TestAgent): Promise { + const response = await agent.get( + `/api/v1/discord/callback?code=${VALID_OAUTH_CODE}`, + ); + expect(response.status).toBe(302); + + await vi.waitFor(async () => { + const synced = await prisma.discordGuildRecord.count(); + expect(synced).toBeGreaterThan(0); + }); +} + +async function seedFrames(): Promise { + await prisma.frame.createMany({ + data: [ + { + id: OWNED_FRAME_ID, + canvasId: 1, + ownerUserId: BigInt(MOCK_DISCORD_USER_ID), + name: "My Frame", + x0: 0, + y0: 0, + x1: 2, + y1: 2, + }, + { + id: GUILD_FRAME_ID, + canvasId: 1, + ownerGuildId: 1n, + name: "Guild Frame", + x0: 0, + y0: 0, + x1: 2, + y1: 2, + }, + { + id: OTHER_FRAME_ID, + canvasId: 1, + ownerUserId: BigInt(OTHER_USER_ID), + name: "Someone Else's Frame", + x0: 0, + y0: 0, + x1: 2, + y1: 2, + }, + ], + }); +} + +describe("Frame routes (e2e)", () => { + let app: NestExpressApplication; + let cacheService: CanvasCacheService; + let canvasesPath: string; + + beforeAll(async () => { + mockDiscordServer.listen({ onUnhandledRequest }); + + canvasesPath = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "frame-e2e-"), + ); + + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(appConfig.KEY) + .useValue({ + environment: "test", + port: 3001, + frontendUrl: "http://localhost:3000", + paths: { root: canvasesPath, canvases: canvasesPath }, + }) + .compile(); + + app = configureApp( + moduleRef.createNestApplication(), + ); + await app.init(); + + cacheService = app.get(CanvasCacheService); + }); + + afterAll(async () => { + await app.close(); + mockDiscordServer.close(); + await fs.promises.rm(canvasesPath, { recursive: true, force: true }); + }); + + beforeEach(async () => { + await seedAll(); + }); + + afterEach(async () => { + mockDiscordServer.resetHandlers(); + resetMockDiscord(); + await cacheService.clearCachedCanvas(1); + }); + + describe("GET /api/v1/frame/:frameId", () => { + it("returns a frame by ID", async () => { + await seedFrames(); + + const response = await request(app.getHttpServer()) + .get(`/api/v1/frame/${OWNED_FRAME_ID}`) + .expect(200); + + expect(response.body).toMatchObject({ + id: OWNED_FRAME_ID, + canvasId: 1, + name: "My Frame", + owner: { + type: "user", + user: { id: MOCK_DISCORD_USER_ID }, + }, + }); + }); + + it("returns 404 for an unknown frame", async () => { + const response = await request(app.getHttpServer()) + .get("/api/v1/frame/ffffff") + .expect(404); + + expect(response.body).toStrictEqual({ message: "Frame not found" }); + }); + + it("rejects an invalid frame ID", async () => { + await request(app.getHttpServer()) + .get("/api/v1/frame/nothex") + .expect(400); + }); + }); + + describe("GET /api/v1/frame/user/:userId/:canvasId", () => { + it("returns a user's frames with the max-frames flag", async () => { + await seedFrames(); + + const response = await request(app.getHttpServer()) + .get(`/api/v1/frame/user/${MOCK_DISCORD_USER_ID}/1`) + .expect(200); + + expect(response.body.hasReachedMaxFrames).toBe(false); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0]).toMatchObject({ + id: OWNED_FRAME_ID, + owner: { type: "user" }, + }); + }); + }); + + describe("GET /api/v1/frame/guilds/:canvasId", () => { + it("returns frames owned by the given guilds with per-guild flags", async () => { + await seedFrames(); + + const response = await request(app.getHttpServer()) + .get("/api/v1/frame/guilds/1?guildIds=1") + .expect(200); + + expect(response.body.hasReachedMaxFrames).toStrictEqual({ "1": false }); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0]).toMatchObject({ + id: GUILD_FRAME_ID, + owner: { + type: "guild", + guild: { guild_id: "1", name: "Guild 1" }, + }, + }); + }); + }); + + describe("GET /api/v1/frame/:frameId@:scale.png", () => { + it("streams the frame region as a PNG", async () => { + await seedFrames(); + + const response = await request(app.getHttpServer()) + .get(`/api/v1/frame/${OWNED_FRAME_ID}@1.png`) + .responseType("blob") + .expect(200); + + expect(response.headers["content-type"]).toBe("image/png"); + expect(response.headers["content-disposition"]).toBe( + `inline; filename="frame-${OWNED_FRAME_ID}.png"`, + ); + expect((response.body as Buffer).length).toBeGreaterThan(0); + }); + + it("rejects an invalid scale", async () => { + await seedFrames(); + + await request(app.getHttpServer()) + .get(`/api/v1/frame/${OWNED_FRAME_ID}@3.png`) + .expect(400); + }); + }); + + describe("POST /api/v1/frame", () => { + const newFrame = { + name: "Brand New Frame", + canvasId: 1, + x0: 0, + y0: 0, + x1: 2, + y1: 2, + owner: { type: "user", id: MOCK_DISCORD_USER_ID }, + }; + + it("requires login", async () => { + await request(app.getHttpServer()) + .post("/api/v1/frame") + .send(newFrame) + .expect(401); + }); + + it("creates a frame the user owns", async () => { + const agent = request.agent(app.getHttpServer()); + await signIn(agent); + + const response = await agent + .post("/api/v1/frame") + .send(newFrame) + .expect(201); + + expect(response.body).toMatchObject({ + name: "Brand New Frame", + canvasId: 1, + }); + expect(response.body.id).toMatch(/^[0-9a-f]{6}$/); + + await expect( + prisma.frame.count({ + where: { ownerUserId: BigInt(MOCK_DISCORD_USER_ID), canvasId: 1 }, + }), + ).resolves.toBe(1); + }); + + it("forbids creating a frame for another user", async () => { + const agent = request.agent(app.getHttpServer()); + await signIn(agent); + + await agent + .post("/api/v1/frame") + .send({ ...newFrame, owner: { type: "user", id: OTHER_USER_ID } }) + .expect(403); + }); + + it("rejects an invalid body", async () => { + const agent = request.agent(app.getHttpServer()); + await signIn(agent); + + // x0 === x1 violates the bounds refinement. + await agent + .post("/api/v1/frame") + .send({ ...newFrame, x0: 2, x1: 2 }) + .expect(400); + }); + }); + + describe("PUT /api/v1/frame/:frameId/edit", () => { + const edit = { name: "Renamed Frame", x0: 0, y0: 0, x1: 2, y1: 2 }; + + it("requires login", async () => { + await request(app.getHttpServer()) + .put(`/api/v1/frame/${OWNED_FRAME_ID}/edit`) + .send(edit) + .expect(401); + }); + + it("edits a frame the user owns", async () => { + await seedFrames(); + + const agent = request.agent(app.getHttpServer()); + await signIn(agent); + + const response = await agent + .put(`/api/v1/frame/${OWNED_FRAME_ID}/edit`) + .send(edit) + .expect(200); + + expect(response.body).toMatchObject({ + id: OWNED_FRAME_ID, + name: "Renamed Frame", + }); + + await expect( + prisma.frame.findUnique({ where: { id: OWNED_FRAME_ID } }), + ).resolves.toMatchObject({ name: "Renamed Frame" }); + }); + + it("forbids editing a frame the user does not own", async () => { + await seedFrames(); + + const agent = request.agent(app.getHttpServer()); + await signIn(agent); + + await agent + .put(`/api/v1/frame/${OTHER_FRAME_ID}/edit`) + .send(edit) + .expect(403); + }); + }); + + describe("DELETE /api/v1/frame/:frameId/delete", () => { + it("requires login", async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/frame/${OWNED_FRAME_ID}/delete`) + .expect(401); + }); + + it("deletes a frame the user owns", async () => { + await seedFrames(); + + const agent = request.agent(app.getHttpServer()); + await signIn(agent); + + await agent.delete(`/api/v1/frame/${OWNED_FRAME_ID}/delete`).expect(204); + + await expect( + prisma.frame.findUnique({ where: { id: OWNED_FRAME_ID } }), + ).resolves.toBeNull(); + }); + }); +}); diff --git a/packages/backend/src/routes/api/v1/canvas.ts b/packages/backend/src/routes/api/v1/canvas.ts index aadb6ab46..d3d24a8e2 100644 --- a/packages/backend/src/routes/api/v1/canvas.ts +++ b/packages/backend/src/routes/api/v1/canvas.ts @@ -1,5 +1,6 @@ import { stat } from "node:fs/promises"; import { + type BoundsInput, CanvasExportParamModel, type CanvasExportScale, CanvasIdParamModel, @@ -10,8 +11,7 @@ import { CreateCanvasBodyModel, DEFAULT_CANVAS_EXPORT_SCALE, EditCanvasBodyModel, - type FrameBoundsInput, - OptionalFrameBoundsModel, + OptionalBoundsModel, } from "@blurple-canvas-web/types"; import { type Response, Router } from "express"; import BadRequestError from "@/errors/BadRequestError"; @@ -83,7 +83,7 @@ canvasRouter.get("/current", async (req, res) => { canvasRouter.get( "/:canvasId@:scale.png", - validate({ params: CanvasExportParamModel, query: OptionalFrameBoundsModel }), + validate({ params: CanvasExportParamModel, query: OptionalBoundsModel }), async (req, res) => { const scale = req.params.scale; addSpanAttributes(req, { @@ -316,7 +316,7 @@ async function sendCachedCanvas( canvasId: number, cachedCanvas: CachedCanvas, scale: CanvasExportScale = DEFAULT_CANVAS_EXPORT_SCALE, - bounds?: FrameBoundsInput, + bounds?: BoundsInput, ): Promise { if (cachedCanvas.placeState === CanvasPlaceState.NoOne) { const canvasPath = getLockedCanvasPath(cachedCanvas.canvasPaths, scale); diff --git a/packages/backend/src/services/canvasService.ts b/packages/backend/src/services/canvasService.ts index c9aad9e61..bb4a46862 100644 --- a/packages/backend/src/services/canvasService.ts +++ b/packages/backend/src/services/canvasService.ts @@ -4,7 +4,7 @@ import type { CanvasExportScale, CanvasInfo, CanvasSummary, - OptionalFrameBoundsModel, + OptionalBoundsModel, PixelColor, PlacePixelArray, Point, @@ -117,7 +117,7 @@ export function getCanvasFilename( canvasId: number, isLocked = false, scale: CanvasExportScale = DEFAULT_CANVAS_EXPORT_SCALE, - bounds?: z.infer, + bounds?: z.infer, ): string { const scaleSuffix = scale === 1 ? "" : `@${scale}x`; const boundsSuffix = diff --git a/packages/types/src/models/bounds.models.ts b/packages/types/src/models/bounds.models.ts new file mode 100644 index 000000000..7e48cc467 --- /dev/null +++ b/packages/types/src/models/bounds.models.ts @@ -0,0 +1,64 @@ +import z from "zod"; + +export const BoundsModel = z.object({ + x0: z.coerce.number().int().nonnegative(), + y0: z.coerce.number().int().nonnegative(), + x1: z.coerce.number().int().positive(), + y1: z.coerce.number().int().positive(), +}); + +export const boundsRefiner = ( + { x0, y0, x1, y1 }: z.infer, + ctx: z.core.$RefinementCtx, +) => { + if (x0 === x1) { + ctx.addIssue({ + code: "custom", + path: ["x1"], + message: "x0 must not be equal to x1", + }); + } + + if (y0 === y1) { + ctx.addIssue({ + code: "custom", + path: ["y1"], + message: "y0 must not be equal to y1", + }); + } +}; + +export const OptionalBoundsModel = BoundsModel.partial() + .superRefine((data, ctx) => { + const vals = [data.x0, data.y0, data.x1, data.y1]; + const definedCount = vals.filter((v) => v !== undefined).length; + if (definedCount > 0 && definedCount < 4) { + ctx.addIssue({ + code: "custom", + message: + "All bounds fields (x0, y0, x1, y1) must be provided or all omitted", + }); + return; + } + if (definedCount === 4) { + boundsRefiner(data as z.infer, ctx); + } + }) + .transform((data) => + data.x0 === undefined ? + undefined + : (data as z.infer), + ); + +export type BoundsInput = z.infer; + +/** Orders bounds so (x0, y0) is the top-left and (x1, y1) the bottom-right. */ +export const normalizeBounds = >( + data: T, +): T => ({ + ...data, + x0: Math.min(data.x0, data.x1), + y0: Math.min(data.y0, data.y1), + x1: Math.max(data.x0, data.x1), + y1: Math.max(data.y0, data.y1), +}); diff --git a/packages/types/src/models/frame.models.ts b/packages/types/src/models/frame.models.ts index afce81383..812f86748 100644 --- a/packages/types/src/models/frame.models.ts +++ b/packages/types/src/models/frame.models.ts @@ -1,5 +1,6 @@ import z from "zod"; import { FrameOwnerType } from "../frame.js"; +import { BoundsModel, boundsRefiner, normalizeBounds } from "./bounds.models.js"; import { CanvasExportScaleSchema, CanvasIdParamModel, @@ -11,64 +12,15 @@ export const FrameIdParamModel = z.object({ frameId: z.string().regex(/^[0-9a-fA-F]{6}$/), }); -const FrameBoundsModel = z.object({ - x0: z.coerce.number().int().nonnegative(), - y0: z.coerce.number().int().nonnegative(), - x1: z.coerce.number().int().positive(), - y1: z.coerce.number().int().positive(), -}); - -export const OptionalFrameBoundsModel = FrameBoundsModel.partial() - .superRefine((data, ctx) => { - const vals = [data.x0, data.y0, data.x1, data.y1]; - const definedCount = vals.filter((v) => v !== undefined).length; - if (definedCount > 0 && definedCount < 4) { - ctx.addIssue({ - code: "custom", - message: - "All bounds fields (x0, y0, x1, y1) must be provided or all omitted", - }); - return; - } - if (definedCount === 4) { - frameBoundsRefiner(data as z.infer, ctx); - } - }) - .transform((data) => - data.x0 === undefined ? - undefined - : (data as z.infer), - ); - -export type FrameBoundsInput = z.infer; - -const frameBoundsRefiner = ( - { x0, y0, x1, y1 }: z.infer, - ctx: z.core.$RefinementCtx, -) => { - if (x0 === x1) { - ctx.addIssue({ - code: "custom", - path: ["x1"], - message: "x0 must not be equal to x1", - }); - } - - if (y0 === y1) { - ctx.addIssue({ - code: "custom", - path: ["y1"], - message: "y0 must not be equal to y1", - }); - } -}; - export const FrameDataParamModel = z .object({ name: z.string().min(1).max(100), - ...FrameBoundsModel.shape, + ...BoundsModel.shape, }) - .superRefine(frameBoundsRefiner); + .superRefine(boundsRefiner) + .transform(normalizeBounds); + +export type FrameDataInput = z.infer; export const FrameOwnerParamModel = z .object({ @@ -90,11 +42,14 @@ export type FrameOwnerInput = z.infer; export const CreateFrameBodyModel = z .object({ name: z.string().min(1).max(100), - ...FrameBoundsModel.shape, + ...BoundsModel.shape, ...CanvasIdParamModel.shape, owner: FrameOwnerParamModel, }) - .superRefine(frameBoundsRefiner); + .superRefine(boundsRefiner) + .transform(normalizeBounds); + +export type CreateFrameBodyModel = z.infer; export const FrameGuildIdsQueryModel = z.object({ guildIds: z diff --git a/packages/types/src/models/index.ts b/packages/types/src/models/index.ts index 4a2e6a092..8180e9492 100644 --- a/packages/types/src/models/index.ts +++ b/packages/types/src/models/index.ts @@ -1,5 +1,6 @@ export * from "./auditLog.models.js"; export * from "./blocklist.models.js"; +export * from "./bounds.models.js"; export * from "./canvas.models.js"; export * from "./color.models.js"; export * from "./event.models.js";