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
23 changes: 23 additions & 0 deletions backend/docs/DATABASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ type UserRow = {

role: UserRole // 'player' | 'creator' | 'bot'

ban?: UserBan | null // Informações de banimento (ver abaixo)

elo: UserRowElo // Dados de rating (ver abaixo)

stats: {
Expand All @@ -67,6 +69,27 @@ type UserRow = {
}
```

### Campo `ban` (UserBan)

```typescript
type UserBan = {
type: UserBanType // 'temporary' | 'permanent'
created_at: Date // Data/hora do banimento
banned_by: string // UID do criador que aplicou o ban
reason?: string // Motivo opcional
expires_at?: Date | null // Apenas para ban temporário
}
```

### Enum `UserBanType`

```typescript
enum UserBanType {
Temporary = 'temporary',
Permanent = 'permanent',
}
```

### Campo `elo` (UserRowElo)

```typescript
Expand Down
50 changes: 49 additions & 1 deletion backend/src/common/websocket/base.gateway.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { UserBanType } from '@magic3t/database-types'
import { Inject, UseFilters, UseGuards } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'
import { OnGatewayConnection, OnGatewayInit, WebSocketServer } from '@nestjs/websockets'
import { EventNames, EventParams, EventsMap } from '@socket.io/component-emitter'
import { DefaultEventsMap, Namespace, Server, Socket } from 'socket.io'

import { UserRepository } from '@/infra/database'
import { WebsocketEmitterEvent } from '@/infra/websocket/types'
import { WebsocketCountingService } from '@/infra/websocket/websocket-counting.service'
Expand Down Expand Up @@ -81,6 +81,54 @@ export class BaseGateway<
return
}

const user = await this.usersRepository.getById(userId)
if (user?.data.ban) {
const ban = user.data.ban

if (ban.type === UserBanType.Permanent) {
client.send('error', {
errorCode: 'user-banned',
metadata: {
type: ban.type,
reason: ban.reason,
createdAt: ban.created_at,
},
})
client.disconnect()
return
}

const expiresAt = ban.expires_at
if (!expiresAt || !(expiresAt instanceof Date)) {
client.send('error', {
errorCode: 'user-banned',
metadata: {
type: ban.type,
reason: ban.reason,
createdAt: ban.created_at,
},
})
client.disconnect()
return
}

if (expiresAt.getTime() > Date.now()) {
client.send('error', {
errorCode: 'user-banned',
metadata: {
type: ban.type,
reason: ban.reason,
createdAt: ban.created_at,
expiresAt,
},
})
client.disconnect()
return
}

await this.usersRepository.clearBan(userId)
}

// Attach user ID to the socket data
const authClient = client as AuthenticSocket
authClient.data.userId = userId
Expand Down
18 changes: 17 additions & 1 deletion backend/src/infra/database/repositories/user/user.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IconAssignmentRow, UserRow } from '@magic3t/database-types'
import { IconAssignmentRow, UserBan, UserRow } from '@magic3t/database-types'
import { Injectable, Logger } from '@nestjs/common'
import { FirestoreDataConverter } from 'firebase-admin/firestore'
import { unexpected } from '@/common'
Expand Down Expand Up @@ -111,6 +111,22 @@ export class UserRepository extends BaseFirestoreRepository<UserRow> {
this.user_logger.verbose(`update user "${id}" nickname to ${nickname}.`)
}

/** Sets a user's ban data. */
async setBan(id: string, ban: UserBan): Promise<void> {
await this.collection.doc(id).update({
ban,
})
this.user_logger.verbose(`set ban for user "${id}".`)
}

/** Clears a user's ban data. */
async clearBan(id: string): Promise<void> {
await this.collection.doc(id).update({
ban: null,
})
this.user_logger.verbose(`cleared ban for user "${id}".`)
}

/// Gets all bot profiles, sorted by the bot name (bot0, bot1, bot2 and bot3)
async getBots(): Promise<ListResult<UserRow>> {
const bots = await this.configService.getBotConfigs()
Expand Down
68 changes: 66 additions & 2 deletions backend/src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,81 @@
import { Controller, Post, UseGuards } from '@nestjs/common'
import { ApiOperation } from '@nestjs/swagger'
import { BanUserCommand } from '@magic3t/api-types'
import { UserBanType } from '@magic3t/database-types'
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'
import { ApiBearerAuth, ApiOperation, ApiProperty } from '@nestjs/swagger'
import { IsEnum, IsNumber, IsOptional, IsString, Min, ValidateIf } from 'class-validator'
import { respondError } from '@/common'
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'

export class BanUserCommandClass implements BanUserCommand {
@ApiProperty({
enum: [UserBanType.Temporary, UserBanType.Permanent],
})
@IsEnum([UserBanType.Temporary, UserBanType.Permanent])
type: UserBanType

@ApiProperty({
required: false,
description: 'Required for temporary bans. Duration in seconds.',
})
@ValidateIf((o) => o.type === UserBanType.Temporary)
@IsNumber()
@Min(1)
durationSeconds?: number

@ApiProperty({
required: false,
})
@IsOptional()
@IsString()
reason?: string
}

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

@ApiOperation({
summary: 'Ban a user (temporary or permanent)',
})
@Post('users/:id/ban')
async banUser(
@UserId() actorId: string,
@Param('id') userId: string,
@Body() body: BanUserCommandClass
) {
if (actorId === userId) {
respondError('cannot-ban-self', 400, 'You cannot ban yourself')
}

const user = await this.usersRepository.getById(userId)
if (!user) respondError('user-not-found', 404, 'User not found')

let expiresAt: Date | null = null
if (body.type === UserBanType.Temporary) {
if (!body.durationSeconds || body.durationSeconds <= 0) {
respondError('invalid-ban-duration', 400, 'Temporary bans require a duration in seconds')
}

expiresAt = new Date(Date.now() + body.durationSeconds * 1000)
}

await this.usersRepository.setBan(userId, {
type: body.type,
created_at: new Date(),
banned_by: actorId,
reason: body.reason,
expires_at: expiresAt,
})
}

@ApiOperation({})
@Post('reset-ratings')
async resetRatings() {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/modules/admin/admin.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '@/infra/database'
import { AdminController } from './admin.controller'
import { AdminGuard } from './admin.guard'
import { AdminService } from './admin.service'

@Module({
controllers: [AdminController],
providers: [AdminService],
providers: [AdminService, AdminGuard],
imports: [DatabaseModule],
})
export class AdminModule {}
42 changes: 41 additions & 1 deletion backend/src/modules/auth/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { UserBanType } from '@magic3t/database-types'
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { Socket } from 'socket.io'
import { respondError } from '@/common'
import { UserRepository } from '@/infra/database'
import { AuthService } from './auth.service'
import { AuthenticRequest } from './auth-request'
import { SKIP_AUTH_KEY } from './skip-auth.decorator'
Expand All @@ -12,7 +14,8 @@ export class AuthGuard implements CanActivate {

constructor(
private readonly authService: AuthService,
private readonly reflector: Reflector
private readonly reflector: Reflector,
private readonly userRepository: UserRepository
) {}

async canActivate(context: ExecutionContext) {
Expand Down Expand Up @@ -48,6 +51,7 @@ export class AuthGuard implements CanActivate {
if (!token) respondError('unauthorized', 401, '"Authorization" header is missing')
const userId = await this.authService.validateToken(token.replace('Bearer ', ''))
if (!userId) respondError('unauthorized', 401, 'Invalid auth token')
await this.assertUserNotBanned(userId)
request.userId = userId
return true
}
Expand All @@ -60,6 +64,42 @@ export class AuthGuard implements CanActivate {
return false
}

await this.assertUserNotBanned(socket.data.userId)

return true
}

private async assertUserNotBanned(userId: string): Promise<void> {
const user = await this.userRepository.getById(userId)
if (!user || !user.data.ban) return

const ban = user.data.ban
if (ban.type === UserBanType.Permanent) {
respondError('user-banned', 403, {
type: ban.type,
reason: ban.reason,
createdAt: ban.created_at,
})
}

const expiresAt = ban.expires_at
if (!expiresAt || !(expiresAt instanceof Date)) {
respondError('user-banned', 403, {
type: ban.type,
reason: ban.reason,
createdAt: ban.created_at,
})
}

if (expiresAt.getTime() > Date.now()) {
respondError('user-banned', 403, {
type: ban.type,
reason: ban.reason,
createdAt: ban.created_at,
expiresAt,
})
}

await this.userRepository.clearBan(userId)
}
}
5 changes: 3 additions & 2 deletions backend/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Global, Module } from '@nestjs/common'
import { FirebaseModule } from '@/infra/firebase'
import { AuthGuard } from './auth.guard'
import { AuthService } from './auth.service'

@Global()
@Module({
imports: [FirebaseModule],
providers: [AuthService],
exports: [AuthService],
providers: [AuthService, AuthGuard],
exports: [AuthService, AuthGuard],
})
export class AuthModule {
imports: [FirebaseModule]
Expand Down
1 change: 1 addition & 0 deletions backend/src/modules/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export class UserController {
challenger: false,
matches: 0,
},
ban: null,
experience: 0,
identification: {
last_changed: new Date(),
Expand Down
Loading
Loading