photo_server_json_con_aves22/server.js.ok
2026-04-18 20:14:42 +02:00

697 lines
19 KiB
Text
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 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();
//
// ===============================
// STATICI PUBBLICI
// ===============================
server.use(express.static(path.join(__dirname, 'public')));
//
// ===============================
// LOGIN PAGE PUBBLICA
// ===============================
server.get('/login', (req, res) => {
res.sendFile(path.join(__dirname, 'public/login.html'));
});
//
// ===============================
// 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();
});
//
// ===============================
// 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 db('photos').where({ id: e.id, user }).del();
await deleteThumbsById(e.id);
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 lelenco 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 });
}
});
//
// ===============================
// 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);
//
// ===============================
// 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);