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:
@@ -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({
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user