feat: enhance CORS configuration and implement throttling for API endpoints; add admin role checks in controllers

This commit is contained in:
Nils-Johan Gynther
2026-04-21 08:17:44 +02:00
parent 7748ad311f
commit e370062b5c
10 changed files with 44 additions and 24 deletions
+2
View File
@@ -23,6 +23,8 @@ MISTRAL_API_KEY=
# Publik URL (används av frontend)
NEXT_PUBLIC_APP_URL=https://recept.gynther.se
NEXT_PUBLIC_API_URL=https://recept.gynther.se
# CORS — tillåtna origins för backend-API (normalt samma som APP_URL)
ALLOWED_ORIGIN=https://recept.gynther.se
# Bootstrap-användare (skapas/uppdateras vid appstart)
ADMIN_NADMIN_PASSWORD=byt-ut-mig
+1 -13
View File
@@ -10,23 +10,11 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
}
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
const path = request.path;
const method = request.method;
console.log(`[JwtAuthGuard.canActivate] ${method} ${path}`);
console.log(`[JwtAuthGuard.canActivate] Authorization header:`, authHeader ? 'YES' : 'NO');
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
console.log(`[JwtAuthGuard.canActivate] isPublic:`, isPublic);
if (isPublic) return true;
const result = super.canActivate(context);
console.log(`[JwtAuthGuard.canActivate] super.canActivate result:`, result);
return result;
return super.canActivate(context);
}
}
+1 -4
View File
@@ -15,9 +15,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(payload: { sub: number; username: string; role: string; isPremium: boolean }) {
console.log('[JwtStrategy.validate] Payload received:', payload);
const result = { userId: payload.sub, username: payload.username, role: payload.role ?? 'user', isPremium: payload.isPremium ?? false };
console.log('[JwtStrategy.validate] Returning user:', result);
return result;
return { userId: payload.sub, username: payload.username, role: payload.role ?? 'user', isPremium: payload.isPremium ?? false };
}
}
+9
View File
@@ -7,6 +7,15 @@ import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// CORS — tillåt endast appens egen origin (sätts via ALLOWED_ORIGIN i miljövariabler)
const allowedOrigin = process.env.ALLOWED_ORIGIN || 'https://recept.gynther.se';
app.enableCors({
origin: allowedOrigin,
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
});
// Helmet som säkerhetsbackup (CSP hanteras av Next.js/Caddy)
app.use(
helmet({
+7 -4
View File
@@ -63,11 +63,13 @@ export class ProductsController {
return this.productsService.findAllTags();
}
@Roles('admin')
@Get('duplicates')
findDuplicates() {
return this.productsService.findDuplicateCandidates();
}
@Roles('admin')
@Get('merge-preview')
previewMerge(
@Query('sourceProductId', ParseIntPipe) sourceProductId: number,
@@ -130,10 +132,7 @@ export class ProductsController {
@Roles('admin')
@Post()
create(@Body() body: CreateProductDto, @Request() req: any) {
console.log('[ProductsController.create] Request received');
console.log('[ProductsController.create] User:', req.user);
console.log('[ProductsController.create] Body:', body);
create(@Body() body: CreateProductDto) {
return this.productsService.create(body);
}
@@ -151,6 +150,7 @@ export class ProductsController {
return this.productsService.merge(body.sourceProductId, body.targetProductId);
}
@Roles('admin')
@Patch(':id/canonical-name')
updateCanonicalName(
@Param('id', ParseIntPipe) id: number,
@@ -159,6 +159,7 @@ export class ProductsController {
return this.productsService.updateCanonicalName(id, body.canonicalName);
}
@Roles('admin')
@Put(':id/tags')
setTags(
@Param('id', ParseIntPipe) id: number,
@@ -167,6 +168,7 @@ export class ProductsController {
return this.productsService.setTags(id, body.tags);
}
@Roles('admin')
@Put(':id/nutrition')
upsertNutrition(
@Param('id', ParseIntPipe) id: number,
@@ -175,6 +177,7 @@ export class ProductsController {
return this.productsService.upsertNutrition(id, body);
}
@Roles('admin')
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@@ -1,4 +1,5 @@
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { QuickImportDto } from './dto/quick-import.dto';
@@ -9,6 +10,7 @@ export class QuickImportController {
constructor(private readonly quickImportService: QuickImportService) {}
@Post()
@Throttle({ default: { ttl: 60_000, limit: 20 } })
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
@@ -1,7 +1,9 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
import { ReceiptAliasService } from './receipt-alias.service';
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
import { Roles } from '../auth/decorators/roles.decorator';
@Roles('admin')
@Controller('receipt-aliases')
export class ReceiptAliasController {
constructor(private readonly receiptAliasService: ReceiptAliasService) {}
@@ -3,7 +3,6 @@ import {
Post,
Request,
UploadedFile,
UseGuards,
UseInterceptors,
BadRequestException,
} from '@nestjs/common';
@@ -12,7 +11,6 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { ReceiptImportService } from './receipt-import.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
const ALLOWED_MIMES = [
'image/jpeg',
@@ -29,7 +27,6 @@ export class ReceiptImportController {
@Post()
@Throttle({ default: { ttl: 60_000, limit: 20 } })
@UseGuards(JwtAuthGuard)
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
+1
View File
@@ -43,6 +43,7 @@ services:
DATABASE_URL: "mysql://root:${MARIADB_ROOT_PASSWORD}@recipe-db:3306/${MARIADB_DATABASE}"
MISTRAL_API_KEY: "${MISTRAL_API_KEY:-}"
JWT_SECRET: "${JWT_SECRET}"
ALLOWED_ORIGIN: "${NEXT_PUBLIC_APP_URL}"
ADMIN_NADMIN_PASSWORD: "${ADMIN_NADMIN_PASSWORD}"
ADMIN_PADMIN_PASSWORD: "${ADMIN_PADMIN_PASSWORD}"
SEED_USER1_PASSWORD: "${SEED_USER1_PASSWORD}"
+19
View File
@@ -0,0 +1,19 @@
import { auth } from './auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const { pathname } = req.nextUrl;
if (pathname.startsWith('/admin')) {
const role = (req.auth?.user as any)?.role;
if (role !== 'admin') {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
}
});
export const config = {
matcher: ['/admin/:path*'],
};