/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see .
*/
import http from 'node:http';
import https from 'node:https';
import log from 'electron-log';
import {BUILD_CHANNEL} from '../common/build-channel.js';
import {CANARY_APP_URL, STABLE_APP_URL} from '../common/constants.js';
export const API_PROXY_PORT = BUILD_CHANNEL === 'canary' ? 21862 : 21861;
const PROXY_PATH = '/proxy';
const PROXY_INITIATOR_HEADER = 'x-fluxer-proxy-initiator';
const ALLOWED_ORIGINS = [STABLE_APP_URL, CANARY_APP_URL];
const isAllowedOrigin = (origin?: string): boolean => {
if (!origin) return false;
return ALLOWED_ORIGINS.includes(origin);
};
const refererMatchesAllowedOrigin = (referer?: string): boolean => {
if (!referer) return false;
return ALLOWED_ORIGINS.some((allowed) => referer.startsWith(allowed));
};
const parseOrigin = (value?: string): string | null => {
if (!value) {
return null;
}
try {
return new URL(value).origin;
} catch {
return null;
}
};
const sanitizeUpstreamHeaders = (headers: http.IncomingHttpHeaders): Record => {
const out: Record = {};
for (const [key, value] of Object.entries(headers)) {
if (!key) continue;
if (typeof value === 'string') {
out[key.toLowerCase()] = value;
} else if (Array.isArray(value)) {
out[key.toLowerCase()] = value.join(', ');
}
}
return out;
};
const buildForwardHeaders = (req: http.IncomingMessage, targetUrl: URL): Record => {
const headers = sanitizeUpstreamHeaders(req.headers);
delete headers.host;
delete headers.connection;
delete headers.origin;
delete headers.referer;
delete headers['sec-fetch-site'];
delete headers['sec-fetch-mode'];
delete headers['sec-fetch-dest'];
delete headers['proxy-connection'];
delete headers[PROXY_INITIATOR_HEADER];
headers.host = targetUrl.host;
headers.referer = targetUrl.origin;
return headers;
};
const setCorsHeaders = (res: http.ServerResponse, origin?: string) => {
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization,Content-Type,Accept,Origin,X-Requested-With,X-Fluxer-Proxy-Initiator',
);
res.setHeader('Access-Control-Expose-Headers', 'x-fluxer-sudo-mode-jwt,x-request-id,content-type');
};
const rejectIfDisallowedPage = (
req: http.IncomingMessage,
res: http.ServerResponse,
targetUrl?: URL,
initiator?: string,
): boolean => {
const origin = req.headers.origin;
const referer = req.headers.referer;
const initiatorHeader = initiator ? initiator : undefined;
const targetOrigin = targetUrl?.origin;
if (!origin && !referer && !initiatorHeader) {
res.writeHead(403);
res.end();
return true;
}
const originCandidate = origin ?? parseOrigin(initiatorHeader ?? undefined);
if (originCandidate && !isAllowedOrigin(originCandidate) && originCandidate !== targetOrigin) {
res.writeHead(403);
res.end();
return true;
}
const refererCandidate = referer ?? initiatorHeader;
if (
refererCandidate &&
!refererMatchesAllowedOrigin(refererCandidate) &&
!(targetOrigin && refererCandidate.startsWith(targetOrigin))
) {
res.writeHead(403);
res.end();
return true;
}
if (originCandidate && refererCandidate && !refererCandidate.startsWith(originCandidate)) {
res.writeHead(403);
res.end();
return true;
}
return false;
};
const isValidTargetUrl = (raw: string | null): raw is string => {
if (!raw) return false;
try {
const parsed = new URL(raw);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
};
const pipeRequest = (
req: http.IncomingMessage,
res: http.ServerResponse,
targetUrl: string,
_retriesRemaining: number,
): void => {
const parsedTarget = new URL(targetUrl);
const agent = parsedTarget.protocol === 'https:' ? https : http;
const method = (req.method ?? 'GET').toUpperCase();
const requestOptions: http.RequestOptions = {
hostname: parsedTarget.hostname,
port: parsedTarget.port || (parsedTarget.protocol === 'https:' ? 443 : 80),
path: parsedTarget.pathname + parsedTarget.search,
method,
headers: buildForwardHeaders(req, parsedTarget),
};
const upstreamReq = agent.request(requestOptions, (upstreamRes) => {
const status = upstreamRes.statusCode ?? 502;
const headers = sanitizeUpstreamHeaders(upstreamRes.headers);
delete headers.connection;
delete headers['proxy-connection'];
delete headers['transfer-encoding'];
headers['Access-Control-Allow-Origin'] = req.headers.origin ?? req.headers.referer ?? 'https://web.fluxer.app';
headers['Access-Control-Allow-Credentials'] = 'true';
headers['Access-Control-Expose-Headers'] = 'x-fluxer-sudo-mode-jwt,x-request-id,content-type';
headers.Vary = 'Origin';
res.writeHead(status, headers);
upstreamRes.pipe(res);
});
upstreamReq.on('error', (error: Error) => {
log.error('[API Proxy] Upstream request error:', error);
if (!res.headersSent) {
setCorsHeaders(res, req.headers.origin ?? req.headers.referer);
res.writeHead(502, {'Content-Type': 'text/plain'});
}
res.end('Bad Gateway');
});
res.on('close', () => {
try {
upstreamReq.destroy();
} catch {}
});
req.pipe(upstreamReq);
};
let server: http.Server | null = null;
export const startApiProxyServer = (): Promise => {
return new Promise((resolve, reject) => {
if (server) {
resolve();
return;
}
server = http.createServer((req, res) => {
const remoteAddress = req.socket.remoteAddress;
if (remoteAddress !== '127.0.0.1' && remoteAddress !== '::1' && remoteAddress !== '::ffff:127.0.0.1') {
res.writeHead(403);
res.end();
return;
}
const requestUrl = new URL(req.url ?? '/', `http://${req.headers.host}`);
if (requestUrl.pathname !== PROXY_PATH) {
res.writeHead(404);
res.end();
return;
}
const target = requestUrl.searchParams.get('target');
if (!isValidTargetUrl(target)) {
res.writeHead(400, {'Content-Type': 'text/plain'});
res.end('Missing or invalid target');
return;
}
const targetUrl = new URL(target);
const initiator = req.headers[PROXY_INITIATOR_HEADER];
const initiatorHeader = Array.isArray(initiator) ? initiator[0] : initiator;
if (rejectIfDisallowedPage(req, res, targetUrl, initiatorHeader ?? undefined)) {
return;
}
setCorsHeaders(res, req.headers.origin ?? initiatorHeader ?? req.headers.referer ?? undefined);
if (req.method?.toUpperCase() === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS');
res.writeHead(204);
res.end();
return;
}
pipeRequest(req, res, target, 5);
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
log.warn(`[API Proxy] Port ${API_PROXY_PORT} already in use, API proxy disabled`);
server = null;
resolve();
} else {
log.error('[API Proxy] Server error:', error);
reject(error);
}
});
server.listen(API_PROXY_PORT, '127.0.0.1', () => {
log.info(`[API Proxy] Server listening on http://127.0.0.1:${API_PROXY_PORT}${PROXY_PATH}`);
resolve();
});
});
};
export const stopApiProxyServer = (): Promise => {
return new Promise((resolve) => {
if (!server) {
resolve();
return;
}
server.close((err) => {
if (err) {
log.error('[API Proxy] Error closing server:', err);
}
server = null;
resolve();
});
});
};