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