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 <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-13 22:54:52 +00:00
parent 31a225ec15
commit ae97cbe67b
3 changed files with 42 additions and 9 deletions

View File

@@ -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<ContentKey>,
@InjectRepository(Content)
private readonly contentRepository: Repository<Content>,
) {}
@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({