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)