Harden API validation throttling and error handling

This commit is contained in:
Tor Matz Andrén
2026-05-14 16:25:52 +02:00
parent 0f9b1f9477
commit 41f5a80ffb
12 changed files with 231 additions and 24 deletions
+50 -2
View File
@@ -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",
+5
View File
@@ -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"
}
}
+11
View File
@@ -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 {}
+15 -1
View File
@@ -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);
}
@@ -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;
}
@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class UpdatePlaylistDto {
@IsOptional()
@IsBoolean()
active?: boolean;
}
@@ -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<void> {
await this.service.create(playlist);
async create(@Body() playlist: CreatePlaylistDto): Promise<void> {
await this.service.create(playlist as PlaylistEntity);
}
@Put(':id')
update(
@Param('id') id: number,
@Body() playlist: Partial<PlaylistEntity>,
@Param('id', ParseIntPipe) id: number,
@Body() playlist: UpdatePlaylistDto,
): Promise<void> {
return this.service.update(id, playlist);
}
@Delete(':id')
remove(@Param('id') id: number): Promise<void> {
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.remove(id);
}
@Get('retry/:id')
retryFailedOfPlaylist(@Param('id') id: number): Promise<void> {
retryFailedOfPlaylist(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
return this.service.retryFailedOfPlaylist(id);
}
}
+14 -3
View File
@@ -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 = {
@@ -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);
}
@@ -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<Response>();
const request = ctx.getRequest<Request>();
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,
});
}
}
+32 -9
View File
@@ -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<TrackEntity[]> {
getAllByPlaylist(
@Param('id', ParseIntPipe) playlistId: number,
): Promise<TrackEntity[]> {
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<StreamableFile> {
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<void> {
remove(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
return this.service.remove(id);
}
@Get('retry/:id')
retry(@Param('id') id: number): Promise<void> {
retry(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
return this.service.retry(id);
}
}
+13 -3
View File
@@ -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<void> {
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 = {