diff --git a/package-lock.json b/package-lock.json index 89ca489..0d374fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,11 @@ "src/backend", "src/frontend" ], + "dependencies": { + "@nestjs/throttler": "^6.5.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1" + }, "devDependencies": { "@release-it/bumper": "^7.0.1", "auto-changelog": "^2.5.0", @@ -6054,6 +6059,16 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nestjs/typeorm": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", @@ -8241,6 +8256,11 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -10472,6 +10492,21 @@ "node": ">= 0.3.0" } }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/class-validator": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -16929,6 +16964,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.1.tgz", + "integrity": "sha512-GEw0GLL7YUUA6nv21IsCvVjtI5Ejn84sjbdfQ9KxdbqEVOk1PZh7xejn01EEiniKw+dBeCfim+8MGeuvVuE2BA==" + }, "node_modules/license-webpack-plugin": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", @@ -24476,6 +24516,14 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -25704,7 +25752,7 @@ "license": "MIT" }, "src/backend": { - "version": "2.3.4", + "version": "2.4.2", "license": "UNLICENSED", "dependencies": { "@nestjs/bullmq": "^10.2.3", @@ -25772,7 +25820,7 @@ "license": "MIT" }, "src/frontend": { - "version": "2.3.4", + "version": "2.4.2", "dependencies": { "@angular/animations": "^19.0.6", "@angular/common": "^19.0.6", diff --git a/package.json b/package.json index 2782fb6..c4f6c20 100644 --- a/package.json +++ b/package.json @@ -30,5 +30,10 @@ "release-it": "^18.1.2", "release-it-docker-plugin": "^2.0.0", "rimraf": "^6.0.1" + }, + "dependencies": { + "@nestjs/throttler": "^6.5.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1" } } diff --git a/src/backend/src/app.module.ts b/src/backend/src/app.module.ts index ed33f2b..8b352cf 100644 --- a/src/backend/src/app.module.ts +++ b/src/backend/src/app.module.ts @@ -13,6 +13,7 @@ import { EnvironmentEnum } from './environmentEnum'; import { BullModule } from '@nestjs/bullmq'; import { APP_GUARD } from '@nestjs/core'; import { AuthGuard } from './shared/auth.guard'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; @Module({ imports: [ @@ -44,6 +45,12 @@ import { AuthGuard } from './shared/auth.guard'; ], inject: [ConfigService], }), + ThrottlerModule.forRoot([ + { + ttl: 60000, + limit: 60, + }, + ]), BullModule.forRootAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ @@ -66,6 +73,10 @@ import { AuthGuard } from './shared/auth.guard'; provide: APP_GUARD, useClass: AuthGuard, }, + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, ], }) export class AppModule {} diff --git a/src/backend/src/main.ts b/src/backend/src/main.ts index 26e9c41..fd890c6 100644 --- a/src/backend/src/main.ts +++ b/src/backend/src/main.ts @@ -1,4 +1,6 @@ import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { HttpExceptionFilter } from './shared/filters/http-exception.filter'; import { AppModule } from './app.module'; import * as fs from 'fs'; import { resolve } from 'path'; @@ -31,7 +33,19 @@ async function bootstrap() { } } - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + bodyParser: true, + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + app.useGlobalFilters(new HttpExceptionFilter()); app.setGlobalPrefix('api'); await app.listen(process.env.PORT || 3000); } diff --git a/src/backend/src/playlist/dto/create-playlist.dto.ts b/src/backend/src/playlist/dto/create-playlist.dto.ts new file mode 100644 index 0000000..cfeebf4 --- /dev/null +++ b/src/backend/src/playlist/dto/create-playlist.dto.ts @@ -0,0 +1,11 @@ +import { IsString, IsUrl, MaxLength } from 'class-validator'; + +export class CreatePlaylistDto { + @IsString() + @MaxLength(2048) + @IsUrl({ + require_protocol: true, + protocols: ['https'], + }) + spotifyUrl: string; +} diff --git a/src/backend/src/playlist/dto/update-playlist.dto.ts b/src/backend/src/playlist/dto/update-playlist.dto.ts new file mode 100644 index 0000000..bd6e0dd --- /dev/null +++ b/src/backend/src/playlist/dto/update-playlist.dto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdatePlaylistDto { + @IsOptional() + @IsBoolean() + active?: boolean; +} diff --git a/src/backend/src/playlist/playlist.controller.ts b/src/backend/src/playlist/playlist.controller.ts index 5e5d784..f270f51 100644 --- a/src/backend/src/playlist/playlist.controller.ts +++ b/src/backend/src/playlist/playlist.controller.ts @@ -4,11 +4,14 @@ import { Delete, Get, Param, + ParseIntPipe, Post, Put, } from '@nestjs/common'; import { PlaylistService } from './playlist.service'; import { PlaylistEntity } from './playlist.entity'; +import { CreatePlaylistDto } from './dto/create-playlist.dto'; +import { UpdatePlaylistDto } from './dto/update-playlist.dto'; @Controller('playlist') export class PlaylistController { @@ -20,25 +23,27 @@ export class PlaylistController { } @Post() - async create(@Body() playlist: PlaylistEntity): Promise { - await this.service.create(playlist); + async create(@Body() playlist: CreatePlaylistDto): Promise { + await this.service.create(playlist as PlaylistEntity); } @Put(':id') update( - @Param('id') id: number, - @Body() playlist: Partial, + @Param('id', ParseIntPipe) id: number, + @Body() playlist: UpdatePlaylistDto, ): Promise { return this.service.update(id, playlist); } @Delete(':id') - remove(@Param('id') id: number): Promise { + remove(@Param('id', ParseIntPipe) id: number): Promise { return this.service.remove(id); } @Get('retry/:id') - retryFailedOfPlaylist(@Param('id') id: number): Promise { + retryFailedOfPlaylist( + @Param('id', ParseIntPipe) id: number, + ): Promise { return this.service.retryFailedOfPlaylist(id); } } diff --git a/src/backend/src/playlist/playlist.service.ts b/src/backend/src/playlist/playlist.service.ts index 8239df2..35942f2 100644 --- a/src/backend/src/playlist/playlist.service.ts +++ b/src/backend/src/playlist/playlist.service.ts @@ -10,6 +10,7 @@ import { Interval } from '@nestjs/schedule'; import { TrackStatusEnum } from '../track/track.entity'; import { UtilsService } from '../shared/utils.service'; import { SpotifyService } from '../shared/spotify.service'; +import { toSafeErrorMessage } from '../shared/errors/safe-error'; enum WsPlaylistOperation { New = 'playlistNew', @@ -77,7 +78,11 @@ export class PlaylistService { // Don't create folder structure for individual tracks - they go in root } catch (err) { this.logger.error(`Error getting track details: ${err}`); - playlist2Save = { ...playlist, error: String(err), isTrack: true }; + playlist2Save = { + ...playlist, + error: toSafeErrorMessage(err), + isTrack: true, + }; } const savedPlaylist = await this.save(playlist2Save); @@ -117,7 +122,10 @@ export class PlaylistService { this.createPlaylistFolderStructure(playlist2Save.name); } catch (err) { this.logger.error(`Error getting playlist details: ${err}`); - playlist2Save = { ...playlist, error: String(err) }; + playlist2Save = { + ...playlist, + error: toSafeErrorMessage(err), + }; } const savedPlaylist = await this.save(playlist2Save); @@ -224,7 +232,10 @@ export class PlaylistService { ); this.createPlaylistFolderStructure(playlist.name); } catch (err) { - await this.update(playlist.id, { ...playlist, error: String(err) }); + await this.update(playlist.id, { + ...playlist, + error: toSafeErrorMessage(err), + }); } for (const track of tracks ?? []) { const track2Save = { diff --git a/src/backend/src/shared/errors/safe-error.ts b/src/backend/src/shared/errors/safe-error.ts new file mode 100644 index 0000000..72b5f2e --- /dev/null +++ b/src/backend/src/shared/errors/safe-error.ts @@ -0,0 +1,20 @@ +export function toSafeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return sanitize(error.message); + } + + if (typeof error === 'string') { + return sanitize(error); + } + + return 'Unknown error'; +} + +function sanitize(text: string): string { + return text + .replace(/\/home\/[^\s]+/g, '[path]') + .replace(/\/root\/[^\s]+/g, '[path]') + .replace(/token=[^\s&]+/gi, 'token=[redacted]') + .replace(/authorization:[^\n]+/gi, 'authorization:[redacted]') + .slice(0, 500); +} diff --git a/src/backend/src/shared/filters/http-exception.filter.ts b/src/backend/src/shared/filters/http-exception.filter.ts new file mode 100644 index 0000000..23195da --- /dev/null +++ b/src/backend/src/shared/filters/http-exception.filter.ts @@ -0,0 +1,42 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import type { Request, Response } from 'express'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.message + : 'Internal server error'; + + this.logger.error( + `${request.method} ${request.url} -> ${status}: ${String(exception)}`, + ); + + response.status(status).json({ + statusCode: status, + message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/src/backend/src/track/track.controller.ts b/src/backend/src/track/track.controller.ts index bbf7065..4dd58cb 100644 --- a/src/backend/src/track/track.controller.ts +++ b/src/backend/src/track/track.controller.ts @@ -2,12 +2,14 @@ import { Controller, Delete, Get, + NotFoundException, Param, + ParseIntPipe, Res, StreamableFile, } from '@nestjs/common'; import { TrackService } from './track.service'; -import { createReadStream } from 'fs'; +import { createReadStream, existsSync } from 'fs'; import type { Response } from 'express'; import { ConfigService } from '@nestjs/config'; import { TrackEntity } from './track.entity'; @@ -20,33 +22,54 @@ export class TrackController { ) {} @Get('playlist/:id') - getAllByPlaylist(@Param('id') playlistId: number): Promise { + getAllByPlaylist( + @Param('id', ParseIntPipe) playlistId: number, + ): Promise { return this.service.getAllByPlaylist(playlistId); } @Get('download/:id') async getFile( @Res({ passthrough: true }) res: Response, - @Param('id') id: number, + @Param('id', ParseIntPipe) id: number, ): Promise { const track = await this.service.get(id); + + if (!track || !track.playlist) { + throw new NotFoundException('Track not found'); + } + + const filePath = this.service.getFolderName(track, track.playlist); + + if (!existsSync(filePath)) { + throw new NotFoundException('Track file not found'); + } + const fileName = this.service.getTrackFileName(track); - const readStream = createReadStream( - this.service.getFolderName(track, track.playlist), - ); + + const readStream = createReadStream(filePath); + res.set({ - 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}`, + 'Content-Disposition': + `attachment; filename="${encodeURIComponent(fileName)}"`, + 'Content-Type': 'application/octet-stream', + 'X-Content-Type-Options': 'nosniff', }); + return new StreamableFile(readStream); } @Delete(':id') - remove(@Param('id') id: number): Promise { + remove( + @Param('id', ParseIntPipe) id: number, + ): Promise { return this.service.remove(id); } @Get('retry/:id') - retry(@Param('id') id: number): Promise { + retry( + @Param('id', ParseIntPipe) id: number, + ): Promise { return this.service.retry(id); } } diff --git a/src/backend/src/track/track.service.ts b/src/backend/src/track/track.service.ts index 3365625..6a43006 100644 --- a/src/backend/src/track/track.service.ts +++ b/src/backend/src/track/track.service.ts @@ -12,6 +12,7 @@ import { UtilsService } from '../shared/utils.service'; import { Queue } from 'bullmq'; import { InjectQueue } from '@nestjs/bullmq'; import { YoutubeService } from '../shared/youtube.service'; +import { toSafeErrorMessage } from '../shared/errors/safe-error'; enum WsTrackOperation { New = 'trackNew', @@ -73,7 +74,16 @@ export class TrackService { async retry(id: number): Promise { const track = await this.get(id); - await this.trackSearchQueue.add('', track, { jobId: `id-${id}` }); + const existingJob = await this.trackSearchQueue.getJob(`id-${id}`); + + if (existingJob) { + this.logger.warn(`Search job already exists for track ${id}`); + return; + } + + await this.trackSearchQueue.add('', track, { + jobId: `id-${id}`, + }); await this.update(id, { ...track, status: TrackStatusEnum.New }); } @@ -96,7 +106,7 @@ export class TrackService { this.logger.error(err); updatedTrack = { ...track, - error: String(err), + error: toSafeErrorMessage(err), status: TrackStatusEnum.Error, }; await this.update(track.id, updatedTrack); @@ -150,7 +160,7 @@ export class TrackService { } } catch (err) { this.logger.error(err); - error = String(err); + error = toSafeErrorMessage(err); } const updatedTrack = {