asset_browser/server.js
setonc a558804026 Initial commit — asset browser web app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:06:25 -04:00

476 lines
20 KiB
JavaScript
Executable file

'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}`);
});