183 lines
7.3 KiB
JavaScript
183 lines
7.3 KiB
JavaScript
import { config as loadEnv } from 'dotenv';
|
|
import express from 'express';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { corsMiddleware, corsErrorHandler } from './middleware/cors.js';
|
|
import { initPool } from './utils/db.js';
|
|
|
|
import { authenticate } from '../shared/auth/middleware.js';
|
|
import { requestLog } from '../shared/logging/request-log.js';
|
|
import { logger } from '../shared/logging/logger.js';
|
|
import { errorHandler } from '../shared/errors/handler.js';
|
|
|
|
import productsRouter from './routes/products.js';
|
|
import dashboardRouter from './routes/dashboard.js';
|
|
import ordersRouter from './routes/orders.js';
|
|
import csvRouter from './routes/data-management.js';
|
|
import analyticsRouter from './routes/analytics.js';
|
|
import purchaseOrdersRouter from './routes/purchase-orders.js';
|
|
import configRouter from './routes/config.js';
|
|
import metricsRouter from './routes/metrics.js';
|
|
import importRouter from './routes/import.js';
|
|
import aiValidationRouter from './routes/ai-validation.js';
|
|
import aiRouter, { initInBackground as initAiInBackground } from './routes/ai.js';
|
|
import templatesRouter from './routes/templates.js';
|
|
import aiPromptsRouter from './routes/ai-prompts.js';
|
|
import reusableImagesRouter from './routes/reusable-images.js';
|
|
import categoriesAggregateRouter from './routes/categoriesAggregate.js';
|
|
import vendorsAggregateRouter from './routes/vendorsAggregate.js';
|
|
import brandsAggregateRouter from './routes/brandsAggregate.js';
|
|
import htsLookupRouter from './routes/hts-lookup.js';
|
|
import specLookupRouter from './routes/spec-lookup.js';
|
|
import importSessionsRouter from './routes/import-sessions.js';
|
|
import importAuditLogRouter from './routes/import-audit-log.js';
|
|
import productEditorAuditLogRouter from './routes/product-editor-audit-log.js';
|
|
import newsletterRouter from './routes/newsletter.js';
|
|
import linesAggregateRouter from './routes/linesAggregate.js';
|
|
import repeatOrdersRouter from './routes/repeat-orders.js';
|
|
import apiv2BridgeRouter from './routes/apiv2-bridge.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const envPath = '/var/www/inventory/.env';
|
|
loadEnv({ path: envPath });
|
|
logger.info({
|
|
envPath,
|
|
envExists: fs.existsSync(envPath),
|
|
NODE_ENV: process.env.NODE_ENV || 'not set',
|
|
PORT: process.env.PORT || 'not set',
|
|
DB_HOST: process.env.DB_HOST || 'not set',
|
|
DB_NAME: process.env.DB_NAME || 'not set',
|
|
DB_PASSWORD: process.env.DB_PASSWORD ? '[set]' : 'not set',
|
|
DB_SSL: process.env.DB_SSL || 'not set',
|
|
}, 'inventory-server starting');
|
|
|
|
if (!process.env.JWT_SECRET) {
|
|
logger.error('JWT_SECRET is not set; refusing to start (per Phase 6.4)');
|
|
process.exit(1);
|
|
}
|
|
|
|
const serverRoot = path.resolve(__dirname, '..');
|
|
const configuredUploadsDir = process.env.UPLOADS_DIR;
|
|
const uploadsDir = configuredUploadsDir
|
|
? (path.isAbsolute(configuredUploadsDir)
|
|
? configuredUploadsDir
|
|
: path.resolve(serverRoot, configuredUploadsDir))
|
|
: path.resolve(serverRoot, 'uploads');
|
|
|
|
process.env.UPLOADS_DIR = uploadsDir;
|
|
|
|
const requiredDirs = [path.resolve(serverRoot, 'logs'), uploadsDir];
|
|
requiredDirs.forEach((dir) => {
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
const app = express();
|
|
|
|
// Trust X-Forwarded-* only when the immediate hop is loopback (Caddy on the same
|
|
// host). Anything stricter would leave req.ip as 127.0.0.1; anything looser would
|
|
// let arbitrary clients spoof their source IP via X-Forwarded-For. Required for
|
|
// the KIOSK_IPS bypass in shared/auth/middleware.js to match real client IPs.
|
|
app.set('trust proxy', 'loopback');
|
|
|
|
// Phase 6.5/6.9: structured access log (replaces the previous header-dumping debug
|
|
// middleware that wrote raw Authorization values to stdout). Pino redaction strips
|
|
// `authorization` and `cookie` automatically — see shared/logging/logger.js.
|
|
app.use(requestLog());
|
|
|
|
app.use(corsMiddleware);
|
|
|
|
app.use(express.json({ limit: '10mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
|
|
async function startServer() {
|
|
try {
|
|
const pool = await initPool({
|
|
host: process.env.DB_HOST,
|
|
user: process.env.DB_USER,
|
|
password: process.env.DB_PASSWORD,
|
|
database: process.env.DB_NAME,
|
|
port: process.env.DB_PORT || 5432,
|
|
max: process.env.NODE_ENV === 'production' ? 20 : 10,
|
|
idleTimeoutMillis: 30_000,
|
|
connectionTimeoutMillis: 2_000,
|
|
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
|
});
|
|
|
|
app.locals.pool = pool;
|
|
|
|
// Phase 6.1/6.2: every /api request requires a valid JWT. Defense in depth — Caddy
|
|
// forward_auth (when enabled) is the first reject; this is the second. Frontend
|
|
// service files MUST include `Authorization: Bearer <token>` on every fetch.
|
|
app.use('/api', authenticate({ pool, secret: process.env.JWT_SECRET }));
|
|
|
|
app.use('/api/products', productsRouter);
|
|
app.use('/api/dashboard', dashboardRouter);
|
|
app.use('/api/orders', ordersRouter);
|
|
app.use('/api/csv', csvRouter);
|
|
app.use('/api/analytics', analyticsRouter);
|
|
app.use('/api/purchase-orders', purchaseOrdersRouter);
|
|
app.use('/api/config', configRouter);
|
|
app.use('/api/metrics', metricsRouter);
|
|
app.use('/api/vendors', vendorsAggregateRouter);
|
|
app.use('/api/categories', categoriesAggregateRouter);
|
|
app.use('/api/categories-aggregate', categoriesAggregateRouter);
|
|
app.use('/api/vendors-aggregate', vendorsAggregateRouter);
|
|
app.use('/api/brands-aggregate', brandsAggregateRouter);
|
|
app.use('/api/import', importRouter);
|
|
app.use('/api/ai-validation', aiValidationRouter);
|
|
app.use('/api/ai', aiRouter);
|
|
app.use('/api/templates', templatesRouter);
|
|
app.use('/api/ai-prompts', aiPromptsRouter);
|
|
app.use('/api/reusable-images', reusableImagesRouter);
|
|
app.use('/api/hts-lookup', htsLookupRouter);
|
|
app.use('/api/spec-lookup', specLookupRouter);
|
|
app.use('/api/import-sessions', importSessionsRouter);
|
|
app.use('/api/import-audit-log', importAuditLogRouter);
|
|
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
|
|
app.use('/api/newsletter', newsletterRouter);
|
|
app.use('/api/lines-aggregate', linesAggregateRouter);
|
|
app.use('/api/repeat-orders', repeatOrdersRouter);
|
|
// Side-service: lets external apps (import / product-edit skills) post to the
|
|
// legacy PHP /apiv2 API without a browser cookie. Isolated, additive router;
|
|
// inherits the same user-JWT auth applied to /api above. See apiv2-bridge.js.
|
|
app.use('/api/apiv2-bridge', apiv2BridgeRouter);
|
|
|
|
app.get('/health', (req, res) => {
|
|
res.json({
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
environment: process.env.NODE_ENV,
|
|
});
|
|
});
|
|
|
|
app.use(corsErrorHandler);
|
|
app.use(errorHandler);
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
app.listen(PORT, () => {
|
|
logger.info({ port: PORT, mode: process.env.NODE_ENV || 'development' }, 'inventory-server listening');
|
|
initAiInBackground();
|
|
});
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'Failed to start server');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
process.on('uncaughtException', (err) => {
|
|
logger.error({ err }, 'Uncaught Exception');
|
|
process.exit(1);
|
|
});
|
|
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
logger.error({ reason, promise }, 'Unhandled Rejection');
|
|
});
|
|
|
|
startServer();
|