Skip to content
Open
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
42 changes: 42 additions & 0 deletions backend/src/common/guards/ban.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'
import { respondError } from '@/common'
import { AdminService } from '@/modules/admin/admin.service'
import { AuthenticRequest } from '@/modules/auth/auth-request'
import { AuthenticSocket } from '@/modules/auth/auth-socket'

/**
* Guard that blocks banned users from accessing endpoints
*/
@Injectable()
export class BanGuard implements CanActivate {
constructor(private readonly adminService: AdminService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
let userId: string | undefined

switch (context.getType()) {
case 'http': {
const request = context.switchToHttp().getRequest<AuthenticRequest>()
userId = request.userId
break
}
case 'ws': {
const socket = context.switchToWs().getClient<AuthenticSocket>()
userId = socket.data.userId
break
}
default: {
respondError('not-implemented', 501, 'Ban guard not implemented for this context type')
}
}

if (!userId) return false

const isBanned = await this.adminService.isUserBanned(userId)
if (isBanned) {
respondError('forbidden', 403, 'User is banned')
}

return true
}
}
2 changes: 2 additions & 0 deletions backend/src/common/guards/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './ban.guard'
export * from './ws-throttler.guard'
34 changes: 32 additions & 2 deletions backend/src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { Controller, Post, UseGuards } from '@nestjs/common'
import { BanUserBody, BanUserResult, UnbanUserBody, UnbanUserResult } from '@magic3t/api-types'
import { Body, Controller, Post, UseGuards } from '@nestjs/common'
import { ApiOperation } from '@nestjs/swagger'
import { ConfigRepository, UserRepository } from '@/infra/database'
import { AuthGuard } from '@/modules/auth/auth.guard'
import { UserId } from '@/modules/auth/user-id.decorator'
import { AdminGuard } from './admin.guard'
import { AdminService } from './admin.service'

@Controller('admin')
@UseGuards(AuthGuard, AdminGuard)
export class AdminController {
constructor(
private usersRepository: UserRepository,
private configRepository: ConfigRepository
private configRepository: ConfigRepository,
private adminService: AdminService
) {}

@ApiOperation({})
Expand All @@ -32,4 +36,30 @@ export class AdminController {
})
)
}

@ApiOperation({ summary: 'Ban a user temporarily or permanently' })
@Post('ban-user')
async banUser(@Body() body: BanUserBody, @UserId() adminId: string): Promise<BanUserResult> {
const expiresAt = await this.adminService.banUser(
body.userId,
adminId,
body.reason,
body.durationMinutes
)

return {
success: true,
bannedUntil: expiresAt,
}
}

@ApiOperation({ summary: 'Unban a user' })
@Post('unban-user')
async unbanUser(@Body() body: UnbanUserBody): Promise<UnbanUserResult> {
await this.adminService.unbanUser(body.userId)

return {
success: true,
}
}
}
1 change: 1 addition & 0 deletions backend/src/modules/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ import { AdminService } from './admin.service'
controllers: [AdminController],
providers: [AdminService],
imports: [DatabaseModule],
exports: [AdminService],
})
export class AdminModule {}
114 changes: 112 additions & 2 deletions backend/src/modules/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,114 @@
import { Injectable } from '@nestjs/common'
import { Injectable, Logger } from '@nestjs/common'
import { respondError } from '@/common'
import { UserRepository } from '@/infra/database'

@Injectable()
export class AdminService {}
export class AdminService {
private readonly logger = new Logger(AdminService.name, { timestamp: true })

constructor(private readonly userRepository: UserRepository) {}

/**
* Ban a user either temporarily or permanently
* @param userId - The ID of the user to ban
* @param adminId - The ID of the admin performing the ban
* @param reason - The reason for the ban
* @param durationMinutes - Duration in minutes (undefined for permanent ban)
* @returns The date when the ban expires, or undefined for permanent bans
*/
async banUser(
userId: string,
adminId: string,
reason: string,
durationMinutes?: number
): Promise<Date | undefined> {
const user = await this.userRepository.getById(userId)
if (!user) {
respondError('not-found', 404, 'User not found')
}

if (user.data.role === 'creator') {
respondError('forbidden', 403, 'Cannot ban a creator')
}

const bannedAt = new Date()
const expiresAt = durationMinutes
? new Date(bannedAt.getTime() + durationMinutes * 60000)
: new Date(0)

await this.userRepository.set(userId, {
...user.data,
ban: {
isBanned: true,
reason,
bannedAt,
expiresAt,
bannedBy: adminId,
},
})

this.logger.log(
`User ${user.data.identification.nickname} (${userId}) was ${expiresAt ? 'temporarily' : 'permanently'} banned by admin ${adminId}. Reason: ${reason}`
)

return expiresAt
}

/**
* Unban a user
* @param userId - The ID of the user to unban
*/
async unbanUser(userId: string): Promise<void> {
const user = await this.userRepository.getById(userId)
if (!user) {
respondError('not-found', 404, 'User not found')
}

if (!user.data.ban?.isBanned) {
respondError('bad-request', 400, 'User is not banned')
}

await this.userRepository.set(userId, {
...user.data,
ban: {
...user.data.ban,
isBanned: false,
},
})

this.logger.log(`User ${user.data.identification.nickname} (${userId}) was unbanned`)
}

/**
* Check if a user is currently banned
* @param userId - The ID of the user to check
* @returns true if the user is banned, false otherwise
*/
async isUserBanned(userId: string): Promise<boolean> {
const user = await this.userRepository.getById(userId)
if (!user || !user.data.ban) return false

const ban = user.data.ban

// If not marked as banned, return false
if (!ban.isBanned) return false

// If it's a permanent ban (no expiration date), return true
if (!ban.expiresAt) return true

// Check if temporary ban has expired
if (new Date() > ban.expiresAt) {
// Auto-unban if the ban has expired
await this.userRepository.set(userId, {
...user.data,
ban: {
...ban,
isBanned: false,
},
})
return false
}

return true
}
}
5 changes: 3 additions & 2 deletions backend/src/modules/match/match.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { ApiOperation, ApiResponse } from '@nestjs/swagger'
import { clamp } from 'lodash'
import { respondError } from '@/common'
import { BanGuard } from '@/common/guards'
import { MatchRepository } from '@/infra/database'
import { AuthGuard } from '@/modules/auth/auth.guard'
import { UserId } from '@/modules/auth/user-id.decorator'
Expand Down Expand Up @@ -84,7 +85,7 @@ export class MatchController {
}

@Get('current')
@UseGuards(AuthGuard)
@UseGuards(AuthGuard, BanGuard)
handleCurrentMatch(@UserId() userId: string) {
const perspective = this.matchBank.getPerspective(userId)
// TODO: shouldn't return 404
Expand All @@ -95,7 +96,7 @@ export class MatchController {
}

@Get('me/am-active')
@UseGuards(AuthGuard)
@UseGuards(AuthGuard, BanGuard)
handleActiveMatch(@UserId() userId: string) {
const perspective = this.matchBank.getPerspective(userId)
if (!perspective) return false
Expand Down
3 changes: 2 additions & 1 deletion backend/src/modules/match/match.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
WebSocketGateway,
} from '@nestjs/websockets'
import { ChoicePipe } from '@/common'
import { BanGuard } from '@/common/guards'
import { BaseGateway } from '@/common/websocket/base.gateway'
import { WebsocketEmitterService } from '@/infra/websocket/websocket-emitter.service'
import { UserId } from '@/modules/auth/user-id.decorator'
Expand All @@ -29,7 +30,7 @@ import { matchException } from './types/match-error'

const MAX_MESSAGE_LENGTH = 500

@UseGuards(AuthGuard, MatchGuard)
@UseGuards(AuthGuard, BanGuard, MatchGuard)
@WebSocketGateway({ cors: { origin: CORS_ALLOWED_ORIGINS, credentials: true }, namespace: 'match' })
export class MatchGateway extends BaseGateway<GameClientEventsMap, GameServerEventsMap, 'match'> {
constructor(
Expand Down
2 changes: 2 additions & 0 deletions backend/src/modules/match/match.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'
import { AdminModule } from '@/modules/admin'
import { ClientSyncService } from './client-sync.service'
import { MatchBank } from './lib/match-bank'
import { MatchController } from './match.controller'
Expand All @@ -8,6 +9,7 @@ import { PersistanceService } from './persistance.service'

@Module({
controllers: [MatchController],
imports: [AdminModule],
providers: [MatchGateway, MatchBank, MatchService, PersistanceService, ClientSyncService],
exports: [MatchService],
})
Expand Down
3 changes: 2 additions & 1 deletion backend/src/modules/queue/queue.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { BotName } from '@magic3t/database-types'
import { Body, Controller, Delete, Post, UseGuards } from '@nestjs/common'
import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'
import { respondError } from '@/common'
import { BanGuard } from '@/common/guards'
import { AuthGuard } from '@/modules/auth/auth.guard'
import { UserId } from '@/modules/auth/user-id.decorator'
import { EnqueueDto, QueueMode } from './dtos/enqueue-dto'
import { QueueService } from './queue.service'

@Controller('queue')
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(AuthGuard, BanGuard)
export class QueueController {
// private readonly logger = new Logger(QueueController.name, {
// timestamp: true,
Expand Down
3 changes: 3 additions & 0 deletions backend/src/modules/queue/queue.gateway.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { QueueClientEventsMap, QueueServerEvents, QueueServerEventsMap } from '@magic3t/api-types'
import { BotName } from '@magic3t/database-types'
import { UseGuards } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { MessageBody, SubscribeMessage, WebSocketGateway } from '@nestjs/websockets'
import { BanGuard } from '@/common/guards'
import { BaseGateway } from '@/common/websocket/base.gateway'
import { WebsocketCountingService } from '@/infra/websocket/websocket-counting.service'
import { UserId } from '@/modules/auth/user-id.decorator'
Expand All @@ -10,6 +12,7 @@ import { GameModePipe } from './pipes/game-mode.pipe'
import { QueueService } from './queue.service'

@WebSocketGateway({ cors: { origin: CORS_ALLOWED_ORIGINS, credentials: true }, namespace: 'queue' })
@UseGuards(BanGuard)
export class QueueGateway extends BaseGateway<QueueClientEventsMap, QueueServerEventsMap, 'queue'> {
constructor(
private queueService: QueueService,
Expand Down
3 changes: 2 additions & 1 deletion backend/src/modules/queue/queue.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '@/infra/database'
import { FirebaseModule } from '@/infra/firebase'
import { AdminModule } from '@/modules/admin'
import { MatchModule } from '@/modules/match'
import { QueueController } from './queue.controller'
import { QueueGateway } from './queue.gateway'
Expand All @@ -10,7 +11,7 @@ export const QueueSocketsService = Symbol('QueueSocketsService')

@Module({
controllers: [QueueController],
imports: [MatchModule, DatabaseModule, FirebaseModule],
imports: [MatchModule, DatabaseModule, FirebaseModule, AdminModule],
providers: [QueueGateway, QueueService],
})
export class QueueModule {}
Loading
Loading