Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/backend-nest/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,6 +27,7 @@ import { RealtimeModule } from "@/realtime/realtime.module";
RealtimeModule,
EventModule,
CanvasModule,
FrameModule,
PixelModule,
NoticeModule,
BlocklistModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = {}): Request {
return {
Expand Down
4 changes: 2 additions & 2 deletions packages/backend-nest/src/canvas/canvas-cache.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
12 changes: 6 additions & 6 deletions packages/backend-nest/src/canvas/canvas.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type BoundsInput,
CanvasExportParamModel,
type CanvasExportScale,
CanvasIdParamModel,
Expand All @@ -9,8 +10,7 @@ import {
CreateCanvasBodyModel,
DEFAULT_CANVAS_EXPORT_SCALE,
EditCanvasBodyModel,
type FrameBoundsInput,
OptionalFrameBoundsModel,
OptionalBoundsModel,
} from "@blurple-canvas-web/types";
import {
Body,
Expand Down Expand Up @@ -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<NonNullable<FrameBoundsInput>>,
OptionalBoundsModel as z.ZodType<NonNullable<BoundsInput>>,
) {}

class CreateCanvasBodyDto extends createZodDto(CreateCanvasBodyModel) {}
Expand Down Expand Up @@ -179,7 +179,7 @@ export class CanvasController {
cachedCanvas,
params.scale,
// The schema transforms an absent crop to `undefined`.
bounds as FrameBoundsInput,
bounds as BoundsInput,
);
}

Expand Down Expand Up @@ -322,7 +322,7 @@ export class CanvasController {
canvasId: number,
cachedCanvas: CachedCanvas,
scale: CanvasExportScale = DEFAULT_CANVAS_EXPORT_SCALE,
bounds?: FrameBoundsInput,
bounds?: BoundsInput,
): Promise<void> {
if (cachedCanvas.isLocked) {
const canvasPath = cachedCanvas.canvasPaths[scale];
Expand Down
231 changes: 231 additions & 0 deletions packages/backend-nest/src/frame/frame.controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, boolean> = {};
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<void> {
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,
),
);
}
}
14 changes: 14 additions & 0 deletions packages/backend-nest/src/frame/frame.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading