- 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>
138 lines
5.2 KiB
JavaScript
138 lines
5.2 KiB
JavaScript
const https = require('https');
|
|
const { requireAuth, jsonError, getClientIp } = require('./auth-middleware');
|
|
|
|
const ONE2EDIT_API = 'https://oliver.one2edit.com/v3/Api.php';
|
|
const SERVICE_USERNAME = () => process.env.SERVICE_USERNAME;
|
|
const SERVICE_PASSWORD = () => process.env.SERVICE_PASSWORD;
|
|
const UPSTREAM_MS = parseInt(process.env.UPSTREAM_TIMEOUT_MS, 10) || 30000;
|
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
|
|
const CORS_HEADERS = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
};
|
|
|
|
function injectCredentials(params, userExternSessionId) {
|
|
params.delete('authUsername');
|
|
params.delete('authPassword');
|
|
|
|
if (params.has('sessionId')) {
|
|
// Anti-hijack: the sessionId must be the one we issued to this user
|
|
if (userExternSessionId && params.get('sessionId') !== userExternSessionId) {
|
|
return false;
|
|
}
|
|
// sessionId present — One2Edit accepts it directly, no service-account creds needed
|
|
return true;
|
|
}
|
|
if (!params.has('authDomain')) params.set('authDomain', 'local');
|
|
params.set('authUsername', SERVICE_USERNAME());
|
|
params.set('authPassword', SERVICE_PASSWORD());
|
|
return true;
|
|
}
|
|
|
|
function logParams(method, params) {
|
|
const p = new URLSearchParams(params);
|
|
p.set('authPassword', '***');
|
|
if (IS_PROD) {
|
|
if (p.has('authUsername')) p.set('authUsername', '***');
|
|
if (p.has('username')) p.set('username', '***');
|
|
}
|
|
console.log(`Proxy ${method}:`, p.toString().substring(0, 200));
|
|
}
|
|
|
|
function handleApiResponse(res) {
|
|
return (apiRes) => {
|
|
if (apiRes.statusCode === 301 || apiRes.statusCode === 302) {
|
|
res.writeHead(401, { 'Content-Type': 'text/xml;charset=UTF-8', ...CORS_HEADERS });
|
|
res.end('<?xml version="1.0" encoding="UTF-8"?><error><message>Authentication failed</message></error>');
|
|
return;
|
|
}
|
|
const chunks = [];
|
|
apiRes.on('data', c => chunks.push(c));
|
|
apiRes.on('end', () => {
|
|
const ct = apiRes.headers['content-type'] || 'text/xml;charset=UTF-8';
|
|
res.writeHead(apiRes.statusCode, { 'Content-Type': ct, ...CORS_HEADERS });
|
|
res.end(Buffer.concat(chunks));
|
|
});
|
|
};
|
|
}
|
|
|
|
function handleProxyError(res) {
|
|
return (err) => {
|
|
console.error('Proxy error:', err.message);
|
|
if (res.headersSent) return;
|
|
res.writeHead(502, { 'Content-Type': 'text/plain', ...CORS_HEADERS });
|
|
res.end('Error connecting to API');
|
|
};
|
|
}
|
|
|
|
function attachTimeout(apiReq, res) {
|
|
apiReq.setTimeout(UPSTREAM_MS, () => {
|
|
apiReq.destroy(new Error('Upstream timeout'));
|
|
if (res.headersSent) return;
|
|
res.writeHead(504, { 'Content-Type': 'text/plain', ...CORS_HEADERS });
|
|
res.end('Upstream request timed out');
|
|
});
|
|
}
|
|
|
|
function proxyGet(queryString, res, userExternSessionId) {
|
|
const params = new URLSearchParams(queryString);
|
|
if (!injectCredentials(params, userExternSessionId)) {
|
|
res.writeHead(403, { 'Content-Type': 'text/xml;charset=UTF-8', ...CORS_HEADERS });
|
|
res.end('<?xml version="1.0" encoding="UTF-8"?><error><message>Session mismatch</message></error>');
|
|
return;
|
|
}
|
|
logParams('GET', params);
|
|
const apiReq = https.get(`${ONE2EDIT_API}?${params}`, handleApiResponse(res));
|
|
apiReq.on('error', handleProxyError(res));
|
|
attachTimeout(apiReq, res);
|
|
}
|
|
|
|
function proxyPost(body, res, userExternSessionId) {
|
|
const params = new URLSearchParams(body);
|
|
if (!injectCredentials(params, userExternSessionId)) {
|
|
res.writeHead(403, { 'Content-Type': 'text/xml;charset=UTF-8', ...CORS_HEADERS });
|
|
res.end('<?xml version="1.0" encoding="UTF-8"?><error><message>Session mismatch</message></error>');
|
|
return;
|
|
}
|
|
logParams('POST', params);
|
|
const newBody = params.toString();
|
|
const apiUrl = new URL(ONE2EDIT_API);
|
|
const options = {
|
|
hostname: apiUrl.hostname,
|
|
path: apiUrl.pathname,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(newBody) },
|
|
};
|
|
const apiReq = https.request(options, handleApiResponse(res));
|
|
apiReq.on('error', handleProxyError(res));
|
|
attachTimeout(apiReq, res);
|
|
apiReq.write(newBody);
|
|
apiReq.end();
|
|
}
|
|
|
|
function handleProxy(req, res, parsedUrl) {
|
|
// Require authenticated portal session
|
|
if (!requireAuth(req, res)) return;
|
|
|
|
// Reject limited sessions (must change password)
|
|
if (req.user.must_change_password) {
|
|
const xmlErr = '<?xml version="1.0" encoding="UTF-8"?><error><message>Password change required</message></error>';
|
|
res.writeHead(403, { 'Content-Type': 'text/xml;charset=UTF-8', ...CORS_HEADERS });
|
|
res.end(xmlErr);
|
|
return;
|
|
}
|
|
|
|
const userExternSessionId = req.session.one2edit_extern_session_id;
|
|
|
|
if (req.method === 'POST') {
|
|
const chunks = [];
|
|
req.on('data', c => chunks.push(c));
|
|
req.on('end', () => proxyPost(Buffer.concat(chunks).toString(), res, userExternSessionId));
|
|
} else {
|
|
proxyGet(parsedUrl.search.slice(1), res, userExternSessionId);
|
|
}
|
|
}
|
|
|
|
module.exports = { handleProxy, CORS_HEADERS };
|