gallery_server_client_s190js/routes/share.js
2026-10-26 11:00:14 +01:00

1900 lines
43 KiB
JavaScript

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