mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(desktop): bundled renderer, modules and instance accounts (#3254)
This commit is contained in:
1 parent
c748c8af4e
commit
7c5fa2180a
1275 files changed
+215074
-81246
No files matched your search
@@ -1,105 +1,135 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {runtimeInstanceKey} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import ageGeos from '@fluxer/constants/src/AgeGeos.json';
|
||||
import type {GeoEntry} from '@fluxer/instance_bootstrap/src/Types';
|
||||
import {GeolocationResponse} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas';
|
||||
import {makeAutoObservable, runInAction} from 'mobx';
|
||||
|
||||
const GEOIP_PATH = '/_geoip';
|
||||
const GEOIP_ATTEMPTS = 3;
|
||||
const GEOIP_ATTEMPT_TIMEOUT_MS = 2000;
|
||||
const GEOIP_RETRY_DELAY_MS = 200;
|
||||
import {AGE_BLOCKED_GEOS, AGE_RESTRICTED_GEOS} from '@fluxer/instance_bootstrap/src/AgeGeos';
|
||||
import type {GeoEntry, GeolocationResponse, InstanceAgePolicy} from '@fluxer/instance_bootstrap/src/Types';
|
||||
import {compareStructural, makeAutoObservable, reaction, runInAction} from 'mobx';
|
||||
|
||||
const logger = new Logger('GeoIP');
|
||||
const RESOLVE_TIMEOUT_MS = 5000;
|
||||
|
||||
interface ConnectionGeoCoordinates {
|
||||
export type GeoIPResolution = 'pending' | 'resolved' | 'unavailable';
|
||||
|
||||
interface ConnectionGeo {
|
||||
countryCode: string | null;
|
||||
regionCode: string | null;
|
||||
latitude: string | null;
|
||||
longitude: string | null;
|
||||
}
|
||||
|
||||
const UNRESOLVED_GEOIP: GeolocationResponse = {
|
||||
countryCode: null,
|
||||
regionCode: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
ageRestrictedGeos: ageGeos.ageRestrictedGeos,
|
||||
ageBlockedGeos: ageGeos.ageBlockedGeos,
|
||||
};
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
interface AgeTables {
|
||||
restricted: ReadonlyArray<GeoEntry>;
|
||||
blocked: ReadonlyArray<GeoEntry>;
|
||||
}
|
||||
|
||||
async function fetchGeoipOnce(): Promise<GeolocationResponse> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), GEOIP_ATTEMPT_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(GEOIP_PATH, {
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
headers: {Accept: 'application/json'},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`GeoIP lookup failed with status ${response.status}`);
|
||||
}
|
||||
return GeolocationResponse.parse(await response.json());
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
interface ActiveRuntimeContext {
|
||||
readonly instanceKey: string;
|
||||
readonly agePolicy: InstanceAgePolicy | null;
|
||||
readonly transportApiEndpoint: string;
|
||||
}
|
||||
|
||||
function ageTablesFor(policy: InstanceAgePolicy | null): AgeTables {
|
||||
if (policy == null) {
|
||||
return {restricted: AGE_RESTRICTED_GEOS, blocked: AGE_BLOCKED_GEOS};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGeoip(): Promise<GeolocationResponse> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await fetchGeoipOnce();
|
||||
} catch (error) {
|
||||
if (attempt >= GEOIP_ATTEMPTS) {
|
||||
logger.warn('GeoIP lookup failed, continuing without a location:', error);
|
||||
return UNRESOLVED_GEOIP;
|
||||
}
|
||||
await wait(GEOIP_RETRY_DELAY_MS * attempt);
|
||||
const restricted: Array<GeoEntry> = [];
|
||||
const blocked: Array<GeoEntry> = [];
|
||||
for (const geo of policy.geos) {
|
||||
const entry: GeoEntry = {countryCode: geo.country_code, regionCode: geo.region_code};
|
||||
if (geo.action === 'block') {
|
||||
blocked.push(entry);
|
||||
} else {
|
||||
restricted.push(entry);
|
||||
}
|
||||
}
|
||||
return {restricted, blocked};
|
||||
}
|
||||
|
||||
let loading: Promise<void> | null = null;
|
||||
function activeRuntimeContext(): ActiveRuntimeContext | null {
|
||||
const snapshot = RuntimeConfig.getSnapshotOrNull();
|
||||
if (snapshot === null) {
|
||||
return null;
|
||||
}
|
||||
const instanceKey = runtimeInstanceKey(snapshot);
|
||||
if (instanceKey === null) {
|
||||
throw new Error(`Active runtime has an unusable API endpoint: ${snapshot.apiEndpoint}`);
|
||||
}
|
||||
return {
|
||||
instanceKey,
|
||||
agePolicy: snapshot.agePolicy ?? null,
|
||||
transportApiEndpoint: RuntimeConfig.transportApiEndpoint,
|
||||
};
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function parseGeolocationResponse(payload: unknown): ConnectionGeo | null {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return null;
|
||||
}
|
||||
const source = payload as Partial<GeolocationResponse>;
|
||||
return {
|
||||
countryCode: nonEmptyString(source.countryCode),
|
||||
regionCode: nonEmptyString(source.regionCode),
|
||||
latitude: nonEmptyString(source.latitude),
|
||||
longitude: nonEmptyString(source.longitude),
|
||||
};
|
||||
}
|
||||
|
||||
class GeoIP {
|
||||
countryCode: string | null = UNRESOLVED_GEOIP.countryCode;
|
||||
regionCode: string | null = UNRESOLVED_GEOIP.regionCode;
|
||||
latitude: string | null = UNRESOLVED_GEOIP.latitude;
|
||||
longitude: string | null = UNRESOLVED_GEOIP.longitude;
|
||||
ageRestrictedGeos: ReadonlyArray<GeoEntry> = UNRESOLVED_GEOIP.ageRestrictedGeos;
|
||||
ageBlockedGeos: ReadonlyArray<GeoEntry> = UNRESOLVED_GEOIP.ageBlockedGeos;
|
||||
countryCode: string | null = null;
|
||||
regionCode: string | null = null;
|
||||
latitude: string | null = null;
|
||||
longitude: string | null = null;
|
||||
ageRestrictedGeos: ReadonlyArray<GeoEntry> = AGE_RESTRICTED_GEOS;
|
||||
ageBlockedGeos: ReadonlyArray<GeoEntry> = AGE_BLOCKED_GEOS;
|
||||
resolution: GeoIPResolution = 'pending';
|
||||
private generation = 0;
|
||||
private instanceKey: string | null = null;
|
||||
private transportApiEndpoint: string | null = null;
|
||||
private readyWaiters: Array<() => void> = [];
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
const runtime = activeRuntimeContext();
|
||||
this.instanceKey = runtime?.instanceKey ?? null;
|
||||
this.transportApiEndpoint = runtime?.transportApiEndpoint ?? null;
|
||||
this.resolution = runtime === null ? 'unavailable' : 'pending';
|
||||
this.applyAgeTables(ageTablesFor(runtime?.agePolicy ?? null));
|
||||
makeAutoObservable<GeoIP, 'readyWaiters'>(this, {readyWaiters: false}, {autoBind: true});
|
||||
reaction(
|
||||
() => activeRuntimeContext(),
|
||||
(nextRuntime) => {
|
||||
this.adoptRuntime(nextRuntime);
|
||||
},
|
||||
{equals: compareStructural},
|
||||
);
|
||||
if (runtime !== null) {
|
||||
void this.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
load(): Promise<void> {
|
||||
loading ??= fetchGeoip().then((data) => {
|
||||
runInAction(() => {
|
||||
this.countryCode = data.countryCode;
|
||||
this.regionCode = data.regionCode;
|
||||
this.latitude = data.latitude;
|
||||
this.longitude = data.longitude;
|
||||
this.ageRestrictedGeos = data.ageRestrictedGeos;
|
||||
this.ageBlockedGeos = data.ageBlockedGeos;
|
||||
});
|
||||
ready(): Promise<void> {
|
||||
if (this.resolution !== 'pending') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((settle) => {
|
||||
this.readyWaiters.push(settle);
|
||||
});
|
||||
return loading;
|
||||
}
|
||||
|
||||
applyConnectionFallbackCoordinates(data: ConnectionGeoCoordinates): void {
|
||||
if (data.latitude === null || data.longitude === null) {
|
||||
return;
|
||||
}
|
||||
if (this.latitude !== null && this.longitude !== null) {
|
||||
return;
|
||||
}
|
||||
applyConnectionFallbackGeo(data: ConnectionGeo): void {
|
||||
runInAction(() => {
|
||||
if (this.countryCode === null) {
|
||||
this.countryCode = data.countryCode;
|
||||
}
|
||||
if (this.regionCode === null) {
|
||||
this.regionCode = data.regionCode;
|
||||
}
|
||||
if (data.latitude === null || data.longitude === null) return;
|
||||
if (this.latitude === null) {
|
||||
this.latitude = data.latitude;
|
||||
}
|
||||
@@ -117,6 +147,93 @@ class GeoIP {
|
||||
return geo.regionCode === this.regionCode;
|
||||
});
|
||||
}
|
||||
|
||||
private adoptRuntime(runtime: ActiveRuntimeContext | null): void {
|
||||
const instanceKey = runtime?.instanceKey ?? null;
|
||||
const transportApiEndpoint = runtime?.transportApiEndpoint ?? null;
|
||||
const switchedInstance = instanceKey !== this.instanceKey;
|
||||
const endpointChanged = transportApiEndpoint !== this.transportApiEndpoint;
|
||||
const retryUnresolved = this.resolution === 'unavailable' && transportApiEndpoint !== null;
|
||||
const restartResolution = transportApiEndpoint !== null && (switchedInstance || endpointChanged || retryUnresolved);
|
||||
runInAction(() => {
|
||||
this.instanceKey = instanceKey;
|
||||
this.transportApiEndpoint = transportApiEndpoint;
|
||||
this.applyAgeTables(ageTablesFor(runtime?.agePolicy ?? null));
|
||||
if (switchedInstance) {
|
||||
this.countryCode = null;
|
||||
this.regionCode = null;
|
||||
this.latitude = null;
|
||||
this.longitude = null;
|
||||
}
|
||||
if (restartResolution) {
|
||||
this.generation += 1;
|
||||
this.resolution = 'pending';
|
||||
} else if (transportApiEndpoint === null) {
|
||||
this.generation += 1;
|
||||
this.resolution = 'unavailable';
|
||||
}
|
||||
});
|
||||
if (transportApiEndpoint === null) {
|
||||
this.settle('unavailable', this.generation);
|
||||
} else if (restartResolution) {
|
||||
void this.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
private applyAgeTables(tables: AgeTables): void {
|
||||
this.ageRestrictedGeos = tables.restricted;
|
||||
this.ageBlockedGeos = tables.blocked;
|
||||
}
|
||||
|
||||
private async resolve(): Promise<void> {
|
||||
const endpoint = this.transportApiEndpoint;
|
||||
if (endpoint === null) {
|
||||
this.settle('unavailable', this.generation);
|
||||
return;
|
||||
}
|
||||
const generation = this.generation;
|
||||
try {
|
||||
const response = await fetch(`${endpoint.replace(/\/$/u, '')}/ip`, {
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(RESOLVE_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Location lookup failed with status ${response.status}`);
|
||||
}
|
||||
const location = parseGeolocationResponse(await response.json());
|
||||
if (location === null) {
|
||||
throw new Error('Location lookup returned an unreadable body');
|
||||
}
|
||||
if (generation !== this.generation) return;
|
||||
runInAction(() => {
|
||||
if (location.countryCode !== null) {
|
||||
this.countryCode = location.countryCode;
|
||||
this.regionCode = location.regionCode;
|
||||
}
|
||||
if (location.latitude !== null && location.longitude !== null) {
|
||||
this.latitude = location.latitude;
|
||||
this.longitude = location.longitude;
|
||||
}
|
||||
});
|
||||
this.settle('resolved', generation);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to resolve the client location', error);
|
||||
this.settle('unavailable', generation);
|
||||
}
|
||||
}
|
||||
|
||||
private settle(resolution: GeoIPResolution, generation: number): void {
|
||||
if (generation !== this.generation) return;
|
||||
runInAction(() => {
|
||||
this.resolution = resolution;
|
||||
});
|
||||
const waiters = this.readyWaiters;
|
||||
this.readyWaiters = [];
|
||||
for (const waiter of waiters) {
|
||||
waiter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new GeoIP();
|
||||
Reference in new issue
Block a user