fluxer/fluxer_app/src/stores/RuntimeConfigStore.tsx
2026-02-17 12:22:36 +00:00

575 lines
16 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
import Config from '@app/Config';
import type {HttpRequestConfig} from '@app/lib/HttpClient';
import HttpClient from '@app/lib/HttpClient';
import {makePersistent} from '@app/lib/MobXPersistence';
import relayClient from '@app/lib/RelayClient';
import DeveloperOptionsStore from '@app/stores/DeveloperOptionsStore';
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
import {expandWireFormat} from '@fluxer/limits/src/LimitDiffer';
import type {LimitConfigSnapshot, LimitConfigWireFormat} from '@fluxer/limits/src/LimitTypes';
import {makeAutoObservable, reaction, runInAction} from 'mobx';
export interface InstanceFeatures {
sms_mfa_enabled: boolean;
voice_enabled: boolean;
stripe_enabled: boolean;
self_hosted: boolean;
manual_review_enabled: boolean;
}
export interface InstanceSsoConfig {
enabled: boolean;
enforced: boolean;
display_name: string | null;
redirect_uri: string;
}
export interface InstanceEndpoints {
api: string;
api_client?: string;
api_public?: string;
gateway: string;
media: string;
static_cdn: string;
marketing: string;
admin: string;
invite: string;
gift: string;
webapp: string;
}
export interface InstanceCaptcha {
provider: 'hcaptcha' | 'turnstile' | 'none';
hcaptcha_site_key: string | null;
turnstile_site_key: string | null;
}
export interface InstancePush {
public_vapid_key: string | null;
}
export interface InstanceAppPublic {
sentry_dsn: string;
sentry_proxy_path: string;
sentry_report_host: string;
sentry_project_id: string;
sentry_public_key: string;
}
export type GifProvider = 'klipy' | 'tenor';
export interface InstanceDiscoveryResponse {
api_code_version: number;
endpoints: InstanceEndpoints;
captcha: InstanceCaptcha;
features: InstanceFeatures;
gif?: {provider: GifProvider};
sso?: InstanceSsoConfig;
limits: LimitConfigSnapshot | LimitConfigWireFormat;
push?: InstancePush;
app_public: InstanceAppPublic;
}
export interface RuntimeConfigSnapshot {
apiEndpoint: string;
apiPublicEndpoint: string;
gatewayEndpoint: string;
mediaEndpoint: string;
staticCdnEndpoint: string;
marketingEndpoint: string;
adminEndpoint: string;
inviteEndpoint: string;
giftEndpoint: string;
webAppEndpoint: string;
gifProvider: GifProvider;
captchaProvider: 'hcaptcha' | 'turnstile' | 'none';
hcaptchaSiteKey: string | null;
turnstileSiteKey: string | null;
apiCodeVersion: number;
features: InstanceFeatures;
sso: InstanceSsoConfig | null;
publicPushVapidKey: string | null;
limits: LimitConfigSnapshot;
sentryDsn: string;
sentryProxyPath: string;
sentryReportHost: string;
sentryProjectId: string;
sentryPublicKey: string;
relayDirectoryUrl: string | null;
}
type InitState = 'initializing' | 'ready' | 'error';
class RuntimeConfigStore {
private _initState: InitState = 'initializing';
private _initError: Error | null = null;
private _initPromise: Promise<void>;
private _resolveInit!: () => void;
private _rejectInit!: (err: Error) => void;
private _connectSeq = 0;
apiEndpoint: string = '';
apiPublicEndpoint: string = '';
gatewayEndpoint: string = '';
mediaEndpoint: string = '';
staticCdnEndpoint: string = '';
marketingEndpoint: string = '';
adminEndpoint: string = '';
inviteEndpoint: string = '';
giftEndpoint: string = '';
webAppEndpoint: string = '';
gifProvider: GifProvider = 'klipy';
captchaProvider: 'hcaptcha' | 'turnstile' | 'none' = 'none';
hcaptchaSiteKey: string | null = null;
turnstileSiteKey: string | null = null;
apiCodeVersion: number = API_CODE_VERSION;
features: InstanceFeatures = {
sms_mfa_enabled: false,
voice_enabled: false,
stripe_enabled: false,
self_hosted: false,
manual_review_enabled: false,
};
sso: InstanceSsoConfig | null = null;
publicPushVapidKey: string | null = null;
limits: LimitConfigSnapshot = this.createEmptyLimitConfig();
currentDefaultsHash: string | null = null;
sentryDsn: string = '';
sentryProxyPath: string = '/error-reporting-proxy';
sentryReportHost: string = '';
sentryProjectId: string = '';
sentryPublicKey: string = '';
relayDirectoryUrl: string | null = Config.PUBLIC_RELAY_DIRECTORY_URL;
get relayModeEnabled(): boolean {
return this.relayDirectoryUrl != null;
}
constructor() {
this._initPromise = new Promise<void>((resolve, reject) => {
this._resolveInit = resolve;
this._rejectInit = reject;
});
makeAutoObservable(this, {}, {autoBind: true});
this.initialize().catch(() => {});
reaction(
() => this.apiEndpoint,
(endpoint) => {
if (endpoint) {
HttpClient.setBaseUrl(endpoint, this.apiCodeVersion);
this.updateTargetInstanceDomain(endpoint);
}
},
{fireImmediately: true},
);
reaction(
() => this.relayDirectoryUrl,
(directoryUrl) => {
HttpClient.setRelayDirectoryUrl(directoryUrl);
if (directoryUrl) {
relayClient.setRelayDirectoryUrl(directoryUrl);
}
},
{fireImmediately: true},
);
}
private updateTargetInstanceDomain(endpoint: string): void {
try {
const url = new URL(endpoint);
HttpClient.setTargetInstanceDomain(url.hostname);
} catch {
HttpClient.setTargetInstanceDomain(null);
}
}
private async initialize(): Promise<void> {
try {
await makePersistent(this, 'runtimeConfig', [
'apiEndpoint',
'apiPublicEndpoint',
'gatewayEndpoint',
'mediaEndpoint',
'staticCdnEndpoint',
'marketingEndpoint',
'adminEndpoint',
'inviteEndpoint',
'giftEndpoint',
'webAppEndpoint',
'gifProvider',
'captchaProvider',
'hcaptchaSiteKey',
'turnstileSiteKey',
'apiCodeVersion',
'features',
'sso',
'publicPushVapidKey',
'limits',
'currentDefaultsHash',
'sentryDsn',
'sentryProxyPath',
'sentryReportHost',
'sentryProjectId',
'sentryPublicKey',
'relayDirectoryUrl',
]);
const bootstrapEndpoint = this.apiEndpoint || Config.PUBLIC_BOOTSTRAP_API_ENDPOINT;
await this.connectToEndpoint(bootstrapEndpoint);
runInAction(() => {
this._initState = 'ready';
this._initError = null;
});
this._resolveInit();
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
runInAction(() => {
this._initState = 'error';
this._initError = err;
});
this._rejectInit(err);
}
}
waitForInit(): Promise<void> {
return this._initPromise;
}
get initialized(): boolean {
return this._initState === 'ready';
}
get initError(): Error | null {
return this._initError;
}
applySnapshot(snapshot: RuntimeConfigSnapshot): void {
this.apiEndpoint = snapshot.apiEndpoint;
this.apiPublicEndpoint = snapshot.apiPublicEndpoint;
this.gatewayEndpoint = snapshot.gatewayEndpoint;
this.mediaEndpoint = snapshot.mediaEndpoint;
this.staticCdnEndpoint = snapshot.staticCdnEndpoint;
this.marketingEndpoint = snapshot.marketingEndpoint;
this.adminEndpoint = snapshot.adminEndpoint;
this.inviteEndpoint = snapshot.inviteEndpoint;
this.giftEndpoint = snapshot.giftEndpoint;
this.webAppEndpoint = snapshot.webAppEndpoint;
this.gifProvider = snapshot.gifProvider;
this.captchaProvider = snapshot.captchaProvider;
this.hcaptchaSiteKey = snapshot.hcaptchaSiteKey;
this.turnstileSiteKey = snapshot.turnstileSiteKey;
this.apiCodeVersion = snapshot.apiCodeVersion;
this.features = snapshot.features;
this.sso = snapshot.sso;
this.publicPushVapidKey = snapshot.publicPushVapidKey;
this.limits = this.normalizeLimits(snapshot.limits ?? this.createEmptyLimitConfig());
this.currentDefaultsHash = null;
this.sentryDsn = snapshot.sentryDsn;
this.sentryProxyPath = snapshot.sentryProxyPath;
this.sentryReportHost = snapshot.sentryReportHost;
this.sentryProjectId = snapshot.sentryProjectId;
this.sentryPublicKey = snapshot.sentryPublicKey;
this.relayDirectoryUrl = snapshot.relayDirectoryUrl;
}
getSnapshot(): RuntimeConfigSnapshot {
return {
apiEndpoint: this.apiEndpoint,
apiPublicEndpoint: this.apiPublicEndpoint,
gatewayEndpoint: this.gatewayEndpoint,
mediaEndpoint: this.mediaEndpoint,
staticCdnEndpoint: this.staticCdnEndpoint,
marketingEndpoint: this.marketingEndpoint,
adminEndpoint: this.adminEndpoint,
inviteEndpoint: this.inviteEndpoint,
giftEndpoint: this.giftEndpoint,
webAppEndpoint: this.webAppEndpoint,
gifProvider: this.gifProvider,
captchaProvider: this.captchaProvider,
hcaptchaSiteKey: this.hcaptchaSiteKey,
turnstileSiteKey: this.turnstileSiteKey,
apiCodeVersion: this.apiCodeVersion,
features: {...this.features},
sso: this.sso ? {...this.sso} : null,
publicPushVapidKey: this.publicPushVapidKey,
limits: this.cloneLimits(this.limits),
sentryDsn: this.sentryDsn,
sentryProxyPath: this.sentryProxyPath,
sentryReportHost: this.sentryReportHost,
sentryProjectId: this.sentryProjectId,
sentryPublicKey: this.sentryPublicKey,
relayDirectoryUrl: this.relayDirectoryUrl,
};
}
private createEmptyLimitConfig(): LimitConfigSnapshot {
return {
version: 1,
traitDefinitions: [],
rules: [],
};
}
private cloneLimits(limits: LimitConfigSnapshot): LimitConfigSnapshot {
return JSON.parse(JSON.stringify(limits));
}
private normalizeLimits(limits?: LimitConfigSnapshot): LimitConfigSnapshot {
const cloned = this.cloneLimits(limits ?? this.createEmptyLimitConfig());
return {
...cloned,
traitDefinitions: cloned.traitDefinitions ?? [],
rules: cloned.rules ?? [],
};
}
private processLimitsFromApi(limits: LimitConfigSnapshot | LimitConfigWireFormat | undefined): LimitConfigSnapshot {
if (limits && 'defaultsHash' in limits && limits.version === 2) {
const expanded = expandWireFormat(limits);
this.currentDefaultsHash = limits.defaultsHash;
return this.normalizeLimits(expanded);
}
this.currentDefaultsHash = null;
return this.normalizeLimits(limits as LimitConfigSnapshot | undefined);
}
async withSnapshot<T>(snapshot: RuntimeConfigSnapshot, fn: () => Promise<T>): Promise<T> {
const before = this.getSnapshot();
this.applySnapshot(snapshot);
try {
return await fn();
} finally {
this.applySnapshot(before);
}
}
async resetToDefaults(): Promise<void> {
await this.connectToEndpoint(Config.PUBLIC_BOOTSTRAP_API_ENDPOINT);
}
async connectToEndpoint(input: string): Promise<void> {
const connectId = ++this._connectSeq;
const apiEndpoint = this.normalizeEndpoint(input);
const wellKnownUrl = this.buildWellKnownUrl(apiEndpoint);
const request: HttpRequestConfig = {url: wellKnownUrl};
const response = await HttpClient.get<InstanceDiscoveryResponse>(request);
if (connectId !== this._connectSeq) {
return;
}
if (!response.ok) {
throw new Error(`Failed to reach ${wellKnownUrl} (${response.status})`);
}
this.updateFromInstance(response.body);
}
private buildWellKnownUrl(apiEndpoint: string): string {
try {
const url = new URL(apiEndpoint);
url.pathname = '/.well-known/fluxer';
return url.toString();
} catch {
return `${apiEndpoint.replace(/\/api\/?$/, '')}/.well-known/fluxer`;
}
}
private normalizeEndpoint(input: string): string {
const trimmed = input['trim']();
if (!trimmed) {
throw new Error('API endpoint is required');
}
let candidate = trimmed;
if (candidate.startsWith('/')) {
candidate = `${window.location.origin}${candidate}`;
} else if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(candidate)) {
candidate = `https://${candidate}`;
}
const url = new URL(candidate);
if (url.pathname === '' || url.pathname === '/') {
url.pathname = '/api';
}
url.pathname = url.pathname.replace(/\/+$/, '');
return url.toString();
}
private updateFromInstance(instance: InstanceDiscoveryResponse): void {
this.assertCodeVersion(instance.api_code_version);
const apiEndpoint = instance.endpoints.api_client ?? instance.endpoints.api;
const apiPublicEndpoint = instance.endpoints.api_public ?? apiEndpoint;
const sso = instance.sso ?? null;
const gifProvider: GifProvider = instance.gif?.provider === 'tenor' ? 'tenor' : 'klipy';
runInAction(() => {
this.apiEndpoint = apiEndpoint;
this.apiPublicEndpoint = apiPublicEndpoint;
this.gatewayEndpoint = instance.endpoints.gateway;
this.mediaEndpoint = instance.endpoints.media;
this.staticCdnEndpoint = instance.endpoints.static_cdn;
this.marketingEndpoint = instance.endpoints.marketing;
this.adminEndpoint = instance.endpoints.admin;
this.inviteEndpoint = instance.endpoints.invite;
this.giftEndpoint = instance.endpoints.gift;
this.webAppEndpoint = instance.endpoints.webapp;
this.gifProvider = gifProvider;
this.captchaProvider = instance.captcha.provider;
this.hcaptchaSiteKey = instance.captcha.hcaptcha_site_key;
this.turnstileSiteKey = instance.captcha.turnstile_site_key;
this.apiCodeVersion = instance.api_code_version;
this.features = instance.features;
this.sso = sso;
this.publicPushVapidKey = instance.push?.public_vapid_key ?? null;
this.limits = this.processLimitsFromApi(instance.limits);
if (instance.app_public) {
this.sentryDsn = instance.app_public.sentry_dsn;
this.sentryProxyPath = instance.app_public.sentry_proxy_path;
this.sentryReportHost = instance.app_public.sentry_report_host;
this.sentryProjectId = instance.app_public.sentry_project_id;
this.sentryPublicKey = instance.app_public.sentry_public_key;
}
});
}
private assertCodeVersion(instanceVersion: number): void {
if (instanceVersion < API_CODE_VERSION) {
throw new Error(
`Incompatible server (code version ${instanceVersion}); this client requires ${API_CODE_VERSION}.`,
);
}
}
get webAppBaseUrl(): string {
if (this.webAppEndpoint) {
return this.webAppEndpoint.replace(/\/$/, '');
}
try {
const url = new URL(this.apiEndpoint);
if (url.pathname.endsWith('/api')) {
url.pathname = url.pathname.slice(0, -4) || '/';
}
return url.toString().replace(/\/$/, '');
} catch {
return this.apiEndpoint.replace(/\/api$/, '');
}
}
isSelfHosted(): boolean {
return DeveloperOptionsStore.selfHostedModeOverride || this.features.self_hosted;
}
get marketingHost(): string {
try {
return new URL(this.marketingEndpoint).host;
} catch {
return '';
}
}
get inviteHost(): string {
try {
return new URL(this.inviteEndpoint).host;
} catch {
return '';
}
}
get giftHost(): string {
try {
return new URL(this.giftEndpoint).host;
} catch {
return '';
}
}
get inviteUrlBase(): string {
try {
const url = new URL(this.inviteEndpoint);
const path = url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '';
return `${url.host}${path}`;
} catch {
return '';
}
}
get giftUrlBase(): string {
try {
const url = new URL(this.giftEndpoint);
const path = url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '';
return `${url.host}${path}`;
} catch {
return '';
}
}
get localInstanceDomain(): string {
try {
const url = new URL(this.apiEndpoint);
return url.hostname;
} catch {
return 'localhost';
}
}
}
export function describeApiEndpoint(endpoint: string): string {
try {
const url = new URL(endpoint);
const path = url.pathname === '/api' ? '' : url.pathname;
return `${url.host}${path}`;
} catch {
return endpoint;
}
}
export default new RuntimeConfigStore();