server_photo_s35js/server.js
2026-07-18 13:51:20 +02:00

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