191 lines
6.9 KiB
TypeScript
Executable file
191 lines
6.9 KiB
TypeScript
Executable file
import { createReadStream } from 'node:fs'
|
|
import { stat } from 'node:fs/promises'
|
|
import { Readable } from 'node:stream'
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { assertPublicHttpUrl } from '@/lib/server/ssrf'
|
|
import { getStreamEntry, refreshStreamEntry } from '@/lib/server/stream-store'
|
|
|
|
export const runtime = 'nodejs'
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
/**
|
|
* GET /api/audio/stream?token=<hex32>
|
|
* Same-origin byte proxy for a resolved upstream audio URL — or, since v7.5,
|
|
* the server for a CONVERTED MP3 produced by the resolve pipeline. Same-origin
|
|
* is what keeps the Web Audio analyser untainted (a cross-origin <audio>
|
|
* source zeroes the FFT data).
|
|
*
|
|
* Two entry kinds (see stream-store.ts):
|
|
* • file entries — serve the finished MP3 from disk with full HTTP Range
|
|
* support (seek, loop and buffered playback all become plain byte ranges;
|
|
* every browser plays a well-formed MP3 — the "media resource not
|
|
* suitable" class of failures is gone by construction);
|
|
* • proxy entries (remoteUrl) — fetch the upstream and pass its bytes,
|
|
* statuses and media headers through, forwarding Range requests.
|
|
*/
|
|
|
|
const UPSTREAM_UA =
|
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
|
|
|
const EXACT_MEDIA_TYPES = new Set(['application/octet-stream', 'application/ogg'])
|
|
|
|
const PASSTHROUGH_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges']
|
|
|
|
function fail(status: number, error: string): NextResponse {
|
|
return NextResponse.json({ ok: false, error }, { status })
|
|
}
|
|
|
|
// ---- v7.5 local-file serving -------------------------------------------------
|
|
|
|
/** Parse a Range header against a file of `size` bytes.
|
|
* Returns null when the header is absent (full body) or unsatisfiable —
|
|
* the caller distinguishes the two by re-testing the raw header. */
|
|
function parseRange(header: string, size: number): { start: number; end: number } | null {
|
|
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
|
|
if (!m || (!m[1] && !m[2])) return null
|
|
let start: number
|
|
let end: number
|
|
if (m[1]) {
|
|
start = parseInt(m[1], 10)
|
|
end = m[2] ? Math.min(parseInt(m[2], 10), size - 1) : size - 1
|
|
} else {
|
|
// suffix form: bytes=-N → the final N bytes
|
|
const suffix = parseInt(m[2], 10)
|
|
start = Math.max(0, size - suffix)
|
|
end = size - 1
|
|
}
|
|
if (start > end || start >= size) return null
|
|
return { start, end }
|
|
}
|
|
|
|
/** Serve the entry's MP3 with Range support. Never proxies. */
|
|
async function serveFile(token: string, path: string, bytes: number, request: NextRequest): Promise<Response> {
|
|
// The file must still exist and match the registered size — a lapsed entry
|
|
// may have been swept between getStreamEntry() and here (404, the visitor
|
|
// simply resolves the link again), as may a mid-stream unlink (open fds
|
|
// keep streaming on Linux; only NEW opens fail).
|
|
let size: number
|
|
try {
|
|
const s = await stat(path)
|
|
if (!s.isFile()) throw new Error('gone')
|
|
size = s.size
|
|
} catch {
|
|
return fail(404, 'This stream has expired — resolve the link again.')
|
|
}
|
|
if (size !== bytes) {
|
|
// the file on disk changed under us — trust the disk, clamp ranges to it
|
|
bytes = size
|
|
}
|
|
|
|
const rangeHeader = request.headers.get('range')
|
|
const headers = new Headers({
|
|
'Content-Type': 'audio/mpeg',
|
|
'Accept-Ranges': 'bytes',
|
|
'Cache-Control': 'public, max-age=1800',
|
|
})
|
|
|
|
if (rangeHeader) {
|
|
const range = parseRange(rangeHeader, bytes)
|
|
if (!range) {
|
|
// unsatisfiable or malformed → the standard 416 tells the browser where
|
|
// the real bounds are so it can fall back to a full-body request
|
|
return new Response(null, {
|
|
status: 416,
|
|
headers: { 'Content-Range': `bytes */${bytes}` },
|
|
})
|
|
}
|
|
const length = range.end - range.start + 1
|
|
headers.set('Content-Range', `bytes ${range.start}-${range.end}/${bytes}`)
|
|
headers.set('Content-Length', String(length))
|
|
refreshStreamEntry(token)
|
|
const nodeStream = createReadStream(path, { start: range.start, end: range.end })
|
|
return new Response(Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>, {
|
|
status: 206,
|
|
headers,
|
|
})
|
|
}
|
|
|
|
headers.set('Content-Length', String(bytes))
|
|
refreshStreamEntry(token)
|
|
const nodeStream = createReadStream(path)
|
|
return new Response(Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>, {
|
|
status: 200,
|
|
headers,
|
|
})
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const token = request.nextUrl.searchParams.get('token')
|
|
if (!token) {
|
|
return fail(404, 'Unknown or expired stream token')
|
|
}
|
|
|
|
const entry = getStreamEntry(token)
|
|
if (!entry) {
|
|
return fail(404, 'Unknown or expired stream token')
|
|
}
|
|
|
|
// v7.5: file entries serve from disk — no upstream fetch, no SSRF surface
|
|
// (remoteUrl on a file entry is just context: the page the track came from).
|
|
if (entry.file) {
|
|
return serveFile(token, entry.file.path, entry.file.bytes, request)
|
|
}
|
|
|
|
// Re-run the SSRF gate — a re-resolved host that now points private is refused.
|
|
try {
|
|
await assertPublicHttpUrl(entry.remoteUrl)
|
|
} catch (err) {
|
|
return fail(502, err instanceof Error ? err.message : 'That stream host is not allowed.')
|
|
}
|
|
|
|
const range = request.headers.get('range')
|
|
const upstreamHeaders: Record<string, string> = { 'User-Agent': UPSTREAM_UA }
|
|
if (range) upstreamHeaders.Range = range
|
|
// Visitor login cookie — only present when the resolve step decided this
|
|
// upstream belongs to the same music service domain. In-memory only.
|
|
if (entry.cookieHeader) upstreamHeaders.Cookie = entry.cookieHeader
|
|
|
|
// Upstreams occasionally reset mid-handshake — one immediate retry on a
|
|
// network-level failure (HTTP error statuses are NOT retried).
|
|
let upstream: Response | null = null
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
try {
|
|
upstream = await fetch(entry.remoteUrl, {
|
|
headers: upstreamHeaders,
|
|
redirect: 'follow',
|
|
cache: 'no-store',
|
|
})
|
|
break
|
|
} catch {
|
|
// network error — fall through to retry / 502
|
|
}
|
|
}
|
|
if (!upstream) {
|
|
return fail(502, 'Upstream could not be reached.')
|
|
}
|
|
|
|
if (!upstream.ok) {
|
|
return fail(502, `Upstream failed (HTTP ${upstream.status}).`)
|
|
}
|
|
|
|
const contentType = upstream.headers.get('content-type') ?? ''
|
|
const mediaOk =
|
|
contentType.startsWith('audio/') ||
|
|
contentType.startsWith('video/') ||
|
|
EXACT_MEDIA_TYPES.has(contentType)
|
|
if (!mediaOk) {
|
|
return fail(502, 'Upstream did not return audio.')
|
|
}
|
|
|
|
// successful GET — slide the TTL window forward
|
|
refreshStreamEntry(token)
|
|
|
|
const headers = new Headers()
|
|
for (const name of PASSTHROUGH_HEADERS) {
|
|
const value = upstream.headers.get(name)
|
|
if (value) headers.set(name, value)
|
|
}
|
|
headers.set('Cache-Control', 'public, max-age=1800')
|
|
|
|
return new Response(upstream.body, { status: upstream.status, headers })
|
|
}
|