From ae97cbe67b5a5a5d4c3119b2d2ecd01e56005f0d Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Feb 2026 22:54:52 +0000 Subject: [PATCH] fix: resolve AES-128 key delivery blocking HLS video playback Root cause: HLS content is AES-128 encrypted, but the key endpoint required mandatory auth (HybridAuthGuard). HLS.js fetches the key without auth headers, causing a silent 401 and playback failure. Backend: - Changed key.controller.ts to use OptionalHybridAuthGuard - Free content (price <= 0) now serves keys without authentication - Paid content still requires auth, returns 401 for anon requests - Added Content entity injection to look up pricing Frontend: - Configured HLS.js xhrSetup to attach Bearer token on /key requests - Uses nostr_token or auth_token from sessionStorage - Ensures logged-in users can play paid encrypted content Co-authored-by: Cursor --- backend/src/contents/key.controller.ts | 38 +++++++++++++++++++++----- docker-compose.yml | 4 +-- src/components/VideoPlayer.vue | 9 ++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/backend/src/contents/key.controller.ts b/backend/src/contents/key.controller.ts index f3ada09..b1797a0 100644 --- a/backend/src/contents/key.controller.ts +++ b/backend/src/contents/key.controller.ts @@ -12,14 +12,15 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ContentKey } from './entities/content-key.entity'; +import { Content } from './entities/content.entity'; import { Response, Request } from 'express'; -import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard'; +import { OptionalHybridAuthGuard } from 'src/auth/guards/optional-hybrid-auth.guard'; /** * AES-128 Key Server for HLS content protection. * * HLS players request encryption keys via the #EXT-X-KEY URI in the manifest. - * This endpoint serves those keys, gated behind Nostr JWT authentication. + * Free content serves keys without auth; paid content requires authentication. * * The key is returned as raw binary (application/octet-stream) -- the standard * format expected by HLS.js and native HLS players. @@ -31,17 +32,40 @@ export class KeyController { constructor( @InjectRepository(ContentKey) private readonly keyRepository: Repository, + @InjectRepository(Content) + private readonly contentRepository: Repository, ) {} @Get(':id/key') - @UseGuards(HybridAuthGuard) + @UseGuards(OptionalHybridAuthGuard) async getKey( @Param('id') contentId: string, - @Req() req: Request, + @Req() req: any, @Res() res: Response, ) { - // User is authenticated via HybridAuthGuard (Nostr JWT or NIP-98) - // Future: check user's subscription/rental access here + // Look up the content + project to determine pricing + const content = await this.contentRepository.findOne({ + where: { id: contentId }, + relations: ['project'], + }); + + if (!content) { + throw new NotFoundException('Content not found'); + } + + const projectPrice = Number(content.project?.rentalPrice ?? 0); + const contentPrice = Number(content.rentalPrice ?? 0); + const isFree = projectPrice <= 0 && contentPrice <= 0; + + // Paid content requires authentication + if (!isFree && !req.user) { + this.logger.warn( + `[key] Unauthenticated key request for paid content ${contentId}`, + ); + throw new UnauthorizedException( + 'Authentication required for paid content', + ); + } const key = await this.keyRepository.findOne({ where: { contentId }, @@ -53,7 +77,7 @@ export class KeyController { } this.logger.log( - `Key served for content ${contentId} (rotation: ${key.rotationIndex})`, + `Key served for content ${contentId} (free=${isFree}, user=${req.user?.id ?? 'anon'}, rotation: ${key.rotationIndex})`, ); res.set({ diff --git a/docker-compose.yml b/docker-compose.yml index be95c38..2fce963 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: context: . dockerfile: Dockerfile args: - CACHEBUST: "26" + CACHEBUST: "27" VITE_USE_MOCK_DATA: "false" VITE_CONTENT_ORIGIN: ${FRONTEND_URL} VITE_INDEEHUB_API_URL: /api @@ -47,7 +47,7 @@ services: context: ./backend dockerfile: Dockerfile args: - CACHEBUST: "11" + CACHEBUST: "12" restart: unless-stopped environment: # ── Core ───────────────────────────────────────────── diff --git a/src/components/VideoPlayer.vue b/src/components/VideoPlayer.vue index 1c3e766..4633c71 100644 --- a/src/components/VideoPlayer.vue +++ b/src/components/VideoPlayer.vue @@ -367,6 +367,15 @@ function initPlayer(url: string) { hls = new Hls({ enableWorker: true, lowLatencyMode: false, + // Attach auth tokens to key requests so paid content can be decrypted + xhrSetup(xhr: XMLHttpRequest, xhrUrl: string) { + if (xhrUrl.includes('/key')) { + const token = sessionStorage.getItem('nostr_token') || sessionStorage.getItem('auth_token') + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`) + } + } + }, }) hls.loadSource(url) hls.attachMedia(video)