// server.js require('dotenv').config(); const fs = require('fs'); const path = require('path'); const express = require('express'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const scanPhoto = require('./api_v1/scanner/scanPhoto.js'); const { handleAddFile, handleDeleteFile, handleAddDir, handleDeleteDir } = require('./api_v1/scanner/scanAuto.js'); const { WEB_ROOT } = require('./api_v1/config'); const config = require('./api_v1/config'); const RETENTION_DAYS = parseInt(process.env.PHOTO_RETENTION_DAYS || "30", 10); const SECRET_KEY = process.env.JWT_SECRET || '123456789'; const EXPIRES_IN = process.env.JWT_EXPIRES || '1h'; const PORT = process.env.SERVER_PORT || 4000; const db = require('./db/knex'); const createCleanupFunctions = require('./api_v1/scanner/orphanCleanup'); const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db, RETENTION_DAYS); const photosRouter = require('./routes/photos'); const wss = require('./ws-server'); const dbWs = require('./db/dbWs'); const scanFile = require('./api_v1/scanner/scanFileEntry'); const scanPhotoSingle = require('./api_v1/scanner/scanPhotoSingle'); const scanPhotosUser = require('./api_v1/scanner/scanPhotosUser'); const createDeleteFolderFunctions = require('./api_v1/scanner/deleteFolder'); const { deleteFolderForUser } = createDeleteFolderFunctions(db, RETENTION_DAYS); const server = express(); // =============================== // PUBLIC BASE URL (per Chromecast) // =============================== function getPublicBaseUrl(req) { // Se definito in config, è la sorgente di verità if (config.BASE_URL) { return config.BASE_URL.replace(/\/+$/, ''); } // Altrimenti ricostruisce da reverse proxy const proto = req.headers['x-forwarded-proto'] || req.protocol || 'https'; const host = req.headers['x-forwarded-host'] || req.headers.host; return `${proto}://${host}`; } // // =============================== // STATICI PUBBLICI // =============================== server.use(express.static(path.join(__dirname, 'public'))); // // =============================== // server HLS video // =============================== server.use('/hls', express.static( path.join(__dirname, 'public/hls'), { setHeaders: (res, filePath) => { if (filePath.endsWith('.m3u8')) { res.setHeader( 'Content-Type', 'application/vnd.apple.mpegurl' ); } if (filePath.endsWith('.ts')) { res.setHeader( 'Content-Type', 'video/mp2t' ); } res.setHeader( 'Access-Control-Allow-Origin', '*' ); res.setHeader( 'Cache-Control', 'public, max-age=3600' ); } } )); // // =============================== // LOGIN PAGE PUBBLICA // =============================== server.get('/login', (req, res) => { res.sendFile(path.join(__dirname, 'public/login.html')); }); // // =============================== // PING SUPER LEGGERO (pubblico) // =============================== server.get('/ping', (req, res) => { console.log("Ping"); res.json({ status: "ok" }); }); // // =============================== // BODY PARSER // =============================== server.use(express.json()); server.use(express.urlencoded({ extended: true })); // // =============================== // CONFIG PUBBLICO // =============================== server.get('/config', (req, res) => { res.json({ baseUrl: config.BASE_URL, pathFull: config.PATH_FULL, galleryRefreshSeconds: config.GALLERY_REFRESH_SECONDS }); }); // // =============================== // USERS DB // =============================== const userdb = JSON.parse(fs.readFileSync('./api_v1/users.json', 'utf-8')); // // =============================== // JWT HELPERS // =============================== function createToken(payload) { return jwt.sign(payload, SECRET_KEY, { expiresIn: EXPIRES_IN }); } const denylist = new Map(); function addToDenylist(token) { try { const decoded = jwt.decode(token); const exp = decoded?.exp || Math.floor(Date.now() / 1000) + 60; denylist.set(token, exp); } catch { denylist.set(token, Math.floor(Date.now() / 1000) + 60); } } function isRevoked(token) { const exp = denylist.get(token); if (!exp) return false; if (exp * 1000 < Date.now()) { denylist.delete(token); return false; } return true; } function verifyToken(token) { return jwt.verify(token, SECRET_KEY); } function relPath2Cartella(p) { const parts = p.split("/").filter(Boolean); return parts.slice(3).join("/"); } // // =============================== // HOME // =============================== server.get('/', (req, res) => { res.sendFile(path.resolve('public/index.html')); }); // // =============================== // LOGIN API // =============================== server.post('/auth/login', async (req, res) => { const { email, password } = req.body; const user = userdb.users.find((u) => u.email === email); if (!user) { return res.status(401).json({ status: 401, message: 'Incorrect email or password' }); } const ok = await bcrypt.compare(password, user.password); if (!ok) { return res.status(401).json({ status: 401, message: 'Incorrect email or password' }); } const token = createToken({ id: user.id, email: user.email, name: user.name, }); res.status(200).json({ token, name: user.name, }); }); // // =============================== // LOGOUT // =============================== server.post('/auth/logout', (req, res) => { const auth = req.headers.authorization || ''; const [scheme, token] = auth.split(' '); if (scheme === 'Bearer' && token) { addToDenylist(token); } return res.status(204).end(); }); // // =============================== // SHARE PUBLIC ENDPOINT (serve file by token, supports Range) // This must be defined BEFORE the JWT middleware so it's public. // =============================== const { v4: uuidv4 } = require('uuid'); let mime; try { mime = require('mime-types'); } catch (_) { mime = null; } const shareTokens = new Map(); // token -> { entryId, filePath, expiresAt, oneTime, owner } const SHARE_CLEANUP_INTERVAL_MS = 60 * 1000; // pulizia ogni minuto function createShareRecord(entryId, filePath, ttlSeconds = 300, oneTime = false, owner = null) { const token = uuidv4(); const expiresAt = Date.now() + ttlSeconds * 1000; shareTokens.set(token, { entryId, filePath, expiresAt, oneTime, owner }); return { token, expiresAt }; } function getShareRecord(token) { const rec = shareTokens.get(token); if (!rec) return null; if (rec.expiresAt <= Date.now()) { shareTokens.delete(token); return null; } return rec; } function cleanupShareTokens() { const now = Date.now(); for (const [t, rec] of shareTokens.entries()) { if (rec.expiresAt <= now) shareTokens.delete(t); } } setInterval(cleanupShareTokens, SHARE_CLEANUP_INTERVAL_MS); // helper: try to resolve entryId -> filesystem path using photos table (best effort) async function resolveFilePathForEntryId(entryId) { try { const row = await db('photos').where({ id: entryId }).first(); if (!row) return null; const candidates = [ row.path, row.file, row.file_path, row.original, row.uri, row.fullpath, row.storage_path, row.local_path ]; for (let c of candidates) { if (!c || typeof c !== 'string') continue; c = c.trim(); // se è un path assoluto valido sul filesystem if (path.isAbsolute(c) && fs.existsSync(c)) return c; // se inizia con slash ma non è un path assoluto valido nel contesto del server, // prova a risolvere rispetto a __dirname e WEB_ROOT if (c.startsWith('/')) { const p1 = path.join(__dirname, c); // __dirname + /photos/... if (fs.existsSync(p1)) return p1; const p2 = path.join(__dirname, WEB_ROOT, c.replace(/^\/+/, '')); // __dirname/WEB_ROOT/photos/... if (fs.existsSync(p2)) return p2; } else { // path relativo: prova WEB_ROOT + c e poi __dirname + c const p3 = path.join(__dirname, WEB_ROOT, c); if (fs.existsSync(p3)) return p3; const p4 = path.join(__dirname, c); if (fs.existsSync(p4)) return p4; } } // fallback: compone path da user/cartella/name (usa i campi presenti nel tuo DB) if (row.user && row.cartella && (row.name || row.file)) { const filename = row.name || row.file; const p = path.join(__dirname, WEB_ROOT, 'photos', row.user, 'original', row.cartella, filename); if (fs.existsSync(p)) return p; } return null; } catch (err) { console.error('resolveFilePathForEntryId error:', err); return null; } } // Range helper: parse "bytes=start-end" function parseRange(rangeHeader, totalLength) { if (!rangeHeader) return null; const m = /bytes=(\d*)-(\d*)/.exec(rangeHeader); if (!m) return null; const start = m[1] ? parseInt(m[1], 10) : 0; const end = m[2] ? parseInt(m[2], 10) : totalLength - 1; if (isNaN(start) || isNaN(end) || start > end || start >= totalLength) return null; return { start, end }; } // Public GET to serve shared resource by token (supports Range) server.get('/share/:token', async (req, res) => { try { const { token } = req.params; const rec = getShareRecord(token); if (!rec) return res.status(404).send('Not found'); let filePath = rec.filePath; // if filePath not stored, try to resolve from entryId if (!filePath && rec.entryId) { filePath = await resolveFilePathForEntryId(rec.entryId); if (!filePath) return res.status(404).send('File not found'); } if (!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; res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); // try to detect mime let contentType = 'application/octet-stream'; try { if (mime) { const mt = mime.lookup(filePath); if (mt) contentType = mt; } } catch (_) {} res.setHeader('Content-Type', contentType); if (!rangeHeader) { res.setHeader('Content-Length', total); const stream = fs.createReadStream(filePath); stream.pipe(res); } 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); } // if oneTime, invalidate token after serving (note: for range requests this may break seeking) if (rec.oneTime) { shareTokens.delete(token); } } catch (err) { console.error('Error serving share token:', err); res.status(500).send('Server error'); } }); // // =============================== // JWT MIDDLEWARE // =============================== server.use(/^(?!\/auth)(?!\/login)(?!\/login.html)(?!\/css)(?!\/js)(?!\/img)(?!\/fonts)(?!\/favicon).*$/, (req, res, next) => { const auth = req.headers.authorization || ''; const [scheme, token] = auth.split(' '); if (scheme !== 'Bearer' || !token) { return res.status(401).json({ status: 401, message: 'Bad authorization header' }); } if (isRevoked(token)) { return res.status(401).json({ status: 401, message: 'Token revoked' }); } try { const decoded = verifyToken(token); req.user = decoded; next(); } catch (err) { return res.status(401).json({ status: 401, message: 'Error: access_token is not valid' }); } }); // // =============================== // SCAN FOTO // =============================== server.get('/scan', async (req, res) => { try { const loggedUser = req.user.name; if (loggedUser === 'Admin') { await scanPhotosUser('Common', db); for (const u of userdb.users) { await scanPhotosUser(u.name, db); } return res.send({ status: 'Scansione completata', user: 'Admin', scope: 'tutti gli utenti + Common' }); } await scanPhotosUser(loggedUser, db); res.send({ status: 'Scansione completata', user: loggedUser, scope: 'utente corrente' }); } catch (err) { console.error('Errore scan:', err); res.status(500).json({ error: 'Errore durante lo scan', details: err.message }); } }); // // =============================== // AUTO SCAN (VERSIONE DEFINITIVA) // =============================== server.post('/api_v1/auto_scan', async (req, res) => { const { type, file, path: relPath, user } = req.body; const cart = relPath2Cartella(relPath); const absFile = path.join(__dirname, WEB_ROOT, relPath, file); try { switch (type) { // // =============================== // ADD_DIR — aggiunta cartella // → manda SOLO eventi "added" // =============================== // /*case 'ADD_DIR': { const scanCartella = require('./api_v1/scanner/scanCartella'); //const absCartella = path.join(__dirname, WEB_ROOT, user, file); const absCartella = path.join(__dirname, WEB_ROOT, "photos", user, "original", file); console.log("start ADD_DIR"); console.log(user); console.log(file); console.log(absCartella); const newFiles = []; for await (const f of scanCartella(user, file, absCartella, db)) { // elimina eventuali record vecchi await db('deleted_hard').where({ id: f.id, user }).del(); await db('photos').where({ id: f.id, user }).del(); // salva nel DB come fa ADD const arr = []; await scanPhotoSingle(db, user, file, f, arr); newFiles.push(f); // 🔥 manda evento "added" per ogni file wss.broadcastToUser(user, { type: "added", id: f.id }); } return res.json({ status: 'OK', action: 'ADD_DIR', count: newFiles.length }); }*/ case 'ADD_DIR': { const scanCartella = require('./api_v1/scanner/scanCartella'); const absCartella = path.join(__dirname, WEB_ROOT, "photos", user, "original", file); const BULK_THRESHOLD = parseInt(process.env.WS_BULK_THRESHOLD || "500", 10); let count = 0; let bulkMode = false; // since: prima di iniziare gli insert, così /photos/changes?since=since prende tutto const since = new Date().toISOString(); console.log("start ADD_DIR"); console.log(user); console.log(file); console.log(absCartella); console.log(`[ADD_DIR] BULK_THRESHOLD=${BULK_THRESHOLD} since=${since}`); for await (const f of scanCartella(user, file, absCartella, db)) { count++; // elimina eventuali record vecchi await db('deleted_hard').where({ id: f.id, user }).del(); await db('photos').where({ id: f.id, user }).del(); // salva nel DB come fa ADD const arr = []; await scanPhotoSingle(db, user, file, f, arr); // 🔥 se supera soglia -> manda UN SOLO evento bulk e stop eventi per-file if (!bulkMode && count > BULK_THRESHOLD) { bulkMode = true; wss.broadcastToUser(user, { type: "add_dir", folder: file, mode: "bulk", count, since }); // da qui in poi NON mandiamo più "added" per ogni file continue; } // se non bulk, manda evento "added" per ogni file if (!bulkMode) { wss.broadcastToUser(user, { type: "added", id: f.id }); } } // opzionale: evento di fine bulk (utile per log/UI) if (bulkMode) { wss.broadcastToUser(user, { type: "add_dir_done", folder: file, mode: "bulk", count, since }); } return res.json({ status: 'OK', action: 'ADD_DIR', count, bulkMode }); } // // =============================== // DEL — singolo file // =============================== // case 'DEL': { const e = await scanFile(user, cart, absFile); await deleteThumbsById(e.id); await db('photos').where({ id: e.id, user }).del(); await db('deleted_hard').insert({ id: e.id, user, deleted_at: new Date().toISOString() }); wss.broadcastToUser(user, { type: "del", id: e.id }); return res.json({ status: 'OK', action: 'DEL', id: e.id }); } // // =============================== // ADD — singolo file // =============================== // case 'ADD': { const f = await scanFile(user, cart, absFile); await db('deleted_hard').where({ id: f.id, user }).del(); await db('photos').where({ id: f.id, user }).del(); const newFiles = []; await scanPhotoSingle(db, user, cart, f, newFiles); wss.broadcastToUser(user, { type: "added", id: f.id }); return res.json({ status: 'OK', action: 'ADD', id: f.id }); } // // =============================== // DEL_DIR — rimozione cartella // → manda SOLO eventi "del" per ogni file // =============================== // /*case 'DEL_DIR': { const photos = await db('photos') .where({ user, cartella: file }); for (const p of photos) { await db('photos').where({ id: p.id, user }).del(); await deleteThumbsById(p.id); await db('deleted_hard').insert({ id: p.id, user, deleted_at: new Date().toISOString() }); // 🔥 manda evento "del" per ogni file wss.broadcastToUser(user, { type: "del", id: p.id }); } return res.json({ status: 'OK', action: 'DEL_DIR', folder: file }); }*/ case 'DEL_DIR': { // prendiamo l’elenco prima per sapere quanti sono const photos = await db('photos').where({ user, cartella: file }); const BULK_THRESHOLD = parseInt(process.env.WS_BULK_THRESHOLD || "500", 10); // since: timestamp di riferimento per deleted_hard/changes const since = new Date().toISOString(); console.log(`[DEL_DIR] folder=${file} count=${photos.length} BULK_THRESHOLD=${BULK_THRESHOLD} since=${since}`); const bulkMode = photos.length > BULK_THRESHOLD; // se bulk: manda UN SOLO evento e poi basta per-file del if (bulkMode) { wss.broadcastToUser(user, { type: "del_dir", folder: file, mode: "bulk", count: photos.length, since }); } for (const p of photos) { await db('photos').where({ id: p.id, user }).del(); await deleteThumbsById(p.id); await db('deleted_hard').insert({ id: p.id, user, deleted_at: new Date().toISOString() }); // se non bulk: manda evento "del" per ogni file if (!bulkMode) { wss.broadcastToUser(user, { type: "del", id: p.id }); } } // opzionale: evento fine bulk if (bulkMode) { wss.broadcastToUser(user, { type: "del_dir_done", folder: file, mode: "bulk", count: photos.length, since }); } return res.json({ status: 'OK', action: 'DEL_DIR', folder: file, count: photos.length, bulkMode }); } default: return res.status(400).json({ error: 'Tipo non valido' }); } } catch (err) { console.error('Errore scan_auto:', err); res.status(500).json({ error: err.message }); } }); // // =============================== // FILE STATICI // =============================== server.get('/files', (req, res) => { const requested = req.query.file || ''; const publicDir = path.resolve(path.join(__dirname, 'public')); const resolved = path.resolve(publicDir, requested); if (!resolved.startsWith(publicDir)) { return res.status(400).json({ error: 'Invalid path' }); } if (!fs.existsSync(resolved) || fs.statSync(resolved).isDirectory()) { return res.status(404).json({ error: 'Not found' }); } res.sendFile(resolved); }); // // =============================== // DELETE FOTO (PATCHATO) // =============================== server.delete('/delphoto/:id', async (req, res) => { const { id } = req.params; try { const ok = await deleteFromDB(id, req.user.name); wss.broadcastToUser(req.user.name, { type: "removed", id }); return res.json({ ok, id }); } catch (err) { console.error('DELPHOTO errore:', err); return res.status(500).json({ ok: false, error: err.message }); } }); // // =============================== // RESET DB PER UTENTE // =============================== server.get('/initDBuser', async (req, res) => { try { let targetUser = req.user.name; if (req.user.name === 'Admin') { targetUser = req.query.user; if (!targetUser) { return res.status(400).json({ error: "Admin deve specificare ?user=" }); } } const trx = await db.transaction(); const deleted = await trx('photos').where({ user: targetUser }).del(); await trx.commit(); res.json({ status: "OK", user: targetUser, deletedRecords: deleted }); } catch (err) { console.error("initDBuser errore:", err); res.status(500).json({ error: "Errore durante initDBuser", details: err.message }); } }); // // =============================== // LISTA UTENTI (solo Admin) // =============================== server.get('/users', (req, res) => { if (req.user.name !== 'Admin') { return res.status(403).json({ error: "Solo Admin può vedere la lista utenti" }); } const list = userdb.users.map(u => ({ id: u.id, name: u.name, email: u.email })); res.json(list); }); // // =============================== // HARD DELETE LIST (per progressive sync) // =============================== server.get('/photos/deleted_hard', async (req, res) => { const user = req.user.name; const since = req.query.since; if (!since) { return res.status(400).json({ error: "Missing ?since=timestamp" }); } try { const rows = await db("deleted_hard") .where({ user }) .andWhere("deleted_at", ">", since) .select("id", "deleted_at"); res.json({ deleted: rows }); } catch (err) { console.error("deleted_hard API error:", err); res.status(500).json({ error: err.message }); } }); // =============================== // SYNC DONE (aggiorna last_sync) // =============================== server.post('/photos/sync_done', async (req, res) => { const { session_id, device_id, last_sync } = req.body; if (!session_id || !device_id || !last_sync) { return res.status(400).json({ error: "missing session_id, device_id or last_sync" }); } try { await dbWs.setLastSync(session_id, device_id, parseInt(last_sync, 10)); await dbWs.clearNeedFullSync(session_id, device_id); return res.json({ ok: true }); } catch (err) { console.error("sync_done error:", err); return res.status(500).json({ error: err.message }); } }); // // =============================== // TOGGLE SOFT DELETE (via Admin) // =============================== server.post('/photos/toggle_soft/:id', async (req, res) => { const { id } = req.params; const user = req.user.name; try { const row = await db("photos") .where({ id, user }) .first(); if (!row) { return res.status(404).json({ error: "Foto non trovata" }); } const newDeletedAt = row.deleted_at ? null : new Date().toISOString(); await db("photos") .where({ id, user }) .update({ deleted_at: newDeletedAt, updated_at: new Date().toISOString() }); wss.broadcastToUser(user, { type: "updated", id, deleted_at: newDeletedAt }); return res.json({ id, deleted_at: newDeletedAt, status: newDeletedAt ? "soft-deleted" : "restored" }); } catch (err) { console.error("toggle_soft error:", err); res.status(500).json({ error: err.message }); } }); // // =============================== // ROUTER PHOTOS // =============================== server.use('/photos', photosRouter); // // =============================== // SHARE CREATE (protected) - create ephemeral URL // This route requires authentication (JWT middleware applied). // =============================== server.post('/share/create', async (req, res) => { try { const { entryId, ttlSeconds = 300, oneTime = false } = req.body; if (!entryId) return res.status(400).json({ error: 'missing entryId' }); // resolve file path (try DB) let filePath = await resolveFilePathForEntryId(entryId); // if not found in DB, maybe entryId is already a path if (!filePath) { const candidate = path.isAbsolute(entryId) ? entryId : path.join(__dirname, WEB_ROOT, entryId); if (fs.existsSync(candidate)) filePath = candidate; } if (!filePath) return res.status(404).json({ error: 'file not found' }); // permission check: Admin can share any file; others only their own try { const row = await db('photos').where({ id: entryId }).first(); if (row) { if (req.user.name !== 'Admin' && row.user !== req.user.name) { return res.status(403).json({ error: 'forbidden' }); } } else { // if no DB row, allow only Admin to share arbitrary paths if (req.user.name !== 'Admin') { return res.status(403).json({ error: 'forbidden' }); } } } catch (err) { console.warn('share permission check error:', err); } const { token, expiresAt } = createShareRecord(entryId, filePath, parseInt(ttlSeconds, 10), !!oneTime, req.user?.name || null); // URL pubblico coerente (HTTPS, dominio corretto) const baseUrl = getPublicBaseUrl(req); const url = `${baseUrl}/share/${token}`; return res.json({ url, expiresAt }); } catch (err) { console.error('Error creating share:', err); res.status(500).json({ error: err.message }); } }); // // =============================== // START SERVER // =============================== server.listen(PORT, () => { console.log(`Auth API server running on port ${PORT} ...`); }); // // =============================== // RETENTION CLEANUP (soft → hard delete) // =============================== async function retentionCleanup() { try { const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000).toISOString(); const rows = await db("photos") .whereNotNull("deleted_at") .andWhere("deleted_at", "<", cutoff) .select("id", "user"); for (const r of rows) { const { id, user } = r; await deleteThumbsById(id); await db("photos").where({ id, user }).del(); await db("deleted_hard").insert({ id, user, deleted_at: new Date().toISOString() }); console.log(`🧹 HARD DELETE (retention) → ${id}`); } } catch (err) { console.error("Errore retentionCleanup:", err); } } setInterval(retentionCleanup, 3600 * 1000); // // =============================== // PULIZIA DENYLIST // =============================== setInterval(() => { const nowSec = Math.floor(Date.now() / 1000); for (const [tok, exp] of denylist.entries()) { if (exp < nowSec) denylist.delete(tok); } }, 60 * 1000);