// 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}`; } // =============================== // 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(); let ttlSeconds = parseInt(req.body.ttlSeconds || '300', 10); if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { ttlSeconds = 300; } const oneTime = !!req.body.oneTime; // 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) { 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 = 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 { // 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, }); } catch (err) { console.error('[share/create] 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; // 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; // headers comuni res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); // MIME type let contentType = 'application/octet-stream'; try { const mt = mime.lookup(filePath); if (mt) contentType = mt; } catch (_) {} res.setHeader('Content-Type', contentType); // =============================== // SENZA RANGE -> file completo // =============================== if (!rangeHeader) { res.setHeader('Content-Length', total); const stream = fs.createReadStream(filePath); stream.pipe(res); } // =============================== // CON RANGE -> partial content // =============================== else { const range = parseRange(rangeHeader, total); if (!range) { res.status(416).setHeader('Content-Range', `bytes */${total}`).end(); return; } const { start, end } = range; res.status(206); res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`); res.setHeader('Content-Length', (end - start) + 1); const stream = fs.createReadStream(filePath, { start, end }); stream.pipe(res); } // =============================== // ONE-TIME TOKEN -> invalida dopo uso // Nota: per video e Range può rompere seeking, // quindi di default 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;