167 lines
6.3 KiB
JavaScript
167 lines
6.3 KiB
JavaScript
// Generated, do not edit. sha256:29c149da1bd119d466012f8c80b13c9f6ba6ac924e6326dd2b0629b05ed6ce6e
|
|||
|
|
import { readFile } from 'node:fs/promises';
|
||
|
|
import { join, resolve, normalize, extname, dirname, sep } from 'node:path';
|
||
|
|
import { fileURLToPath } from 'node:url';
|
||
|
|
import { handleApi, readJsonBody } from './api.mjs';
|
||
|
|
import {
|
||
|
|
clearedSessionCookie, passwordMatches, sendUnauthorized, sessionCookie, sessionTokenOf,
|
||
|
|
} from './auth.mjs';
|
||
|
|
import { createAttemptLimiter, createSessionStore } from './sessions.mjs';
|
||
|
|
import { CONFIG } from '../shared/config.js';
|
||
|
|
import { connectSrcOrigins } from '../shared/csp.js';
|
||
|
|
import { listConnections } from './data/connections.js';
|
||
|
|
import { errorPayload } from '../shared/app-error.js';
|
||
|
|
|
||
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
|
export const CLIENT = join(ROOT, 'client');
|
||
|
|
const SHARED = join(ROOT, 'shared');
|
||
|
|
|
||
|
|
const MIME = {
|
||
|
|
'.html': 'text/html; charset=utf-8',
|
||
|
|
'.js': 'text/javascript; charset=utf-8',
|
||
|
|
'.mjs': 'text/javascript; charset=utf-8',
|
||
|
|
'.css': 'text/css; charset=utf-8',
|
||
|
|
'.json': 'application/json; charset=utf-8',
|
||
|
|
'.wasm': 'application/wasm',
|
||
|
|
'.svg': 'image/svg+xml',
|
||
|
|
'.png': 'image/png',
|
||
|
|
'.jpg': 'image/jpeg',
|
||
|
|
'.webp': 'image/webp',
|
||
|
|
'.ico': 'image/x-icon',
|
||
|
|
'.woff2': 'font/woff2',
|
||
|
|
'.woff': 'font/woff',
|
||
|
|
'.ttf': 'font/ttf',
|
||
|
|
};
|
||
|
|
|
||
|
|
function explorerUrls() {
|
||
|
|
try { return listConnections('btc_explorer').map(c => c.url); } catch { return []; }
|
||
|
|
}
|
||
|
|
const directive = (naam, ...bronnen) => [naam, ...bronnen.flat().filter(Boolean)].join(' ');
|
||
|
|
const csp = () => [
|
||
|
|
directive('default-src', `'self'`),
|
||
|
|
directive('script-src', `'self'`),
|
||
|
|
directive('style-src', `'self'`, `'unsafe-inline'`),
|
||
|
|
directive('style-src-elem', `'self'`),
|
||
|
|
directive('style-src-attr', `'unsafe-inline'`),
|
||
|
|
directive('font-src', `'self'`),
|
||
|
|
directive('img-src', `'self'`, 'data:'),
|
||
|
|
directive('connect-src', `'self'`, connectSrcOrigins(explorerUrls())),
|
||
|
|
directive('object-src', `'none'`),
|
||
|
|
directive('base-uri', `'self'`),
|
||
|
|
directive('frame-ancestors', `'none'`),
|
||
|
|
].join('; ');
|
||
|
|
|
||
|
|
const HTML_CACHE_CONTROL = 'no-store';
|
||
|
|
const STATIC_CACHE_CONTROL = 'no-cache';
|
||
|
|
|
||
|
|
function resolveStatic(urlPath) {
|
||
|
|
let rel;
|
||
|
|
try { rel = decodeURIComponent(urlPath.split('?')[0]); }
|
||
|
|
catch { return null; }
|
||
|
|
if (rel === '/' || rel === '/index.html') return join(CLIENT, 'index.html');
|
||
|
|
if (rel === CONFIG.AUTH.LOGIN_PATH) return join(CLIENT, 'login.html');
|
||
|
|
const onder = (basis, pad) => {
|
||
|
|
const abs = normalize(join(basis, pad));
|
||
|
|
return abs.startsWith(basis + sep) ? abs : null;
|
||
|
|
};
|
||
|
|
if (rel.startsWith('/shared/')) return onder(SHARED, rel.slice('/shared'.length));
|
||
|
|
return onder(CLIENT, rel);
|
||
|
|
}
|
||
|
|
|
||
|
|
function send(res, status, body, headers = {}) {
|
||
|
|
res.writeHead(status, { 'X-Content-Type-Options': 'nosniff', ...headers });
|
||
|
|
res.end(body);
|
||
|
|
}
|
||
|
|
function sendJson(res, status, obj, extra = {}) {
|
||
|
|
send(res, status, JSON.stringify(obj), { 'Content-Type': 'application/json; charset=utf-8', ...extra });
|
||
|
|
}
|
||
|
|
|
||
|
|
const LOGIN_ASSETS = new Set([
|
||
|
|
'/login.js',
|
||
|
|
'/css/styles.css',
|
||
|
|
'/img/favicon.svg',
|
||
|
|
'/vendor/inter/inter.css',
|
||
|
|
]);
|
||
|
|
const LOGIN_ASSET_PREFIX = '/vendor/inter/files/';
|
||
|
|
|
||
|
|
const magZonderSessie = (pad) =>
|
||
|
|
pad === CONFIG.AUTH.LOGIN_PATH || LOGIN_ASSETS.has(pad) || pad.startsWith(LOGIN_ASSET_PREFIX);
|
||
|
|
|
||
|
|
export function createRequestListener({ credentials = null, secure = false } = {}) {
|
||
|
|
const sessies = createSessionStore();
|
||
|
|
const limiet = createAttemptLimiter();
|
||
|
|
|
||
|
|
async function inloggen(req, res) {
|
||
|
|
const adres = req.socket?.remoteAddress || 'unknown';
|
||
|
|
const wacht = limiet.retryAfterMs(adres);
|
||
|
|
if (wacht > 0) {
|
||
|
|
return sendJson(res, 429, { error: 'Too many attempts.', retryAfterMs: wacht },
|
||
|
|
{ 'Retry-After': String(Math.ceil(wacht / 1000)) });
|
||
|
|
}
|
||
|
|
let body;
|
||
|
|
try { body = await readJsonBody(req); }
|
||
|
|
catch (e) { return sendJson(res, 400, errorPayload(e)); }
|
||
|
|
|
||
|
|
if (!passwordMatches(body?.password, credentials)) {
|
||
|
|
return sendJson(res, 401, { error: 'Wrong password.', retryAfterMs: limiet.fail(adres) });
|
||
|
|
}
|
||
|
|
limiet.succeed(adres);
|
||
|
|
return sendJson(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie(sessies.create(), { secure }) });
|
||
|
|
}
|
||
|
|
|
||
|
|
function uitloggen(req, res) {
|
||
|
|
const token = sessionTokenOf(req);
|
||
|
|
if (token) sessies.drop(token);
|
||
|
|
return sendJson(res, 200, { ok: true }, { 'Set-Cookie': clearedSessionCookie({ secure }) });
|
||
|
|
}
|
||
|
|
|
||
|
|
return async (req, res) => {
|
||
|
|
try {
|
||
|
|
const url = req.url || '/';
|
||
|
|
const pad = url.split('?')[0];
|
||
|
|
|
||
|
|
if (credentials) {
|
||
|
|
if (pad === '/api/login') {
|
||
|
|
if (req.method !== 'POST') return sendJson(res, 405, { error: 'Method not allowed' });
|
||
|
|
return await inloggen(req, res);
|
||
|
|
}
|
||
|
|
if (pad === '/api/logout') {
|
||
|
|
if (req.method !== 'POST') return sendJson(res, 405, { error: 'Method not allowed' });
|
||
|
|
return uitloggen(req, res);
|
||
|
|
}
|
||
|
|
|
||
|
|
const ingelogd = sessies.isValid(sessionTokenOf(req));
|
||
|
|
if (ingelogd && pad === CONFIG.AUTH.LOGIN_PATH) {
|
||
|
|
return send(res, 302, '', { Location: '/', 'Cache-Control': 'no-store' });
|
||
|
|
}
|
||
|
|
if (!ingelogd && !magZonderSessie(pad)) {
|
||
|
|
return sendUnauthorized(res, { document: !pad.startsWith('/api/') });
|
||
|
|
}
|
||
|
|
} else if (pad === CONFIG.AUTH.LOGIN_PATH) {
|
||
|
|
return send(res, 302, '', { Location: '/', 'Cache-Control': 'no-store' });
|
||
|
|
}
|
||
|
|
|
||
|
|
if (url.startsWith('/api/')) {
|
||
|
|
const handled = await handleApi(req, res, url);
|
||
|
|
if (!handled) sendJson(res, 404, { error: 'Unknown endpoint' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||
|
|
return sendJson(res, 405, { error: 'Method not allowed' });
|
||
|
|
}
|
||
|
|
const file = resolveStatic(url);
|
||
|
|
if (!file) return send(res, 404, 'Not found');
|
||
|
|
|
||
|
|
const data = await readFile(file);
|
||
|
|
const ext = extname(file).toLowerCase();
|
||
|
|
const type = MIME[ext] || 'application/octet-stream';
|
||
|
|
const cacheControl = ext === '.html' ? HTML_CACHE_CONTROL : STATIC_CACHE_CONTROL;
|
||
|
|
return send(res, 200, req.method === 'HEAD' ? '' : data, { 'Content-Type': type, 'Content-Security-Policy': csp(), 'Cache-Control': cacheControl });
|
||
|
|
} catch (err) {
|
||
|
|
if (err && err.code === 'ENOENT') return send(res, 404, 'Not found');
|
||
|
|
console.error('[assetapp] requestfout:', err);
|
||
|
|
return send(res, 500, 'Interne fout');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|