1001 lines
27 KiB
JavaScript
1001 lines
27 KiB
JavaScript
// 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=<nome>" });
|
||
}
|
||
}
|
||
|
||
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);
|