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//original// 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//original// // - 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//original// // ----------------------------------------------------- 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//original// */ 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;