galleria_photo_face_rknn_op.../routes/share.js
2026-10-03 12:16:58 +02:00

1315 lines
31 KiB
JavaScript

// 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;