/* eslint-disable no-console */ require('dotenv').config(); const fs = require('fs'); const fsp = require('fs/promises'); const path = require('path'); const crypto = require('crypto'); const ExifReader = require('exifreader'); const sharp = require('sharp'); const axios = require('axios'); const BASE_URL = process.env.BASE_URL; // es: https://api.tuoserver.tld (backend con /auth/login e /photos) const EMAIL = process.env.EMAIL; const PASSWORD = process.env.PASSWORD; // Opzioni const SEND_PHOTOS = (process.env.SEND_PHOTOS || 'true').toLowerCase() === 'true'; // invia ogni record via POST /photos const WRITE_INDEX = (process.env.WRITE_INDEX || 'true').toLowerCase() === 'true'; // scrivi public/photos/index.json const WEB_ROOT = process.env.WEB_ROOT || 'public'; // radice dei file serviti const INDEX_PATH = process.env.INDEX_PATH || path.posix.join('photos', 'index.json'); // estensioni supportate const SUPPORTED_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif']); // ----------------------------------------------------------------------------- // UTILS // ----------------------------------------------------------------------------- // usa sempre POSIX per i path web (slash '/') function toPosix(p) { return p.split(path.sep).join('/'); } function sha256(s) { return crypto.createHash('sha256').update(s).digest('hex'); } function inferMimeFromExt(ext) { switch (ext.toLowerCase()) { case '.jpg': case '.jpeg': return 'image/jpeg'; case '.png': return 'image/png'; case '.webp': return 'image/webp'; case '.heic': case '.heif': return 'image/heic'; default: return 'application/octet-stream'; } } // EXIF "YYYY:MM:DD HH:mm:ss" -> ISO-8601 UTC function parseExifDateUtc(s) { if (!s) return null; const re = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/; const m = re.exec(s); if (!m) return null; const dt = new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6])); return dt.toISOString(); } // normalizza GPS da {Latitude,Longitude,Altitude} -> {lat,lng,alt} function mapGps(gps) { if (!gps) return null; const lat = gps.Latitude; const lng = gps.Longitude; const alt = gps.Altitude; if (lat == null || lng == null) return null; const toNum = (v) => (typeof v === 'number' ? v : Number(v)); const obj = { lat: toNum(lat), lng: toNum(lng) }; if (alt != null) obj.alt = toNum(alt); return obj; } // ----------------------------------------------------------------------------- // AUTH / POST // ----------------------------------------------------------------------------- let cachedToken = null; async function getToken(force = false) { if (!SEND_PHOTOS) return null; if (cachedToken && !force) return cachedToken; try { const res = await axios.post(`${BASE_URL}/auth/login`, { email: EMAIL, password: PASSWORD }); cachedToken = res.data.token; return cachedToken; } catch (err) { console.error('ERRORE LOGIN:', err.message); return null; } } async function postWithAuth(url, payload) { if (!SEND_PHOTOS) return; let token = await getToken(); if (!token) throw new Error('Token assente'); try { await axios.post(url, payload, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 20000, }); } catch (err) { if (err.response && err.response.status === 401) { token = await getToken(true); if (!token) throw err; await axios.post(url, payload, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 20000, }); } else { throw err; } } } // ----------------------------------------------------------------------------- // THUMBNAILS // ----------------------------------------------------------------------------- async function createThumbnails(filePath, thumbMinPath, thumbAvgPath) { try { await sharp(filePath) .resize({ width: 100, height: 100, fit: 'inside', withoutEnlargement: true }) .withMetadata() .toFile(thumbMinPath); await sharp(filePath) .resize({ width: 400, withoutEnlargement: true }) .withMetadata() .toFile(thumbAvgPath); } catch (err) { console.error('Errore creazione thumbnails:', err.message, filePath); } } // ----------------------------------------------------------------------------- // SCANSIONE RICORSIVA (asincrona) // ----------------------------------------------------------------------------- async function scanDir(dirAbs, results = []) { const dirEntries = await fsp.readdir(dirAbs, { withFileTypes: true }); for (const dirent of dirEntries) { const absPath = path.join(dirAbs, dirent.name); if (dirent.isDirectory()) { await scanDir(absPath, results); continue; } const ext = path.extname(dirent.name).toLowerCase(); if (!SUPPORTED_EXTS.has(ext)) continue; console.log('Trovato:', absPath); // path relativo (web-safe) rispetto a WEB_ROOT const relFile = toPosix(path.relative(WEB_ROOT, absPath)); // es. photos/original/.../IMG_0092.JPG const relDir = toPosix(path.posix.dirname(relFile)); // es. photos/original/... (POSIX) // cartella thumbs parallela const relThumbDir = relDir.replace(/original/i, 'thumbs'); const absThumbDir = path.join(WEB_ROOT, relThumbDir); await fsp.mkdir(absThumbDir, { recursive: true }); const baseName = path.parse(dirent.name).name; const extName = path.parse(dirent.name).ext; // path ASSOLUTI (filesystem) per i file thumb const absThumbMin = path.join(absThumbDir, `${baseName}_min${extName}`); const absThumbAvg = path.join(absThumbDir, `${baseName}_avg${extName}`); // crea thumbnails await createThumbnails(absPath, absThumbMin, absThumbAvg); // path RELATIVI (web) dei thumb const relThumbMin = toPosix(path.relative(WEB_ROOT, absThumbMin)); // photos/thumbs/..._min.JPG const relThumbAvg = toPosix(path.relative(WEB_ROOT, absThumbAvg)); // photos/thumbs/..._avg.JPG // EXIF e metadata immagine let tags = {}; try { tags = await ExifReader.load(absPath, { expanded: true }); } catch (err) { // nessun EXIF: ok } const timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null; const takenAtIso = parseExifDateUtc(timeRaw); const gps = mapGps(tags?.gps); // dimensioni e peso let width = null, height = null, size_bytes = null; try { const meta = await sharp(absPath).metadata(); width = meta.width || null; height = meta.height || null; const st = await fsp.stat(absPath); size_bytes = st.size; } catch (err) { try { const st = await fsp.stat(absPath); size_bytes = st.size; } catch {} } const mime_type = inferMimeFromExt(ext); const id = sha256(relFile); // id stabile // RECORD allineato all’app results.push({ id, // sha256 del path relativo name: dirent.name, path: relFile, thub1: relThumbMin, thub2: relThumbAvg, gps, // {lat,lng,alt} oppure null data: timeRaw, // EXIF originale (legacy) taken_at: takenAtIso, // ISO-8601 UTC mime_type, width, height, size_bytes, location: null, }); } return results; } // ----------------------------------------------------------------------------- // MAIN // ----------------------------------------------------------------------------- async function scanPhoto(dir) { try { console.log('Inizio scansione:', dir); // dir può essere "public/photos/original" o simile const absDir = path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir); const photos = await scanDir(absDir); console.log('Trovate', photos.length, 'foto'); // 1) (opzionale) invio a backend /photos if (SEND_PHOTOS && BASE_URL) { for (const p of photos) { try { await postWithAuth(`${BASE_URL}/photos`, p); } catch (err) { console.error('Errore invio foto:', err.message); } } } // 2) (opzionale) scrivo indice statico public/photos/index.json if (WRITE_INDEX) { const absIndexPath = path.join(WEB_ROOT, INDEX_PATH); // es. public/photos/index.json await fsp.mkdir(path.dirname(absIndexPath), { recursive: true }); await fsp.writeFile(absIndexPath, JSON.stringify(photos, null, 2), 'utf8'); console.log('Scritto indice:', absIndexPath); } console.log('Scansione completata'); return photos; } catch (e) { console.error('Errore generale scanPhoto:', e); throw e; } } module.exports = scanPhoto; // Esempio di esecuzione diretta: // node -e "require('./api_v1/scanphoto')('public/photos/original')"