server_photo_s85js/routes/share.js
2026-08-11 08:45:04 +02:00

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;