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({

View File

@@ -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 ─────────────────────────────────────────────

View File

@@ -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)