mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
129 lines
5.3 KiB
JavaScript
129 lines
5.3 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* File Name: mediaToken.service.js
|
|
* Type of Program: Service
|
|
* Description: Issues + caches short-lived JWT stream tokens (and presigned S3
|
|
* thumbnail URLs) for asset preview. Shared by:
|
|
* - controllers/admin/media.controller.js (POST /admin/media/token(s))
|
|
* - controllers/admin/assets.controller.js (embeds tokens directly
|
|
* into GET /admin/assets rows so pickers don't need a second
|
|
* round-trip just to render thumbnails)
|
|
*
|
|
* Author: Kenneth Obsequio (@lash0000)
|
|
***********************************************************************************************************************************************************************/
|
|
"use strict";
|
|
|
|
const jwt = require("jsonwebtoken");
|
|
const s3 = require("./s3.service");
|
|
|
|
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
|
|
const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session
|
|
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
|
|
|
|
// ─── In-memory token cache (no Redis yet) ──────────────────────────────────────
|
|
// Avoids re-signing a JWT / re-presigning the thumbnail S3 URL for an asset that
|
|
// already has a still-valid token. Keyed by (asset_id, ip) because the stream
|
|
// endpoint (/api/client/media/stream/:token) pins the token to the issuing
|
|
// request's IP — reusing a token minted for a different IP would get rejected.
|
|
// Single-process only; each app instance keeps its own cache.
|
|
const TOKEN_CACHE_MARGIN_SEC = 120; // re-mint a bit before actual expiry
|
|
const tokenCache = new Map(); // `${asset_id}:${ip}` -> { token, thumbnail_url, expiresAt }
|
|
|
|
function tokenCacheKey(assetId, ip) {
|
|
return `${assetId}:${ip}`;
|
|
}
|
|
|
|
function getCachedToken(assetId, ip) {
|
|
const key = tokenCacheKey(assetId, ip);
|
|
const entry = tokenCache.get(key);
|
|
if (!entry) return null;
|
|
if (Date.now() >= entry.expiresAt) {
|
|
tokenCache.delete(key);
|
|
return null;
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function setCachedToken(assetId, ip, token, thumbnail_url) {
|
|
tokenCache.set(tokenCacheKey(assetId, ip), {
|
|
token,
|
|
thumbnail_url,
|
|
expiresAt: Date.now() + (TOKEN_TTL_SEC - TOKEN_CACHE_MARGIN_SEC) * 1000,
|
|
});
|
|
}
|
|
|
|
// 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) {
|
|
const forwarded = req.headers["x-forwarded-for"];
|
|
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
|
|
return normalizeIp(raw);
|
|
}
|
|
|
|
// ─── signMediaToken ─────────────────────────────────────────────────────────────
|
|
//
|
|
// Low-level JWT signer shared by every media-token caller (asset previews here,
|
|
// avatar resolution in utils/resolveAvatar.util.js) so the secret-resolution +
|
|
// payload shape only lives in one place. `asset_id`/`user_id`/`ip` are optional —
|
|
// omitting `ip` means the stream endpoint's IP-pin check is skipped for that token.
|
|
function signMediaToken({ asset_id, storage_key, file_type, mime_type, user_id, ip, expiresIn = TOKEN_TTL_SEC }) {
|
|
return jwt.sign(
|
|
{ asset_id, user_id, storage_key, file_type, mime_type, ip },
|
|
MEDIA_SECRET,
|
|
{ expiresIn }
|
|
);
|
|
}
|
|
|
|
function signToken(asset, userId, ip) {
|
|
return signMediaToken({
|
|
asset_id: asset.asset_id,
|
|
storage_key: asset.storage_key,
|
|
file_type: asset.file_type,
|
|
mime_type: asset.mime_type,
|
|
user_id: userId,
|
|
ip,
|
|
});
|
|
}
|
|
|
|
// ─── issueForAsset ─────────────────────────────────────────────────────────────
|
|
//
|
|
// Returns { token, thumbnail_url } for an S3 asset, minting + caching on first
|
|
// call and serving from tokenCache on subsequent calls within the TTL margin.
|
|
// `asset` needs: asset_id, storage_key, file_type, mime_type, thumbnail_storage_key.
|
|
//
|
|
async function issueForAsset(asset, userId, ip) {
|
|
const cached = getCachedToken(asset.asset_id, ip);
|
|
if (cached) return { token: cached.token, thumbnail_url: cached.thumbnail_url };
|
|
|
|
const token = signToken(asset, userId, ip);
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
setCachedToken(asset.asset_id, ip, token, thumbnail_url);
|
|
return { token, thumbnail_url };
|
|
}
|
|
|
|
module.exports = {
|
|
TOKEN_TTL_SEC,
|
|
SUPPORTED_TYPES,
|
|
resolveIp,
|
|
issueForAsset,
|
|
signMediaToken,
|
|
};
|