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

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;