// routes/share.js const express = require("express"); const router = express.Router(); const fs = require("fs"); const path = require("path"); 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. */ 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: * * /photos/Fabio/thumbs/cartella/IMG_0244_cast.jpg */ const thumbsPath = normalized.replace("/original/", "/thumbs/"); const dir = path.posix.dirname(thumbsPath); const ext = path.posix.extname(thumbsPath); const base = path.posix.basename(thumbsPath, ext); return path.posix.join(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 pickSharePathFromPhotoRow(row, variant) { if (!row) { return null; } const requested = String(variant || "original").toLowerCase(); if (requested === "cast") { return deriveCastPublicPathFromOriginal(row.path) || row.thub2 || 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); const { token, expiresAt } = createShareRecord( cleanEntryId, filePath, ttlSeconds, oneTime, req.user?.name || null, ); 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, }); } catch (err) { console.error("[share/create] error:", err); return res.status(500).json({ status: 500, message: err.message, }); } }); /* * ============================================================ * 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, expiresAt: expirationData.expiresAt, remainingTtlSeconds: expirationData.remainingTtlSeconds, }); let filePath = rec.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;