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

1072 lines
26 KiB
JavaScript

// routes/activities.js
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");
const normalizeActivity = require("../helpers/normalizeActivity");
const { parseFitTrackPoints } = require("../api_v1/scanner/parsers/parseFit");
const {
getUserAccess,
filterReadableSources,
canWrite,
} = require("../api_v1/userAccess");
// -----------------------------------------------------
// File helpers hard delete attività
// -----------------------------------------------------
const PUBLIC_ROOT = path.resolve(__dirname, "..", "public");
const ACTIVITIES_ROOT = path.join(PUBLIC_ROOT, "activities");
const DEL_ACTIVITIES_ROOT = path.join(PUBLIC_ROOT, "del_activities");
function getActivityReadAccess(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;
}
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 getOriginalActivityPath(row) {
return safeJoin(
ACTIVITIES_ROOT,
row.user,
"original",
row.cartella || "",
row.file_name,
);
}
function getDeletedActivityPath(row) {
return safeJoin(
DEL_ACTIVITIES_ROOT,
row.user,
"original",
row.cartella || "",
row.file_name,
);
}
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 attività non trovato, skip move: ${src}`);
return false;
}
// Crea automaticamente public/del_activities/<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;
}
}
// -----------------------------------------------------
// POST /activities
// Salva attività inviata dallo scanner locale
// -----------------------------------------------------
router.post("/", async (req, res) => {
const a = req.body;
const start = Date.now();
console.log(`📥 [POST /activities] Richiesta at=${new Date().toISOString()}`);
console.log("📥 Body:", a);
try {
const loggedUser = String(req.user?.name ?? "").trim();
const targetSource = String(a?.user ?? "").trim();
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,
});
}
if (!targetSource) {
return res.status(400).json({
ok: false,
error: "Missing activity user/source",
});
}
if (!canWrite(loggedUser, targetSource)) {
console.warn("⛔ Inserimento attività non autorizzato", {
loggedUser,
targetSource,
id: a?.id,
});
return res.status(403).json({
ok: false,
error: "Write not allowed",
targetSource,
});
}
console.log(
`🏃 [DB INSERT] Salvo activity id=${a.id} user=${targetSource}`,
);
const now = new Date().toISOString();
await db("activities")
.insert({
// Mantieni qui tutti i campi attuali
id: a.id,
user: targetSource,
cartella: a.cartella,
file_name: a.file_name,
file_extension: a.file_extension,
format: a.format,
display_name: a.display_name,
route_name: a.route_name,
title: a.title,
description: a.description,
notes: a.notes,
sport: a.sport,
activity_type: a.activity_type,
device_manufacturer: a.device_manufacturer,
device_model: a.device_model,
source_app: a.source_app,
start_time_millis: a.start_time_millis,
end_time_millis: a.end_time_millis,
duration_millis: a.duration_millis,
distance_meters: a.distance_meters,
elevation_gain_meters: a.elevation_gain_meters,
elevation_loss_meters: a.elevation_loss_meters,
min_elevation_meters: a.min_elevation_meters,
max_elevation_meters: a.max_elevation_meters,
file_distance_meters: a.file_distance_meters,
file_elevation_gain_meters: a.file_elevation_gain_meters,
computed_distance_meters: a.computed_distance_meters,
computed_elevation_gain_meters: a.computed_elevation_gain_meters,
average_speed_mps: a.average_speed_mps,
max_speed_mps: a.max_speed_mps,
average_pace_sec_per_km: a.average_pace_sec_per_km,
average_heart_rate: a.average_heart_rate,
max_heart_rate: a.max_heart_rate,
average_cadence: a.average_cadence,
max_cadence: a.max_cadence,
calories: a.calories,
power_avg: a.power_avg,
power_max: a.power_max,
point_count: a.point_count,
segment_count: a.segment_count,
lap_count: a.lap_count,
min_latitude: a.min_latitude,
max_latitude: a.max_latitude,
min_longitude: a.min_longitude,
max_longitude: a.max_longitude,
center_latitude: a.center_latitude,
center_longitude: a.center_longitude,
size_bytes: a.size_bytes,
last_modified_millis: a.last_modified_millis,
date_added_millis: a.date_added_millis,
date_modified_millis: a.date_modified_millis,
last_scan_millis: a.last_scan_millis,
is_favorite: a.is_favorite,
is_hidden: a.is_hidden,
rating: a.rating,
tags: a.tags ? JSON.stringify(a.tags) : null,
track_preview: a.track_preview ? JSON.stringify(a.track_preview) : null,
scan_status: a.scan_status,
scan_error: a.scan_error,
diagnostics: a.diagnostics ? JSON.stringify(a.diagnostics) : null,
location: a.location ? JSON.stringify(a.location) : null,
path: a.path,
fast_hash: a.fast_hash,
deleted_at: a.deleted_at || null,
created_at: now,
updated_at: now,
})
.onConflict("id")
.merge();
wss.broadcastToUser(targetSource, {
type: "added",
id: a.id,
entity: "activity",
});
console.log(
`✅ [POST /activities] Activity id=${a.id} salvata ` +
`durata=${Date.now() - start}ms`,
);
return res.json({
ok: true,
});
} catch (err) {
console.error("❌ Errore POST /activities:", err);
return res.status(500).json({
ok: false,
error: err.message,
});
}
});
// -----------------------------------------------------
// GET /activities/changes
// Progressive sync
// -----------------------------------------------------
router.get("/changes", async (req, res) => {
const access = getActivityReadAccess(req, res);
if (!access) {
return;
}
const since = req.query.since;
const start = Date.now();
console.log(
`🔄 [SYNC SINCE] GET /activities/changes ` +
`user=${access.name} since=${since} ` +
`at=${new Date().toISOString()}`,
);
console.log("🔐 Sorgenti leggibili:", access.readableSources);
if (!since) {
return res.status(400).json({
error: "Missing ?since=timestamp",
});
}
if (access.readableSources.length === 0) {
return res.json([]);
}
try {
const rows = await db("activities")
.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} attività ` +
`durata=${Date.now() - start}ms`,
);
return res.json(rows.map(normalizeActivity));
} catch (err) {
console.error("❌ Errore GET /activities/changes:", err);
return res.status(500).json({
error: err.message,
});
}
});
// -----------------------------------------------------
// GET /activities/byIds
// -----------------------------------------------------
router.get("/byIds", async (req, res) => {
const access = getActivityReadAccess(req, res);
if (!access) {
return;
}
const ids = req.query.id;
const requestedUsers = req.query.user;
const start = Date.now();
console.log(`📤 [GET /activities/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("activities")
.whereIn("id", idList)
.whereIn("user", allowedSources);
console.log(
`📤 [GET /activities/byIds] trovate ${rows.length} attività ` +
`durata=${Date.now() - start}ms`,
);
return res.json(rows.map(normalizeActivity));
} catch (err) {
console.error("❌ Errore GET /activities/byIds:", err);
return res.status(500).json({
error: err.message,
});
}
});
// -----------------------------------------------------
// GET /activities
// Full sync
// -----------------------------------------------------
router.get("/", async (req, res) => {
const access = getActivityReadAccess(req, res);
if (!access) {
return;
}
const start = Date.now();
console.log(
`📤 [FULL SYNC] GET /activities 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("activities")
.whereIn("user", access.readableSources)
.orderBy("start_time_millis", "desc");
console.log(
`📤 [FULL SYNC] Risposta: ${rows.length} attività ` +
`durata=${Date.now() - start}ms`,
);
return res.json(rows.map(normalizeActivity));
} catch (err) {
console.error("❌ Errore GET /activities:", err);
return res.status(500).json({
error: err.message,
});
}
});
// -----------------------------------------------------
// POST /activities/initDB
// Reset DB attività
// - Admin: cancella tutte le attività
// - Utente normale: cancella solo le proprie
// -----------------------------------------------------
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 /activities/initDB]", {
requestedBy: loggedUser,
writableSources,
});
if (writableSources.length === 0) {
return res.json({
ok: true,
scope: "none",
user: loggedUser,
writableSources: [],
deletedActivities: 0,
deletedHardRecords: 0,
deletedSyncRecords: 0,
duration_ms: Date.now() - start,
message: "Nessuna sorgente scrivibile da svuotare",
});
}
const result = await db.transaction(async (trx) => {
const deletedActivities = await trx("activities")
.whereIn("user", writableSources)
.del();
const deletedHardRecords = await trx("deleted_hard_activities")
.whereIn("user", writableSources)
.del();
/*
* sync_done_activities non contiene attualmente la colonna user.
* Possiamo limitarlo ai device scrivibili usando device_id.
*/
const deletedSyncRecords = await trx("sync_done_activities")
.whereIn("device_id", writableSources)
.del();
return {
deletedActivities,
deletedHardRecords,
deletedSyncRecords,
};
});
return res.json({
ok: true,
scope: "writableSources",
user: loggedUser,
writableSources,
...result,
duration_ms: Date.now() - start,
message:
`DB attività svuotato per le sorgenti scrivibili ` + `di ${loggedUser}`,
});
} catch (err) {
console.error("❌ Errore POST /activities/initDB:", err);
return res.status(500).json({
ok: false,
error: err.message,
duration_ms: Date.now() - start,
});
}
});
// -----------------------------------------------------
// POST /activities/initDBuser
// Reset DB attività per utente specifico
// Solo Admin
// -----------------------------------------------------
router.post("/initDBuser", async (req, res) => {
try {
const loggedUser = String(req.user?.name ?? "").trim();
if (loggedUser !== "Admin") {
return res.status(403).json({
ok: false,
error: "Forbidden",
loggedUser,
});
}
const targetUser = String(
req.body?.user ||
req.query?.user ||
req.body?.name ||
req.query?.name ||
"",
).trim();
if (!targetUser) {
return res.status(400).json({
ok: false,
error: "Missing user",
});
}
console.log(
`🧹 [POST /activities/initDBuser] ` +
`Admin=${loggedUser} target=${targetUser}`,
);
const result = await db.transaction(async (trx) => {
const deletedActivities = await trx("activities")
.where({ user: targetUser })
.del();
const deletedHardRecords = await trx("deleted_hard_activities")
.where({ user: targetUser })
.del();
const deletedSyncRecords = await trx("sync_done_activities")
.where({ device_id: targetUser })
.del();
return {
deletedActivities,
deletedHardRecords,
deletedSyncRecords,
};
});
return res.json({
ok: true,
user: targetUser,
...result,
message: `DB attività svuotato per user=${targetUser}`,
});
} catch (err) {
console.error("❌ Errore POST /activities/initDBuser:", err);
return res.status(500).json({
ok: false,
error: err.message,
});
}
});
// -----------------------------------------------------
// GET /activities/deleted_hard
// Deve stare PRIMA di /:id
// -----------------------------------------------------
router.get("/deleted_hard", async (req, res) => {
const access = getActivityReadAccess(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_activities")
.whereIn("user", access.readableSources)
.andWhere("deleted_at", ">", since)
.orderBy("deleted_at", "asc");
return res.json({
deleted: rows,
});
} catch (err) {
console.error("❌ Errore GET /activities/deleted_hard:", err);
return res.status(500).json({
error: err.message,
});
}
});
// -----------------------------------------------------
// POST /activities/sync_done
// -----------------------------------------------------
router.post("/sync_done", async (req, res) => {
try {
const loggedUser = String(req.user?.name ?? "").trim();
const deviceId = String(req.body?.device_id ?? "").trim();
const sessionId = String(req.body?.session_id ?? "").trim();
if (!loggedUser) {
return res.status(401).json({
ok: false,
error: "Unauthorized",
});
}
if (!getUserAccess(loggedUser)) {
return res.status(403).json({
ok: false,
error: "Utente non configurato",
user: loggedUser,
});
}
if (!deviceId) {
return res.status(400).json({
ok: false,
error: "Missing device_id",
});
}
if (!sessionId) {
return res.status(400).json({
ok: false,
error: "Missing session_id",
});
}
if (!canWrite(loggedUser, deviceId)) {
return res.status(403).json({
ok: false,
error: "Write not allowed",
device_id: deviceId,
});
}
await db("sync_done_activities").insert({
session_id: sessionId,
device_id: deviceId,
last_sync: req.body.last_sync,
});
return res.json({
ok: true,
});
} catch (err) {
return res.status(500).json({
ok: false,
error: err.message,
});
}
});
// -----------------------------------------------------
// POST /activities/:id/toggle-soft
// Toggle soft delete attività 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 /activities/${id}] ` + `richiesto da=${loggedUser}`,
);
const row = await db("activities").where({ id }).first();
if (!row || !canWrite(loggedUser, row.user)) {
return res.status(404).json({
ok: false,
error: "Activity not found or not allowed",
id,
});
}
const now = new Date().toISOString();
const nowMs = Date.now();
const nextDeletedAt = row.deleted_at ? null : now;
await db("activities").where({ id }).update({
deleted_at: nextDeletedAt,
updated_at: now,
date_modified_millis: nowMs,
});
try {
wss.broadcastToUser(row.user, {
type: "updated",
id,
entity: "activity",
deleted_at: nextDeletedAt,
});
} catch (err) {
console.warn(
"⚠️ WS broadcast activity toggle soft fallito:",
err.message,
);
}
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 /activities/:id/toggle-soft:", err);
return res.status(500).json({
ok: false,
error: err.message,
});
}
});
// -----------------------------------------------------
// DELETE /activities/:id
// HARD DELETE attività per ID
// - Sposta originale in public/del_activities/<user>/original/<cartella>/<attività>
// - Rimuove record da activities
// - Registra ID in deleted_hard_activities
// -----------------------------------------------------
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 /activities/${id}] richiesto da=${loggedUser}`,
);
const row = await db("activities").where({ id }).first();
if (!row || !canWrite(loggedUser, row.user)) {
return res.status(404).json({
ok: false,
error: "Activity not found or not allowed",
id,
});
}
if (!row) {
return res.status(404).json({
ok: false,
error: "Activity not found or not allowed",
id,
});
}
const deletedAt = new Date().toISOString();
// 1. Sposta file originale attività
const originalSrc = getOriginalActivityPath(row);
const originalDst = getDeletedActivityPath(row);
const originalMoved = await moveFileSafe(originalSrc, originalDst);
// 2. Elimina record da activities
await db("activities").where({ id }).del();
// 3. Registra hard delete
await db("deleted_hard_activities")
.insert({
id,
user: row.user,
deleted_at: deletedAt,
})
.onConflict("id")
.merge();
// 4. WS realtime
try {
wss.broadcastToUser(row.user, {
type: "removed",
id,
entity: "activity",
});
} catch (e) {
console.warn("⚠️ WS broadcast activity hard delete fallito:", e.message);
}
console.log(
`✅ [HARD DELETE /activities/${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,
},
duration_ms: Date.now() - start,
});
} catch (err) {
console.error("❌ Errore HARD DELETE /activities/:id:", err);
res.status(500).json({
ok: false,
error: err.message,
});
}
});
// -----------------------------------------------------
// GET /activities/:id/route-points
// Carica tutti i punti della traccia FIT su richiesta.
// I punti non vengono salvati nel database.
// Deve stare PRIMA di GET /activities/:id.
// -----------------------------------------------------
router.get("/:id/route-points", async (req, res) => {
const id = req.params.id;
const loggedUser = req.user?.name;
//const startedAt = Date.now();
if (!loggedUser) {
return res.status(401).json({
ok: false,
error: "Unauthorized",
});
}
try {
const access = getUserAccess(loggedUser);
if (!access) {
return res.status(403).json({
ok: false,
error: "Utente non configurato",
user: loggedUser,
});
}
if (access.readableSources.length === 0) {
return res.status(404).json({
ok: false,
error: "Activity not found or not allowed",
id,
});
}
const row = await db("activities")
.where({ id })
.whereIn("user", access.readableSources)
.first();
if (!row) {
return res.status(404).json({
ok: false,
error: "Activity not found or not allowed",
id,
});
}
const format = String(row.format || row.file_extension || "")
.toLowerCase()
.replaceAll(".", "")
.trim();
const fileName = String(row.file_name || "").toLowerCase();
const isFit = format === "fit" || fileName.endsWith(".fit");
if (!isFit) {
return res.status(400).json({
ok: false,
error: "Full route endpoint currently supports FIT activities only",
id,
format,
});
}
const originalPath = getOriginalActivityPath(row);
const exists = await pathExists(originalPath);
if (!exists) {
console.warn(
"[activity route-points] File FIT non trovato:",
originalPath,
);
return res.status(404).json({
ok: false,
error: "Activity file not found",
id,
});
}
const points = await parseFitTrackPoints(originalPath);
/*
console.log(
'[activity route-points] traccia FIT caricata',
{
id,
user:
row.user,
requestedBy:
loggedUser,
expectedPoints:
row.point_count,
loadedPoints:
points.length,
durationMs:
Date.now() -
startedAt
}
);
*/
res.set("Cache-Control", "private, no-store");
return res.json({
ok: true,
activity_id: row.id,
format: "FIT",
expected_point_count: Number(row.point_count) || null,
point_count: points.length,
points,
});
} catch (error) {
console.error("[activity route-points] Errore:", error);
return res.status(500).json({
ok: false,
error: "Errore caricamento traccia FIT",
details: error.message,
});
}
});
// -----------------------------------------------------
// GET /activities/:id
// Deve stare DOPO deleted_hard, changes, byIds, initDB,
// sync_done, toggle-soft e delete.
// -----------------------------------------------------
router.get("/:id", async (req, res) => {
const access = getActivityReadAccess(req, res);
if (!access) {
return;
}
const id = req.params.id;
const start = Date.now();
console.log(
`📤 [GET /activities/${id}] Richiesta da=${access.name} ` +
`at=${new Date().toISOString()}`,
);
if (access.readableSources.length === 0) {
return res.json([]);
}
try {
const rows = await db("activities")
.where({ id })
.whereIn("user", access.readableSources);
console.log(
`📤 [GET /activities/${id}] Risposta: ${rows.length} attività ` +
`durata=${Date.now() - start}ms`,
);
if (!rows.length) {
return res.json([]);
}
return res.json(rows.map(normalizeActivity));
} catch (err) {
console.error("❌ Errore GET /activities/:id:", err);
return res.status(500).json({
error: err.message,
});
}
});
module.exports = router;