/*********************************************************************************************************************************************************************** * 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); } function signToken(asset, userId, ip) { return jwt.sign( { asset_id: asset.asset_id, user_id: userId, storage_key: asset.storage_key, file_type: asset.file_type, mime_type: asset.mime_type, ip, }, MEDIA_SECRET, { expiresIn: TOKEN_TTL_SEC } ); } // ─── 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.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC); } 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, };