494 lines
13 KiB
JavaScript
494 lines
13 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');
|
|
|
|
// ===============================
|
|
// PUBLIC BASE URL
|
|
// Per Chromecast deve essere un URL raggiungibile dal device Cast.
|
|
// ===============================
|
|
function getPublicBaseUrl(req) {
|
|
if (config.BASE_URL) {
|
|
return config.BASE_URL.replace(/\/+$/, '');
|
|
}
|
|
|
|
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'https';
|
|
const host = req.headers['x-forwarded-host'] || req.headers.host;
|
|
|
|
return `${proto}://${host}`;
|
|
}
|
|
|
|
function deriveCastPublicPathFromOriginal(originalPublicPath) {
|
|
if (!originalPublicPath || typeof originalPublicPath !== 'string') return null;
|
|
|
|
const normalized = originalPublicPath.replace(/\\/g, '/');
|
|
|
|
// /photos/Fabio/original/cartella/IMG_0244.JPG
|
|
// ->
|
|
// /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;
|
|
}
|
|
|
|
if (requested === 'original') {
|
|
return row.path;
|
|
}
|
|
|
|
return row.path;
|
|
}
|
|
|
|
// ===============================
|
|
// SHARE CREATE ENDPOINT
|
|
// Protected: crea URL temporanea per Chromecast/app.
|
|
//
|
|
// Endpoint finale:
|
|
// POST /share/create
|
|
//
|
|
// Perché in server.js hai:
|
|
// server.use('/share', shareRouter);
|
|
// ===============================
|
|
router.post('/create', 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();
|
|
|
|
//const variant = String(req.body.variant || req.body.shareVariant || 'original').toLowerCase();
|
|
const variant = String(req.body.variant || req.body.shareVariant || 'cast').toLowerCase();
|
|
console.log('[share/create] entryId=', cleanEntryId, 'variant=', variant);
|
|
|
|
let ttlSeconds = parseInt(req.body.ttlSeconds || '300', 10);
|
|
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
|
|
ttlSeconds = 300;
|
|
}
|
|
|
|
const oneTime = !!req.body.oneTime;
|
|
|
|
// Prova a leggere la riga DB, così possiamo scegliere original/avg/min.
|
|
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,
|
|
});
|
|
|
|
// Se la riga DB esiste, scegli il path in base alla variant richiesta.
|
|
let filePath = null;
|
|
|
|
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,
|
|
'entryId=',
|
|
cleanEntryId,
|
|
'publicPath=',
|
|
selectedPublicPath,
|
|
'filePath=',
|
|
filePath
|
|
);
|
|
} else {
|
|
console.warn(
|
|
'[share/create] selected variant missing, fallback to original',
|
|
variant,
|
|
selectedPublicPath,
|
|
selectedFilePath
|
|
);
|
|
}
|
|
}
|
|
|
|
// Fallback originale: se avg/min non esiste o non c'è riga DB.
|
|
if (!filePath) {
|
|
filePath = await resolveFilePathForEntryId(cleanEntryId);
|
|
}
|
|
|
|
// Fallback: se entryId è già un path.
|
|
if (!filePath) {
|
|
const candidate = path.isAbsolute(cleanEntryId)
|
|
? cleanEntryId
|
|
: path.join(__dirname, '..', WEB_ROOT, cleanEntryId);
|
|
|
|
if (fs.existsSync(candidate)) {
|
|
filePath = candidate;
|
|
}
|
|
}
|
|
|
|
|
|
if (!filePath) {
|
|
return res.status(404).json({
|
|
status: 404,
|
|
message: 'file not found',
|
|
entryId: cleanEntryId,
|
|
});
|
|
}
|
|
|
|
if (!fs.existsSync(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) {
|
|
return res.status(403).json({
|
|
status: 403,
|
|
message: 'forbidden',
|
|
});
|
|
}
|
|
} else {
|
|
// Se non abbiamo riga DB, consenti path arbitrari solo ad Admin.
|
|
if (req.user?.name !== 'Admin') {
|
|
return res.status(403).json({
|
|
status: 403,
|
|
message: 'forbidden',
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[share/create] permission check error:', err);
|
|
}
|
|
|
|
const { token, expiresAt } = createShareRecord(
|
|
cleanEntryId,
|
|
filePath,
|
|
ttlSeconds,
|
|
oneTime,
|
|
req.user?.name || null,
|
|
);
|
|
|
|
const baseUrl = getPublicBaseUrl(req);
|
|
const url = `${baseUrl}/share/${encodeURIComponent(token)}`;
|
|
|
|
return res.json({
|
|
url,
|
|
expiresAt,
|
|
variant,
|
|
fileName: path.basename(filePath),
|
|
});
|
|
} catch (err) {
|
|
console.error('[share/create] error:', err);
|
|
|
|
return res.status(500).json({
|
|
status: 500,
|
|
message: err.message,
|
|
});
|
|
}
|
|
});
|
|
// ===============================
|
|
// SHARE BASE64 ENDPOINT
|
|
// Restituisce direttamente il file in base64
|
|
// Endpoint finale:
|
|
// 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();
|
|
|
|
// Risolve path file dal DB
|
|
let filePath = await resolveFilePathForEntryId(cleanEntryId);
|
|
|
|
// Fallback: se entryId è già un path
|
|
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,
|
|
});
|
|
}
|
|
|
|
// Permission check identico a /share/create
|
|
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);
|
|
}
|
|
|
|
// Leggi file come buffer
|
|
const buffer = fs.readFileSync(filePath);
|
|
|
|
// MIME type
|
|
let mimeType = mime.lookup(filePath) || 'application/octet-stream';
|
|
|
|
// Converti in base64
|
|
const base64 = buffer.toString('base64');
|
|
|
|
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
|
|
// Serve file by token, supporta Range.
|
|
// Endpoint finale:
|
|
// GET /share/:token
|
|
// ===============================
|
|
router.get('/:token', async (req, res) => {
|
|
try {
|
|
const { token } = req.params;
|
|
|
|
console.log(
|
|
new Date().toISOString(),
|
|
req.method,
|
|
req.originalUrl,
|
|
'range=',
|
|
req.headers.range || '-',
|
|
'ip=',
|
|
req.ip,
|
|
'ua=',
|
|
req.headers['user-agent']
|
|
);
|
|
|
|
console.log('[SHARE TOKEN]', token);
|
|
|
|
// recupera record token
|
|
const rec = getShareRecord(token);
|
|
if (!rec) return res.status(404).send('Not found');
|
|
|
|
let filePath = rec.filePath;
|
|
|
|
// se non c'è filePath, prova a risolvere via entryId
|
|
if (!filePath && rec.entryId) {
|
|
filePath = await resolveFilePathForEntryId(rec.entryId);
|
|
if (!filePath) return res.status(404).send('File not found');
|
|
}
|
|
|
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
return res.status(404).send('File not found');
|
|
}
|
|
|
|
const stat = fs.statSync(filePath);
|
|
const total = stat.size;
|
|
const rangeHeader = req.headers.range;
|
|
|
|
// MIME type
|
|
let contentType = 'application/octet-stream';
|
|
try {
|
|
const mt = mime.lookup(filePath);
|
|
if (mt) contentType = mt;
|
|
} catch (_) {}
|
|
|
|
// headers comuni
|
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
res.setHeader('Content-Type', contentType);
|
|
|
|
// Cache policy:
|
|
// Per immagini permettiamo cache breve, così Chromecast può riusare la prima GET.
|
|
// Per video/audio lasciamo no-store, più sicuro per Range/seeking/token temporanei.
|
|
if (contentType.startsWith('image/')) {
|
|
res.setHeader('Cache-Control', 'private, max-age=300');
|
|
} else {
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
}
|
|
|
|
console.log(
|
|
'[SHARE]',
|
|
token,
|
|
contentType,
|
|
total,
|
|
'cache=',
|
|
res.getHeader('Cache-Control')
|
|
);
|
|
|
|
// ===============================
|
|
// SENZA RANGE -> file completo
|
|
// ===============================
|
|
if (!rangeHeader) {
|
|
res.setHeader('Content-Length', total);
|
|
|
|
const start = Date.now();
|
|
const stream = fs.createReadStream(filePath);
|
|
|
|
stream.on('close', () => {
|
|
console.log(
|
|
'[STREAM DONE]',
|
|
token,
|
|
`${Date.now() - start} ms`
|
|
);
|
|
});
|
|
|
|
stream.on('error', (err) => {
|
|
console.error('[STREAM ERROR]', token, err);
|
|
if (!res.headersSent) {
|
|
res.status(500).send('Stream error');
|
|
} else {
|
|
res.destroy(err);
|
|
}
|
|
});
|
|
|
|
stream.pipe(res);
|
|
}
|
|
|
|
// ===============================
|
|
// CON RANGE -> partial content
|
|
// ===============================
|
|
else {
|
|
const range = parseRange(rangeHeader, total);
|
|
|
|
if (!range) {
|
|
res.status(416).setHeader('Content-Range', `bytes */${total}`);
|
|
return res.end();
|
|
}
|
|
|
|
const { start, end } = range;
|
|
|
|
res.status(206);
|
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
|
|
res.setHeader('Content-Length', (end - start) + 1);
|
|
|
|
const streamStart = Date.now();
|
|
const stream = fs.createReadStream(filePath, { start, end });
|
|
|
|
stream.on('close', () => {
|
|
console.log(
|
|
'[STREAM DONE RANGE]',
|
|
token,
|
|
`bytes=${start}-${end}`,
|
|
`${Date.now() - streamStart} ms`
|
|
);
|
|
});
|
|
|
|
stream.on('error', (err) => {
|
|
console.error('[STREAM ERROR RANGE]', token, err);
|
|
if (!res.headersSent) {
|
|
res.status(500).send('Stream error');
|
|
} else {
|
|
res.destroy(err);
|
|
}
|
|
});
|
|
|
|
stream.pipe(res);
|
|
}
|
|
|
|
// ===============================
|
|
// ONE-TIME TOKEN
|
|
// Per immagini/video Cast meglio oneTime=false.
|
|
// ===============================
|
|
if (rec.oneTime) {
|
|
if (shareTokens && typeof shareTokens.delete === 'function') {
|
|
shareTokens.delete(token);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error serving share token:', err);
|
|
res.status(500).send('Server error');
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|