/** * Express app factory (useful for tests). */ import express from 'express'; import cors from 'cors'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { readFile } from 'fs/promises'; import { apiRouter } from './api/index.js'; import { getDataDir } from './config/dataDir.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); function createBasicAuthGuard() { function getAuthConfig() { const user = process.env.BASIC_AUTH_USER || ''; const pass = process.env.BASIC_AUTH_PASSWORD || ''; return { user, pass, enabled: user.length > 0 && pass.length > 0, }; } function isAuthorized(req: express.Request): boolean { const { user: authUser, pass: authPass, enabled } = getAuthConfig(); if (!enabled) return false; const authHeader = req.headers.authorization || ''; if (!authHeader.startsWith('Basic ')) return false; const encoded = authHeader.slice('Basic '.length).trim(); let decoded = ''; try { decoded = Buffer.from(encoded, 'base64').toString('utf-8'); } catch { return false; } const separatorIndex = decoded.indexOf(':'); if (separatorIndex === -1) return false; const user = decoded.slice(0, separatorIndex); const pass = decoded.slice(separatorIndex + 1); return user === authUser && pass === authPass; } function isPublicReadOnlyRoute(method: string, path: string): boolean { const normalizedMethod = method.toUpperCase(); const normalizedPath = path.split('?')[0] || path; if (normalizedMethod === 'POST' && normalizedPath === '/api/visa-sponsors/search') return true; return false; } function requiresAuth(method: string, path: string): boolean { if (isPublicReadOnlyRoute(method, path)) return false; return !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()); } const middleware = (req: express.Request, res: express.Response, next: express.NextFunction) => { const { enabled } = getAuthConfig(); if (!enabled || !requiresAuth(req.method, req.path)) return next(); if (isAuthorized(req)) return next(); res.setHeader('WWW-Authenticate', 'Basic realm="Job Ops"'); res.status(401).send('Authentication required'); }; return { middleware, isAuthorized, basicAuthEnabled: getAuthConfig().enabled, }; } export function createApp() { const app = express(); const authGuard = createBasicAuthGuard(); app.use(cors()); app.use(express.json({ limit: '5mb' })); // Logging middleware app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { const duration = Date.now() - start; console.log(`${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`); }); next(); }); // Optional Basic Auth for write access (read-only by default) app.use(authGuard.middleware); // API routes app.use('/api', apiRouter); // Serve static files for generated PDFs const pdfDir = join(getDataDir(), 'pdfs'); app.use('/pdfs', express.static(pdfDir)); // Health check app.get('/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Serve client app in production if (process.env.NODE_ENV === 'production') { const clientDir = join(__dirname, '../../dist/client'); app.use(express.static(clientDir)); // SPA fallback const indexPath = join(clientDir, 'index.html'); let cachedIndexHtml: string | null = null; app.get('*', async (req, res) => { if (!req.accepts('html')) { res.status(404).end(); return; } if (!cachedIndexHtml) { cachedIndexHtml = await readFile(indexPath, 'utf-8'); } res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(cachedIndexHtml); }); } return app; }