Files

291 lines
13 KiB
JavaScript

/***********************************************************************************************************************************************************************
* File Name: media.controller.js (client)
* Type of Program: Controller
* Description: Secure media delivery for S3/Garage assets only.
*
* Chibisafe assets use their raw file_url directly — no token needed.
* The block content already has the URL saved at CMS time (handleSelect).
*
* S3 Flow:
* 1. POST /client/media/token { asset_id }
* → validates tier access
* → signs JWT with user_id + IP binding
* → returns { token, provider: "s3", file_type }
*
* 2. Browser sets <video/audio src> = API_BASE + "/client/media/stream/" + token
* → Express verifies JWT
* → Checks IP matches the one that issued the token
* → Generates 60s pre-signed Garage URL, proxies bytes
* → Real S3 URL never reaches the browser
*
* Protection layers:
* 1. JWT signature — token can't be forged
* 2. 5-min TTL — token expires quickly
* 3. IP binding — token is useless if shared with another machine
* 4. Token tracking — tokens are tracked; logged after first use
* (range requests from the same token are allowed
* since the browser reuses the token for seeking)
*
* Supported file_type values: video, audio, document, image
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 12, 2026
***********************************************************************************************************************************************************************/
"use strict";
const https = require("https");
const http = require("http");
const jwt = require("jsonwebtoken");
const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl");
const s3 = require("../../services/s3.service");
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
const TOKEN_TTL_SEC = 4 * 60 * 60; // 4 hours — token must outlive the longest video
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
// ─── In-memory token tracker ──────────────────────────────────────────────────
//
// Tracks tokens that have been used at least once.
// Allows reuse within TTL for range requests (browser seeking reuses the token).
// Auto-cleans after TTL to prevent unbounded memory growth.
// In production with multiple server instances, replace with Redis.
//
const activeTokens = new Map(); // token → { firstUsed, ip }
function trackToken(token, ip) {
if (activeTokens.has(token)) return; // already tracked, allow reuse
activeTokens.set(token, { firstUsed: Date.now(), ip });
setTimeout(() => activeTokens.delete(token), TOKEN_TTL_SEC * 1000);
}
// ─── Helper: resolve client IP ───────────────────────────────────────────────
// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1")
// down to a single canonical form. Without this, a token minted off one
// "localhost" connection (IPv4) fails IP-pin verification on a sibling
// request that happened to land on the other stack (IPv6) — browsers race
// both when resolving "localhost", so mint and stream requests can land on
// different stacks even from the same client.
function normalizeIp(ip) {
if (ip === "::1") return "127.0.0.1";
if (ip.startsWith("::ffff:")) return ip.slice(7);
return ip;
}
function resolveIp(req) {
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
const forwarded = req.headers["x-forwarded-for"];
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
return normalizeIp(raw);
}
// ─── Helper: pipe S3 pre-signed URL to response (Range-aware) ────────────────
function pipeRemoteStream(remoteUrl, req, res) {
const parsed = new URL(remoteUrl);
const transport = parsed.protocol === "https:" ? https : http;
const proxyHeaders = { "User-Agent": "StarrMediaProxy/1.0" };
if (req.headers.range) proxyHeaders["Range"] = req.headers.range;
// Tracks whether the client dropped the connection first.
// proxyReq.destroy() itself fires an "error" event — we silence it when
// we were the ones who triggered the teardown (client-closed case).
let clientClosed = false;
const proxyReq = transport.request(remoteUrl, { headers: proxyHeaders }, (proxyRes) => {
const status = proxyRes.statusCode ?? 502;
// Upstream (Garage/S3) returned something other than a successful
// content response — surface the real failure instead of piping its
// (often tiny XML/JSON) error body through as if it were the file.
if (status !== 200 && status !== 206) {
proxyRes.resume(); // drain so the socket can close cleanly
console.error(`[CLIENT][MEDIA][PROXY] Upstream returned ${status} for ${remoteUrl}`);
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
return;
}
[
"content-type",
"content-length",
"content-range",
"accept-ranges",
"last-modified",
"etag",
"content-disposition",
].forEach((h) => {
if (proxyRes.headers[h]) res.setHeader(h, proxyRes.headers[h]);
});
res.setHeader("Cache-Control", "no-store");
res.setHeader("X-Content-Type-Options", "nosniff");
res.status(status);
proxyRes.pipe(res);
});
proxyReq.on("error", (err) => {
if (clientClosed) return; // browser navigated away / component unmounted — expected
console.error("[CLIENT][MEDIA][PROXY] Stream error:", err.message);
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
});
req.on("close", () => {
clientClosed = true;
proxyReq.destroy();
});
proxyReq.end();
}
// ─── POST /client/media/token ─────────────────────────────────────────────────
//
// S3 assets only — Chibisafe assets use their raw file_url directly.
// Returns: { token, provider: "s3", file_type }
//
// TOKEN HITS: If a consumer (e.g. ClientNav badge) re-fetches unexpectedly,
// the fix lives on the frontend — not here. Use a useRef cache key by
// asset_id on the consumer side so this endpoint is called exactly once per
// asset per session. The 4h token TTL makes ref-caching safe within a session.
exports.issueToken = async (req, res) => {
try {
const { asset_id } = req.body;
if (!asset_id) return R.error(res, "asset_id is required.", 400);
const asset = await mdl_Assets.findOne({
where: { asset_id, deletedAt: null },
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "thumbnail_storage_key"],
});
if (!asset) return R.error(res, "File not found.", 404);
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
return R.error(res, `File type "${asset.file_type}" is not supported.`, 400);
}
if (asset.storage_provider !== "s3") {
return R.error(res, "Token flow is for S3 files only. Use the raw file_url for other providers.", 400);
}
// ── Bind token to the requester's IP ──────────────────────────────────────
const ip = resolveIp(req);
const token = jwt.sign(
{
asset_id,
user_id: req.user.user_id,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip, // ← IP binding — verified on every stream request
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
// ── Presign thumbnail URL so the browser can load it directly ─────────────
let thumbnail_url = null;
if (asset.thumbnail_storage_key) {
try {
thumbnail_url = await s3.getPublicUrl(asset.thumbnail_storage_key);
} catch {
// Non-fatal — thumbnail is cosmetic
}
}
return R.success(res, "Token issued.", {
token,
provider: "s3",
file_type: asset.file_type,
thumbnail_url,
});
} catch (err) {
console.error("[CLIENT][MEDIA][TOKEN]", err);
return R.error(res, "Could not issue media token.", 500);
}
};
// ─── GET /client/media/stream/:token ─────────────────────────────────────────
//
// Called ONLY by the browser's <video>/<audio>/document element.
// Never called via axios — that would consume the stream as JSON.
//
// Protection checks (in order):
// 1. JWT signature valid
// 2. Token not expired (TTL enforced by JWT)
// 3. Requester IP matches the IP that issued the token
//
// Range requests for the same token are allowed (browser seeking).
// pipeRemoteStream() above forwards the real upstream status instead of
// collapsing everything to 200 — see its non-200/206 branch. A similar
// swallowed-status issue may still exist in s3.service.js (~line 168-171),
// not addressed here.
exports.streamAsset = async (req, res) => {
const { token } = req.params;
// ── CORS ──────────────────────────────────────────────────────────────────
// Mirrors server.js's global cors() origin check (reflect against
// ALLOWED_ORIGINS) instead of a single hardcoded FRONTEND_URL — a static
// origin here silently overwrote the correct header the global middleware
// already set, breaking any CORS-checked read (e.g. pdf.js's Range-header
// fetch) whenever FRONTEND_URL drifted from the deployed frontend domain.
// <img>/<video> tags were unaffected since opaque loads skip CORS checks.
const allowedOrigins = (process.env.ALLOWED_ORIGINS || process.env.APP_URL || "*").split(",");
const requestOrigin = req.headers.origin;
if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
res.setHeader("Access-Control-Allow-Origin", requestOrigin);
}
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Range, Authorization");
res.setHeader("Access-Control-Expose-Headers", "Content-Range, Content-Length, Accept-Ranges, Content-Disposition");
if (req.method === "OPTIONS") return res.sendStatus(204);
// ── Block direct browser navigation ──────────────────────────────────────
// Sec-Fetch-Mode is "navigate" when a user pastes the URL into the address
// bar or opens it in a new tab. Legitimate <video src> requests use "no-cors"
// and fetch() calls use "cors" — both are allowed.
const fetchMode = req.headers["sec-fetch-mode"];
if (fetchMode === "navigate") {
return res.status(401).json({ message: "Unauthorized." });
}
// ── Verify JWT ────────────────────────────────────────────────────────────
let payload;
try {
payload = jwt.verify(token, MEDIA_SECRET);
} catch {
return res.status(401).json({ message: "Invalid or expired media token." });
}
const { storage_key, ip: tokenIp } = payload;
if (!storage_key) return res.status(401).json({ message: "Unauthorized." });
// ── IP binding check ──────────────────────────────────────────────────────
const requestIp = resolveIp(req);
if (tokenIp && requestIp !== tokenIp) {
console.warn(`[CLIENT][MEDIA][STREAM] IP mismatch — token: ${tokenIp}, request: ${requestIp}`);
return res.status(403).json({ message: "Token IP mismatch." });
}
// ── Track token (allow reuse for range requests) ──────────────────────────
trackToken(token, requestIp);
// ── Generate pre-signed URL and proxy bytes ───────────────────────────────
let presignedUrl;
try {
presignedUrl = await s3.getSignedDownloadUrl(storage_key, 60);
// ── Just comment out for debug if S3_ENDPOINT is undefined ────────────────
// console.log("Presigned URL:", presignedUrl);
} catch (err) {
console.error("[CLIENT][MEDIA][STREAM] Pre-sign failed:", err.message);
return res.status(500).json({ message: "Could not resolve media stream." });
}
return pipeRemoteStream(presignedUrl, req, res);
};