changemaker.lite/api/dist/services/listmonk-proxy.service.js

155 lines
6.2 KiB
JavaScript

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.startProxy = startProxy;
exports.stopProxy = stopProxy;
const node_http_1 = __importDefault(require("node:http"));
const node_crypto_1 = __importDefault(require("node:crypto"));
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const client_1 = require("@prisma/client");
const env_1 = require("../config/env");
const logger_1 = require("../utils/logger");
// HMAC secret — regenerated each server start (cookie sessions reset on restart)
const HMAC_SECRET = node_crypto_1.default.randomBytes(32);
const COOKIE_NAME = '_lmp_auth';
const COOKIE_MAX_AGE_S = 4 * 60 * 60; // 4 hours
// Extract Listmonk internal host/port from LISTMONK_URL (e.g. "http://listmonk-app:9000")
function parseListmonkTarget() {
const url = new URL(env_1.env.LISTMONK_URL);
return { hostname: url.hostname, port: parseInt(url.port || '9000', 10) };
}
// Basic auth header for Listmonk API user
function listmonkAuthHeader() {
const creds = Buffer.from(`${env_1.env.LISTMONK_ADMIN_USER}:${env_1.env.LISTMONK_ADMIN_PASSWORD}`).toString('base64');
return `Basic ${creds}`;
}
function signCookie(payload) {
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = node_crypto_1.default.createHmac('sha256', HMAC_SECRET).update(data).digest('base64url');
return `${data}.${sig}`;
}
function verifyCookie(cookie) {
const dot = cookie.indexOf('.');
if (dot === -1)
return null;
const data = cookie.slice(0, dot);
const sig = cookie.slice(dot + 1);
const expected = node_crypto_1.default.createHmac('sha256', HMAC_SECRET).update(data).digest('base64url');
if (!node_crypto_1.default.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
return null;
try {
const payload = JSON.parse(Buffer.from(data, 'base64url').toString());
if (payload.exp < Date.now() / 1000)
return null;
return payload;
}
catch {
return null;
}
}
function parseCookies(header) {
const cookies = {};
if (!header)
return cookies;
for (const pair of header.split(';')) {
const eq = pair.indexOf('=');
if (eq === -1)
continue;
const key = pair.slice(0, eq).trim();
const val = pair.slice(eq + 1).trim();
cookies[key] = val;
}
return cookies;
}
let server = null;
function startProxy() {
const target = parseListmonkTarget();
server = node_http_1.default.createServer((req, res) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
// --- /auth?token=<jwt> — exchange JWT for proxy cookie ---
if (url.pathname === '/auth') {
const token = url.searchParams.get('token');
if (!token) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Missing token parameter');
return;
}
try {
const payload = jsonwebtoken_1.default.verify(token, env_1.env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] });
const payloadRoles = payload.roles || [payload.role];
if (!payloadRoles.includes(client_1.UserRole.SUPER_ADMIN)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden — SUPER_ADMIN role required');
return;
}
const cookieVal = signCookie({
uid: payload.id,
exp: Math.floor(Date.now() / 1000) + COOKIE_MAX_AGE_S,
});
res.writeHead(302, {
Location: '/admin/',
'Set-Cookie': `${COOKIE_NAME}=${cookieVal}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE_S}`,
});
res.end();
}
catch {
res.writeHead(401, { 'Content-Type': 'text/plain' });
res.end('Invalid or expired token');
}
return;
}
// --- All other routes: verify cookie, proxy to Listmonk ---
const cookies = parseCookies(req.headers.cookie);
const cookieVal = cookies[COOKIE_NAME];
if (!cookieVal || !verifyCookie(cookieVal)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden — authenticate at /auth?token=<jwt> first');
return;
}
// Build proxy request headers (copy originals, inject auth, remove cookie)
const proxyHeaders = { ...req.headers };
delete proxyHeaders.host;
delete proxyHeaders.cookie;
proxyHeaders.authorization = listmonkAuthHeader();
const proxyReq = node_http_1.default.request({
hostname: target.hostname,
port: target.port,
path: req.url,
method: req.method,
headers: proxyHeaders,
}, (proxyRes) => {
// Strip frame-busting headers from Listmonk responses
const responseHeaders = { ...proxyRes.headers };
delete responseHeaders['x-frame-options'];
delete responseHeaders['content-security-policy'];
res.writeHead(proxyRes.statusCode || 502, responseHeaders);
proxyRes.pipe(res);
});
proxyReq.on('error', (err) => {
logger_1.logger.error('Listmonk proxy error:', err);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
}
res.end('Listmonk is unreachable');
});
// Pipe request body for POST/PUT/PATCH
req.pipe(proxyReq);
});
server.listen(env_1.env.LISTMONK_PROXY_PORT, () => {
logger_1.logger.info(`Listmonk proxy listening on port ${env_1.env.LISTMONK_PROXY_PORT}`);
});
return server;
}
function stopProxy() {
return new Promise((resolve) => {
if (server) {
server.close(() => resolve());
}
else {
resolve();
}
});
}
//# sourceMappingURL=listmonk-proxy.service.js.map