1408 lines
33 KiB
JavaScript
1408 lines
33 KiB
JavaScript
const express = require("express");
|
|
const router = express.Router();
|
|
|
|
const path = require("path");
|
|
const fsp = require("fs/promises");
|
|
const multer = require("multer");
|
|
|
|
const db = require("../db/knex");
|
|
const wss = require("../ws-server");
|
|
const {
|
|
getUserAccess,
|
|
filterReadableSources,
|
|
canWrite,
|
|
} = require("../api_v1/userAccess");
|
|
|
|
// -----------------------------------------------------
|
|
// Upload manuale foto
|
|
// -----------------------------------------------------
|
|
const MEDIA_UPLOAD_MAX_FILES = 100;
|
|
const MEDIA_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
|
|
const ALLOWED_MEDIA_EXTENSIONS = new Set([
|
|
// Foto
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".webp",
|
|
".gif",
|
|
".heic",
|
|
".heif",
|
|
".tif",
|
|
".tiff",
|
|
|
|
// Video
|
|
".mp4",
|
|
".mov",
|
|
".m4v",
|
|
".webm",
|
|
".mkv",
|
|
".avi",
|
|
".3gp",
|
|
".3g2",
|
|
".mts",
|
|
".m2ts",
|
|
".mpeg",
|
|
".mpg",
|
|
]);
|
|
|
|
const uploadMedia = multer({
|
|
storage: multer.memoryStorage(),
|
|
|
|
limits: {
|
|
files: MEDIA_UPLOAD_MAX_FILES,
|
|
fileSize: MEDIA_UPLOAD_MAX_BYTES,
|
|
},
|
|
|
|
fileFilter: (req, file, callback) => {
|
|
const extension = path.extname(file.originalname || "").toLowerCase();
|
|
|
|
if (!ALLOWED_MEDIA_EXTENSIONS.has(extension)) {
|
|
return callback(
|
|
new Error(
|
|
`Formato media non supportato: ${extension || "sconosciuto"}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
const mimeType = String(file.mimetype || "").toLowerCase();
|
|
|
|
const validMime =
|
|
mimeType.startsWith("image/") ||
|
|
mimeType.startsWith("video/") ||
|
|
mimeType === "application/octet-stream";
|
|
|
|
if (!validMime) {
|
|
return callback(
|
|
new Error(`Tipo MIME non supportato: ${mimeType || "sconosciuto"}`),
|
|
);
|
|
}
|
|
|
|
return callback(null, true);
|
|
},
|
|
});
|
|
|
|
function normalizeUploadValue(value) {
|
|
return String(value ?? "").trim();
|
|
}
|
|
|
|
function validateDirectoryName(value, fieldName) {
|
|
const normalized = normalizeUploadValue(value);
|
|
|
|
if (!normalized) {
|
|
throw new Error(`${fieldName} mancante`);
|
|
}
|
|
|
|
if (
|
|
normalized === "." ||
|
|
normalized === ".." ||
|
|
normalized.includes("/") ||
|
|
normalized.includes("\\") ||
|
|
normalized.includes("\0")
|
|
) {
|
|
throw new Error(`${fieldName} non valido`);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function sanitizeUploadedMediaName(originalName) {
|
|
const baseName = path.basename(normalizeUploadValue(originalName));
|
|
|
|
if (!baseName) {
|
|
throw new Error("Nome file mancante");
|
|
}
|
|
|
|
const extension = path.extname(baseName).toLowerCase();
|
|
|
|
if (!ALLOWED_MEDIA_EXTENSIONS.has(extension)) {
|
|
throw new Error(
|
|
`Formato media non supportato: ${extension || "sconosciuto"}`,
|
|
);
|
|
}
|
|
|
|
const nameWithoutExtension = path.basename(baseName, path.extname(baseName));
|
|
|
|
const safeBaseName = nameWithoutExtension
|
|
.normalize("NFKC")
|
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
if (!safeBaseName) {
|
|
throw new Error("Nome file non valido");
|
|
}
|
|
|
|
return `${safeBaseName}${extension}`;
|
|
}
|
|
|
|
async function writeUploadedFileWithoutOverwrite(
|
|
destinationDirectory,
|
|
requestedFileName,
|
|
buffer,
|
|
) {
|
|
const extension = path.extname(requestedFileName);
|
|
const baseName = path.basename(requestedFileName, extension);
|
|
|
|
let candidateName = requestedFileName;
|
|
let counter = 1;
|
|
|
|
while (true) {
|
|
const destinationPath = safeJoin(destinationDirectory, candidateName);
|
|
|
|
try {
|
|
await fsp.writeFile(destinationPath, buffer, {
|
|
flag: "wx",
|
|
});
|
|
|
|
return {
|
|
fileName: candidateName,
|
|
filePath: destinationPath,
|
|
renamed: candidateName !== requestedFileName,
|
|
};
|
|
} catch (err) {
|
|
if (err.code !== "EEXIST") {
|
|
throw err;
|
|
}
|
|
|
|
candidateName = `${baseName}_${counter}${extension}`;
|
|
counter += 1;
|
|
|
|
if (counter > 9999) {
|
|
throw new Error("Impossibile trovare un nome file disponibile");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
function getPhotoReadAccess(req, res) {
|
|
const loggedUser = String(req.user?.name ?? "").trim();
|
|
|
|
if (!loggedUser) {
|
|
res.status(401).json({
|
|
error: "Unauthorized",
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
const access = getUserAccess(loggedUser);
|
|
|
|
if (!access) {
|
|
res.status(403).json({
|
|
error: "Utente non configurato",
|
|
user: loggedUser,
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
return access;
|
|
}
|
|
|
|
// -----------------------------------------------------
|
|
// 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);
|
|
|
|
const isInsideBase =
|
|
resolved === resolvedBase ||
|
|
resolved.startsWith(`${resolvedBase}${path.sep}`);
|
|
|
|
if (!isInsideBase) {
|
|
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;
|
|
|
|
const loggedUser = String(req.user?.name ?? "").trim();
|
|
const targetSource = String(p.user ?? "").trim();
|
|
|
|
if (!loggedUser) {
|
|
return res.status(401).json({
|
|
ok: false,
|
|
error: "Unauthorized",
|
|
});
|
|
}
|
|
|
|
if (!targetSource) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: "Missing photo user/source",
|
|
});
|
|
}
|
|
|
|
if (!canWrite(loggedUser, targetSource)) {
|
|
console.warn("⛔ Inserimento foto non autorizzato", {
|
|
loggedUser,
|
|
targetSource,
|
|
id: p.id,
|
|
});
|
|
|
|
return res.status(403).json({
|
|
ok: false,
|
|
error: "Write not allowed",
|
|
targetSource,
|
|
});
|
|
}
|
|
|
|
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 || "",
|
|
cast: p.cast || "",
|
|
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
|
|
// Mostra solamente usersR + devices
|
|
// -----------------------------------------------------
|
|
router.get("/changes", async (req, res) => {
|
|
const access = getPhotoReadAccess(req, res);
|
|
|
|
if (!access) {
|
|
return;
|
|
}
|
|
|
|
const since = req.query.since;
|
|
const start = Date.now();
|
|
|
|
console.log(
|
|
`🔄 [SYNC SINCE] GET /photos/changes user=${access.name} since=${since} at=${new Date().toISOString()}`,
|
|
);
|
|
|
|
console.log("🔐 Sorgenti leggibili:", access.readableSources);
|
|
|
|
if (!since) {
|
|
console.log("⚠️ [SYNC SINCE] Richiesta senza ?since");
|
|
|
|
return res.status(400).json({
|
|
error: "Missing ?since=timestamp",
|
|
});
|
|
}
|
|
|
|
if (access.readableSources.length === 0) {
|
|
return res.json([]);
|
|
}
|
|
|
|
try {
|
|
const rows = await db("photos")
|
|
.whereIn("user", access.readableSources)
|
|
.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`,
|
|
);
|
|
|
|
return res.json(rows.map(normalizePhoto));
|
|
} catch (err) {
|
|
console.error("❌ Errore GET /photos/changes:", err);
|
|
|
|
return res.status(500).json({
|
|
error: err.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// GET /photos/byIds
|
|
// Restituisce solo ID appartenenti a sorgenti leggibili
|
|
// -----------------------------------------------------
|
|
router.get("/byIds", async (req, res) => {
|
|
const access = getPhotoReadAccess(req, res);
|
|
|
|
if (!access) {
|
|
return;
|
|
}
|
|
|
|
const ids = req.query.id;
|
|
const requestedUsers = req.query.user;
|
|
const start = Date.now();
|
|
|
|
console.log(`📤 [GET /photos/byIds] at=${new Date().toISOString()}`);
|
|
console.log("📤 ids:", ids);
|
|
console.log("📤 requestedUsers:", requestedUsers);
|
|
console.log("🔐 readableSources:", access.readableSources);
|
|
|
|
if (!ids) {
|
|
return res.json([]);
|
|
}
|
|
|
|
const idList = Array.isArray(ids) ? ids : [ids];
|
|
|
|
const allowedSources = filterReadableSources(access.name, requestedUsers);
|
|
|
|
console.log("🔐 allowedSources:", allowedSources);
|
|
|
|
if (allowedSources.length === 0) {
|
|
return res.json([]);
|
|
}
|
|
|
|
try {
|
|
const rows = await db("photos")
|
|
.whereIn("id", idList)
|
|
.whereIn("user", allowedSources);
|
|
|
|
console.log(
|
|
`📤 [GET /photos/byIds] trovate ${rows.length} foto durata=${Date.now() - start}ms`,
|
|
);
|
|
|
|
return res.json(rows.map(normalizePhoto));
|
|
} catch (err) {
|
|
console.error("❌ Errore /photos/byIds:", err);
|
|
|
|
return res.status(500).json({
|
|
error: err.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// GET /photos
|
|
// Full sync
|
|
// Mostra solamente usersR + devices
|
|
// -----------------------------------------------------
|
|
router.get("/", async (req, res) => {
|
|
const access = getPhotoReadAccess(req, res);
|
|
|
|
if (!access) {
|
|
return;
|
|
}
|
|
|
|
const start = Date.now();
|
|
|
|
console.log(
|
|
`📤 [FULL SYNC] GET /photos da=${access.name} at=${new Date().toISOString()}`,
|
|
);
|
|
|
|
console.log("🔐 Sorgenti leggibili:", access.readableSources);
|
|
|
|
if (access.readableSources.length === 0) {
|
|
return res.json([]);
|
|
}
|
|
|
|
try {
|
|
const rows = await db("photos")
|
|
.whereIn("user", access.readableSources)
|
|
.orderBy("mtimeMs", "desc");
|
|
|
|
console.log(
|
|
`📤 [FULL SYNC] Risposta: ${rows.length} foto durata=${Date.now() - start}ms`,
|
|
);
|
|
|
|
return res.json(rows.map(normalizePhoto));
|
|
} catch (err) {
|
|
console.error("❌ Errore GET /photos:", err);
|
|
|
|
return res.status(500).json({
|
|
error: err.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// POST /photos/initDB
|
|
// Svuota dal DB tutte le sorgenti scrivibili dall'utente
|
|
//
|
|
// Admin:
|
|
// Common, Fabio, Jessica, Fabio13TPro, Fabio11TPro
|
|
//
|
|
// Fabio:
|
|
// Fabio, Fabio13TPro, Fabio11TPro
|
|
//
|
|
// Jessica:
|
|
// Jessica
|
|
// -----------------------------------------------------
|
|
router.post("/initDB", async (req, res) => {
|
|
const loggedUser = String(req.user?.name ?? "").trim();
|
|
const start = Date.now();
|
|
|
|
try {
|
|
if (!loggedUser) {
|
|
return res.status(401).json({
|
|
ok: false,
|
|
error: "Unauthorized",
|
|
});
|
|
}
|
|
|
|
const access = getUserAccess(loggedUser);
|
|
|
|
if (!access) {
|
|
return res.status(403).json({
|
|
ok: false,
|
|
error: "Utente non configurato",
|
|
user: loggedUser,
|
|
});
|
|
}
|
|
|
|
const writableSources = access.writableSources;
|
|
|
|
console.log("🧹 [POST /photos/initDB]", {
|
|
requestedBy: loggedUser,
|
|
writableSources,
|
|
});
|
|
|
|
if (!Array.isArray(writableSources) || writableSources.length === 0) {
|
|
return res.json({
|
|
ok: true,
|
|
scope: "none",
|
|
user: loggedUser,
|
|
deletedPhotos: 0,
|
|
deletedHardRecords: 0,
|
|
writableSources: [],
|
|
duration_ms: Date.now() - start,
|
|
message: "Nessuna sorgente scrivibile da svuotare",
|
|
});
|
|
}
|
|
|
|
const result = await db.transaction(async (trx) => {
|
|
const deletedPhotos = await trx("photos")
|
|
.whereIn("user", writableSources)
|
|
.del();
|
|
|
|
const deletedHardRecords = await trx("deleted_hard")
|
|
.whereIn("user", writableSources)
|
|
.del();
|
|
|
|
return {
|
|
deletedPhotos,
|
|
deletedHardRecords,
|
|
};
|
|
});
|
|
|
|
console.log("✅ [POST /photos/initDB] completato", {
|
|
requestedBy: loggedUser,
|
|
writableSources,
|
|
deletedPhotos: result.deletedPhotos,
|
|
deletedHardRecords: result.deletedHardRecords,
|
|
duration_ms: Date.now() - start,
|
|
});
|
|
|
|
return res.json({
|
|
ok: true,
|
|
scope: "writableSources",
|
|
user: loggedUser,
|
|
writableSources,
|
|
deletedPhotos: result.deletedPhotos,
|
|
deletedHardRecords: result.deletedHardRecords,
|
|
duration_ms: Date.now() - start,
|
|
message: `DB foto svuotato per le sorgenti scrivibili di ${loggedUser}`,
|
|
});
|
|
} catch (err) {
|
|
console.error("❌ Errore POST /photos/initDB:", err);
|
|
|
|
return res.status(500).json({
|
|
ok: false,
|
|
error: err.message,
|
|
duration_ms: Date.now() - start,
|
|
});
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// 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
|
|
// Restituisce solamente eliminazioni leggibili
|
|
// Deve stare PRIMA di /:id
|
|
// -----------------------------------------------------
|
|
router.get("/deleted_hard", async (req, res) => {
|
|
const access = getPhotoReadAccess(req, res);
|
|
|
|
if (!access) {
|
|
return;
|
|
}
|
|
|
|
const since = req.query.since;
|
|
|
|
if (!since) {
|
|
return res.status(400).json({
|
|
error: "Missing ?since=timestamp",
|
|
});
|
|
}
|
|
|
|
if (access.readableSources.length === 0) {
|
|
return res.json({
|
|
deleted: [],
|
|
});
|
|
}
|
|
|
|
try {
|
|
const rows = await db("deleted_hard")
|
|
.whereIn("user", access.readableSources)
|
|
.andWhere("deleted_at", ">", since)
|
|
.orderBy("deleted_at", "asc");
|
|
|
|
return res.json({
|
|
deleted: rows,
|
|
});
|
|
} catch (err) {
|
|
console.error("❌ Errore GET /photos/deleted_hard:", err);
|
|
|
|
return 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 = String(req.user?.name ?? "").trim();
|
|
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}`);
|
|
|
|
const row = await db("photos").where({ id }).first();
|
|
|
|
if (!row) {
|
|
return res.status(404).json({
|
|
ok: false,
|
|
error: "Photo not found",
|
|
id,
|
|
});
|
|
}
|
|
|
|
if (!canWrite(loggedUser, row.user)) {
|
|
console.warn("⛔ Toggle soft non autorizzato", {
|
|
loggedUser,
|
|
photoUser: row.user,
|
|
id,
|
|
});
|
|
|
|
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`,
|
|
);
|
|
|
|
return res.json({
|
|
ok: true,
|
|
id,
|
|
user: row.user,
|
|
deleted_at: nextDeletedAt,
|
|
soft_deleted: Boolean(nextDeletedAt),
|
|
restored: !nextDeletedAt,
|
|
duration_ms: Date.now() - start,
|
|
});
|
|
} catch (err) {
|
|
console.error("❌ Errore POST /photos/:id/toggle-soft:", err);
|
|
|
|
return 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}`);
|
|
|
|
const row = await db("photos").where({ id }).first();
|
|
|
|
if (!row) {
|
|
return res.status(404).json({
|
|
ok: false,
|
|
error: "Photo not found",
|
|
id,
|
|
});
|
|
}
|
|
|
|
if (!canWrite(loggedUser, row.user)) {
|
|
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 castPath = getThumbPath(row, row.cast);
|
|
|
|
const thumb1Deleted = await deleteFileIfExists(thumb1Path);
|
|
const thumb2Deleted = await deleteFileIfExists(thumb2Path);
|
|
const castDeleted = await deleteFileIfExists(castPath);
|
|
|
|
// 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,
|
|
cast: castPath,
|
|
cast_deleted: castDeleted,
|
|
},
|
|
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/upload-targets
|
|
// Restituisce le destinazioni disponibili per upload:
|
|
// - utente autenticato
|
|
// - device associati all'utente
|
|
// -----------------------------------------------------
|
|
router.get("/upload-targets", async (req, res) => {
|
|
try {
|
|
const loggedUser = normalizeUploadValue(req.user?.name);
|
|
|
|
if (!loggedUser) {
|
|
return res.status(401).json({
|
|
ok: false,
|
|
error: "Unauthorized",
|
|
});
|
|
}
|
|
|
|
const access = getUserAccess(loggedUser);
|
|
|
|
if (!access) {
|
|
return res.status(403).json({
|
|
ok: false,
|
|
error: "Utente non configurato",
|
|
user: loggedUser,
|
|
});
|
|
}
|
|
|
|
const candidates = [
|
|
{
|
|
value: access.name,
|
|
label: access.name,
|
|
type: "user",
|
|
},
|
|
|
|
...access.devices.map((device) => ({
|
|
value: device,
|
|
label: device,
|
|
type: "device",
|
|
})),
|
|
];
|
|
|
|
const targets = candidates.filter((target) => {
|
|
return canWrite(loggedUser, target.value);
|
|
});
|
|
|
|
return res.json({
|
|
ok: true,
|
|
user: access.name,
|
|
targets,
|
|
});
|
|
} catch (err) {
|
|
console.error("❌ Errore GET /photos/upload-targets:", err);
|
|
|
|
return res.status(500).json({
|
|
ok: false,
|
|
error: err.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// POST /photos/upload
|
|
//
|
|
// multipart/form-data:
|
|
// - source: utente o device di destinazione
|
|
// - folder: nome cartella
|
|
// - photo: file della foto
|
|
//
|
|
// Salva in:
|
|
// public/photos/<source>/original/<folder>/<file>
|
|
// -----------------------------------------------------
|
|
router.post(
|
|
"/upload",
|
|
uploadMedia.array("photo", MEDIA_UPLOAD_MAX_FILES),
|
|
async (req, res) => {
|
|
const start = Date.now();
|
|
|
|
try {
|
|
const loggedUser = normalizeUploadValue(req.user?.name);
|
|
|
|
if (!loggedUser) {
|
|
return res.status(401).json({
|
|
ok: false,
|
|
error: "Unauthorized",
|
|
});
|
|
}
|
|
|
|
const access = getUserAccess(loggedUser);
|
|
|
|
if (!access) {
|
|
return res.status(403).json({
|
|
ok: false,
|
|
error: "Utente non configurato",
|
|
user: loggedUser,
|
|
});
|
|
}
|
|
|
|
const source = validateDirectoryName(req.body?.source, "Sorgente");
|
|
|
|
const folder = validateDirectoryName(req.body?.folder, "Cartella");
|
|
|
|
const uploadedFiles = Array.isArray(req.files) ? req.files : [];
|
|
|
|
if (uploadedFiles.length === 0) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: "Missing media files",
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Il menu deve contenere esclusivamente:
|
|
* - l'utente autenticato
|
|
* - i device associati all'utente
|
|
*
|
|
* Non basta fidarsi del menu del client, perché la richiesta
|
|
* HTTP può essere costruita manualmente.
|
|
*/
|
|
const allowedUploadTargets = new Set(
|
|
[access.name, ...access.devices].map((value) => {
|
|
return String(value).toLowerCase();
|
|
}),
|
|
);
|
|
|
|
if (!allowedUploadTargets.has(source.toLowerCase())) {
|
|
console.warn("⛔ Destinazione upload non assegnata", {
|
|
loggedUser,
|
|
source,
|
|
});
|
|
|
|
return res.status(403).json({
|
|
ok: false,
|
|
error: "Upload target not assigned to user",
|
|
source,
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Secondo controllo centralizzato.
|
|
* La sorgente deve anche essere presente fra quelle scrivibili.
|
|
*/
|
|
if (!canWrite(loggedUser, source)) {
|
|
console.warn("⛔ Upload media non autorizzato", {
|
|
loggedUser,
|
|
source,
|
|
});
|
|
|
|
return res.status(403).json({
|
|
ok: false,
|
|
error: "Write not allowed",
|
|
source,
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Estensioni usate per riconoscere i video quando il browser
|
|
* trasmette un MIME generico, per esempio application/octet-stream.
|
|
*/
|
|
const videoExtensions = new Set([
|
|
".mp4",
|
|
".mov",
|
|
".m4v",
|
|
".webm",
|
|
".mkv",
|
|
".avi",
|
|
".3gp",
|
|
".3g2",
|
|
".mts",
|
|
".m2ts",
|
|
".mpeg",
|
|
".mpg",
|
|
]);
|
|
|
|
/*
|
|
* Prima validiamo e prepariamo tutti i file.
|
|
* Se un nome o un'estensione non è valido, nessun file viene scritto.
|
|
*/
|
|
const preparedFiles = uploadedFiles.map((file) => {
|
|
const fileName = sanitizeUploadedMediaName(file.originalname);
|
|
|
|
const extension = path.extname(fileName).toLowerCase();
|
|
|
|
const mimeType = String(file.mimetype || "").toLowerCase();
|
|
|
|
const mediaType = mimeType.startsWith("video/")
|
|
? "video"
|
|
: mimeType.startsWith("image/")
|
|
? "photo"
|
|
: videoExtensions.has(extension)
|
|
? "video"
|
|
: "photo";
|
|
|
|
return {
|
|
file,
|
|
fileName,
|
|
extension,
|
|
mimeType,
|
|
mediaType,
|
|
};
|
|
});
|
|
|
|
/*
|
|
* Cartella finale:
|
|
* public/photos/<user-device>/original/<cartella>/
|
|
*/
|
|
const destinationDirectory = safeJoin(
|
|
PHOTOS_ROOT,
|
|
source,
|
|
"original",
|
|
folder,
|
|
);
|
|
|
|
/*
|
|
* Crea la cartella se non esiste.
|
|
*/
|
|
await fsp.mkdir(destinationDirectory, {
|
|
recursive: true,
|
|
});
|
|
|
|
const savedFiles = [];
|
|
|
|
/*
|
|
* Salva tutti i file in sequenza.
|
|
*
|
|
* writeUploadedFileWithoutOverwrite() evita di sovrascrivere
|
|
* un file esistente e, se necessario, aggiunge _1, _2, ecc.
|
|
*/
|
|
for (const prepared of preparedFiles) {
|
|
const saved = await writeUploadedFileWithoutOverwrite(
|
|
destinationDirectory,
|
|
prepared.fileName,
|
|
prepared.file.buffer,
|
|
);
|
|
|
|
const savedFile = {
|
|
mediaType: prepared.mediaType,
|
|
mimeType: prepared.mimeType,
|
|
fileName: saved.fileName,
|
|
originalFileName: prepared.file.originalname,
|
|
renamed: saved.renamed,
|
|
sizeBytes: prepared.file.size,
|
|
};
|
|
|
|
savedFiles.push(savedFile);
|
|
|
|
console.log("✅ [POST /photos/upload] Media salvato", {
|
|
requestedBy: loggedUser,
|
|
source,
|
|
folder,
|
|
mediaType: prepared.mediaType,
|
|
mimeType: prepared.mimeType,
|
|
originalName: prepared.file.originalname,
|
|
savedName: saved.fileName,
|
|
filePath: saved.filePath,
|
|
sizeBytes: prepared.file.size,
|
|
renamed: saved.renamed,
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Riepilogo dell'upload.
|
|
*/
|
|
const totalSizeBytes = savedFiles.reduce(
|
|
(total, file) => total + file.sizeBytes,
|
|
0,
|
|
);
|
|
|
|
const photoCount = savedFiles.filter((file) => {
|
|
return file.mediaType === "photo";
|
|
}).length;
|
|
|
|
const videoCount = savedFiles.filter((file) => {
|
|
return file.mediaType === "video";
|
|
}).length;
|
|
|
|
console.log("✅ [POST /photos/upload] Upload completato", {
|
|
requestedBy: loggedUser,
|
|
source,
|
|
folder,
|
|
fileCount: savedFiles.length,
|
|
photoCount,
|
|
videoCount,
|
|
totalSizeBytes,
|
|
destinationDirectory,
|
|
durationMs: Date.now() - start,
|
|
});
|
|
|
|
/*
|
|
* Non viene inserito nulla direttamente nel DB.
|
|
* Il watcher rileverà e indicizzerà i nuovi media.
|
|
*/
|
|
return res.status(201).json({
|
|
ok: true,
|
|
source,
|
|
folder,
|
|
fileCount: savedFiles.length,
|
|
photoCount,
|
|
videoCount,
|
|
totalSizeBytes,
|
|
files: savedFiles,
|
|
watcherPending: true,
|
|
duration_ms: Date.now() - start,
|
|
});
|
|
} catch (err) {
|
|
console.error("❌ Errore POST /photos/upload:", err);
|
|
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: err.message,
|
|
duration_ms: Date.now() - start,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
// -----------------------------------------------------
|
|
// GET /photos/:id
|
|
// Deve stare DOPO tutte le route con percorso specifico
|
|
// -----------------------------------------------------
|
|
router.get("/:id", async (req, res) => {
|
|
const access = getPhotoReadAccess(req, res);
|
|
|
|
if (!access) {
|
|
return;
|
|
}
|
|
|
|
const id = req.params.id;
|
|
const start = Date.now();
|
|
|
|
console.log(
|
|
`📤 [GET /photos/${id}] Richiesta da=${access.name} at=${new Date().toISOString()}`,
|
|
);
|
|
|
|
if (access.readableSources.length === 0) {
|
|
return res.json([]);
|
|
}
|
|
|
|
try {
|
|
const rows = await db("photos")
|
|
.where({ id })
|
|
.whereIn("user", access.readableSources);
|
|
|
|
console.log(
|
|
`📤 [GET /photos/${id}] Risposta: ${rows.length} foto durata=${Date.now() - start}ms`,
|
|
);
|
|
|
|
if (!rows.length) {
|
|
return res.json([]);
|
|
}
|
|
|
|
return res.json(rows.map(normalizePhoto));
|
|
} catch (err) {
|
|
console.error("❌ Errore GET /photos/:id:", err);
|
|
|
|
return res.status(500).json({
|
|
error: err.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------
|
|
// Gestione errori upload Multer
|
|
// Deve stare dopo tutte le route e prima di module.exports
|
|
// -----------------------------------------------------
|
|
router.use((err, req, res, next) => {
|
|
if (err instanceof multer.MulterError) {
|
|
console.error("❌ Errore Multer upload media:", err);
|
|
|
|
if (err.code === "LIMIT_FILE_SIZE") {
|
|
return res.status(413).json({
|
|
ok: false,
|
|
error: "Uno dei file supera la dimensione massima",
|
|
maxBytesPerFile: MEDIA_UPLOAD_MAX_BYTES,
|
|
});
|
|
}
|
|
|
|
if (err.code === "LIMIT_FILE_COUNT") {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: "Troppi file selezionati",
|
|
maxFiles: MEDIA_UPLOAD_MAX_FILES,
|
|
});
|
|
}
|
|
|
|
if (err.code === "LIMIT_UNEXPECTED_FILE") {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error:
|
|
'Campo file non valido o troppi file. Usare il campo "photo".',
|
|
});
|
|
}
|
|
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: err.message,
|
|
code: err.code,
|
|
});
|
|
}
|
|
|
|
if (err) {
|
|
console.error("❌ Errore upload media:", err);
|
|
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: err.message,
|
|
});
|
|
}
|
|
|
|
return next();
|
|
});
|
|
|
|
module.exports = router;
|
|
|
|
module.exports = router;
|