feat: Sprint 1 — backend fondations TetaRdPG
Auth SuperOAuth (JWT validation + httpOnly cookie), entités users/characters/level_thresholds, lazy calculation endurance, seed 100 niveaux, config prod-ready (trust proxy, helmet, CORS, rate limit). Validé : health 200, auth flow, character CRUD, endurance lazy, 401 sans cookie.
This commit is contained in:
37
src/app.module.ts
Normal file
37
src/app.module.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CharacterModule } from './character/character.module';
|
||||
import { HealthController } from './common/health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
url: config.get<string>('DATABASE_URL'),
|
||||
autoLoadEntities: true,
|
||||
synchronize: config.get('NODE_ENV') !== 'production',
|
||||
logging: config.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60_000,
|
||||
limit: 20,
|
||||
},
|
||||
]),
|
||||
|
||||
AuthModule,
|
||||
CharacterModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
44
src/auth/auth.controller.ts
Normal file
44
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Res,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Response, Request } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthGuard } from './guards/auth.guard';
|
||||
import { SetSessionDto } from './dto/set-session.dto';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('session')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ default: { ttl: 60_000, limit: 10 } })
|
||||
async setSession(
|
||||
@Body() dto: SetSessionDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
return this.authService.setSession(dto, res);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AuthGuard)
|
||||
async getMe(@Req() req: Request & { user: User }) {
|
||||
return this.authService.getMe(req.user);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
logout(@Res({ passthrough: true }) res: Response) {
|
||||
this.authService.logout(res);
|
||||
}
|
||||
}
|
||||
25
src/auth/auth.module.ts
Normal file
25
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { User } from '../user/user.entity';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthGuard } from './guards/auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User]),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('SUPER_OAUTH_JWT_SECRET'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthGuard],
|
||||
exports: [AuthGuard, TypeOrmModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
89
src/auth/auth.service.ts
Normal file
89
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Response } from 'express';
|
||||
import { User } from '../user/user.entity';
|
||||
import { SetSessionDto } from './dto/set-session.dto';
|
||||
|
||||
// Payload émis par SuperOAuth
|
||||
interface SuperOAuthPayload {
|
||||
sub: string; // ID provider (Twitch ID, Discord ID…)
|
||||
provider: string; // 'twitch' | 'discord' | 'google' | 'github'
|
||||
username: string;
|
||||
avatar_url?: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'oauthId'>> {
|
||||
let payload: SuperOAuthPayload;
|
||||
|
||||
try {
|
||||
payload = await this.jwtService.verifyAsync<SuperOAuthPayload>(dto.jwt, {
|
||||
secret: this.configService.get<string>('SUPER_OAUTH_JWT_SECRET'),
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('JWT SuperOAuth invalide ou expiré');
|
||||
}
|
||||
|
||||
if (!payload.sub || !payload.provider || !payload.username) {
|
||||
throw new UnauthorizedException('Payload JWT incomplet');
|
||||
}
|
||||
|
||||
// Upsert user
|
||||
let user = await this.userRepository.findOne({
|
||||
where: { oauthId: payload.sub, provider: payload.provider },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = this.userRepository.create({
|
||||
oauthId: payload.sub,
|
||||
provider: payload.provider,
|
||||
username: payload.username,
|
||||
avatarUrl: payload.avatar_url ?? null,
|
||||
});
|
||||
} else {
|
||||
user.username = payload.username;
|
||||
user.avatarUrl = payload.avatar_url ?? null;
|
||||
}
|
||||
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// Cookie httpOnly signé — valeur = UUID interne
|
||||
const isProduction = this.configService.get('NODE_ENV') === 'production';
|
||||
res.cookie('session', user.id, {
|
||||
httpOnly: true,
|
||||
signed: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||
});
|
||||
|
||||
const { oauthId: _, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
async getMe(user: User): Promise<Omit<User, 'oauthId'>> {
|
||||
const { oauthId: _, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
logout(res: Response): void {
|
||||
res.clearCookie('session');
|
||||
}
|
||||
}
|
||||
7
src/auth/dto/set-session.dto.ts
Normal file
7
src/auth/dto/set-session.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class SetSessionDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
jwt: string;
|
||||
}
|
||||
41
src/auth/guards/auth.guard.ts
Normal file
41
src/auth/guards/auth.guard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../../user/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const userId: string | false = request.signedCookies?.session;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('Session manquante ou invalide');
|
||||
}
|
||||
|
||||
// Validation UUID basique avant la requête DB
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(userId)) {
|
||||
throw new UnauthorizedException('Session invalide');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Utilisateur introuvable');
|
||||
}
|
||||
|
||||
request.user = user;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
40
src/character/character.controller.ts
Normal file
40
src/character/character.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { CharacterService } from './character.service';
|
||||
import { CreateCharacterDto } from './dto/create-character.dto';
|
||||
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@Controller('characters')
|
||||
@UseGuards(AuthGuard)
|
||||
export class CharacterController {
|
||||
constructor(private readonly characterService: CharacterService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
create(
|
||||
@Body() dto: CreateCharacterDto,
|
||||
@Req() req: Request & { user: User },
|
||||
) {
|
||||
return this.characterService.create(dto, req.user);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
findMe(@Req() req: Request & { user: User }) {
|
||||
return this.characterService.findByUser(req.user);
|
||||
}
|
||||
|
||||
@Get('me/endurance')
|
||||
getEndurance(@Req() req: Request & { user: User }) {
|
||||
return this.characterService.getEndurance(req.user);
|
||||
}
|
||||
}
|
||||
17
src/character/character.module.ts
Normal file
17
src/character/character.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Character } from './entities/character.entity';
|
||||
import { LevelThreshold } from './entities/level-threshold.entity';
|
||||
import { CharacterController } from './character.controller';
|
||||
import { CharacterService } from './character.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Character, LevelThreshold]),
|
||||
AuthModule, // pour AuthGuard + User repository
|
||||
],
|
||||
controllers: [CharacterController],
|
||||
providers: [CharacterService],
|
||||
})
|
||||
export class CharacterModule {}
|
||||
97
src/character/character.service.ts
Normal file
97
src/character/character.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Character } from './entities/character.entity';
|
||||
import { LevelThreshold } from './entities/level-threshold.entity';
|
||||
import { CreateCharacterDto } from './dto/create-character.dto';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
const STAT_POOL = 10; // 5 stats × 1 base + 5 points à distribuer
|
||||
const ENDURANCE_REGEN_MINUTES = 6; // 1 pt d'endurance toutes les 6 min = 10 pts/heure
|
||||
|
||||
@Injectable()
|
||||
export class CharacterService {
|
||||
constructor(
|
||||
@InjectRepository(Character)
|
||||
private readonly characterRepository: Repository<Character>,
|
||||
@InjectRepository(LevelThreshold)
|
||||
private readonly levelThresholdRepository: Repository<LevelThreshold>,
|
||||
) {}
|
||||
|
||||
// Pattern lazy calculation — pas de timer actif
|
||||
private calculateEndurance(character: Character): number {
|
||||
const elapsedMinutes =
|
||||
(Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||
const recharge = Math.floor(elapsedMinutes / ENDURANCE_REGEN_MINUTES);
|
||||
return Math.min(character.enduranceSaved + recharge, character.enduranceMax);
|
||||
}
|
||||
|
||||
async create(dto: CreateCharacterDto, user: User): Promise<Character & { enduranceCurrent: number }> {
|
||||
const totalStats =
|
||||
dto.force + dto.agilite + dto.intelligence + dto.chance + dto.vitalite;
|
||||
|
||||
if (totalStats !== STAT_POOL) {
|
||||
throw new BadRequestException(
|
||||
`La somme des stats doit être égale à ${STAT_POOL} (reçu : ${totalStats})`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.characterRepository.findOne({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Ce joueur possède déjà un personnage');
|
||||
}
|
||||
|
||||
const character = this.characterRepository.create({
|
||||
userId: user.id,
|
||||
name: dto.name,
|
||||
force: dto.force,
|
||||
agilite: dto.agilite,
|
||||
intelligence: dto.intelligence,
|
||||
chance: dto.chance,
|
||||
vitalite: dto.vitalite,
|
||||
enduranceSaved: 100,
|
||||
lastEnduranceTs: new Date(),
|
||||
enduranceMax: 100,
|
||||
});
|
||||
|
||||
const saved = await this.characterRepository.save(character);
|
||||
return { ...saved, enduranceCurrent: this.calculateEndurance(saved) };
|
||||
}
|
||||
|
||||
async findByUser(user: User): Promise<Character & { enduranceCurrent: number }> {
|
||||
const character = await this.characterRepository.findOne({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (!character) {
|
||||
throw new NotFoundException('Aucun personnage trouvé pour ce joueur');
|
||||
}
|
||||
|
||||
return { ...character, enduranceCurrent: this.calculateEndurance(character) };
|
||||
}
|
||||
|
||||
async getEndurance(
|
||||
user: User,
|
||||
): Promise<{ enduranceCurrent: number; enduranceMax: number; rechargeRatePerHour: number }> {
|
||||
const character = await this.characterRepository.findOne({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (!character) {
|
||||
throw new NotFoundException('Aucun personnage trouvé pour ce joueur');
|
||||
}
|
||||
|
||||
return {
|
||||
enduranceCurrent: this.calculateEndurance(character),
|
||||
enduranceMax: character.enduranceMax,
|
||||
rechargeRatePerHour: 60 / ENDURANCE_REGEN_MINUTES,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
src/character/dto/create-character.dto.ts
Normal file
35
src/character/dto/create-character.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { IsInt, IsString, Length, Min, Max } from 'class-validator';
|
||||
|
||||
// 5 points de stats à répartir — chaque stat démarre à 1
|
||||
// Contrainte : force + agilite + intelligence + chance + vitalite = 10 (5 base + 5 extra)
|
||||
// Validé dans le service
|
||||
export class CreateCharacterDto {
|
||||
@IsString()
|
||||
@Length(2, 100)
|
||||
name: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(6)
|
||||
force: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(6)
|
||||
agilite: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(6)
|
||||
intelligence: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(6)
|
||||
chance: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(6)
|
||||
vitalite: number;
|
||||
}
|
||||
75
src/character/entities/character.entity.ts
Normal file
75
src/character/entities/character.entity.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/user.entity';
|
||||
|
||||
@Entity('characters')
|
||||
@Unique(['userId'])
|
||||
export class Character {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ default: 1 })
|
||||
level: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
xp: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
gold: number;
|
||||
|
||||
// Stats (cap : 101)
|
||||
@Column({ default: 1 })
|
||||
force: number;
|
||||
|
||||
@Column({ default: 1 })
|
||||
agilite: number;
|
||||
|
||||
@Column({ default: 1 })
|
||||
intelligence: number;
|
||||
|
||||
@Column({ default: 1 })
|
||||
chance: number;
|
||||
|
||||
@Column({ default: 1 })
|
||||
vitalite: number;
|
||||
|
||||
@Column({ name: 'hp_current', default: 100 })
|
||||
hpCurrent: number;
|
||||
|
||||
@Column({ name: 'hp_max', default: 100 })
|
||||
hpMax: number;
|
||||
|
||||
// Endurance — lazy calculation (pas de timer actif)
|
||||
@Column({ name: 'endurance_saved', default: 100 })
|
||||
enduranceSaved: number;
|
||||
|
||||
@Column({ name: 'last_endurance_ts', type: 'timestamp', default: () => 'NOW()' })
|
||||
lastEnduranceTs: Date;
|
||||
|
||||
@Column({ name: 'endurance_max', default: 100 })
|
||||
enduranceMax: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
10
src/character/entities/level-threshold.entity.ts
Normal file
10
src/character/entities/level-threshold.entity.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Entity, PrimaryColumn, Column } from 'typeorm';
|
||||
|
||||
@Entity('level_thresholds')
|
||||
export class LevelThreshold {
|
||||
@PrimaryColumn()
|
||||
level: number;
|
||||
|
||||
@Column({ name: 'xp_required' })
|
||||
xpRequired: number;
|
||||
}
|
||||
9
src/common/health.controller.ts
Normal file
9
src/common/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
14
src/database/data-source.ts
Normal file
14
src/database/data-source.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { User } from '../user/user.entity';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { LevelThreshold } from '../character/entities/level-threshold.entity';
|
||||
|
||||
// DataSource pour le CLI TypeORM (migrations manuelles)
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg',
|
||||
entities: [User, Character, LevelThreshold],
|
||||
migrations: ['src/database/migrations/*.ts'],
|
||||
synchronize: false,
|
||||
});
|
||||
46
src/database/seed.ts
Normal file
46
src/database/seed.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { LevelThreshold } from '../character/entities/level-threshold.entity';
|
||||
|
||||
const dataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg',
|
||||
entities: [LevelThreshold],
|
||||
synchronize: false,
|
||||
});
|
||||
|
||||
async function seed() {
|
||||
await dataSource.initialize();
|
||||
console.log('DB connectée');
|
||||
|
||||
const repo = dataSource.getRepository(LevelThreshold);
|
||||
const existing = await repo.count();
|
||||
|
||||
if (existing >= 100) {
|
||||
console.log('Level thresholds déjà seedés — skip');
|
||||
await dataSource.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// XP requis = 100 × level^1.5
|
||||
// Level 1 : 100 XP
|
||||
// Level 10 : 3162 XP
|
||||
// Level 100: 1 000 000 XP
|
||||
const thresholds: LevelThreshold[] = Array.from({ length: 100 }, (_, i) => {
|
||||
const level = i + 1;
|
||||
const threshold = new LevelThreshold();
|
||||
threshold.level = level;
|
||||
threshold.xpRequired = Math.round(100 * Math.pow(level, 1.5));
|
||||
return threshold;
|
||||
});
|
||||
|
||||
await repo.save(thresholds);
|
||||
console.log('✅ 100 level_thresholds seedés');
|
||||
|
||||
await dataSource.destroy();
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error('Seed échoué :', err);
|
||||
process.exit(1);
|
||||
});
|
||||
49
src/main.ts
Normal file
49
src/main.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// VPS derrière Apache / reverse proxy — obligatoire pour rate limiter + IP logs corrects
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
|
||||
// Cookie parser avec signature
|
||||
const cookieSecret = process.env.COOKIE_SECRET;
|
||||
if (!cookieSecret) throw new Error('COOKIE_SECRET manquant');
|
||||
app.use(cookieParser(cookieSecret));
|
||||
|
||||
// CORS — multi-origin depuis l'env
|
||||
const allowedOrigins = (process.env.FRONTEND_URL ?? 'http://localhost:5173')
|
||||
.split(',')
|
||||
.map((o) => o.trim());
|
||||
|
||||
app.enableCors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Validation globale
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Prefix global
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const port = process.env.PORT ?? 4000;
|
||||
await app.listen(port);
|
||||
console.log(`TetaRdPG backend démarré sur le port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
33
src/user/user.entity.ts
Normal file
33
src/user/user.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('users')
|
||||
@Unique(['oauthId', 'provider'])
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'oauth_id', length: 255 })
|
||||
oauthId: string;
|
||||
|
||||
@Column({ length: 50 })
|
||||
provider: string;
|
||||
|
||||
@Column({ length: 255 })
|
||||
username: string;
|
||||
|
||||
@Column({ name: 'avatar_url', type: 'varchar', length: 500, nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
9
src/user/user.module.ts
Normal file
9
src/user/user.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
exports: [TypeOrmModule],
|
||||
})
|
||||
export class UserModule {}
|
||||
Reference in New Issue
Block a user