mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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);
|
||||
};
|
||||
Reference in New Issue
Block a user