galleria_photo_face_rknn_op.../routes/photos.js
2026-10-03 12:16:58 +02:00

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;