Files
fluxer/fluxer_app/src/features/app/state/GeoIP.ts
T

225 lines
6.8 KiB
TypeScript

// 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 {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;
export type GeoIPResolution = 'pending' | 'resolved' | 'unavailable';
interface ConnectionGeo {
countryCode: string | null;
regionCode: string | null;
latitude: string | null;
longitude: string | null;
}
interface AgeTables {
restricted: ReadonlyArray<GeoEntry>;
}
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};
}
const restricted: Array<GeoEntry> = [];
for (const geo of policy.geos) {
const entry: GeoEntry = {countryCode: geo.country_code, regionCode: geo.region_code};
if (geo.action !== 'block') {
restricted.push(entry);
}
}
return {restricted};
}
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 = null;
regionCode: string | null = null;
latitude: string | null = null;
longitude: string | null = null;
ageRestrictedGeos: ReadonlyArray<GeoEntry> = AGE_RESTRICTED_GEOS;
resolution: GeoIPResolution = 'pending';
private generation = 0;
private instanceKey: string | null = null;
private transportApiEndpoint: string | null = null;
private readyWaiters: Array<() => void> = [];
constructor() {
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();
}
}
ready(): Promise<void> {
if (this.resolution !== 'pending') {
return Promise.resolve();
}
return new Promise<void>((settle) => {
this.readyWaiters.push(settle);
});
}
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;
}
if (this.longitude === null) {
this.longitude = data.longitude;
}
});
}
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;
}
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();