Files
starr-philproperties/services/mediaToken.service.js
T
kennethobsequio 1d12f04967 new commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-03 16:20:58 +08:00

118 lines
4.6 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);
}
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,
};