476 lines
20 KiB
JavaScript
Executable file
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}`);
|
|
});
|