3m-portal/server.js
Vadym Samoilenko 53a85c788d Add full auth system: SQLite sessions, email invites, admin console
- 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>
2026-05-05 11:26:40 +01:00

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