From 3986c5ace6ba5778c66d13638a5b494c906c14b7 Mon Sep 17 00:00:00 2001 From: Nicholas O'Donnell Date: Sun, 17 Dec 2023 18:23:42 -0500 Subject: [PATCH] Stream token as query string / HLS config for less buffering (#4) * Set more secuirty headers * Modify HLS.js options for less buffering * Pass stream token as query param --- src/actions/getStreamToken.ts | 16 ++++++++++++++++ src/actions/setGlobalHeaders.ts | 19 +++++++++++++++++++ src/actions/setStreamToken.ts | 23 ++++++++++++++++++----- src/app/api/live/route.ts | 12 ++++++++---- src/app/page.tsx | 7 ++++++- src/components/player.tsx | 9 +++++---- src/lib/jwt.ts | 12 ++++++++++-- src/middleware.ts | 7 +++---- 8 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 src/actions/getStreamToken.ts diff --git a/src/actions/getStreamToken.ts b/src/actions/getStreamToken.ts new file mode 100644 index 0000000..49d04c3 --- /dev/null +++ b/src/actions/getStreamToken.ts @@ -0,0 +1,16 @@ +import { ResponseCookies } from 'next/dist/compiled/@edge-runtime/cookies' +import { cookies, headers } from 'next/headers' + +import { STREAM_TOKEN_COOKIE_NAME } from '../constants' + +export function getStreamToken(): string { + const headersStore = headers() + const cookiesStore = cookies() + const responseCookies = new ResponseCookies(headersStore as Headers) + + const streamTokenCookie = + cookiesStore.get(STREAM_TOKEN_COOKIE_NAME) ?? + responseCookies.get(STREAM_TOKEN_COOKIE_NAME) + + return streamTokenCookie?.value as string +} diff --git a/src/actions/setGlobalHeaders.ts b/src/actions/setGlobalHeaders.ts index f86d374..82a88aa 100644 --- a/src/actions/setGlobalHeaders.ts +++ b/src/actions/setGlobalHeaders.ts @@ -12,4 +12,23 @@ export function setGlobalHeaders( // Cache response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate') response.headers.set('Pragma', 'no-cache') + + // Content Security Policy + response.headers.set('Content-Security-Policy', 'self') + + // X-Content-Type-Options + response.headers.set('X-Content-Type-Options', 'nosniff') + + // Referrer Policy + response.headers.set('Referrer-Policy', 'no-referrer') + + // Strict-Transport-Security + response.headers.set('Strict-Transport-Security', 'max-age=63072000; preload') + + // X-Frame-Options + response.headers.set('X-Frame-Options', 'DENY') + + // Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy + response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp') + response.headers.set('Cross-Origin-Opener-Policy', 'same-origin') } diff --git a/src/actions/setStreamToken.ts b/src/actions/setStreamToken.ts index 5d04b5a..eed2476 100644 --- a/src/actions/setStreamToken.ts +++ b/src/actions/setStreamToken.ts @@ -1,15 +1,28 @@ -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { v4 as uuid } from 'uuid' import { STREAM_TOKEN_COOKIE_NAME } from '../constants' import { sign } from '../lib/jwt' -export async function setStreamToken(response: NextResponse): Promise { - const token = await sign({ - viewerId: uuid(), - }) +const ONE_DAY_MS = 1000 * 60 * 60 * 24 * 30 + +export async function setStreamToken( + request: NextRequest, + response: NextResponse, +): Promise { + if (request.cookies.get(STREAM_TOKEN_COOKIE_NAME)) { + return + } + + const token = await sign( + { + viewerId: uuid(), + }, + { exp: '1d' }, + ) response.cookies.set({ + expires: new Date(Date.now() + ONE_DAY_MS), httpOnly: true, name: STREAM_TOKEN_COOKIE_NAME, path: '/', diff --git a/src/app/api/live/route.ts b/src/app/api/live/route.ts index 5510aa9..9740c78 100644 --- a/src/app/api/live/route.ts +++ b/src/app/api/live/route.ts @@ -1,7 +1,7 @@ import HttpError from 'http-errors' import { NextRequest, NextResponse } from 'next/server' -import { RS_URL, STREAM_TOKEN_COOKIE_NAME } from '../../../constants' +import { RS_URL } from '../../../constants' import { verify } from '../../../lib/jwt' import * as logger from '../../../lib/logger' @@ -10,16 +10,20 @@ export const fetchCache = 'force-no-store' export const revalidate = 0 export async function GET(request: NextRequest): Promise { + const url: URL = new URL(request.nextUrl) const ip = request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for') || request.ip - const token = request.cookies.get(STREAM_TOKEN_COOKIE_NAME) - const claims = token ? await verify(token.value) : undefined + const userAgent = request.headers.get('user-agent') + + const token: string | null = url.searchParams.get('token') + const claims = token ? await verify(token) : undefined const meta = { geo: request.geo?.city as string, ip: ip as string, + userAgent: userAgent as string, viewerId: claims?.viewerId as string, } @@ -40,7 +44,7 @@ export async function GET(request: NextRequest): Promise { }, }) } catch (e: any) { - const error = new Error(`Failed to fetch playlist: ${e.message}`, { + const error = new Error(`Failed to fetch stream: ${e.message}`, { cause: e, }) diff --git a/src/app/page.tsx b/src/app/page.tsx index 6c46985..f35a258 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ import { getStreamOnline } from '../actions/getStreamOnline' +import { getStreamToken } from '../actions/getStreamToken' import { Header } from '../components/header' import { Logo } from '../components/logo' import { Main } from '../components/main' @@ -11,6 +12,7 @@ export const fetchCache = 'force-no-store' export default async function Page() { const online: boolean = await getStreamOnline() + const token: string = getStreamToken() return ( <> @@ -19,7 +21,10 @@ export default async function Page() {
{online ? ( - + ) : ( )} diff --git a/src/components/player.tsx b/src/components/player.tsx index 934e2ab..b3a6da8 100644 --- a/src/components/player.tsx +++ b/src/components/player.tsx @@ -17,11 +17,12 @@ const hlsConfig = { enableWorker: true, // use workers initialLiveManifestSize: 2, // preload 2 chunks before autostart liveDurationInfinity: true, // instructs browser that video is live - liveMaxLatencyDuration: 10, // if higher than this, adujst to liveSyncDuration - liveSyncDuration: 5, // how close to live to target? shorter than 3sec causes frequent buffering issues + liveMaxLatencyDuration: 40, // maximum latency allowed before HLS.js seeks forward to reduce the latency (A lower value here helps in keeping the stream closer to real-time) + liveSyncDuration: 30, // how close to live to target? shorter than 3sec causes frequent buffering issues lowLatencyMode: true, // enable low latency mode - maxBufferLength: 10, // limit forward buffer - maxLiveSyncPlaybackRate: 2, // if running behind, speed up video + maxBufferLength: 30, // maximum length, in seconds, of the buffer + maxBufferSize: 60 * 1000 * 1000, // 60 MB maximum buffer size in bytes + maxMaxBufferLength: 60, // safeguard to ensure buffering doesn't exceed this value nudgeMaxRetry: 5, // increase retries before buffer stalled progressive: true, // use fetch instead of xhr testBandwidth: false, // disable auto bandwidth estimation diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts index 057eae3..97d20aa 100644 --- a/src/lib/jwt.ts +++ b/src/lib/jwt.ts @@ -3,14 +3,22 @@ import type { JWTPayload } from 'jose' import { SIGNING_SECRET } from '../constants' +export type SignOptions = { + alg?: string + exp?: string +} + const secret = new TextEncoder().encode(SIGNING_SECRET) const alg = 'HS256' -export async function sign(claims: JWTPayload): Promise { +export async function sign( + claims: JWTPayload, + { alg = 'HS256', exp = '1d' }: SignOptions = {}, +): Promise { return await new jose.SignJWT(claims) .setProtectedHeader({ alg }) .setIssuedAt() - .setExpirationTime('1d') + .setExpirationTime(exp) .sign(secret) } diff --git a/src/middleware.ts b/src/middleware.ts index e01ba17..de417a9 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -8,14 +8,13 @@ function isHomepage(pathname: string): boolean { } export async function middleware(request: NextRequest) { - const url = new URL(request.url) - const pathname = request.nextUrl.pathname + const url: URL = new URL(request.nextUrl) const response: NextResponse = NextResponse.next() setGlobalHeaders(response, { origin: url.origin }) - if (isHomepage(pathname)) { - await setStreamToken(response) + if (isHomepage(url.pathname)) { + await setStreamToken(request, response) } return response