235 lines
6.3 KiB
JavaScript
235 lines
6.3 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}`;
|
|
}
|
|
|
|
// ===============================
|
|
// 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;
|