- Real email/password login backed by SQLite (better-sqlite3) - HttpOnly cookie sessions with 8h sliding TTL - Admin role: invite users via Mailgun magic-link, manage roles/status - Per-user One2Edit username mapping for job filtering - Self-service forgot-password / reset-password via email - Admin console (admin.html) with user table, invite modal, row actions - New pages: change-password, forgot-password, reset-password, accept-invite - Gated /api proxy: requires valid session, anti-hijack sessionId check - Bootstrap initial admins from INITIAL_ADMINS env var on first boot - Remove Oliver login button, SSO buttons, and legacy api.js/login.js - deploy.sh: add build-essential (for native module), npm install, data dir Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
114 lines
4.1 KiB
JavaScript
114 lines
4.1 KiB
JavaScript
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// ── Env ──────────────────────────────────────────────────────
|
|
try {
|
|
fs.readFileSync(path.join(__dirname, '.env'), 'utf8').split('\n').forEach(line => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) return;
|
|
const eq = trimmed.indexOf('=');
|
|
if (eq === -1) return;
|
|
const key = trimmed.slice(0, eq).trim();
|
|
const val = trimmed.slice(eq + 1).trim();
|
|
if (key && !(key in process.env)) process.env[key] = val;
|
|
});
|
|
} catch (_) {}
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
if (!process.env.SERVICE_USERNAME || !process.env.SERVICE_PASSWORD) {
|
|
console.error('ERROR: SERVICE_USERNAME and SERVICE_PASSWORD are required in .env');
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Bootstrap DB + sessions ──────────────────────────────────
|
|
const { bootstrapInitialAdmin } = require('./lib/db');
|
|
const session = require('./lib/session');
|
|
const tokens = require('./lib/tokens');
|
|
bootstrapInitialAdmin();
|
|
|
|
// Cleanup expired sessions and tokens every hour
|
|
setInterval(() => { session.cleanup(); tokens.cleanup(); }, 60 * 60 * 1000).unref();
|
|
|
|
// ── Router ────────────────────────────────────────────────────
|
|
const { createRouter } = require('./lib/router');
|
|
const { handleProxy, CORS_HEADERS } = require('./lib/proxy');
|
|
|
|
const routes = [
|
|
...require('./lib/routes/auth'),
|
|
...require('./lib/routes/admin'),
|
|
];
|
|
|
|
// Wrap route handlers to pass parsedUrl (needed for query-string GET endpoints)
|
|
const routesWithUrl = routes.map(r => ({
|
|
...r,
|
|
handler: (req, res, params) => r.handler(req, res, params, req._parsedUrl),
|
|
}));
|
|
|
|
const dispatch = createRouter(routesWithUrl);
|
|
|
|
// ── Static files ──────────────────────────────────────────────
|
|
const MIME_TYPES = {
|
|
'.html': 'text/html',
|
|
'.js': 'text/javascript',
|
|
'.css': 'text/css',
|
|
'.json': 'application/json',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
};
|
|
|
|
function serveStaticFile(filePath, res) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
fs.readFile(filePath, (err, content) => {
|
|
if (err) {
|
|
const status = err.code === 'ENOENT' ? 404 : 500;
|
|
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
|
res.end(err.code === 'ENOENT' ? '404 Not Found' : '500 Internal Server Error');
|
|
} else {
|
|
res.writeHead(200, { 'Content-Type': contentType });
|
|
res.end(content);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── HTTP server ────────────────────────────────────────────────
|
|
const server = http.createServer((req, res) => {
|
|
const parsedUrl = new URL(req.url, `http://localhost:${PORT}`);
|
|
const { pathname } = parsedUrl;
|
|
|
|
req._parsedUrl = parsedUrl;
|
|
|
|
// CORS preflight
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204, CORS_HEADERS);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// One2Edit API proxy (requires auth — enforced inside handleProxy)
|
|
if (pathname === '/api') {
|
|
handleProxy(req, res, parsedUrl);
|
|
return;
|
|
}
|
|
|
|
// JSON API routes
|
|
if (pathname.startsWith('/api/')) {
|
|
if (!dispatch(req.method, pathname, req, res)) {
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Static files
|
|
const filePath = pathname === '/' ? './login.html' : '.' + pathname;
|
|
serveStaticFile(filePath, res);
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`Server running at http://localhost:${PORT}`);
|
|
});
|