diff --git a/.env.example b/.env.example index d2bbd91f..981f663a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/src/auth/jwt-auth.guard.ts b/backend/src/auth/jwt-auth.guard.ts index 9c54f672..cd8fb83f 100644 --- a/backend/src/auth/jwt-auth.guard.ts +++ b/backend/src/auth/jwt-auth.guard.ts @@ -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(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); } } diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index eb9d4d4e..df341dcc 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -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 }; } } diff --git a/backend/src/main.ts b/backend/src/main.ts index 0a28abf1..3b64e0a4 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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({ diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index c54777b2..708aac87 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -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, diff --git a/backend/src/quick-import/quick-import.controller.ts b/backend/src/quick-import/quick-import.controller.ts index d7c5af8e..ede72501 100644 --- a/backend/src/quick-import/quick-import.controller.ts +++ b/backend/src/quick-import/quick-import.controller.ts @@ -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(), diff --git a/backend/src/receipt-alias/receipt-alias.controller.ts b/backend/src/receipt-alias/receipt-alias.controller.ts index 3f710f9c..46cb1812 100644 --- a/backend/src/receipt-alias/receipt-alias.controller.ts +++ b/backend/src/receipt-alias/receipt-alias.controller.ts @@ -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) {} diff --git a/backend/src/receipt-import/receipt-import.controller.ts b/backend/src/receipt-import/receipt-import.controller.ts index ff7d9cb9..22fd6150 100644 --- a/backend/src/receipt-import/receipt-import.controller.ts +++ b/backend/src/receipt-import/receipt-import.controller.ts @@ -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(), diff --git a/compose.yml b/compose.yml index bb0a6c9c..aaac9823 100644 --- a/compose.yml +++ b/compose.yml @@ -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}" diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 00000000..bb447190 --- /dev/null +++ b/frontend/middleware.ts @@ -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*'], +};