1315 lines
31 KiB
JavaScript
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;
|