728 lines
17 KiB
JavaScript
728 lines
17 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
|
|
const path = require('path');
|
|
const fsp = require('fs/promises');
|
|
|
|
const db = require('../db/knex');
|
|
const wss = require('../ws-server');
|
|
|
|
// -----------------------------------------------------
|
|
// Normalizzazione record DB → formato identico al vecchio index.json
|
|
// -----------------------------------------------------
|
|
function safeJsonParse(value, fallback) {
|
|
try {
|
|
return value ? JSON.parse(value) : fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function normalizePhoto(p) {
|
|
return {
|
|
...p,
|
|
|
|
gps: (p.lat != null && p.lon != null)
|
|
? {
|
|
lat: p.lat,
|
|
lng: p.lon,
|
|
alt: p.alt ?? null
|
|
}
|
|
: null,
|
|
|
|
location: safeJsonParse(p.location, null),
|
|
_indexHash: p._indexHash || null,
|
|
|
|
lat: undefined,
|
|
lon: undefined,
|
|
alt: undefined,
|
|
location_json: undefined
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------
|
|
// File helpers hard delete foto
|
|
// -----------------------------------------------------
|
|
const PUBLIC_ROOT = path.resolve(__dirname, '..', 'public');
|
|
const PHOTOS_ROOT = path.join(PUBLIC_ROOT, 'photos');
|
|
const DEL_PHOTOS_ROOT = path.join(PUBLIC_ROOT, 'del_photos');
|
|
|
|
function safeJoin(base, ...parts) {
|
|
const resolvedBase = path.resolve(base);
|
|
const resolved = path.resolve(base, ...parts);
|
|
|
|
if (!resolved.startsWith(resolvedBase)) {
|
|
throw new Error(`Path non sicuro: ${resolved}`);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
function getOriginalPhotoPath(row) {
|
|
return safeJoin(
|
|
PHOTOS_ROOT,
|
|
row.user,
|
|
'original',
|
|
row.cartella || '',
|
|
row.name
|
|
);
|
|
}
|
|
|
|
function getDeletedPhotoPath(row) {
|
|
return safeJoin(
|
|
DEL_PHOTOS_ROOT,
|
|
row.user,
|
|
'original',
|
|
row.cartella || '',
|
|
row.name
|
|
);
|
|
}
|
|
|
|
function getThumbPath(row, thumbValue) {
|
|
if (!thumbValue) return null;
|
|
|
|
const value = String(thumbValue);
|
|
|
|
// Caso 1: /photos/Fabio/thumbs/prime/thumb.jpg
|
|
if (value.startsWith('/photos/')) {
|
|
const relative = value.replace(/^\/+/, '');
|
|
return safeJoin(PUBLIC_ROOT, relative);
|
|
}
|
|
|
|
// Caso 2: photos/Fabio/thumbs/prime/thumb.jpg
|
|
if (value.startsWith('photos/')) {
|
|
return safeJoin(PUBLIC_ROOT, value);
|
|
}
|
|
|
|
// Caso 3: solo nome file thumb.jpg
|
|
return safeJoin(
|
|
PHOTOS_ROOT,
|
|
row.user,
|
|
'thumbs',
|
|
row.cartella || '',
|
|
value
|
|
);
|
|
}
|
|
|
|
async function pathExists(filePath) {
|
|
try {
|
|
await fsp.access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function moveFileSafe(src, dst) {
|
|
if (!src || !dst) return false;
|
|
|
|
const exists = await pathExists(src);
|
|
|
|
if (!exists) {
|
|
console.warn(`⚠️ File originale non trovato, skip move: ${src}`);
|
|
return false;
|
|
}
|
|
|
|
// Crea automaticamente public/del_photos/<user>/original/<cartella>/
|
|
await fsp.mkdir(path.dirname(dst), { recursive: true });
|
|
|
|
try {
|
|
await fsp.rename(src, dst);
|
|
return true;
|
|
} catch (err) {
|
|
// Fallback se source/destination sono su filesystem diversi
|
|
if (err.code === 'EXDEV') {
|
|
await fsp.copyFile(src, dst);
|
|
await fsp.unlink(src);
|
|
return true;
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function deleteFileIfExists(filePath) {
|
|
if (!filePath) return false;
|
|
|
|
try {
|
|
await fsp.unlink(filePath);
|
|
return true;
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
return false;
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------
|
|
// POST /photos
|
|
// Salva una foto inviata dallo scanner locale
|
|
// -----------------------------------------------------
|
|
router.post('/', async (req, res) => {
|
|
const start = Date.now();
|
|
|
|
console.log(`📥 [POST /photos] Richiesta at=${new Date().toISOString()}`);
|
|
console.log('📥 Body:', req.body);
|
|
|
|
try {
|
|
const p = req.body;
|
|
|
|
console.log(`📸 [DB INSERT] Salvo foto id=${p.id} user=${p.user}`);
|
|
|
|
await db('photos')
|
|
.insert({
|
|
id: p.id,
|
|
user: p.user,
|
|
cartella: p.cartella,
|
|
name: p.name,
|
|
path: p.path,
|
|
thub1: p.thub1,
|
|
thub2: p.thub2,
|
|
mime_type: p.mime_type,
|
|
width: p.width,
|
|
height: p.height,
|
|
rotation: p.rotation,
|
|
size_bytes: p.size_bytes,
|
|
mtimeMs: p.mtimeMs,
|
|
duration_ms: p.duration_ms,
|
|
taken_at: p.taken_at,
|
|
data: p.data,
|
|
lat: p.gps?.lat ?? null,
|
|
lon: p.gps?.lng ?? null,
|
|
alt: p.gps?.alt ?? null,
|
|
location: p.location ? JSON.stringify(p.location) : null,
|
|
_indexHash: p._indexHash,
|
|
fast_hash: p.fast_hash,
|
|
deleted_at: p.deleted_at || null,
|
|
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString()
|
|
})
|
|
.onConflict('id')
|
|
.merge();
|
|
|
|
wss.broadcastToUser(p.user, {
|
|
type: 'added',
|
|
id: p.id,
|
|
entity: 'photo'
|
|
});
|
|
|
|
console.log(`✅ [POST /photos] Foto id=${p.id} salvata durata=${Date.now() - start}ms`);
|
|
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('❌ Errore POST /photos:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// GET /photos/changes
|
|
// Progressive sync ADD + UPDATE
|
|
// -----------------------------------------------------
|
|
router.get('/changes', async (req, res) => {
|
|
const user = req.user.name;
|
|
const since = req.query.since;
|
|
const start = Date.now();
|
|
|
|
console.log(
|
|
`🔄 [SYNC SINCE] GET /photos/changes user=${user} since=${since} at=${new Date().toISOString()}`
|
|
);
|
|
|
|
if (!since) {
|
|
console.log('⚠️ [SYNC SINCE] Richiesta senza ?since');
|
|
return res.status(400).json({ error: 'Missing ?since=timestamp' });
|
|
}
|
|
|
|
try {
|
|
let rows;
|
|
|
|
if (user === 'Admin') {
|
|
rows = await db('photos')
|
|
.where(builder => {
|
|
builder
|
|
.where('created_at', '>', since)
|
|
.orWhere('updated_at', '>', since);
|
|
})
|
|
.orderBy('updated_at', 'desc');
|
|
} else {
|
|
rows = await db('photos')
|
|
.whereIn('user', [user, 'Common'])
|
|
.andWhere(builder => {
|
|
builder
|
|
.where('created_at', '>', since)
|
|
.orWhere('updated_at', '>', since);
|
|
})
|
|
.orderBy('updated_at', 'desc');
|
|
}
|
|
|
|
console.log(
|
|
`🔄 [SYNC SINCE] Risposta: ${rows.length} cambiamenti durata=${Date.now() - start}ms`
|
|
);
|
|
|
|
res.json(rows.map(normalizePhoto));
|
|
} catch (err) {
|
|
console.error('❌ Errore GET /photos/changes:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// GET /photos/byIds
|
|
// -----------------------------------------------------
|
|
router.get('/byIds', async (req, res) => {
|
|
const ids = req.query.id;
|
|
const users = Array.isArray(req.query.user)
|
|
? req.query.user
|
|
: [req.query.user];
|
|
|
|
const start = Date.now();
|
|
|
|
console.log(`📤 [GET /photos/byIds] at=${new Date().toISOString()}`);
|
|
console.log('📤 ids:', ids);
|
|
console.log('📤 users:', users);
|
|
|
|
if (!ids) return res.json([]);
|
|
|
|
const list = Array.isArray(ids) ? ids : [ids];
|
|
|
|
try {
|
|
let rows;
|
|
|
|
if (users.includes('Admin')) {
|
|
rows = await db('photos').whereIn('id', list);
|
|
} else {
|
|
rows = await db('photos')
|
|
.whereIn('id', list)
|
|
.whereIn('user', users);
|
|
}
|
|
|
|
console.log(
|
|
`📤 [GET /photos/byIds] trovate ${rows.length} foto durata=${Date.now() - start}ms`
|
|
);
|
|
|
|
res.json(rows.map(normalizePhoto));
|
|
} catch (err) {
|
|
console.error('❌ Errore /photos/byIds:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// GET /photos
|
|
// Full sync
|
|
// -----------------------------------------------------
|
|
router.get('/', async (req, res) => {
|
|
const loggedUser = req.user?.name;
|
|
const start = Date.now();
|
|
|
|
console.log(
|
|
`📤 [FULL SYNC] GET /photos da=${loggedUser} at=${new Date().toISOString()}`
|
|
);
|
|
|
|
try {
|
|
let rows;
|
|
|
|
if (loggedUser === 'Admin') {
|
|
rows = await db('photos')
|
|
.select('*')
|
|
.orderBy('mtimeMs', 'desc');
|
|
} else {
|
|
rows = await db('photos')
|
|
.whereIn('user', [loggedUser, 'Common'])
|
|
.orderBy('mtimeMs', 'desc');
|
|
}
|
|
|
|
console.log(
|
|
`📤 [FULL SYNC] Risposta: ${rows.length} foto durata=${Date.now() - start}ms`
|
|
);
|
|
|
|
res.json(rows.map(normalizePhoto));
|
|
} catch (err) {
|
|
console.error('❌ Errore GET /photos:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// POST /photos/initDB
|
|
// Reset DB foto
|
|
// - Admin: cancella tutte le foto
|
|
// - Utente normale: cancella solo le proprie
|
|
// -----------------------------------------------------
|
|
router.post('/initDB', async (req, res) => {
|
|
try {
|
|
const loggedUser = req.user?.name;
|
|
|
|
if (!loggedUser) {
|
|
return res.status(401).json({
|
|
ok: false,
|
|
error: 'Unauthorized'
|
|
});
|
|
}
|
|
|
|
console.log(`🧹 [POST /photos/initDB] richiesto da=${loggedUser}`);
|
|
|
|
if (loggedUser === 'Admin') {
|
|
console.log('🧹 Admin → cancello tutte le foto');
|
|
|
|
await db('photos').del();
|
|
await db('deleted_hard').del();
|
|
|
|
return res.json({
|
|
ok: true,
|
|
scope: 'all',
|
|
user: loggedUser,
|
|
message: 'DB foto svuotato completamente'
|
|
});
|
|
}
|
|
|
|
console.log(`🧹 User=${loggedUser} → cancello solo le sue foto`);
|
|
|
|
await db('photos')
|
|
.where({ user: loggedUser })
|
|
.del();
|
|
|
|
await db('deleted_hard')
|
|
.where({ user: loggedUser })
|
|
.del();
|
|
|
|
res.json({
|
|
ok: true,
|
|
scope: 'user',
|
|
user: loggedUser,
|
|
message: `DB foto svuotato per user=${loggedUser}`
|
|
});
|
|
} catch (err) {
|
|
console.error('❌ Errore POST /photos/initDB:', err);
|
|
|
|
res.status(500).json({
|
|
ok: false,
|
|
error: err.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// POST /photos/initDBuser
|
|
// Reset DB foto per utente specifico
|
|
// Solo Admin
|
|
// -----------------------------------------------------
|
|
router.post('/initDBuser', async (req, res) => {
|
|
try {
|
|
const loggedUser = req.user?.name;
|
|
|
|
if (loggedUser !== 'Admin') {
|
|
return res.status(403).json({
|
|
ok: false,
|
|
error: 'Forbidden',
|
|
loggedUser
|
|
});
|
|
}
|
|
|
|
const targetUser =
|
|
req.body?.user ||
|
|
req.query?.user ||
|
|
req.body?.name ||
|
|
req.query?.name;
|
|
|
|
if (!targetUser) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'Missing user'
|
|
});
|
|
}
|
|
|
|
console.log(
|
|
`🧹 [POST /photos/initDBuser] Admin=${loggedUser} target=${targetUser}`
|
|
);
|
|
|
|
await db('photos')
|
|
.where({ user: targetUser })
|
|
.del();
|
|
|
|
await db('deleted_hard')
|
|
.where({ user: targetUser })
|
|
.del();
|
|
|
|
res.json({
|
|
ok: true,
|
|
user: targetUser,
|
|
message: `DB foto svuotato per user=${targetUser}`
|
|
});
|
|
} catch (err) {
|
|
console.error('❌ Errore POST /photos/initDBuser:', err);
|
|
|
|
res.status(500).json({
|
|
ok: false,
|
|
error: err.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// GET /photos/deleted_hard
|
|
// Deve stare PRIMA di /:id
|
|
// -----------------------------------------------------
|
|
router.get('/deleted_hard', async (req, res) => {
|
|
const since = req.query.since;
|
|
const user = req.user?.name;
|
|
|
|
try {
|
|
let rows;
|
|
|
|
if (user === 'Admin') {
|
|
rows = await db('deleted_hard')
|
|
.where('deleted_at', '>', since);
|
|
} else {
|
|
rows = await db('deleted_hard')
|
|
.where('deleted_at', '>', since)
|
|
.where({ user });
|
|
}
|
|
|
|
res.json({ deleted: rows });
|
|
} catch (err) {
|
|
console.error('❌ Errore GET /photos/deleted_hard:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// POST /photos/:id/toggle-soft
|
|
// Toggle soft delete foto per ID
|
|
// - Se deleted_at è null → soft delete
|
|
// - Se deleted_at è valorizzato → restore
|
|
// -----------------------------------------------------
|
|
router.post('/:id/toggle-soft', async (req, res) => {
|
|
const id = req.params.id;
|
|
const loggedUser = req.user?.name;
|
|
const start = Date.now();
|
|
|
|
try {
|
|
if (!loggedUser) {
|
|
return res.status(401).json({
|
|
ok: false,
|
|
error: 'Unauthorized'
|
|
});
|
|
}
|
|
|
|
console.log(`🔁 [TOGGLE SOFT /photos/${id}] richiesto da=${loggedUser}`);
|
|
|
|
let row;
|
|
|
|
if (loggedUser === 'Admin') {
|
|
row = await db('photos')
|
|
.where({ id })
|
|
.first();
|
|
} else {
|
|
row = await db('photos')
|
|
.where({ id, user: loggedUser })
|
|
.first();
|
|
}
|
|
|
|
if (!row) {
|
|
return res.status(404).json({
|
|
ok: false,
|
|
error: 'Photo not found or not allowed',
|
|
id
|
|
});
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const nextDeletedAt = row.deleted_at ? null : now;
|
|
|
|
await db('photos')
|
|
.where({ id })
|
|
.update({
|
|
deleted_at: nextDeletedAt,
|
|
updated_at: now
|
|
});
|
|
|
|
try {
|
|
wss.broadcastToUser(row.user, {
|
|
type: 'updated',
|
|
id,
|
|
entity: 'photo',
|
|
deleted_at: nextDeletedAt
|
|
});
|
|
} catch (e) {
|
|
console.warn('⚠️ WS broadcast photo toggle soft fallito:', e.message);
|
|
}
|
|
|
|
console.log(
|
|
`✅ [TOGGLE SOFT /photos/${id}] user=${row.user} soft_deleted=${!!nextDeletedAt} durata=${Date.now() - start}ms`
|
|
);
|
|
|
|
res.json({
|
|
ok: true,
|
|
id,
|
|
user: row.user,
|
|
deleted_at: nextDeletedAt,
|
|
soft_deleted: !!nextDeletedAt,
|
|
restored: !nextDeletedAt,
|
|
duration_ms: Date.now() - start
|
|
});
|
|
} catch (err) {
|
|
console.error('❌ Errore POST /photos/:id/toggle-soft:', err);
|
|
|
|
res.status(500).json({
|
|
ok: false,
|
|
error: err.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// DELETE /photos/:id
|
|
// HARD DELETE foto per ID
|
|
// - Sposta originale in public/del_photos/<user>/original/<cartella>/<foto>
|
|
// - Elimina thub1 e thub2
|
|
// - Rimuove record da photos
|
|
// - Registra ID in deleted_hard
|
|
// -----------------------------------------------------
|
|
router.delete('/:id', async (req, res) => {
|
|
const id = req.params.id;
|
|
const loggedUser = req.user?.name;
|
|
const start = Date.now();
|
|
|
|
try {
|
|
if (!loggedUser) {
|
|
return res.status(401).json({
|
|
ok: false,
|
|
error: 'Unauthorized'
|
|
});
|
|
}
|
|
|
|
console.log(`🗑️ [HARD DELETE /photos/${id}] richiesto da=${loggedUser}`);
|
|
|
|
let row;
|
|
|
|
if (loggedUser === 'Admin') {
|
|
row = await db('photos')
|
|
.where({ id })
|
|
.first();
|
|
} else {
|
|
row = await db('photos')
|
|
.where({ id, user: loggedUser })
|
|
.first();
|
|
}
|
|
|
|
if (!row) {
|
|
return res.status(404).json({
|
|
ok: false,
|
|
error: 'Photo not found or not allowed',
|
|
id
|
|
});
|
|
}
|
|
|
|
const deletedAt = new Date().toISOString();
|
|
|
|
// 1. Sposta originale
|
|
const originalSrc = getOriginalPhotoPath(row);
|
|
const originalDst = getDeletedPhotoPath(row);
|
|
const originalMoved = await moveFileSafe(originalSrc, originalDst);
|
|
|
|
// 2. Elimina thumbs
|
|
const thumb1Path = getThumbPath(row, row.thub1);
|
|
const thumb2Path = getThumbPath(row, row.thub2);
|
|
|
|
const thumb1Deleted = await deleteFileIfExists(thumb1Path);
|
|
const thumb2Deleted = await deleteFileIfExists(thumb2Path);
|
|
|
|
// 3. Elimina record da photos
|
|
await db('photos')
|
|
.where({ id })
|
|
.del();
|
|
|
|
// 4. Registra hard delete
|
|
await db('deleted_hard')
|
|
.insert({
|
|
id,
|
|
user: row.user,
|
|
deleted_at: deletedAt
|
|
})
|
|
.onConflict('id')
|
|
.merge();
|
|
|
|
// 5. WS realtime
|
|
try {
|
|
wss.broadcastToUser(row.user, {
|
|
type: 'removed',
|
|
id,
|
|
entity: 'photo'
|
|
});
|
|
} catch (e) {
|
|
console.warn('⚠️ WS broadcast photo hard delete fallito:', e.message);
|
|
}
|
|
|
|
console.log(
|
|
`✅ [HARD DELETE /photos/${id}] user=${row.user} durata=${Date.now() - start}ms`
|
|
);
|
|
|
|
res.json({
|
|
ok: true,
|
|
id,
|
|
user: row.user,
|
|
deleted_at: deletedAt,
|
|
hard_deleted: true,
|
|
files: {
|
|
original_src: originalSrc,
|
|
original_dst: originalDst,
|
|
original_moved: originalMoved,
|
|
thumb1: thumb1Path,
|
|
thumb1_deleted: thumb1Deleted,
|
|
thumb2: thumb2Path,
|
|
thumb2_deleted: thumb2Deleted
|
|
},
|
|
duration_ms: Date.now() - start
|
|
});
|
|
} catch (err) {
|
|
console.error('❌ Errore HARD DELETE /photos/:id:', err);
|
|
|
|
res.status(500).json({
|
|
ok: false,
|
|
error: err.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// GET /photos/:id
|
|
// Deve stare DOPO changes, byIds, initDB, deleted_hard, toggle-soft e delete
|
|
// -----------------------------------------------------
|
|
router.get('/:id', async (req, res) => {
|
|
const id = req.params.id;
|
|
const loggedUser = req.user?.name;
|
|
const start = Date.now();
|
|
|
|
console.log(
|
|
`📤 [GET /photos/${id}] Richiesta da=${loggedUser} at=${new Date().toISOString()}`
|
|
);
|
|
|
|
try {
|
|
let rows;
|
|
|
|
if (loggedUser === 'Admin') {
|
|
rows = await db('photos').where({ id });
|
|
} else {
|
|
rows = await db('photos')
|
|
.where({ id })
|
|
.whereIn('user', [loggedUser, 'Common']);
|
|
}
|
|
|
|
console.log(
|
|
`📤 [GET /photos/${id}] Risposta: ${rows.length} foto durata=${Date.now() - start}ms`
|
|
);
|
|
|
|
if (!rows.length) return res.json([]);
|
|
|
|
res.json(rows.map(normalizePhoto));
|
|
} catch (err) {
|
|
console.error('❌ Errore GET /photos/:id:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|