fix(config): carry the public port into derived endpoints (#2329)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-01 20:47:18 +02:00
1 parent a93f9dd0af
commit bc40073a02
21 files changed
+2037 -55

No files matched your search

+36 -2
View File
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {buildNamedFluxerEnvOverrides} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
import {deriveEndpointsFromDomain} from '@fluxer/config/src/EndpointDerivation';
import {
type DerivedEndpoints,
deriveEndpointsFromDomain,
normalizePublicEndpoint,
} from '@fluxer/config/src/EndpointDerivation';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
type ConfigObject = Record<string, unknown>;
@@ -433,6 +437,36 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
return config;
}
function applyPublicPort(config: MasterConfig, endpoints: DerivedEndpoints): MasterConfig {
const {base_domain, public_port} = config.domain;
const normalize = (url: string) => normalizePublicEndpoint(url, base_domain, public_port);
const normalizedEndpoints = {...endpoints};
for (const key of Object.keys(normalizedEndpoints) as Array<keyof DerivedEndpoints>) {
normalizedEndpoints[key] = normalize(normalizedEndpoints[key]);
}
return {
...config,
endpoints: normalizedEndpoints,
services: {
...config.services,
media_proxy: {
...config.services.media_proxy,
upload_relay: {
...config.services.media_proxy.upload_relay,
endpoint: normalize(config.services.media_proxy.upload_relay.endpoint),
},
},
},
auth: {
...config.auth,
passkeys: {
...config.auth.passkeys,
additional_allowed_origins: config.auth.passkeys.additional_allowed_origins.map(normalize),
},
},
};
}
export async function loadConfig(): Promise<MasterConfig> {
if (cachedConfig) {
return cachedConfig;
@@ -441,7 +475,7 @@ export async function loadConfig(): Promise<MasterConfig> {
const normalized = normalizeConfig(merged);
const derived = deriveEndpointsFromDomain(normalized.domain);
const endpoints = {...derived, ...(normalized.endpoint_overrides ?? {})};
cachedConfig = {...normalized, endpoints};
cachedConfig = applyPublicPort(normalized, endpoints);
return cachedConfig;
}
+43 -4
View File
@@ -24,17 +24,56 @@ export interface DerivedEndpoints {
gift: string;
}
export function buildUrl(scheme: string, domain: string, port?: number, path?: string): string {
const isStandardPort =
function isStandardPort(scheme: string, port: number): boolean {
return (
(scheme === 'http' && port === 80) ||
(scheme === 'https' && port === 443) ||
(scheme === 'ws' && port === 80) ||
(scheme === 'wss' && port === 443);
const portPart = port && !isStandardPort ? `:${port}` : '';
(scheme === 'wss' && port === 443)
);
}
function stripTrailingDot(host: string): string {
return host.endsWith('.') ? host.slice(0, -1) : host;
}
export function buildUrl(scheme: string, domain: string, port?: number, path?: string): string {
const portPart = port && !isStandardPort(scheme, port) ? `:${port}` : '';
const pathPart = path || '';
return `${scheme}://${domain}${portPart}${pathPart}`;
}
export function normalizePublicEndpoint(url: string, baseDomain: string, publicPort?: number): string {
const domain = stripTrailingDot(baseDomain.trim().toLowerCase());
if (domain.length === 0 || !publicPort) {
return url;
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return url;
}
if (stripTrailingDot(parsed.hostname.toLowerCase()) !== domain) {
return url;
}
if (isStandardPort(parsed.protocol.slice(0, -1), publicPort)) {
return url;
}
const schemeEnd = url.indexOf('://');
if (schemeEnd === -1 || url.startsWith('/', schemeEnd + 3)) {
return url;
}
const authorityStart = schemeEnd + 3;
const relativeEnd = url.slice(authorityStart).search(/[/\\?#]/);
const authorityEnd = relativeEnd === -1 ? url.length : authorityStart + relativeEnd;
const host = url.slice(authorityStart, authorityEnd).split('@').pop() ?? '';
if (host.slice(host.lastIndexOf(']') + 1).includes(':')) {
return url;
}
return `${url.slice(0, authorityEnd)}:${publicPort}${url.slice(authorityEnd)}`;
}
export function deriveDomain(
endpointType:
| 'api'
@@ -102,6 +102,105 @@ describe('ConfigLoader', () => {
expect(config.endpoints.app).toBe('http://localhost:8088');
});
test('inserts the public port into portless endpoints on a non-standard port', async () => {
stubMinimalEnv({
FLUXER_MARKETING_ENDPOINT: 'http://localhost',
FLUXER_MEDIA_ENDPOINT: 'http://localhost/media',
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://localhost/media',
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'http://localhost',
});
const config = await loadConfig();
expect(config.endpoints.marketing).toBe('http://localhost:8088');
expect(config.endpoints.media).toBe('http://localhost:8088/media');
expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://localhost:8088/media');
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['http://localhost:8088']);
});
test('leaves a default https install untouched', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: 'chat.example',
FLUXER_PUBLIC_SCHEME: 'https',
FLUXER_PUBLIC_PORT: '443',
FLUXER_MARKETING_ENDPOINT: 'https://chat.example',
FLUXER_MEDIA_ENDPOINT: 'https://chat.example/media',
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'https://chat.example/media',
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'https://chat.example',
});
const config = await loadConfig();
expect(config.endpoints.marketing).toBe('https://chat.example');
expect(config.endpoints.media).toBe('https://chat.example/media');
expect(config.endpoints.api).toBe('https://chat.example/api');
expect(config.endpoints.gateway).toBe('wss://chat.example/gateway');
expect(config.services.media_proxy.upload_relay.endpoint).toBe('https://chat.example/media');
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://chat.example']);
});
test('leaves a default http install untouched', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: 'chat.example',
FLUXER_PUBLIC_SCHEME: 'http',
FLUXER_PUBLIC_PORT: '80',
FLUXER_MARKETING_ENDPOINT: 'http://chat.example',
FLUXER_MEDIA_ENDPOINT: 'http://chat.example/media',
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://chat.example/media',
});
const config = await loadConfig();
expect(config.endpoints.marketing).toBe('http://chat.example');
expect(config.endpoints.media).toBe('http://chat.example/media');
expect(config.endpoints.gateway).toBe('ws://chat.example/gateway');
expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://chat.example/media');
});
test('leaves foreign hosts and already ported endpoints untouched', async () => {
stubMinimalEnv({
FLUXER_STATIC_CDN_ENDPOINT: 'https://cdn.example.net',
FLUXER_MEDIA_ENDPOINT: 'https://media.example.net/media',
FLUXER_MARKETING_ENDPOINT: 'http://localhost:9999',
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://localhost:8088/media',
});
const config = await loadConfig();
expect(config.endpoints.static_cdn).toBe('https://cdn.example.net');
expect(config.endpoints.media).toBe('https://media.example.net/media');
expect(config.endpoints.marketing).toBe('http://localhost:9999');
expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://localhost:8088/media');
});
test('normalizes each passkey origin independently', async () => {
stubMinimalEnv({
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:
'http://localhost,http://localhost:3000,https://desktop.example.net,android:apk-key-hash:abc',
});
const config = await loadConfig();
expect(config.auth.passkeys.additional_allowed_origins).toEqual([
'http://localhost:8088',
'http://localhost:3000',
'https://desktop.example.net',
'android:apk-key-hash:abc',
]);
});
test('leaves the default passkey origins untouched', async () => {
stubMinimalEnv();
const config = await loadConfig();
expect(config.auth.passkeys.additional_allowed_origins).toEqual([
'https://fluxer.app',
'https://web.fluxer.app',
'https://web.canary.fluxer.app',
'android:apk-key-hash:keSY4bimyLqZQV7bKXgpa2xYuqXi0qZJzsYtp6gpx7w',
'android:apk-key-hash:zRmCKDKo3uCX2GDZISjJx8Rzo3J-Y3Gbp7s7mAaUH28',
]);
});
test('parses typed named environment variables', async () => {
stubMinimalEnv({
FLUXER_API_PORT: '9090',
@@ -5,6 +5,7 @@ import {
type DomainConfig,
deriveDomain,
deriveEndpointsFromDomain,
normalizePublicEndpoint,
} from '@fluxer/config/src/EndpointDerivation';
import {describe, expect, test} from 'vitest';
@@ -301,3 +302,95 @@ describe('deriveEndpointsFromDomain', () => {
});
});
});
describe('normalizePublicEndpoint', () => {
test('leaves a default https install untouched', () => {
expect(normalizePublicEndpoint('https://fluxer.dev', 'fluxer.dev', 443)).toBe('https://fluxer.dev');
expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 443)).toBe('https://fluxer.dev/media');
expect(normalizePublicEndpoint('wss://fluxer.dev/gateway', 'fluxer.dev', 443)).toBe('wss://fluxer.dev/gateway');
});
test('leaves a default http install untouched', () => {
expect(normalizePublicEndpoint('http://fluxer.dev', 'fluxer.dev', 80)).toBe('http://fluxer.dev');
expect(normalizePublicEndpoint('http://fluxer.dev/media', 'fluxer.dev', 80)).toBe('http://fluxer.dev/media');
expect(normalizePublicEndpoint('ws://fluxer.dev/gateway', 'fluxer.dev', 80)).toBe('ws://fluxer.dev/gateway');
});
test('inserts a non-standard port', () => {
expect(normalizePublicEndpoint('https://fluxer.dev', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:8443');
expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:8443/media',
);
expect(normalizePublicEndpoint('wss://fluxer.dev/gateway', 'fluxer.dev', 8443)).toBe(
'wss://fluxer.dev:8443/gateway',
);
});
test('judges standard ports against the url scheme, not the public scheme', () => {
expect(normalizePublicEndpoint('http://fluxer.dev/media', 'fluxer.dev', 443)).toBe('http://fluxer.dev:443/media');
expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 80)).toBe('https://fluxer.dev:80/media');
});
test('leaves a foreign host untouched', () => {
expect(normalizePublicEndpoint('https://cdn.example.net/media', 'fluxer.dev', 8443)).toBe(
'https://cdn.example.net/media',
);
expect(normalizePublicEndpoint('https://sub.fluxer.dev', 'fluxer.dev', 8443)).toBe('https://sub.fluxer.dev');
});
test('leaves an already ported url untouched', () => {
expect(normalizePublicEndpoint('https://fluxer.dev:8443/media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:8443/media',
);
expect(normalizePublicEndpoint('https://fluxer.dev:9000/media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:9000/media',
);
expect(normalizePublicEndpoint('https://fluxer.dev:443/media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:443/media',
);
});
test('is idempotent', () => {
const once = normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 8443);
expect(normalizePublicEndpoint(once, 'fluxer.dev', 8443)).toBe(once);
});
test('preserves path, query, fragment, trailing slash, and case', () => {
expect(normalizePublicEndpoint('https://fluxer.dev/Media/', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:8443/Media/',
);
expect(normalizePublicEndpoint('https://fluxer.dev/media?a=B#Frag', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:8443/media?a=B#Frag',
);
expect(normalizePublicEndpoint('https://fluxer.dev?a=B', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:8443?a=B');
expect(normalizePublicEndpoint('https://fluxer.dev#Frag', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:8443#Frag');
expect(normalizePublicEndpoint('https://user:[email protected]/media', 'fluxer.dev', 8443)).toBe(
'https://user:[email protected]:8443/media',
);
});
test('matches the host case-insensitively and ignores a trailing dot', () => {
expect(normalizePublicEndpoint('https://FLUXER.dev/media', 'fluxer.dev', 8443)).toBe(
'https://FLUXER.dev:8443/media',
);
expect(normalizePublicEndpoint('https://fluxer.dev./media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev.:8443/media',
);
expect(normalizePublicEndpoint('https://fluxer.dev/media', 'FLUXER.dev.', 8443)).toBe(
'https://fluxer.dev:8443/media',
);
});
test('leaves unparseable and non-http values untouched', () => {
expect(normalizePublicEndpoint('not a url', 'fluxer.dev', 8443)).toBe('not a url');
expect(normalizePublicEndpoint('', 'fluxer.dev', 8443)).toBe('');
expect(normalizePublicEndpoint('android:apk-key-hash:abc', 'fluxer.dev', 8443)).toBe('android:apk-key-hash:abc');
expect(normalizePublicEndpoint('https://fluxer.dev:/media', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:/media');
});
test('leaves malformed authorities untouched', () => {
expect(normalizePublicEndpoint('https:fluxer.dev/media', 'fluxer.dev', 8443)).toBe('https:fluxer.dev/media');
expect(normalizePublicEndpoint('https:/fluxer.dev/media', 'fluxer.dev', 8443)).toBe('https:/fluxer.dev/media');
expect(normalizePublicEndpoint('https:////fluxer.dev/media', 'fluxer.dev', 8443)).toBe(
'https:////fluxer.dev/media',
);
expect(normalizePublicEndpoint('https://fluxer.dev\\media', 'fluxer.dev', 8443)).toBe(
'https://fluxer.dev:8443\\media',
);
});
test('leaves everything untouched without a usable port or base domain', () => {
expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev')).toBe('https://fluxer.dev/media');
expect(normalizePublicEndpoint('https://fluxer.dev/media', '', 8443)).toBe('https://fluxer.dev/media');
expect(normalizePublicEndpoint('https://fluxer.dev/media', ' ', 8443)).toBe('https://fluxer.dev/media');
});
});
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {normalizePublicEndpoint} from '@fluxer/config/src/EndpointDerivation';
import {describe, expect, test} from 'vitest';
interface PublicEndpointVector {
url: string;
base_domain: string;
public_port: number | null;
normalized: string;
}
const VECTORS_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../../fluxer_common/src/testdata/public_endpoint_vectors.json',
);
function readVectors(): Array<PublicEndpointVector> {
const parsed: unknown = JSON.parse(readFileSync(VECTORS_PATH, 'utf8'));
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error(`no public endpoint vectors in ${VECTORS_PATH}`);
}
return parsed.map((vector, index) => {
if (
typeof vector?.url !== 'string' ||
typeof vector?.base_domain !== 'string' ||
typeof vector?.normalized !== 'string' ||
(vector?.public_port !== null && typeof vector?.public_port !== 'number')
) {
throw new Error(`malformed public endpoint vector at index ${index} in ${VECTORS_PATH}`);
}
return vector as PublicEndpointVector;
});
}
const vectors = readVectors();
describe('normalizePublicEndpoint shared vectors', () => {
test('reads the vector file the Rust conformance test reads', () => {
expect(vectors.length).toBeGreaterThan(0);
});
test.each(vectors)('$url @ $base_domain port $public_port', (vector) => {
expect(normalizePublicEndpoint(vector.url, vector.base_domain, vector.public_port ?? undefined)).toBe(
vector.normalized,
);
});
});