'use strict'; require('dotenv').config(); const express = require('express'); const session = require('express-session'); const helmet = require('helmet'); const { rateLimit } = require('express-rate-limit'); const path = require('path'); const fs = require('fs'); const { initDb, getDb } = require('./auth/db'); const { logRequest: _logSyncroRequest } = require('./syncroStats'); const { createSessionStore } = require('./auth/sessionStore'); const { requireAuth, requireRole } = require('./auth/middleware'); const authRoutes = require('./auth/routes'); const adminRoutes = require('./auth/admin'); // ── Validate required environment ──────────────────────────────────────────── if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET.length < 32) { console.error('FATAL: SESSION_SECRET is missing or too short. Set it in .env'); process.exit(1); } if (!process.env.SYNCRO_BASE_URL || !process.env.SYNCRO_API_KEY) { console.error('FATAL: SYNCRO_BASE_URL and SYNCRO_API_KEY must be set in .env'); process.exit(1); } // ── Boot ID — changes on every restart so clients can detect and reload ─────── const SERVER_BOOT_ID = Date.now().toString(36); // ── Syncro in-memory cache ──────────────────────────────────────────────────── const CACHE_TTL_MS = 5 * 60 * 1000; const _cache = { customers: { data: null, ts: 0 }, assets: new Map(), // customerId (number) → { data: [], ts: number } contacts: new Map(), // customerId (number) → { data: [], ts: number } }; let _refreshCooldownUntil = 0; const _inflight = { customers: null, // Promise | null assets: new Map(), // customerId → Promise contacts: new Map(), // customerId → Promise }; // ── Syncro HTTP helpers ─────────────────────────────────────────────────────── async function syncroGet(path, params = {}) { const url = new URL(process.env.SYNCRO_BASE_URL + path); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v)); _logSyncroRequest(); const res = await fetch(url.toString(), { headers: { Authorization: process.env.SYNCRO_API_KEY, Accept: 'application/json' }, }); if (!res.ok) throw new Error(`Syncro ${res.status} on GET ${path}`); return res.json(); } async function syncroPut(path, body) { const url = new URL(process.env.SYNCRO_BASE_URL + path); _logSyncroRequest(); const res = await fetch(url.toString(), { method: 'PUT', headers: { Authorization: process.env.SYNCRO_API_KEY, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(body), }); if (res.status === 204) return {}; const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error ?? data.message ?? `HTTP ${res.status}`); return data; } // ── Paginated Syncro fetchers ───────────────────────────────────────────────── async function _fetchCustomers() { let page = 1, all = []; while (true) { const data = await syncroGet('/customers', { per_page: 100, page }); all = all.concat(data.customers ?? []); if (page >= (data.meta?.total_pages ?? 1)) break; page++; } return all; } async function _fetchCustomerAssets(customerId) { let page = 1, all = []; while (true) { const data = await syncroGet('/customer_assets', { customer_id: customerId, per_page: 250, page }); all = all.concat(data.assets ?? data.customer_assets ?? []); if (page >= (data.meta?.total_pages ?? 1)) break; page++; } return all; } async function _fetchContacts(customerId) { let page = 1, all = []; while (true) { const data = await syncroGet('/contacts', { customer_id: customerId, per_page: 50, page }); all = all.concat(data.contacts ?? []); if (page >= (data.meta?.total_pages ?? 1)) break; page++; } return all; } // ── Cache-with-dedup accessors ──────────────────────────────────────────────── async function cachedCustomers() { const now = Date.now(); if (_cache.customers.data && now - _cache.customers.ts < CACHE_TTL_MS) return _cache.customers.data; if (_inflight.customers) return _inflight.customers; _inflight.customers = _fetchCustomers().then(data => { _cache.customers = { data, ts: Date.now() }; _inflight.customers = null; return data; }).catch(err => { _inflight.customers = null; throw err; }); return _inflight.customers; } async function cachedAssets(customerId) { const id = Number(customerId); const now = Date.now(); const entry = _cache.assets.get(id); if (entry && now - entry.ts < CACHE_TTL_MS) return entry.data; if (_inflight.assets.has(id)) return _inflight.assets.get(id); const p = _fetchCustomerAssets(id).then(data => { _cache.assets.set(id, { data, ts: Date.now() }); _inflight.assets.delete(id); return data; }).catch(err => { _inflight.assets.delete(id); throw err; }); _inflight.assets.set(id, p); return p; } async function cachedContacts(customerId) { const id = Number(customerId); const now = Date.now(); const entry = _cache.contacts.get(id); if (entry && now - entry.ts < CACHE_TTL_MS) return entry.data; if (_inflight.contacts.has(id)) return _inflight.contacts.get(id); const p = _fetchContacts(id).then(data => { _cache.contacts.set(id, { data, ts: Date.now() }); _inflight.contacts.delete(id); return data; }).catch(err => { _inflight.contacts.delete(id); throw err; }); _inflight.contacts.set(id, p); return p; } // ── Bootstrap DB ───────────────────────────────────────────────────────────── const dataDir = path.join(__dirname, 'data'); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); initDb(); // ── App ─────────────────────────────────────────────────────────────────────── const app = express(); const PORT = parseInt(process.env.PORT ?? '3000', 10); // Trust CloudPanel's nginx reverse proxy (needed for secure cookies over HTTPS) app.set('trust proxy', 1); // ── Security headers ───────────────────────────────────────────────────────── app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", 'https://cdn.jsdelivr.net'], // wasm-unsafe-eval needed for WASM instantiation; jsdelivr for barcode-detector polyfill scriptSrcAttr: ["'unsafe-inline'"], // needed for onclick= handlers in generated popup HTML styleSrc: ["'self'", "'unsafe-inline'"], // needed for inline styles in JS-rendered HTML imgSrc: ["'self'", 'data:'], connectSrc: ["'self'", 'https://cdn.jsdelivr.net', 'https://fastly.jsdelivr.net'], // fastly.jsdelivr.net serves the .wasm binary via CDN edge fontSrc: ["'self'"], objectSrc: ["'none'"], frameSrc: ["'none'"], baseUri: ["'self'"], }, }, crossOriginEmbedderPolicy: false, // not needed for this app })); // ── Body parsing ────────────────────────────────────────────────────────────── app.use(express.json({ limit: '1mb' })); app.use(express.urlencoded({ extended: false, limit: '1mb' })); // ── Sessions ────────────────────────────────────────────────────────────────── app.use(session({ store: createSessionStore(getDb()), secret: process.env.SESSION_SECRET, name: 'sid', resave: false, saveUninitialized: false, rolling: true, // slide expiry on activity cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 8 * 60 * 60 * 1000, // 8 hours }, })); // ── Rate limiting (login endpoint only) ─────────────────────────────────────── const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, standardHeaders: true, legacyHeaders: false, skipSuccessfulRequests: true, message: { error: 'Too many login attempts. Try again in 15 minutes.' }, }); // ── Public routes (no auth required) ───────────────────────────────────────── app.post('/auth/login', loginLimiter); app.use('/auth', authRoutes); // Serve login page assets without auth const pub = path.join(__dirname, 'public'); app.get('/login.html', (req, res) => res.sendFile(path.join(pub, 'login.html'))); app.get('/login.js', (req, res) => res.sendFile(path.join(pub, 'login.js'))); app.get('/styles/login.css', (req, res) => res.sendFile(path.join(pub, 'styles', 'login.css'))); app.get('/styles/variables.css', (req, res) => res.sendFile(path.join(pub, 'styles', 'variables.css'))); app.use('/assets', express.static(path.join(pub, 'assets'))); // ── Auth wall ───────────────────────────────────────────────────────────────── app.use(requireAuth); // ── Admin API (superduperadmin + admin only) ─────────────────────────────────── app.use('/admin', requireRole('superduperadmin', 'admin'), adminRoutes); // ── Health / boot ID — lets clients detect a restart and reload ─────────────── app.get('/api/ping', (_req, res) => { res.json({ ok: true, bootId: SERVER_BOOT_ID }); }); // ── Syncro API cache routes (auth-gated) ────────────────────────────────────── app.get('/api/customers', async (req, res) => { try { const user = req.session.user; if (user.role === 'client') { if (!user.syncro_customer_id) return res.json({ customers: [] }); const all = await cachedCustomers(); return res.json({ customers: all.filter(c => c.id === user.syncro_customer_id) }); } res.json({ customers: await cachedCustomers() }); } catch (err) { res.status(502).json({ error: err.message }); } }); app.get('/api/customer_assets/:customerId', async (req, res) => { const user = req.session.user; if (user.role === 'client') { if (!user.syncro_customer_id) return res.status(403).json({ error: 'Forbidden.' }); if (Number(req.params.customerId) !== user.syncro_customer_id) return res.status(403).json({ error: 'Forbidden.' }); } try { const assets = await cachedAssets(req.params.customerId); res.json({ assets }); } catch (err) { res.status(502).json({ error: err.message }); } }); app.get('/api/contacts/:customerId', async (req, res) => { const user = req.session.user; if (user.role === 'client') { if (!user.syncro_customer_id) return res.status(403).json({ error: 'Forbidden.' }); if (Number(req.params.customerId) !== user.syncro_customer_id) return res.status(403).json({ error: 'Forbidden.' }); } try { const contacts = await cachedContacts(req.params.customerId); res.json({ contacts }); } catch (err) { res.status(502).json({ error: err.message }); } }); // Write-through: forward PUT to Syncro then invalidate that customer's asset cache app.put('/api/customer_assets/:id', async (req, res) => { if (req.session.user.role === 'client') return res.status(403).json({ error: 'Forbidden.' }); const { customer_id, ...assetBody } = req.body; try { const result = await syncroPut(`/customer_assets/${req.params.id}`, assetBody); if (customer_id) _cache.assets.delete(Number(customer_id)); res.json(result); } catch (err) { res.status(502).json({ error: err.message }); } }); // ── Label Center queue ──────────────────────────────────────────────────────── function getLabelQueue(userId) { return getDb() .prepare('SELECT * FROM label_queue WHERE user_id = ? ORDER BY added_at ASC') .all(userId); } app.get('/api/label-queue', (req, res) => { try { res.json({ queue: getLabelQueue(req.session.user.id) }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/api/label-queue', (req, res) => { const { asset_id, asset_name, asset_serial, customer_name, customer_phone, custom_line } = req.body; if (!asset_id || !asset_name || !customer_name) { return res.status(400).json({ error: 'asset_id, asset_name, and customer_name are required' }); } const uid = req.session.user.id; try { getDb() .prepare(`INSERT OR REPLACE INTO label_queue (user_id, asset_id, asset_name, asset_serial, customer_name, customer_phone, custom_line, sheet_position, added_at) VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT sheet_position FROM label_queue WHERE user_id = ? AND asset_id = ?), COALESCE( (SELECT added_at FROM label_queue WHERE user_id = ? AND asset_id = ?), datetime('now') ))`) .run(uid, asset_id, asset_name, asset_serial ?? null, customer_name, customer_phone ?? null, custom_line ?? null, uid, asset_id, uid, asset_id); res.json({ queue: getLabelQueue(uid) }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.patch('/api/label-queue/:assetId', (req, res) => { const assetId = parseInt(req.params.assetId, 10); if (isNaN(assetId)) return res.status(400).json({ error: 'Invalid assetId' }); const { sheet_position } = req.body; if (sheet_position !== null && (typeof sheet_position !== 'number' || !Number.isInteger(sheet_position) || sheet_position < 1)) { return res.status(400).json({ error: 'sheet_position must be a positive integer or null' }); } const uid = req.session.user.id; try { getDb() .prepare('UPDATE label_queue SET sheet_position = ? WHERE user_id = ? AND asset_id = ?') .run(sheet_position ?? null, uid, assetId); res.json({ queue: getLabelQueue(uid) }); } catch (err) { // UNIQUE constraint on (user_id, sheet_position) — position already taken if (err.message.includes('UNIQUE')) { return res.status(409).json({ error: 'That position is already occupied' }); } res.status(500).json({ error: err.message }); } }); app.put('/api/label-queue/:assetId', (req, res) => { const assetId = parseInt(req.params.assetId, 10); if (isNaN(assetId)) return res.status(400).json({ error: 'Invalid assetId' }); const { asset_name, asset_serial, customer_name, customer_phone, custom_line } = req.body; if (!asset_name || !customer_name) return res.status(400).json({ error: 'asset_name and customer_name are required' }); const uid = req.session.user.id; try { getDb() .prepare('UPDATE label_queue SET asset_name = ?, asset_serial = ?, customer_name = ?, customer_phone = ?, custom_line = ? WHERE user_id = ? AND asset_id = ?') .run(asset_name, asset_serial ?? null, customer_name, customer_phone ?? null, custom_line ?? null, uid, assetId); res.json({ queue: getLabelQueue(uid) }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.delete('/api/label-queue/:assetId', (req, res) => { const assetId = parseInt(req.params.assetId, 10); if (isNaN(assetId)) return res.status(400).json({ error: 'Invalid assetId' }); const uid = req.session.user.id; try { getDb() .prepare('DELETE FROM label_queue WHERE user_id = ? AND asset_id = ?') .run(uid, assetId); res.json({ queue: getLabelQueue(uid) }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.delete('/api/label-queue', (req, res) => { const uid = req.session.user.id; try { if (req.body?.reset_sheet_only) { getDb() .prepare('UPDATE label_queue SET sheet_position = NULL WHERE user_id = ?') .run(uid); } else { getDb() .prepare('DELETE FROM label_queue WHERE user_id = ?') .run(uid); } res.json({ queue: getLabelQueue(uid) }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Cache refresh with 30s global cooldown app.post('/api/cache/refresh', (req, res) => { if (req.session.user.role === 'client') return res.status(403).json({ error: 'Forbidden.' }); const now = Date.now(); if (now < _refreshCooldownUntil) { return res.status(429).json({ error: 'Refresh cooldown active', retryAfter: Math.ceil((_refreshCooldownUntil - now) / 1000), }); } _refreshCooldownUntil = now + 30_000; _cache.customers = { data: null, ts: 0 }; _cache.assets.clear(); _cache.contacts.clear(); res.json({ ok: true }); }); // ── Syncro single-asset proxy routes (ownership-checked, replaces /syncro-api/ bypass) ── // Must register /api/asset/search BEFORE /api/asset/:id or "search" matches the :id param. app.get('/api/asset/search', async (req, res) => { const user = req.session.user; try { const params = { per_page: req.query.per_page ?? 10 }; if (req.query.query) params.query = req.query.query; if (user.role === 'client') { if (!user.syncro_customer_id) return res.json({ assets: [], customer_assets: [] }); params.customer_id = user.syncro_customer_id; } res.json(await syncroGet('/customer_assets', params)); } catch (err) { res.status(502).json({ error: err.message }); } }); app.get('/api/asset/:id', async (req, res) => { try { const data = await syncroGet(`/customer_assets/${req.params.id}`); const asset = data.asset ?? data; const user = req.session.user; if (user.role === 'client') { if (!user.syncro_customer_id) return res.status(403).json({ error: 'Forbidden.' }); if (asset.customer_id !== user.syncro_customer_id) return res.status(403).json({ error: 'Forbidden.' }); } res.json(data); } catch (err) { const status = err.message.includes('404') ? 404 : 502; res.status(status).json({ error: err.message }); } }); // ── Static app files (auth-gated) ───────────────────────────────────────────── // JS and CSS: no-cache so browsers always revalidate (ETags mean no wasted // bandwidth when files haven't changed — just a fast 304). app.use((req, res, next) => { if (/\.(js|css)$/.test(req.path)) { res.setHeader('Cache-Control', 'no-cache'); } next(); }); app.use(express.static(pub)); // Catch-all: return index.html for any unmatched GET (SPA) app.get('*', (req, res) => res.sendFile(path.join(pub, 'index.html'))); // ── Start ───────────────────────────────────────────────────────────────────── app.listen(PORT, '127.0.0.1', () => { console.log(`Asset Browser listening on port ${PORT}`); });