320 lines
9.0 KiB
TypeScript
320 lines
9.0 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 {Logger} from '@app/lib/Logger';
|
|
import type {
|
|
GifProvider,
|
|
InstanceAppPublic,
|
|
InstanceCaptcha,
|
|
InstanceEndpoints,
|
|
InstanceFeatures,
|
|
InstanceSsoConfig,
|
|
} from '@app/stores/RuntimeConfigStore';
|
|
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
|
|
import {MS_PER_HOUR, MS_PER_MINUTE} from '@fluxer/date_utils/src/DateConstants';
|
|
import {expandWireFormat} from '@fluxer/limits/src/LimitDiffer';
|
|
import type {LimitConfigSnapshot, LimitConfigWireFormat} from '@fluxer/limits/src/LimitTypes';
|
|
import {makeAutoObservable, runInAction} from 'mobx';
|
|
|
|
const logger = new Logger('InstanceConfigStore');
|
|
|
|
const CONFIG_REFRESH_INTERVAL_MS = 30 * MS_PER_MINUTE;
|
|
const CONFIG_STALE_THRESHOLD_MS = MS_PER_HOUR;
|
|
|
|
export interface FederationConfig {
|
|
enabled: boolean;
|
|
version: number;
|
|
}
|
|
|
|
export interface InstancePublicKey {
|
|
id: string;
|
|
algorithm: 'x25519';
|
|
public_key_base64: string;
|
|
}
|
|
|
|
export interface OAuth2Config {
|
|
authorization_endpoint: string;
|
|
token_endpoint: string;
|
|
userinfo_endpoint: string;
|
|
scopes_supported: Array<string>;
|
|
}
|
|
|
|
export interface InstanceConfig {
|
|
domain: string;
|
|
fetchedAt: number;
|
|
apiCodeVersion: number;
|
|
endpoints: InstanceEndpoints;
|
|
captcha: InstanceCaptcha;
|
|
features: InstanceFeatures;
|
|
gif: {provider: GifProvider};
|
|
sso: InstanceSsoConfig | null;
|
|
limits: LimitConfigSnapshot;
|
|
push: {public_vapid_key: string | null} | null;
|
|
appPublic: InstanceAppPublic;
|
|
federation: FederationConfig | null;
|
|
publicKey: InstancePublicKey | null;
|
|
oauth2: OAuth2Config | null;
|
|
}
|
|
|
|
interface InstanceDiscoveryResponse {
|
|
api_code_version: number;
|
|
endpoints: InstanceEndpoints;
|
|
captcha: InstanceCaptcha;
|
|
features: InstanceFeatures;
|
|
gif?: {provider: GifProvider};
|
|
sso?: InstanceSsoConfig;
|
|
limits: LimitConfigSnapshot | LimitConfigWireFormat;
|
|
push?: {public_vapid_key: string | null};
|
|
app_public: InstanceAppPublic;
|
|
federation?: FederationConfig;
|
|
public_key?: InstancePublicKey;
|
|
oauth2?: OAuth2Config;
|
|
}
|
|
|
|
class InstanceConfigStore {
|
|
instanceConfigs: Map<string, InstanceConfig> = new Map();
|
|
localInstanceDomain: string | null = null;
|
|
|
|
private refreshIntervalId: number | null = null;
|
|
private pendingFetches: Map<string, Promise<InstanceConfig>> = new Map();
|
|
|
|
constructor() {
|
|
makeAutoObservable(this, {}, {autoBind: true});
|
|
this.startPeriodicRefresh();
|
|
}
|
|
|
|
private startPeriodicRefresh(): void {
|
|
if (this.refreshIntervalId !== null) {
|
|
return;
|
|
}
|
|
|
|
this.refreshIntervalId = window.setInterval(() => {
|
|
this.refreshAllConfigs().catch((err) => {
|
|
logger.warn('Periodic config refresh failed:', err);
|
|
});
|
|
}, CONFIG_REFRESH_INTERVAL_MS);
|
|
}
|
|
|
|
stopPeriodicRefresh(): void {
|
|
if (this.refreshIntervalId !== null) {
|
|
clearInterval(this.refreshIntervalId);
|
|
this.refreshIntervalId = null;
|
|
}
|
|
}
|
|
|
|
async fetchInstanceConfig(domain: string, forceRefresh = false): Promise<InstanceConfig> {
|
|
const normalizedDomain = domain.toLowerCase();
|
|
|
|
if (!forceRefresh) {
|
|
const cached = this.instanceConfigs.get(normalizedDomain);
|
|
if (cached && !this.isConfigStale(cached)) {
|
|
logger.debug('Using cached config for:', normalizedDomain);
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
const existingFetch = this.pendingFetches.get(normalizedDomain);
|
|
if (existingFetch) {
|
|
logger.debug('Waiting for existing fetch for:', normalizedDomain);
|
|
return existingFetch;
|
|
}
|
|
|
|
const fetchPromise = this.doFetchInstanceConfig(normalizedDomain);
|
|
this.pendingFetches.set(normalizedDomain, fetchPromise);
|
|
|
|
try {
|
|
return await fetchPromise;
|
|
} finally {
|
|
this.pendingFetches.delete(normalizedDomain);
|
|
}
|
|
}
|
|
|
|
private async doFetchInstanceConfig(domain: string): Promise<InstanceConfig> {
|
|
logger.debug('Fetching config for:', domain);
|
|
|
|
const wellKnownUrl = `https://${domain}/.well-known/fluxer`;
|
|
const response = await fetch(wellKnownUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch instance config for ${domain}: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const data = (await response.json()) as InstanceDiscoveryResponse;
|
|
|
|
const limits = this.processLimitsFromApi(data.limits);
|
|
const gifProvider: GifProvider = data.gif?.provider === 'tenor' ? 'tenor' : 'klipy';
|
|
|
|
const config: InstanceConfig = {
|
|
domain,
|
|
fetchedAt: Date.now(),
|
|
apiCodeVersion: data.api_code_version,
|
|
endpoints: data.endpoints,
|
|
captcha: data.captcha,
|
|
features: data.features,
|
|
gif: {provider: gifProvider},
|
|
sso: data.sso ?? null,
|
|
limits,
|
|
push: data.push ?? null,
|
|
appPublic: data.app_public,
|
|
federation: data.federation ?? null,
|
|
publicKey: data.public_key ?? null,
|
|
oauth2: data.oauth2 ?? null,
|
|
};
|
|
|
|
runInAction(() => {
|
|
this.instanceConfigs.set(domain, config);
|
|
});
|
|
|
|
logger.debug('Cached config for:', domain);
|
|
return config;
|
|
}
|
|
|
|
getInstanceConfig(domain: string): InstanceConfig | null {
|
|
const normalizedDomain = domain.toLowerCase();
|
|
const cached = this.instanceConfigs.get(normalizedDomain);
|
|
|
|
if (cached && this.isConfigStale(cached)) {
|
|
this.fetchInstanceConfig(normalizedDomain, true).catch((err) => {
|
|
logger.warn('Background config refresh failed for:', normalizedDomain, err);
|
|
});
|
|
}
|
|
|
|
return cached ?? null;
|
|
}
|
|
|
|
getLocalInstanceConfig(): InstanceConfig | null {
|
|
const domain = RuntimeConfigStore.localInstanceDomain;
|
|
if (!domain) {
|
|
return null;
|
|
}
|
|
|
|
const existing = this.instanceConfigs.get(domain.toLowerCase());
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
return {
|
|
domain,
|
|
fetchedAt: Date.now(),
|
|
apiCodeVersion: RuntimeConfigStore.apiCodeVersion,
|
|
endpoints: {
|
|
api: RuntimeConfigStore.apiEndpoint,
|
|
api_client: RuntimeConfigStore.apiEndpoint,
|
|
api_public: RuntimeConfigStore.apiPublicEndpoint,
|
|
gateway: RuntimeConfigStore.gatewayEndpoint,
|
|
media: RuntimeConfigStore.mediaEndpoint,
|
|
static_cdn: RuntimeConfigStore.staticCdnEndpoint,
|
|
marketing: RuntimeConfigStore.marketingEndpoint,
|
|
admin: RuntimeConfigStore.adminEndpoint,
|
|
invite: RuntimeConfigStore.inviteEndpoint,
|
|
gift: RuntimeConfigStore.giftEndpoint,
|
|
webapp: RuntimeConfigStore.webAppEndpoint,
|
|
},
|
|
captcha: {
|
|
provider: RuntimeConfigStore.captchaProvider,
|
|
hcaptcha_site_key: RuntimeConfigStore.hcaptchaSiteKey,
|
|
turnstile_site_key: RuntimeConfigStore.turnstileSiteKey,
|
|
},
|
|
features: RuntimeConfigStore.features,
|
|
gif: {provider: RuntimeConfigStore.gifProvider},
|
|
sso: RuntimeConfigStore.sso,
|
|
limits: RuntimeConfigStore.limits,
|
|
push: {public_vapid_key: RuntimeConfigStore.publicPushVapidKey},
|
|
appPublic: {
|
|
sentry_dsn: RuntimeConfigStore.sentryDsn,
|
|
},
|
|
federation: null,
|
|
publicKey: null,
|
|
oauth2: null,
|
|
};
|
|
}
|
|
|
|
getLimitsForInstance(domain: string): LimitConfigSnapshot | null {
|
|
const config = this.getInstanceConfig(domain);
|
|
return config?.limits ?? null;
|
|
}
|
|
|
|
async refreshAllConfigs(): Promise<void> {
|
|
const domains = Array.from(this.instanceConfigs.keys());
|
|
|
|
logger.debug('Refreshing configs for', domains.length, 'instances');
|
|
|
|
const refreshPromises = domains.map(async (domain) => {
|
|
try {
|
|
await this.fetchInstanceConfig(domain, true);
|
|
} catch (err) {
|
|
logger.warn('Failed to refresh config for:', domain, err);
|
|
}
|
|
});
|
|
|
|
await Promise.allSettled(refreshPromises);
|
|
}
|
|
|
|
async onGatewayReady(domain: string): Promise<void> {
|
|
try {
|
|
await this.fetchInstanceConfig(domain, true);
|
|
logger.debug('Refreshed config on gateway ready for:', domain);
|
|
} catch (err) {
|
|
logger.warn('Failed to refresh config on gateway ready for:', domain, err);
|
|
}
|
|
}
|
|
|
|
clearInstanceConfig(domain: string): void {
|
|
const normalizedDomain = domain.toLowerCase();
|
|
this.instanceConfigs.delete(normalizedDomain);
|
|
logger.debug('Cleared config for:', normalizedDomain);
|
|
}
|
|
|
|
clearAllConfigs(): void {
|
|
this.instanceConfigs.clear();
|
|
logger.debug('Cleared all instance configs');
|
|
}
|
|
|
|
private isConfigStale(config: InstanceConfig): boolean {
|
|
return Date.now() - config.fetchedAt > CONFIG_STALE_THRESHOLD_MS;
|
|
}
|
|
|
|
private processLimitsFromApi(limits: LimitConfigSnapshot | LimitConfigWireFormat | undefined): LimitConfigSnapshot {
|
|
if (!limits) {
|
|
return this.createEmptyLimitConfig();
|
|
}
|
|
|
|
if ('defaultsHash' in limits && limits.version === 2) {
|
|
return expandWireFormat(limits);
|
|
}
|
|
|
|
return limits as LimitConfigSnapshot;
|
|
}
|
|
|
|
private createEmptyLimitConfig(): LimitConfigSnapshot {
|
|
return {
|
|
version: 1,
|
|
traitDefinitions: [],
|
|
rules: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
export default new InstanceConfigStore();
|