mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
270 lines
12 KiB
JavaScript
270 lines
12 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 === 206 ? 206 : 200;
|
|
|
|
[
|
|
"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, "Asset not found.", 404);
|
|
|
|
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
|
|
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
|
|
}
|
|
|
|
if (asset.storage_provider !== "s3") {
|
|
return R.error(res, "Token flow is for S3 assets 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).
|
|
// TEMPORARY:
|
|
// Same with problem from s3.service.js (Line 168-171)
|
|
// Investigate the issue ourselves.
|
|
|
|
exports.streamAsset = async (req, res) => {
|
|
const { token } = req.params;
|
|
|
|
// ── CORS ──────────────────────────────────────────────────────────────────
|
|
const allowedOrigin = process.env.FRONTEND_URL ?? "http://localhost:5173";
|
|
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
|
|
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);
|
|
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);
|
|
}; |