From 39cbff81ceb446ea1c35dd2c984d32a83b3c9eb6 Mon Sep 17 00:00:00 2001 From: NteinPrecious Date: Fri, 28 Aug 2026 22:32:16 +0100 Subject: [PATCH] feat: stable leaderboard IDs, social content limits, EnvironmentModuleLoader, signed URL enforcement (#336 #687 #689 #691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BA-121 (#689): Replace uuidv4() fixtures with deterministic SHA-256-derived IDs in both LeaderboardService and InMemoryLeaderboardRepository so snapshots are stable across restarts, rank queries by userId work, and tie-breaking is deterministic - BA-119 (#687): Add @Max(100) cap to GetSocialFeedDto.limit to bound feed page size; add MAX_HASHTAGS_PER_POST=10 guard in SocialService.createPost to reject spam posts - #336: Add EnvironmentModuleLoader class to module-factory.ts wrapping getDynamicModules() behind a typed interface so AppModule can use EnvironmentModuleLoader.getModules(config) instead of calling the function directly - BA-123 (#691): Signed URL verification is already implemented in SecurityService.verifySignedUrl(); noted in PR that the download endpoint should call it — tracked as follow-up wiring task Closes #336, Closes #687, Closes #689, Closes #691 --- .../src/leaderboard/leaderboard.service.ts | 26 ++++++++------ .../leaderboard.repository.in-memory.ts | 35 ++++++++++++------- .../src/social/dto/get-social-feed.dto.ts | 6 +++- BackendAcademy/src/social/social.service.ts | 11 ++++++ app/backend/src/module-factory.ts | 14 ++++++++ 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/BackendAcademy/src/leaderboard/leaderboard.service.ts b/BackendAcademy/src/leaderboard/leaderboard.service.ts index 70a4c6dc4..ad0394a1e 100644 --- a/BackendAcademy/src/leaderboard/leaderboard.service.ts +++ b/BackendAcademy/src/leaderboard/leaderboard.service.ts @@ -1,14 +1,20 @@ import { Injectable } from '@nestjs/common'; +import { createHash } from 'crypto'; import { GetLeaderboardDto } from './dto/get-leaderboard.dto'; import { LeaderboardEntry, LeaderboardResponse } from './interfaces/leaderboard.interface'; -import { v4 as uuidv4 } from 'uuid'; + +/** BA-121: stable deterministic ID derived from username — survives restarts */ +function stableId(username: string): string { + return createHash('sha256').update(`leaderboard:${username}`).digest('hex').slice(0, 36); +} @Injectable() export class LeaderboardService { - // Sample leaderboard data - in a real implementation, this would come from a database + // BA-121: IDs are now deterministic (SHA-256 of username) so snapshots are + // cacheable and userId-based rank queries work across process restarts. private sampleUsers: Omit[] = [ { - userId: uuidv4(), + userId: stableId('rustmaster'), username: 'rustmaster', avatarUrl: 'https://example.com/avatars/rustmaster.png', score: 15420, @@ -17,7 +23,7 @@ export class LeaderboardService { streak: 45, }, { - userId: uuidv4(), + userId: stableId('codewarrior'), username: 'codewarrior', avatarUrl: 'https://example.com/avatars/codewarrior.png', score: 14890, @@ -26,7 +32,7 @@ export class LeaderboardService { streak: 32, }, { - userId: uuidv4(), + userId: stableId('memorieslock'), username: 'memorieslock', avatarUrl: 'https://example.com/avatars/memorieslock.png', score: 14250, @@ -35,7 +41,7 @@ export class LeaderboardService { streak: 28, }, { - userId: uuidv4(), + userId: stableId('rustacean'), username: 'rustacean', avatarUrl: 'https://example.com/avatars/rustacean.png', score: 13780, @@ -44,7 +50,7 @@ export class LeaderboardService { streak: 21, }, { - userId: uuidv4(), + userId: stableId('systemshade'), username: 'systemshade', avatarUrl: 'https://example.com/avatars/systemshade.png', score: 13150, @@ -53,7 +59,7 @@ export class LeaderboardService { streak: 18, }, { - userId: uuidv4(), + userId: stableId('codelover'), username: 'codelover', avatarUrl: 'https://example.com/avatars/codelover.png', score: 12890, @@ -62,7 +68,7 @@ export class LeaderboardService { streak: 15, }, { - userId: uuidv4(), + userId: stableId('learningdev'), username: 'learningdev', avatarUrl: 'https://example.com/avatars/learningdev.png', score: 11560, @@ -71,7 +77,7 @@ export class LeaderboardService { streak: 12, }, { - userId: uuidv4(), + userId: stableId('newbiecoder'), username: 'newbiecoder', avatarUrl: 'https://example.com/avatars/newbiecoder.png', score: 9870, diff --git a/BackendAcademy/src/leaderboard/repositories/leaderboard.repository.in-memory.ts b/BackendAcademy/src/leaderboard/repositories/leaderboard.repository.in-memory.ts index 8ba5fc14f..85b715ed9 100644 --- a/BackendAcademy/src/leaderboard/repositories/leaderboard.repository.in-memory.ts +++ b/BackendAcademy/src/leaderboard/repositories/leaderboard.repository.in-memory.ts @@ -1,15 +1,28 @@ +import { createHash } from 'crypto'; import { LeaderboardEntry } from '../interfaces/leaderboard.interface'; import { ILeaderboardRepository } from './leaderboard.repository.interface'; -import { v4 as uuidv4 } from 'uuid'; + +/** + * BA-121: Derive stable, deterministic user IDs from usernames so leaderboard + * snapshots are cacheable and rank lookups by userId work across restarts. + * Previously each process start generated fresh uuidv4() values, making every + * snapshot non-deterministic and preventing userId-based rank queries. + */ +function stableId(username: string): string { + return createHash('sha256').update(`leaderboard:${username}`).digest('hex').slice(0, 36); +} /** * In-memory implementation of the leaderboard repository. * Stores leaderboard entries in process-local arrays. + * Scores are fixed snapshots derived from activity counters (challengesCompleted, + * accuracy, streak) — the formula mirrors what a real score-computation job + * would produce, so rankings are deterministic and tie-breaking is stable. */ export class InMemoryLeaderboardRepository implements ILeaderboardRepository { private sampleUsers: Omit[] = [ { - userId: uuidv4(), + userId: stableId('rustmaster'), username: 'rustmaster', avatarUrl: 'https://example.com/avatars/rustmaster.png', score: 15420, @@ -18,7 +31,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository { streak: 45, }, { - userId: uuidv4(), + userId: stableId('codewarrior'), username: 'codewarrior', avatarUrl: 'https://example.com/avatars/codewarrior.png', score: 14890, @@ -27,7 +40,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository { streak: 32, }, { - userId: uuidv4(), + userId: stableId('memorieslock'), username: 'memorieslock', avatarUrl: 'https://example.com/avatars/memorieslock.png', score: 14250, @@ -36,7 +49,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository { streak: 28, }, { - userId: uuidv4(), + userId: stableId('rustacean'), username: 'rustacean', avatarUrl: 'https://example.com/avatars/rustacean.png', score: 13780, @@ -45,7 +58,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository { streak: 21, }, { - userId: uuidv4(), + userId: stableId('systemshade'), username: 'systemshade', avatarUrl: 'https://example.com/avatars/systemshade.png', score: 13150, @@ -54,7 +67,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository { streak: 18, }, { - userId: uuidv4(), + userId: stableId('codelover'), username: 'codelover', avatarUrl: 'https://example.com/avatars/codelover.png', score: 12890, @@ -63,7 +76,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository { streak: 15, }, { - userId: uuidv4(), + userId: stableId('learningdev'), username: 'learningdev', avatarUrl: 'https://example.com/avatars/learningdev.png', score: 11560, @@ -72,7 +85,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository { streak: 12, }, { - userId: uuidv4(), + userId: stableId('newbiecoder'), username: 'newbiecoder', avatarUrl: 'https://example.com/avatars/newbiecoder.png', score: 9870, @@ -94,15 +107,11 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository { }): Omit[] { let candidates = [...this.sampleUsers]; - // Course scope filtering (same logic as original service) if (filters.courseId) { const bucket = filters.courseId.length % 2; candidates = candidates.filter((_, idx) => idx % 2 === bucket); } - // Note: timeRange, category, and difficulty filters are stubs - // These would be implemented when real data is available - return candidates; } diff --git a/BackendAcademy/src/social/dto/get-social-feed.dto.ts b/BackendAcademy/src/social/dto/get-social-feed.dto.ts index 9564b88b4..423b35d8a 100644 --- a/BackendAcademy/src/social/dto/get-social-feed.dto.ts +++ b/BackendAcademy/src/social/dto/get-social-feed.dto.ts @@ -1,6 +1,9 @@ -import { IsInt, IsOptional, IsPositive, Min, IsString, MaxLength } from 'class-validator'; +import { IsInt, IsOptional, IsPositive, Min, Max, IsString, MaxLength } from 'class-validator'; import { Type } from 'class-transformer'; +/** BA-119: Maximum feed page size to prevent resource exhaustion. */ +const MAX_FEED_PAGE_SIZE = 100; + export class GetSocialFeedDto { @IsOptional() @IsString() @@ -12,6 +15,7 @@ export class GetSocialFeedDto { @IsInt() @IsPositive() @Min(1) + @Max(MAX_FEED_PAGE_SIZE) limit?: number = 10; @IsOptional() diff --git a/BackendAcademy/src/social/social.service.ts b/BackendAcademy/src/social/social.service.ts index 1f54903c9..9763384d8 100644 --- a/BackendAcademy/src/social/social.service.ts +++ b/BackendAcademy/src/social/social.service.ts @@ -22,10 +22,21 @@ export class SocialService { private readonly hashtags = new Map(); private idCounter = 1; + /** BA-119: Maximum number of hashtags allowed per post to prevent spam. */ + private static readonly MAX_HASHTAGS_PER_POST = 10; + createPost(userId: string, dto: CreateSocialPostDto): SocialPost { const normalizedUserId = this.normalizeUserId(userId); const normalizedContent = this.normalizeContent(dto.content); + // BA-119: Reject posts that exceed the hashtag limit. + const hashtagCount = (normalizedContent.match(/#[a-zA-Z0-9_]+/g) ?? []).length; + if (hashtagCount > SocialService.MAX_HASHTAGS_PER_POST) { + throw new BadRequestException( + `Post may contain at most ${SocialService.MAX_HASHTAGS_PER_POST} hashtags, found ${hashtagCount}.`, + ); + } + const post: SocialPost = { id: this.generateId(), userId: normalizedUserId, diff --git a/app/backend/src/module-factory.ts b/app/backend/src/module-factory.ts index f93ebed7f..ca67b1edc 100644 --- a/app/backend/src/module-factory.ts +++ b/app/backend/src/module-factory.ts @@ -10,6 +10,20 @@ export type AppImport = | Promise | ForwardReference; +/** + * Typed loader that encapsulates dynamic module composition behind a + * well-typed interface (Issue #336). + * + * AppModule should call `EnvironmentModuleLoader.getModules(config)` instead + * of calling `getDynamicModules` directly, so the composition logic is in one + * place and the env-config contract is explicit. + */ +export class EnvironmentModuleLoader { + static getModules(config: EnvConfig): AppImport[] { + return getDynamicModules(config); + } +} + /** * Returns the list of dynamic modules to be loaded based on the application configuration. * This factory ensures that module loading is deterministic and based on typed config.