// routes/share.js const express = require("express"); const router = express.Router(); const fs = require("fs"); const path = require("path"); const sharp = require("sharp"); const mime = require("mime-types"); const db = require("../db/knex"); const config = require("../api_v1/config"); const { WEB_ROOT } = require("../api_v1/config"); const { jwtMiddleware } = require("../middleware/jwt"); const { getShareRecord, createShareRecord, shareTokens, } = require("../utils/shareTokens"); const { resolveFilePathForEntryId } = require("../utils/resolveFilePath"); const { parseRange } = require("../utils/range"); /* * ============================================================ * CONFIGURAZIONE SHARE E CAST * ============================================================ */ /* * Durata predefinita dei token Cast. * * 21600 secondi = 6 ore. * * Puoi cambiare facilmente questo valore, per esempio: * * 3600 = 1 ora * 10800 = 3 ore * 21600 = 6 ore * 43200 = 12 ore * 86400 = 24 ore */ const DEFAULT_SHARE_TTL_SECONDS = 21600; /* * Limite massimo accettabile specificato dal client. * * Serve a evitare che un client possa generare accidentalmente * un token con durata eccessiva. * * 604800 secondi = 7 giorni. */ const MAX_SHARE_TTL_SECONDS = 604800; /* * Frequenza dei log durante lo streaming. * * 5000 millisecondi = un log ogni 5 secondi. */ const STREAM_PROGRESS_INTERVAL_MS = 5000; /* * Dopo quanti millisecondi senza nuovi dati deve essere * generato un avviso. */ const STREAM_IDLE_WARNING_MS = 5000; /* * Se true, nei log viene incluso il percorso completo del file. * * Puoi impostarlo a false se non vuoi mostrare i percorsi * completi nei log di produzione. */ const LOG_FULL_FILE_PATH = true; /* * Sequenza interna per assegnare un ID univoco a ogni richiesta. */ const CAST_MAX_WIDTH = 1920; const CAST_MAX_HEIGHT = 1080; const CAST_JPEG_QUALITY = 85; /* * Impedisce di generare due volte la stessa variante quando * arrivano richieste concorrenti per la medesima fotografia. */ const pendingCastVariants = new Map(); let shareRequestSequence = 0; /* * ============================================================ * FUNZIONI DI SUPPORTO PER I LOG * ============================================================ */ function nextShareRequestId() { shareRequestSequence += 1; return shareRequestSequence; } function calculateMbps(bytes, milliseconds) { if ( !Number.isFinite(bytes) || !Number.isFinite(milliseconds) || milliseconds <= 0 ) { return 0; } return (bytes * 8 * 1000) / (milliseconds * 1000000); } function formatMbps(value) { if (!Number.isFinite(value)) { return "0.00"; } return value.toFixed(2); } function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes < 0) { return "0 B"; } if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(2)} KB`; } if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } /* * Non registriamo il token completo nei log, perché equivale * temporaneamente a una credenziale pubblica per accedere al file. */ function maskToken(token) { if (!token || typeof token !== "string") { return "-"; } if (token.length <= 12) { return `${token.substring(0, 4)}...`; } return `${token.substring(0, 6)}...${token.substring(token.length - 4)}`; } function safeFilePathForLog(filePath) { if (!filePath) { return null; } if (LOG_FULL_FILE_PATH) { return filePath; } return path.basename(filePath); } function getRecordExpirationLogData(rec) { if (!rec) { return { expiresAt: null, remainingTtlSeconds: null, }; } const rawExpiresAt = rec.expiresAt || rec.expireAt || rec.expires || null; if (!rawExpiresAt) { return { expiresAt: null, remainingTtlSeconds: null, }; } let expiresAtMs; if (rawExpiresAt instanceof Date) { expiresAtMs = rawExpiresAt.getTime(); } else if (typeof rawExpiresAt === "number") { /* * Se il valore è piccolo, viene interpretato come timestamp Unix * espresso in secondi. Altrimenti viene interpretato in millisecondi. */ expiresAtMs = rawExpiresAt < 100000000000 ? rawExpiresAt * 1000 : rawExpiresAt; } else { expiresAtMs = new Date(rawExpiresAt).getTime(); } if (!Number.isFinite(expiresAtMs)) { return { expiresAt: String(rawExpiresAt), remainingTtlSeconds: null, }; } return { expiresAt: new Date(expiresAtMs).toISOString(), remainingTtlSeconds: Math.floor((expiresAtMs - Date.now()) / 1000), }; } /* * ============================================================ * PUBLIC BASE URL * * Per Chromecast deve essere un URL raggiungibile direttamente * dal dispositivo Cast. * ============================================================ */ function getPublicBaseUrl(req) { if (config.BASE_URL) { return config.BASE_URL.replace(/\/+$/, ""); } const forwardedProto = req.headers["x-forwarded-proto"]; const forwardedHost = req.headers["x-forwarded-host"]; /* * Alcuni proxy possono inviare più valori separati da virgola. * Prendiamo il primo valore. */ const proto = forwardedProto ? String(forwardedProto).split(",")[0].trim() : req.protocol || "https"; const host = forwardedHost ? String(forwardedHost).split(",")[0].trim() : req.headers.host; return `${proto}://${host}`; } /* * ============================================================ * RISOLUZIONE VARIANTI FOTO * ============================================================ */ function deriveCastPublicPathFromOriginal(originalPublicPath) { if (!originalPublicPath || typeof originalPublicPath !== "string") { return null; } const normalized = originalPublicPath.replace(/\\/g, "/"); /* * Esempio: * * /photos/Fabio/original/cartella/IMG_0244.JPG * * diventa: * * /cast/Fabio/photos/cartella/IMG_0244_cast.jpg * * Vengono mantenute anche eventuali sottocartelle: * * /photos/Fabio/original/2026/Vacanze/IMG_0244.JPG * * diventa: * * /cast/Fabio/photos/2026/Vacanze/IMG_0244_cast.jpg */ const match = normalized.match( /^\/photos\/([^/]+)\/original\/(.+)$/ ); if (!match) { return null; } const user = match[1]; const relativePath = match[2]; const dir = path.posix.dirname(relativePath); const ext = path.posix.extname(relativePath); const base = path.posix.basename(relativePath, ext); return path.posix.join( "/cast", user, "photos", dir === "." ? "" : dir, `${base}_cast.jpg` ); } function resolvePublicPathToFile(publicPath) { if (!publicPath || typeof publicPath !== "string") { return null; } const clean = publicPath.replace(/^\/+/, ""); return path.join(__dirname, "..", WEB_ROOT, clean); } function getCastVariantPaths(photoRow) { if (!photoRow || !photoRow.path) { return null; } const publicPath = deriveCastPublicPathFromOriginal(photoRow.path); if (!publicPath) { return null; } const filePath = resolvePublicPathToFile(publicPath); if (!filePath) { return null; } return { publicPath, filePath, }; } async function fileExistsAndIsNotEmpty(filePath) { if (!filePath) { return false; } try { const stat = await fs.promises.stat(filePath); return stat.isFile() && stat.size > 0; } catch { return false; } } async function isCastVariantCurrent({ originalPath, castPath, }) { try { const [originalStat, castStat] = await Promise.all([ fs.promises.stat(originalPath), fs.promises.stat(castPath), ]); return ( originalStat.isFile() && originalStat.size > 0 && castStat.isFile() && castStat.size > 0 && castStat.mtimeMs >= originalStat.mtimeMs ); } catch { return false; } } async function generateCastVariant({ entryId, originalPath, outputPath, }) { const current = await isCastVariantCurrent({ originalPath, castPath: outputPath, }); if (current) { const stat = await fs.promises.stat(outputPath); console.log( "[share/cast-variant] cache-hit", "entryId=", entryId, "path=", safeFilePathForLog(outputPath), "bytes=", stat.size, ); return { filePath: outputPath, generated: false, width: null, height: null, }; } const pending = pendingCastVariants.get(outputPath); if (pending) { console.log( "[share/cast-variant] reuse-pending", "entryId=", entryId, "path=", safeFilePathForLog(outputPath), ); return pending; } const request = (async () => { const generationStartedAt = Date.now(); await fs.promises.mkdir( path.dirname(outputPath), { recursive: true, }, ); /* * Il temporaneo mantiene estensione JPEG per rendere * esplicito a sharp il formato di output. */ const temporaryPath = `${outputPath}.${process.pid}.${Date.now()}.part.jpg`; try { const sourceStat = await fs.promises.stat(originalPath); if ( !sourceStat.isFile() || sourceStat.size <= 0 ) { throw new Error( `Originale Cast non valido: ${originalPath}`, ); } console.log( "[share/cast-variant] generate-start", "entryId=", entryId, "originalPath=", safeFilePathForLog(originalPath), "outputPath=", safeFilePathForLog(outputPath), "originalBytes=", sourceStat.size, ); const result = await sharp( originalPath, { failOn: "none", }, ) /* * Applica l'orientamento EXIF. */ .rotate() .resize({ width: CAST_MAX_WIDTH, height: CAST_MAX_HEIGHT, fit: "inside", withoutEnlargement: true, }) /* * Rimuove la trasparenza prima della conversione JPEG. */ .flatten({ background: { r: 0, g: 0, b: 0, }, }) .jpeg({ quality: CAST_JPEG_QUALITY, progressive: true, chromaSubsampling: "4:2:0", optimiseCoding: true, }) .toFile(temporaryPath); /* * Pubblicazione atomica. * Il file definitivo appare soltanto quando è completo. */ await fs.promises.rename( temporaryPath, outputPath, ); const outputStat = await fs.promises.stat(outputPath); console.log( "[share/cast-variant] generate-end", "entryId=", entryId, "result=success", "width=", result.width, "height=", result.height, "bytes=", outputStat.size, "ms=", Date.now() - generationStartedAt, ); return { filePath: outputPath, generated: true, width: result.width, height: result.height, }; } catch (error) { await fs.promises.rm( temporaryPath, { force: true, }, ); console.error( "[share/cast-variant] generate-end", "entryId=", entryId, "result=error", "ms=", Date.now() - generationStartedAt, "error=", error.message, ); throw error; } })(); pendingCastVariants.set( outputPath, request, ); try { return await request; } finally { if ( pendingCastVariants.get(outputPath) === request ) { pendingCastVariants.delete(outputPath); } } } function pickSharePathFromPhotoRow(row, variant) { if (!row) { return null; } const requested = String(variant || "original").toLowerCase(); if (requested === "cast") { return deriveCastPublicPathFromOriginal(row.path); } if (requested === "avg") { return row.thub2 || row.path; } if (requested === "min") { return row.thub1 || row.thub2 || row.path; } return row.path; } /* * Calcola il TTL effettivo richiesto dal client. * * Se manca o non è valido viene usato DEFAULT_SHARE_TTL_SECONDS. * Se supera MAX_SHARE_TTL_SECONDS viene applicato il limite massimo. */ function resolveShareTtlSeconds(value) { let ttlSeconds = parseInt(value || String(DEFAULT_SHARE_TTL_SECONDS), 10); if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { ttlSeconds = DEFAULT_SHARE_TTL_SECONDS; } if (ttlSeconds > MAX_SHARE_TTL_SECONDS) { ttlSeconds = MAX_SHARE_TTL_SECONDS; } return ttlSeconds; } /* * ============================================================ * SHARE CREATE ENDPOINT * * POST /share/create * ============================================================ */ router.post("/create", jwtMiddleware, async (req, res) => { const requestStartedAt = Date.now(); try { const entryId = req.body.entryId || req.body.remoteId; if (!entryId || String(entryId).trim() === "") { return res.status(400).json({ status: 400, message: "missing entryId", }); } const cleanEntryId = String(entryId).trim(); /* * La variante predefinita rimane "cast" per le fotografie. * * Per i video Flutter dovrebbe inviare: * * variant: original */ const variant = String( req.body.variant || req.body.shareVariant || "cast", ).toLowerCase(); const ttlSeconds = resolveShareTtlSeconds(req.body.ttlSeconds); /* * Per Chromecast deve normalmente essere false. * * Chromecast può inviare più richieste HEAD, GET e Range * per lo stesso contenuto. */ const oneTime = req.body.oneTime === true; console.log( "[share/create]", "entryId=", cleanEntryId, "variant=", variant, "ttlSeconds=", ttlSeconds, "oneTime=", oneTime, "user=", req.user?.name || "-", ); if (oneTime) { console.warn( "[share/create]", "WARNING oneTime=true is not recommended for Chromecast", "entryId=", cleanEntryId, ); } /* * Prova a leggere la riga DB, così possiamo scegliere * original, avg, min oppure cast. */ let photoRow = null; try { photoRow = await db("photos").where({ id: cleanEntryId }).first(); } catch (err) { console.warn("[share/create] photo row lookup error:", err); } console.log("[share/create] photoRow paths", { path: photoRow?.path, thub1: photoRow?.thub1, thub2: photoRow?.thub2, }); let filePath = null; /* * Se la riga DB esiste, prova a scegliere il percorso * richiesto tramite variant. */ if (photoRow) { const selectedPublicPath = pickSharePathFromPhotoRow(photoRow, variant); const selectedFilePath = resolvePublicPathToFile(selectedPublicPath); if (selectedFilePath && fs.existsSync(selectedFilePath)) { filePath = selectedFilePath; console.log( "[share/create] selected variant", "variant=", variant, "entryId=", cleanEntryId, "publicPath=", selectedPublicPath, "filePath=", safeFilePathForLog(filePath), ); } else { console.warn( "[share/create] selected variant missing, fallback to original", "variant=", variant, "publicPath=", selectedPublicPath, "filePath=", safeFilePathForLog(selectedFilePath), ); } } /* * Fallback originale. * * Viene utilizzato anche per i video quando la variante * cast non esiste. */ if (!filePath) { filePath = await resolveFilePathForEntryId(cleanEntryId); } /* * Fallback per entryId già corrispondente a un percorso. */ if (!filePath) { const candidate = path.isAbsolute(cleanEntryId) ? cleanEntryId : path.join(__dirname, "..", WEB_ROOT, cleanEntryId); if (fs.existsSync(candidate)) { filePath = candidate; } } if (!filePath) { console.warn("[share/create] file not found", "entryId=", cleanEntryId); return res.status(404).json({ status: 404, message: "file not found", entryId: cleanEntryId, }); } if (!fs.existsSync(filePath)) { console.warn( "[share/create] file does not exist", "entryId=", cleanEntryId, "filePath=", safeFilePathForLog(filePath), ); return res.status(404).json({ status: 404, message: "file does not exist", entryId: cleanEntryId, filePath, }); } /* * Permission check: * * Admin può condividere tutto. * Gli altri utenti possono condividere solo i propri file. */ try { const row = photoRow; if (row) { if (req.user?.name !== "Admin" && row.user !== req.user?.name) { console.warn( "[share/create] forbidden", "entryId=", cleanEntryId, "owner=", row.user, "requestUser=", req.user?.name, ); return res.status(403).json({ status: 403, message: "forbidden", }); } } else if (req.user?.name !== "Admin") { /* * Se non abbiamo una riga DB, consenti percorsi * arbitrari solo ad Admin. */ console.warn( "[share/create] forbidden path without DB row", "entryId=", cleanEntryId, "requestUser=", req.user?.name, ); return res.status(403).json({ status: 403, message: "forbidden", }); } } catch (err) { console.warn("[share/create] permission check error:", err); return res.status(500).json({ status: 500, message: "permission check failed", }); } const stat = await fs.promises.stat(filePath); /* * /share/create non genera la variante Cast. * * Registra invece nel token: * - percorso originale; * - percorso previsto della variante Cast; * - tipo di variante richiesta. * * La variante viene generata soltanto da * POST /share/prepare-cast. */ const originalPath = photoRow?.path ? resolvePublicPathToFile(photoRow.path) : filePath; const castPaths = variant === "cast" && photoRow ? getCastVariantPaths(photoRow) : null; const shareFilePath = originalPath || filePath; const { token, expiresAt } = createShareRecord( cleanEntryId, shareFilePath, ttlSeconds, oneTime, req.user?.name || null, { variant, originalPath: shareFilePath, castPath: castPaths?.filePath || null, }, ); filePath = shareFilePath; const baseUrl = getPublicBaseUrl(req); const url = `${baseUrl}/share/` + encodeURIComponent(token); console.log( "[share/create] completed", "entryId=", cleanEntryId, "variant=", variant, "token=", maskToken(token), "ttlSeconds=", ttlSeconds, "expiresAt=", expiresAt, "oneTime=", oneTime, "fileName=", path.basename(filePath), "size=", stat.size, "sizeFormatted=", formatBytes(stat.size), "mime=", mime.lookup(filePath) || "application/octet-stream", "ms=", Date.now() - requestStartedAt, ); return res.json({ url, expiresAt, ttlSeconds, oneTime, variant, fileName: path.basename(filePath), size: stat.size, contentType: mime.lookup(filePath) || "application/octet-stream", generated: false, prepared: false, }); } catch (err) { console.error("[share/create] error:", err); return res.status(500).json({ status: 500, message: err.message, }); } }); /* * ============================================================ * PREPARAZIONE BATCH VARIANTI CAST * * POST /share/prepare-cast * * Genera soltanto le foto remote inviate dalla finestra * Flutter -5/+4. * ============================================================ */ router.post( "/prepare-cast", jwtMiddleware, async (req, res) => { const requestStartedAt = Date.now(); const rawEntryIds = Array.isArray(req.body.entryIds) ? req.body.entryIds : []; const entryIds = [ ...new Set( rawEntryIds .map((value) => String(value).trim()) .filter((value) => value.length > 0), ), ]; if (entryIds.length === 0) { return res.status(400).json({ status: 400, message: "missing entryIds", }); } /* * Protezione da richieste batch eccessive. * La finestra prevista è -5/+4, quindi normalmente * contiene al massimo 10 elementi. */ if (entryIds.length > 20) { return res.status(400).json({ status: 400, message: "too many entryIds", maximum: 20, }); } console.log( "[share/prepare-cast] start", "requested=", entryIds.length, "user=", req.user?.name || "-", ); /* * Le elaborazioni sono lanciate insieme. * generateCastVariant deduplica già richieste concorrenti * relative allo stesso file. */ const results = await Promise.all( entryIds.map(async (entryId) => { try { const photoRow = await db("photos") .where({ id: entryId }) .first(); if (!photoRow) { return { entryId, status: "not-found", }; } if ( req.user?.name !== "Admin" && photoRow.user !== req.user?.name ) { return { entryId, status: "forbidden", }; } if (!photoRow.path) { return { entryId, status: "missing-original-path", }; } const originalPath = resolvePublicPathToFile( photoRow.path, ); const castPaths = getCastVariantPaths(photoRow); const originalMime = originalPath ? mime.lookup(originalPath) : null; const originalReady = originalPath ? await fileExistsAndIsNotEmpty( originalPath, ) : false; const isImage = typeof originalMime === "string" && originalMime.startsWith("image/"); if ( !originalPath || !originalReady || !castPaths || !isImage ) { return { entryId, status: "skipped", originalReady, originalMime: originalMime || null, }; } const prepared = await generateCastVariant({ entryId, originalPath, outputPath: castPaths.filePath, }); return { entryId, status: prepared.generated ? "generated" : "cached", generated: prepared.generated, width: prepared.width, height: prepared.height, fileName: path.basename( prepared.filePath, ), }; } catch (error) { console.error( "[share/prepare-cast] item-error", "entryId=", entryId, "error=", error.message, ); return { entryId, status: "error", message: error.message, }; } }), ); const summary = results.reduce( (value, item) => { value[item.status] = (value[item.status] || 0) + 1; return value; }, {}, ); console.log( "[share/prepare-cast] completed", "requested=", entryIds.length, "summary=", summary, "ms=", Date.now() - requestStartedAt, ); return res.json({ status: 200, requested: entryIds.length, summary, results, elapsedMs: Date.now() - requestStartedAt, }); }, ); /* * ============================================================ * SHARE BASE64 ENDPOINT * * POST /share/base64 * ============================================================ */ router.post("/base64", jwtMiddleware, async (req, res) => { try { const entryId = req.body.entryId || req.body.remoteId; if (!entryId || String(entryId).trim() === "") { return res.status(400).json({ status: 400, message: "missing entryId", }); } const cleanEntryId = String(entryId).trim(); let filePath = await resolveFilePathForEntryId(cleanEntryId); if (!filePath) { const candidate = path.isAbsolute(cleanEntryId) ? cleanEntryId : path.join(__dirname, "..", WEB_ROOT, cleanEntryId); if (fs.existsSync(candidate)) { filePath = candidate; } } if (!filePath || !fs.existsSync(filePath)) { return res.status(404).json({ status: 404, message: "file not found", entryId: cleanEntryId, }); } try { const row = await db("photos").where({ id: cleanEntryId }).first(); if (row) { if (req.user?.name !== "Admin" && row.user !== req.user?.name) { return res.status(403).json({ status: 403, message: "forbidden", }); } } else if (req.user?.name !== "Admin") { return res.status(403).json({ status: 403, message: "forbidden", }); } } catch (err) { console.warn("[share/base64] permission check error:", err); return res.status(500).json({ status: 500, message: "permission check failed", }); } /* * Questo endpoint legge il file interamente in memoria. * Non deve essere usato per il casting di video lunghi. */ const buffer = await fs.promises.readFile(filePath); const mimeType = mime.lookup(filePath) || "application/octet-stream"; const base64 = buffer.toString("base64"); console.log( "[share/base64] completed", "entryId=", cleanEntryId, "fileName=", path.basename(filePath), "bytes=", buffer.length, "mime=", mimeType, ); return res.json({ status: 200, mime: mimeType, base64, filePath, }); } catch (err) { console.error("[share/base64] error:", err); return res.status(500).json({ status: 500, message: err.message, }); } }); /* * ============================================================ * SHARE PUBLIC ENDPOINT * * GET /share/:token * HEAD /share/:token * * Supporta: * * 200 OK * 206 Partial Content * 404 Not Found * 405 Method Not Allowed * 416 Range Not Satisfiable * ============================================================ */ async function serveShareToken(req, res) { const requestId = nextShareRequestId(); const requestStartedAt = Date.now(); const token = req.params.token; const maskedToken = maskToken(token); const rangeHeader = req.headers.range || null; let stream = null; let progressTimer = null; let expectedBytes = 0; let streamedBytes = 0; let previousStreamedBytes = 0; let previousProgressAt = requestStartedAt; let lastDataAt = requestStartedAt; let streamEnded = false; let streamClosed = false; let responseFinished = false; let responseClosed = false; let requestAborted = false; let cleanupCompleted = false; let responseStart = null; let responseEnd = null; let responseTotal = null; let responseStatus = null; function log(event, details = {}) { console.log( `[SHARE STREAM][${new Date().toISOString()}]`, `requestId=${requestId}`, `event=${event}`, { token: maskedToken, ...details, }, ); } function stopProgressTimer() { if (progressTimer !== null) { clearInterval(progressTimer); progressTimer = null; } } function cleanup(reason) { if (cleanupCompleted) { return; } cleanupCompleted = true; stopProgressTimer(); const elapsedMs = Date.now() - requestStartedAt; log("cleanup", { reason, method: req.method, status: responseStatus || res.statusCode, range: rangeHeader || "-", responseRange: responseStart !== null ? `${responseStart}-${responseEnd}/${responseTotal}` : null, streamedBytes, streamedBytesFormatted: formatBytes(streamedBytes), expectedBytes, expectedBytesFormatted: formatBytes(expectedBytes), byteCountMatches: expectedBytes > 0 ? streamedBytes === expectedBytes : null, elapsedMs, averageMbps: formatMbps(calculateMbps(streamedBytes, elapsedMs)), streamEnded, streamClosed, responseFinished, responseClosed, requestAborted, headersSent: res.headersSent, writableEnded: res.writableEnded, responseDestroyed: res.destroyed, }); } log("request-start", { method: req.method, url: req.originalUrl, range: rangeHeader || "-", ip: req.ip, protocol: req.protocol, userAgent: req.headers["user-agent"] || "-", connection: req.headers.connection || "-", forwardedFor: req.headers["x-forwarded-for"] || "-", }); try { /* * Recupera il token dalla memoria. * * Se non esiste può essere: * * 1. token errato; * 2. token scaduto; * 3. token oneTime già utilizzato; * 4. server Node riavviato. */ const rec = getShareRecord(token); if (!rec) { responseStatus = 404; log("token-not-found-or-expired", { range: rangeHeader || "-", elapsedMs: Date.now() - requestStartedAt, }); return res.status(404).send("Not found"); } const expirationData = getRecordExpirationLogData(rec); log("token-found", { entryId: rec.entryId || null, owner: rec.owner || null, oneTime: !!rec.oneTime, hasFilePath: !!rec.filePath, variant: rec.variant || "original", hasOriginalPath: !!rec.originalPath, hasCastPath: !!rec.castPath, expiresAt: expirationData.expiresAt, remainingTtlSeconds: expirationData.remainingTtlSeconds, }); let filePath = rec.filePath; /* * Lo stesso URL share può passare automaticamente * dall'originale alla variante Cast. * * Se /share/prepare-cast ha già generato il file, * viene servito il JPEG Cast. Altrimenti resta disponibile * l'originale, senza restituire 404. */ if ( rec.variant === "cast" && rec.castPath ) { const castReady = await fileExistsAndIsNotEmpty( rec.castPath, ); if (castReady) { filePath = rec.castPath; log("cast-representation-selected", { entryId: rec.entryId || null, representation: "cast", filePath: safeFilePathForLog(filePath), }); } else { filePath = rec.originalPath || rec.filePath; log("cast-representation-fallback", { entryId: rec.entryId || null, representation: "original", castPath: safeFilePathForLog( rec.castPath, ), filePath: safeFilePathForLog(filePath), }); } } if (!filePath && rec.entryId) { const resolveStartedAt = Date.now(); filePath = await resolveFilePathForEntryId(rec.entryId); log("file-path-resolved", { entryId: rec.entryId, found: !!filePath, resolveMs: Date.now() - resolveStartedAt, filePath: safeFilePathForLog(filePath), }); } if (!filePath || !fs.existsSync(filePath)) { responseStatus = 404; log("file-not-found", { entryId: rec.entryId || null, filePath: safeFilePathForLog(filePath), }); return res.status(404).send("File not found"); } const statStartedAt = Date.now(); const stat = await fs.promises.stat(filePath); const statMs = Date.now() - statStartedAt; if (!stat.isFile()) { responseStatus = 404; log("path-is-not-file", { filePath: safeFilePathForLog(filePath), }); return res.status(404).send("File not found"); } const total = stat.size; let contentType = "application/octet-stream"; try { contentType = mime.lookup(filePath) || "application/octet-stream"; } catch (err) { log("mime-lookup-error", { error: err.message, }); } res.setHeader("Accept-Ranges", "bytes"); res.setHeader("Content-Type", contentType); /* * Per immagini consentiamo una cache breve. * * Per video e audio usiamo no-cache senza no-store. * In questo modo il receiver può conservare dati già ricevuti, * ma deve rivalidare quando necessario. */ if (contentType.startsWith("image/")) { res.setHeader("Cache-Control", "private, max-age=300"); } else { res.setHeader("Cache-Control", "private, no-cache, must-revalidate"); } log("file-resolved", { entryId: rec.entryId || null, fileName: path.basename(filePath), filePath: safeFilePathForLog(filePath), total, totalFormatted: formatBytes(total), contentType, statMs, lastModified: stat.mtime.toISOString(), cacheControl: res.getHeader("Cache-Control"), remainingTtlSeconds: expirationData.remainingTtlSeconds, }); /* * Gestione file vuoto. */ if (total === 0) { responseStatus = 200; expectedBytes = 0; res.status(200); res.setHeader("Content-Length", "0"); log("empty-file-response", { status: 200, }); return res.end(); } /* * HEAD * * Restituisce gli stessi header essenziali del GET, * ma non apre il file e non invia il body. */ if (req.method === "HEAD") { responseStatus = 200; expectedBytes = total; res.status(200); res.setHeader("Content-Length", total); log("response-head", { status: 200, total, totalFormatted: formatBytes(total), contentType, elapsedMs: Date.now() - requestStartedAt, }); return res.end(); } if (req.method !== "GET") { responseStatus = 405; res.setHeader("Allow", "GET, HEAD"); res.setHeader("Content-Length", "0"); log("method-not-allowed", { method: req.method, }); return res.status(405).end(); } /* * Calcolo intervallo da inviare. */ let start; let end; if (!rangeHeader) { responseStatus = 200; start = 0; end = total - 1; expectedBytes = total; res.status(200); res.setHeader("Content-Length", expectedBytes); } else { const range = parseRange(rangeHeader, total); if (!range) { responseStatus = 416; res.setHeader("Content-Range", `bytes */${total}`); res.setHeader("Content-Length", "0"); log("range-not-satisfiable", { status: 416, requestedRange: rangeHeader, total, }); return res.status(416).end(); } start = range.start; end = range.end; expectedBytes = end - start + 1; responseStatus = 206; res.status(206); res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`); res.setHeader("Content-Length", expectedBytes); } responseStart = start; responseEnd = end; responseTotal = total; log("stream-start", { status: responseStatus, requestedRange: rangeHeader || "-", responseRange: `${start}-${end}/${total}`, expectedBytes, expectedBytesFormatted: formatBytes(expectedBytes), fileName: path.basename(filePath), filePath: safeFilePathForLog(filePath), contentType, remainingTtlSeconds: expirationData.remainingTtlSeconds, preparationMs: Date.now() - requestStartedAt, }); /* * Crea lo stream solo per la sezione richiesta. * * In fs.createReadStream: * * start è incluso. * end è incluso. */ stream = fs.createReadStream(filePath, { start, end, }); /* * Log periodico della velocità. */ progressTimer = setInterval(() => { const now = Date.now(); const intervalMs = now - previousProgressAt; const intervalBytes = streamedBytes - previousStreamedBytes; const idleMs = now - lastDataAt; const currentMbps = calculateMbps(intervalBytes, intervalMs); const averageMbps = calculateMbps(streamedBytes, now - requestStartedAt); const completionPercent = expectedBytes > 0 ? (streamedBytes * 100) / expectedBytes : 0; log("stream-progress", { status: responseStatus, responseRange: `${start}-${end}/${total}`, streamedBytes, streamedBytesFormatted: formatBytes(streamedBytes), expectedBytes, expectedBytesFormatted: formatBytes(expectedBytes), intervalBytes, intervalBytesFormatted: formatBytes(intervalBytes), intervalMs, idleMs, currentMbps: formatMbps(currentMbps), averageMbps: formatMbps(averageMbps), completionPercent: completionPercent.toFixed(1), writableLength: res.writableLength, writableEnded: res.writableEnded, responseDestroyed: res.destroyed, streamPaused: stream?.isPaused() || false, }); if ( idleMs >= STREAM_IDLE_WARNING_MS && streamedBytes < expectedBytes && !streamEnded && !responseFinished ) { log("stream-no-progress-warning", { idleMs, warningThresholdMs: STREAM_IDLE_WARNING_MS, streamedBytes, expectedBytes, remainingBytes: expectedBytes - streamedBytes, writableLength: res.writableLength, streamPaused: stream?.isPaused() || false, responseDestroyed: res.destroyed, }); } previousStreamedBytes = streamedBytes; previousProgressAt = now; }, STREAM_PROGRESS_INTERVAL_MS); /* * I dati vengono conteggiati mentre attraversano lo stream. * * Con stream.pipe(res), la backpressure del socket controlla * il flusso del file. */ stream.on("data", (chunk) => { streamedBytes += chunk.length; lastDataAt = Date.now(); }); stream.on("end", () => { streamEnded = true; const elapsedMs = Date.now() - requestStartedAt; log("file-stream-end", { streamedBytes, streamedBytesFormatted: formatBytes(streamedBytes), expectedBytes, expectedBytesFormatted: formatBytes(expectedBytes), byteCountMatches: streamedBytes === expectedBytes, elapsedMs, averageMbps: formatMbps(calculateMbps(streamedBytes, elapsedMs)), }); }); stream.on("close", () => { streamClosed = true; log("file-stream-close", { streamedBytes, expectedBytes, byteCountMatches: streamedBytes === expectedBytes, streamEnded, responseFinished, requestAborted, }); }); stream.on("error", (err) => { stopProgressTimer(); log("file-stream-error", { error: err.message, code: err.code || null, stack: err.stack, streamedBytes, expectedBytes, streamEnded, responseFinished, requestAborted, }); if (!res.headersSent) { responseStatus = 500; res.status(500).send("Stream error"); } else if (!res.destroyed) { res.destroy(err); } }); req.on("aborted", () => { requestAborted = true; log("request-aborted", { streamedBytes, expectedBytes, streamEnded, responseFinished, writableEnded: res.writableEnded, }); if (stream && !stream.destroyed) { stream.destroy(); } cleanup("request-aborted"); }); res.on("finish", () => { responseFinished = true; const elapsedMs = Date.now() - requestStartedAt; log("response-finish", { status: responseStatus || res.statusCode, streamedBytes, streamedBytesFormatted: formatBytes(streamedBytes), expectedBytes, expectedBytesFormatted: formatBytes(expectedBytes), byteCountMatches: streamedBytes === expectedBytes, elapsedMs, averageMbps: formatMbps(calculateMbps(streamedBytes, elapsedMs)), }); cleanup("response-finish"); }); res.on("close", () => { responseClosed = true; log("response-close", { status: responseStatus || res.statusCode, streamedBytes, expectedBytes, byteCountMatches: streamedBytes === expectedBytes, streamEnded, streamClosed, responseFinished, requestAborted, writableEnded: res.writableEnded, }); if (!responseFinished && stream && !stream.destroyed) { stream.destroy(); } cleanup( responseFinished ? "response-close-after-finish" : "response-close-before-finish", ); }); stream.pipe(res); /* * Token one-time. * * Non è consigliato per Chromecast perché lo stesso file * può ricevere più richieste HEAD, GET e Range. * * Se oneTime=true, il token viene eliminato dopo l'avvio * del primo GET. */ if (rec.oneTime) { log("one-time-token-delete", { warning: "oneTime token is not recommended for Cast", }); if (shareTokens && typeof shareTokens.delete === "function") { shareTokens.delete(token); } } } catch (err) { stopProgressTimer(); log("handler-error", { error: err.message, code: err.code || null, stack: err.stack, streamedBytes, expectedBytes, headersSent: res.headersSent, }); if (!res.headersSent) { responseStatus = 500; res.status(500).send("Server error"); } else if (!res.destroyed) { res.destroy(err); } cleanup("handler-error"); } } /* * Express può utilizzare il gestore GET anche per HEAD. * * La registrazione esplicita mantiene comunque chiaro * il comportamento previsto. */ router.route("/:token").get(serveShareToken).head(serveShareToken); module.exports = router;