refactor(geoip): reconcile geoip system (#31)

This commit is contained in:
hampus-fluxer
2026-01-05 23:19:05 +01:00
committed by GitHub
parent 5d047b2856
commit 2e007b5076
86 changed files with 982 additions and 2648 deletions

View File

@@ -29,7 +29,6 @@ import {AuthController} from '~/auth/AuthController';
import {Config} from '~/Config';
import {ChannelController} from '~/channel/ChannelController';
import type {StreamPreviewService} from '~/channel/services/StreamPreviewService';
import {DebugController} from '~/debug/DebugController';
import {DownloadController} from '~/download/DownloadController';
import {AppErrorHandler, AppNotFoundHandler} from '~/Errors';
import {InvalidApiOriginError} from '~/errors/InvalidApiOriginError';
@@ -230,7 +229,6 @@ routes.get('/_health', async (ctx) => ctx.text('OK'));
GatewayController(routes);
DebugController(routes);
registerAdminControllers(routes);
AuthController(routes);
ChannelController(routes);

View File

@@ -114,8 +114,6 @@ const ConfigSchema = z.object({
}),
geoip: z.object({
provider: z.enum(['ipinfo', 'maxmind']),
host: z.string().optional(),
maxmindDbPath: z.string().optional(),
}),
@@ -313,12 +311,7 @@ function loadConfig() {
: Array.from(new Set([apiPublicEndpoint, webAppEndpoint, apiClientEndpoint]));
const testModeEnabled = optionalBool('FLUXER_TEST_MODE');
const geoipProviderRaw = optional('GEOIP_PROVIDER')?.trim().toLowerCase();
const geoipProvider = geoipProviderRaw === 'maxmind' ? 'maxmind' : 'ipinfo';
const maxmindDbPath = optional('MAXMIND_DB_PATH');
if (geoipProvider === 'maxmind' && !maxmindDbPath) {
throw new Error('Missing required environment variable: MAXMIND_DB_PATH');
}
return ConfigSchema.parse({
nodeEnv: optional('NODE_ENV') || 'development',
@@ -353,8 +346,6 @@ function loadConfig() {
},
geoip: {
provider: geoipProvider,
host: optional('GEOIP_HOST') || 'geoip',
maxmindDbPath,
},

View File

@@ -55,9 +55,9 @@ export const mapUserToAdminResponse = async (user: User, cacheService?: ICacheSe
let lastActiveLocation: string | null = null;
if (user.lastActiveIp) {
try {
const geoip = await IpUtils.getCountryCodeDetailed(user.lastActiveIp);
const geoip = await IpUtils.lookupGeoip(user.lastActiveIp);
const formattedLocation = IpUtils.formatGeoipLocation(geoip);
lastActiveLocation = formattedLocation === IpUtils.UNKNOWN_LOCATION ? null : formattedLocation;
lastActiveLocation = formattedLocation;
} catch {
lastActiveLocation = null;
}

View File

@@ -27,6 +27,8 @@ import type {IEmailService} from '~/infrastructure/IEmailService';
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
import type {IUserRepository} from '~/user/IUserRepository';
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
import * as IpUtils from '~/utils/IpUtils';
import {resolveSessionClientInfo} from '~/utils/UserAgentUtils';
import type {
BulkUpdateUserFlagsRequest,
DisableForSuspiciousActivityRequest,
@@ -350,6 +352,9 @@ export class AdminUserSecurityService {
}
const sessions = await userRepository.listAuthSessions(userIdTyped);
const locationResults = await Promise.allSettled(
sessions.map((session) => IpUtils.getLocationLabelFromIp(session.clientIp)),
);
await auditService.createAuditLog({
adminUserId,
@@ -361,15 +366,23 @@ export class AdminUserSecurityService {
});
return {
sessions: sessions.map((session) => ({
session_id_hash: session.sessionIdHash.toString('base64url'),
created_at: session.createdAt.toISOString(),
approx_last_used_at: session.approximateLastUsedAt.toISOString(),
client_ip: session.clientIp,
client_os: session.clientOs,
client_platform: session.clientPlatform,
client_location: session.clientLocation ?? 'Unknown Location',
})),
sessions: sessions.map((session, index) => {
const locationResult = locationResults[index];
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
const {clientOs, clientPlatform} = resolveSessionClientInfo({
userAgent: session.clientUserAgent,
isDesktopClient: session.clientIsDesktop,
});
return {
session_id_hash: session.sessionIdHash.toString('base64url'),
created_at: session.createdAt.toISOString(),
approx_last_used_at: session.approximateLastUsedAt.toISOString(),
client_ip: session.clientIp,
client_os: clientOs,
client_platform: clientPlatform,
client_location: clientLocation,
};
}),
};
}
}

View File

@@ -20,7 +20,8 @@
import {uint8ArrayToBase64} from 'uint8array-extras';
import type {AuthSession} from '~/Models';
import {createStringType, EmailType, GlobalNameType, PasswordType, UsernameType, z} from '~/Schema';
import {UNKNOWN_LOCATION} from '~/utils/IpUtils';
import {getLocationLabelFromIp} from '~/utils/IpUtils';
import {resolveSessionClientInfo} from '~/utils/UserAgentUtils';
export const RegisterRequest = z.object({
email: EmailType.optional(),
@@ -80,26 +81,45 @@ export const VerifyEmailRequest = z.object({
export type VerifyEmailRequest = z.infer<typeof VerifyEmailRequest>;
export const mapAuthSessionsToResponse = ({
async function resolveAuthSessionLocation(session: AuthSession): Promise<string | null> {
try {
return await getLocationLabelFromIp(session.clientIp);
} catch {
return null;
}
}
export const mapAuthSessionsToResponse = async ({
authSessions,
}: {
authSessions: Array<AuthSession>;
}): Array<AuthSessionResponse> => {
return authSessions
.sort((a, b) => {
const aTime = a.approximateLastUsedAt?.getTime() || 0;
const bTime = b.approximateLastUsedAt?.getTime() || 0;
return bTime - aTime;
})
.map((authSession): AuthSessionResponse => {
return {
id: uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true}),
approx_last_used_at: authSession.approximateLastUsedAt?.toISOString() || null,
client_os: authSession.clientOs,
client_platform: authSession.clientPlatform,
client_location: authSession.clientLocation ?? UNKNOWN_LOCATION,
};
}): Promise<Array<AuthSessionResponse>> => {
const sortedSessions = [...authSessions].sort((a, b) => {
const aTime = a.approximateLastUsedAt?.getTime() || 0;
const bTime = b.approximateLastUsedAt?.getTime() || 0;
return bTime - aTime;
});
const locationResults = await Promise.allSettled(
sortedSessions.map((session) => resolveAuthSessionLocation(session)),
);
return sortedSessions.map((authSession, index): AuthSessionResponse => {
const locationResult = locationResults[index];
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
const {clientOs, clientPlatform} = resolveSessionClientInfo({
userAgent: authSession.clientUserAgent,
isDesktopClient: authSession.clientIsDesktop,
});
return {
id: uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true}),
approx_last_used_at: authSession.approximateLastUsedAt?.toISOString() || null,
client_os: clientOs,
client_platform: clientPlatform,
client_location: clientLocation,
};
});
};
export const AuthSessionResponse = z.object({
@@ -107,7 +127,7 @@ export const AuthSessionResponse = z.object({
approx_last_used_at: z.iso.datetime().nullish(),
client_os: z.string(),
client_platform: z.string(),
client_location: z.string(),
client_location: z.string().nullable(),
});
export type AuthSessionResponse = z.infer<typeof AuthSessionResponse>;

View File

@@ -331,8 +331,8 @@ export class AuthLoginService {
if (!isIpAuthorized) {
const ticket = createIpAuthorizationTicket(await this.generateSecureToken());
const authToken = createIpAuthorizationToken(await this.generateSecureToken());
const geoipResult = await IpUtils.getCountryCodeDetailed(clientIp);
const clientLocation = IpUtils.formatGeoipLocation(geoipResult);
const geoipResult = await IpUtils.lookupGeoip(clientIp);
const clientLocation = IpUtils.formatGeoipLocation(geoipResult) ?? IpUtils.UNKNOWN_LOCATION;
const userAgent = request.headers.get('user-agent') || '';
const platform = request.headers.get('x-fluxer-platform');

View File

@@ -83,8 +83,6 @@ const MINIMUM_AGE_BY_COUNTRY: Record<string, number> = {
const DEFAULT_MINIMUM_AGE = 13;
const USER_AGENT_TRUNCATE_LENGTH = 512;
type CountryResultDetailed = Awaited<ReturnType<typeof IpUtils.getCountryCodeDetailed>>;
interface RegistrationMetadataContext {
metadata: Map<string, string>;
clientIp: string;
@@ -120,11 +118,6 @@ function determineAgeGroup(age: number | null): string {
return '65+';
}
function sanitizeEmail(email: string | null | undefined): {raw: string | null; key: string | null} {
const key = email ? email.toLowerCase() : null;
return {raw: email ?? null, key};
}
function isIpv6(ip: string): boolean {
return ip.includes(':');
}
@@ -189,8 +182,8 @@ export class AuthRegistrationService {
const metrics = getMetricsService();
const clientIp = IpUtils.requireClientIp(request);
const countryCode = await IpUtils.getCountryCodeFromReq(request);
const countryResultDetailed = await IpUtils.getCountryCodeDetailed(clientIp);
const geoipResult = await IpUtils.lookupGeoip(clientIp);
const countryCode = geoipResult.countryCode;
const minAge = (countryCode && MINIMUM_AGE_BY_COUNTRY[countryCode]) || DEFAULT_MINIMUM_AGE;
if (!this.validateAge({dateOfBirth: data.date_of_birth, minAge})) {
@@ -204,7 +197,8 @@ export class AuthRegistrationService {
throw InputValidationError.create('password', 'Password is too common');
}
const {raw: rawEmail, key: emailKey} = sanitizeEmail(data.email);
const rawEmail = data.email ?? null;
const emailKey = rawEmail ? rawEmail.toLowerCase() : null;
const enforceRateLimits = !Config.dev.relaxRegistrationRateLimits;
await this.enforceRegistrationRateLimits({enforceRateLimits, clientIp, emailKey});
@@ -300,7 +294,7 @@ export class AuthRegistrationService {
name: 'user.registration',
dimensions: {
country: countryCode ?? 'unknown',
state: countryResultDetailed.region ?? 'unknown',
state: geoipResult.region ?? 'unknown',
ip_version: isIpv6(clientIp) ? 'v6' : 'v4',
},
});
@@ -310,7 +304,7 @@ export class AuthRegistrationService {
name: 'user.age',
dimensions: {
country: countryCode ?? 'unknown',
state: countryResultDetailed.region ?? 'unknown',
state: geoipResult.region ?? 'unknown',
age: age !== null ? age.toString() : 'unknown',
age_group: determineAgeGroup(age),
},
@@ -333,7 +327,7 @@ export class AuthRegistrationService {
user,
clientIp,
request,
countryResultDetailed,
geoipResult,
});
if (isPendingVerification)
@@ -542,9 +536,9 @@ export class AuthRegistrationService {
user: User;
clientIp: string;
request: Request;
countryResultDetailed: CountryResultDetailed;
geoipResult: IpUtils.GeoipResult;
}): Promise<RegistrationMetadataContext> {
const {user, clientIp, request, countryResultDetailed} = params;
const {user, clientIp, request, geoipResult} = params;
const userAgentHeader = (request.headers.get('user-agent') ?? '').trim();
const fluxerTag = `${user.username}#${user.discriminator.toString().padStart(4, '0')}`;
@@ -559,9 +553,9 @@ export class AuthRegistrationService {
? this.parseUserAgentSafe(userAgentHeader)
: {osInfo: 'Unknown', browserInfo: 'Unknown', deviceInfo: 'Desktop/Unknown'};
const normalizedIp = countryResultDetailed.normalizedIp ?? clientIp;
const geoipReason = countryResultDetailed.reason ?? 'none';
const locationLabel = IpUtils.formatGeoipLocation(countryResultDetailed);
const normalizedIp = geoipResult.normalizedIp ?? clientIp;
const locationLabel = IpUtils.formatGeoipLocation(geoipResult) ?? IpUtils.UNKNOWN_LOCATION;
const safeCountryCode = geoipResult.countryCode ?? 'unknown';
const ipAddressReverse = await IpUtils.getIpAddressReverse(normalizedIp, this.cacheService);
const metadataEntries: Array<[string, string]> = [
@@ -570,27 +564,26 @@ export class AuthRegistrationService {
['email', emailDisplay],
['ip_address', clientIp],
['normalized_ip', normalizedIp],
['country_code', countryResultDetailed.countryCode],
['country_code', safeCountryCode],
['location', locationLabel],
['geoip_reason', geoipReason],
['os', uaInfo.osInfo],
['browser', uaInfo.browserInfo],
['device', uaInfo.deviceInfo],
['user_agent', truncatedUserAgent],
];
if (countryResultDetailed.city) metadataEntries.push(['city', countryResultDetailed.city]);
if (countryResultDetailed.region) metadataEntries.push(['region', countryResultDetailed.region]);
if (countryResultDetailed.countryName) metadataEntries.push(['country_name', countryResultDetailed.countryName]);
if (geoipResult.city) metadataEntries.push(['city', geoipResult.city]);
if (geoipResult.region) metadataEntries.push(['region', geoipResult.region]);
if (geoipResult.countryName) metadataEntries.push(['country_name', geoipResult.countryName]);
if (ipAddressReverse) metadataEntries.push(['ip_address_reverse', ipAddressReverse]);
return {
metadata: new Map(metadataEntries),
clientIp,
countryCode: countryResultDetailed.countryCode,
countryCode: safeCountryCode,
location: locationLabel,
city: countryResultDetailed.city,
region: countryResultDetailed.region,
city: geoipResult.city,
region: geoipResult.region,
osInfo: uaInfo.osInfo,
browserInfo: uaInfo.browserInfo,
deviceInfo: uaInfo.deviceInfo,

View File

@@ -17,12 +17,10 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import Bowser from 'bowser';
import {type AuthSessionResponse, mapAuthSessionsToResponse} from '~/auth/AuthModel';
import type {UserID} from '~/BrandedTypes';
import {AccessDeniedError} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import {Logger} from '~/Logger';
import type {AuthSession, User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import * as IpUtils from '~/utils/IpUtils';
@@ -41,45 +39,6 @@ interface UpdateUserActivityParams {
userId: UserID;
clientIp: string;
}
function formatNameVersion(name?: string | null, version?: string | null): string {
if (!name) return 'Unknown';
if (!version) return name;
return `${name} ${version}`;
}
function nullIfUnknown(value: string | null | undefined): string | null {
if (!value) return null;
return value === IpUtils.UNKNOWN_LOCATION ? null : value;
}
function parseUserAgent(userAgentRaw: string): {clientOs: string; detectedPlatform: string} {
const ua = userAgentRaw.trim();
if (!ua) return {clientOs: 'Unknown', detectedPlatform: 'Unknown'};
try {
const parser = Bowser.getParser(ua);
const osName = parser.getOSName() || 'Unknown';
const osVersion = parser.getOSVersion() || null;
const browserName = parser.getBrowserName() || 'Unknown';
const browserVersion = parser.getBrowserVersion() || null;
return {
clientOs: formatNameVersion(osName, osVersion),
detectedPlatform: formatNameVersion(browserName, browserVersion),
};
} catch (error) {
Logger.warn({error}, 'Failed to parse user agent');
return {clientOs: 'Unknown', detectedPlatform: 'Unknown'};
}
}
function resolveClientPlatform(platformHeader: string | null, detectedPlatform: string): string {
if (!platformHeader) return detectedPlatform;
if (platformHeader === 'desktop') return 'Fluxer Desktop';
return detectedPlatform;
}
export class AuthSessionService {
constructor(
private repository: IUserRepository,
@@ -97,11 +56,7 @@ export class AuthSessionService {
const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null;
const uaRaw = request.headers.get('user-agent') ?? '';
const uaInfo = parseUserAgent(uaRaw);
const geoip = await IpUtils.getCountryCodeDetailed(ip);
const locationLabel = nullIfUnknown(IpUtils.formatGeoipLocation(geoip));
const countryLabel = nullIfUnknown(geoip.countryName ?? geoip.countryCode ?? null);
const isDesktopClient = platformHeader === 'desktop';
const authSession = await this.repository.createAuthSession({
user_id: user.id,
@@ -109,10 +64,8 @@ export class AuthSessionService {
created_at: now,
approx_last_used_at: now,
client_ip: ip,
client_os: uaInfo.clientOs,
client_platform: resolveClientPlatform(platformHeader, uaInfo.detectedPlatform),
client_country: countryLabel,
client_location: locationLabel,
client_user_agent: uaRaw || null,
client_is_desktop: isDesktopClient,
version: 1,
});
@@ -125,7 +78,7 @@ export class AuthSessionService {
async getAuthSessions(userId: UserID): Promise<Array<AuthSessionResponse>> {
const authSessions = await this.repository.listAuthSessions(userId);
return mapAuthSessionsToResponse({authSessions});
return await mapAuthSessionsToResponse({authSessions});
}
async updateAuthSessionLastUsed(tokenHash: Uint8Array): Promise<void> {

View File

@@ -35,10 +35,8 @@ export interface AuthSessionRow {
created_at: Date;
approx_last_used_at: Date;
client_ip: string;
client_os: string;
client_platform: string;
client_country: Nullish<string>;
client_location: Nullish<string>;
client_user_agent: Nullish<string>;
client_is_desktop: Nullish<boolean>;
version: number;
}
@@ -142,10 +140,8 @@ export const AUTH_SESSION_COLUMNS = [
'created_at',
'approx_last_used_at',
'client_ip',
'client_os',
'client_platform',
'client_country',
'client_location',
'client_user_agent',
'client_is_desktop',
'version',
] as const satisfies ReadonlyArray<keyof AuthSessionRow>;

View File

@@ -1,63 +0,0 @@
/*
* 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 type {HonoApp} from '~/App';
import {Config} from '~/Config';
import {DEFAULT_CC, extractClientIp, formatGeoipLocation, getCountryCodeDetailed} from '~/utils/IpUtils';
export const DebugController = (app: HonoApp) => {
app.get('/_debug/geoip', async (ctx) => {
const manualIp = ctx.req.query('ip')?.trim() || null;
const headerIp = extractClientIp(ctx.req.raw);
const chosenIp = manualIp || headerIp;
let countryCode = DEFAULT_CC;
let normalizedIp: string | null = null;
let error: string | null = null;
let reason: string | null = null;
let geoipLocation: string | null = null;
if (chosenIp) {
try {
const result = await getCountryCodeDetailed(chosenIp);
countryCode = result.countryCode;
normalizedIp = result.normalizedIp;
reason = result.reason;
geoipLocation = formatGeoipLocation(result);
} catch (err) {
error = (err as Error).message;
}
}
return ctx.json({
x_forwarded_for: ctx.req.header('x-forwarded-for') || null,
ip: chosenIp,
manual_ip: manualIp,
extracted_ip: headerIp,
country_code: countryCode,
normalized_ip: normalizedIp,
reason,
geoip_host: Config.geoip.host || null,
geoip_provider: Config.geoip.provider,
geoip_location: geoipLocation,
default_cc: DEFAULT_CC,
error,
});
});
};

View File

@@ -26,10 +26,8 @@ export class AuthSession {
readonly createdAt: Date;
readonly approximateLastUsedAt: Date;
readonly clientIp: string;
readonly clientOs: string;
readonly clientPlatform: string;
readonly clientCountry: string | null;
readonly clientLocation: string | null;
readonly clientUserAgent: string | null;
readonly clientIsDesktop: boolean | null;
readonly version: number;
constructor(row: AuthSessionRow) {
@@ -38,10 +36,8 @@ export class AuthSession {
this.createdAt = row.created_at;
this.approximateLastUsedAt = row.approx_last_used_at;
this.clientIp = row.client_ip;
this.clientOs = row.client_os;
this.clientPlatform = row.client_platform;
this.clientCountry = row.client_country ?? null;
this.clientLocation = row.client_location ?? null;
this.clientUserAgent = row.client_user_agent ?? null;
this.clientIsDesktop = row.client_is_desktop ?? null;
this.version = row.version;
}
@@ -52,10 +48,8 @@ export class AuthSession {
created_at: this.createdAt,
approx_last_used_at: this.approximateLastUsedAt,
client_ip: this.clientIp,
client_os: this.clientOs,
client_platform: this.clientPlatform,
client_country: this.clientCountry,
client_location: this.clientLocation,
client_user_agent: this.clientUserAgent,
client_is_desktop: this.clientIsDesktop,
version: this.version,
};
}

View File

@@ -68,6 +68,10 @@ export const RpcRequest = z.discriminatedUnion('type', [
type: z.literal('get_badge_counts'),
user_ids: z.array(Int64Type),
}),
z.object({
type: z.literal('geoip_lookup'),
ip: createStringType(1, 45),
}),
z.object({
type: z.literal('delete_push_subscriptions'),
subscriptions: z.array(
@@ -290,6 +294,10 @@ export const RpcResponse = z.discriminatedUnion('type', [
type: z.literal('validate_custom_status'),
data: RpcResponseValidateCustomStatus,
}),
z.object({
type: z.literal('geoip_lookup'),
data: z.object({country_code: z.string()}),
}),
z.object({
type: z.literal('get_dm_channel'),
data: z.object({

View File

@@ -97,7 +97,7 @@ import {
import {isUserAdult} from '~/utils/AgeUtils';
import {deriveDominantAvatarColor} from '~/utils/AvatarColorUtils';
import {calculateDistance, parseCoordinate} from '~/utils/GeoUtils';
import {formatGeoipLocation, getCountryCodeDetailed} from '~/utils/IpUtils';
import {lookupGeoip} from '~/utils/IpUtils';
import type {VoiceAccessContext, VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
import type {VoiceService} from '~/voice/VoiceService';
import type {IWebhookRepository} from '~/webhook/IWebhookRepository';
@@ -284,6 +284,15 @@ export class RpcService {
},
};
}
case 'geoip_lookup': {
const geoip = await lookupGeoip(request.ip);
return {
type: 'geoip_lookup',
data: {
country_code: geoip.countryCode,
},
};
}
case 'delete_push_subscriptions':
return {
type: 'delete_push_subscriptions',
@@ -678,24 +687,9 @@ export class RpcService {
});
let countryCode = 'US';
let geoipReason: string | null = null;
if (ip) {
const geoip = await getCountryCodeDetailed(ip);
const geoip = await lookupGeoip(ip);
countryCode = geoip.countryCode;
geoipReason = geoip.reason;
if (geoipReason) {
Logger.warn(
{
ip,
normalized_ip: geoip.normalizedIp,
reason: geoipReason,
geoip_host: Config.geoip.host,
geoip_provider: Config.geoip.provider,
geoip_location: formatGeoipLocation(geoip),
},
'GeoIP lookup fell back to default country code',
);
}
} else {
Logger.warn({context: 'rpc_geoip', reason: 'ip_missing'}, 'RPC session request missing IP for GeoIP');
}

View File

@@ -74,8 +74,6 @@ process.env.CLAMAV_ENABLED = 'false';
process.env.FLUXER_APP_HOST = 'localhost:3000';
process.env.FLUXER_APP_PROTOCOL = 'http';
process.env.GEOIP_HOST = 'geoip.test';
process.env.GEOIP_PROVIDER = 'ipinfo';
process.env.SUDO_MODE_SECRET = 'test-sudo-secret';
import {vi} from 'vitest';

View File

@@ -1,136 +0,0 @@
/*
* 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 {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {__clearIpCache, DEFAULT_CC, FETCH_TIMEOUT_MS, formatGeoipLocation, getCountryCode} from './IpUtils';
vi.mock('~/Config', () => ({Config: {geoip: {provider: 'ipinfo', host: 'geoip:8080'}}}));
describe('getCountryCode(ip)', () => {
const realFetch = globalThis.fetch;
beforeEach(() => {
__clearIpCache();
vi.useRealTimers();
vi.restoreAllMocks();
});
afterEach(() => {
globalThis.fetch = realFetch;
});
it('returns US if GEOIP_HOST is not set', async () => {
vi.doMock('~/Config', () => ({Config: {geoip: {provider: 'ipinfo', host: ''}}}));
const {getCountryCode: modFn, __clearIpCache: reset} = await import('./IpUtils');
reset();
const cc = await modFn('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('returns US for invalid IP', async () => {
const cc = await getCountryCode('not_an_ip');
expect(cc).toBe(DEFAULT_CC);
});
it('accepts bracketed IPv6', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('se')});
const cc = await getCountryCode('[2001:db8::1]');
expect(cc).toBe('SE');
});
it('returns uppercase alpha-2 on success', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('se')});
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe('SE');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
it('falls back on non-2xx', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: false, text: () => Promise.resolve('se')});
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('falls back on invalid body', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('USA')});
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('falls back on network error', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('fail'));
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('uses cache for repeated lookups within TTL', async () => {
const spyNow = vi.spyOn(Date, 'now');
spyNow.mockReturnValueOnce(1_000);
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('gb')});
const r1 = await getCountryCode('1.1.1.1');
expect(r1).toBe('GB');
spyNow.mockReturnValueOnce(2_000);
const r2 = await getCountryCode('1.1.1.1');
expect(r2).toBe('GB');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
it('aborts after timeout', async () => {
vi.useFakeTimers();
const abortingFetch = vi.fn<typeof fetch>(
(_url, opts) =>
new Promise((_res, rej) => {
opts?.signal?.addEventListener('abort', () =>
rej(Object.assign(new Error('AbortError'), {name: 'AbortError'})),
);
}),
);
globalThis.fetch = abortingFetch;
const p = getCountryCode('8.8.8.8');
vi.advanceTimersByTime(FETCH_TIMEOUT_MS + 5);
await expect(p).resolves.toBe(DEFAULT_CC);
expect(abortingFetch).toHaveBeenCalledTimes(1);
});
});
describe('formatGeoipLocation(result)', () => {
it('returns Unknown Location when no parts are available', () => {
const label = formatGeoipLocation({
countryCode: DEFAULT_CC,
normalizedIp: '1.1.1.1',
reason: null,
city: null,
region: null,
countryName: null,
});
expect(label).toBe('Unknown Location');
});
it('concatenates available city, region, and country', () => {
const label = formatGeoipLocation({
countryCode: 'US',
normalizedIp: '1.1.1.1',
reason: null,
city: 'San Francisco',
region: 'CA',
countryName: 'United States',
});
expect(label).toBe('San Francisco, CA, United States');
});
});

View File

@@ -25,42 +25,43 @@ import type {ICacheService} from '~/infrastructure/ICacheService';
import {Logger} from '~/Logger';
const CACHE_TTL_MS = 10 * 60 * 1000;
export const DEFAULT_CC = 'US';
export const FETCH_TIMEOUT_MS = 1500;
export const UNKNOWN_LOCATION = 'Unknown Location';
const REVERSE_DNS_CACHE_TTL_SECONDS = 24 * 60 * 60;
const REVERSE_DNS_CACHE_PREFIX = 'reverse-dns:';
export const UNKNOWN_LOCATION = 'Unknown Location';
export interface GeoipResult {
countryCode: string;
countryCode: string | null;
normalizedIp: string | null;
reason: string | null;
city: string | null;
region: string | null;
countryName: string | null;
}
interface CacheVal {
type CacheEntry = {
result: GeoipResult;
exp: number;
}
expiresAt: number;
};
const cache = new Map<string, CacheVal>();
const geoipCache = new Map<string, CacheEntry>();
let maxmindReader: Reader<CityResponse> | null = null;
let maxmindReaderPromise: Promise<Reader<CityResponse>> | null = null;
export function __clearIpCache(): void {
cache.clear();
geoipCache.clear();
}
export function __resetMaxmindReader(): void {
maxmindReader = null;
maxmindReaderPromise = null;
}
export function extractClientIp(req: Request): string | null {
const xff = (req.headers.get('X-Forwarded-For') ?? '').trim();
if (!xff) {
return null;
}
const first = xff.split(',')[0]?.trim() ?? '';
if (!xff) return null;
const [first] = xff.split(',');
if (!first) return null;
return normalizeIpString(first);
}
@@ -72,24 +73,33 @@ export function requireClientIp(req: Request): string {
return ip;
}
function buildFallbackResult(clean: string, reason: string | null): GeoipResult {
export async function lookupGeoip(req: Request): Promise<GeoipResult>;
export async function lookupGeoip(ip: string): Promise<GeoipResult>;
export async function lookupGeoip(input: string | Request): Promise<GeoipResult> {
const ip = typeof input === 'string' ? input : extractClientIp(input);
if (!ip) {
return buildFallbackResult('');
}
return lookupGeoipFromString(ip);
}
function buildFallbackResult(clean: string): GeoipResult {
return {
countryCode: DEFAULT_CC,
normalizedIp: clean,
reason,
countryCode: null,
normalizedIp: clean || null,
city: null,
region: null,
countryName: null,
};
}
async function getMaxmindReader(): Promise<Reader<CityResponse>> {
async function ensureMaxmindReader(): Promise<Reader<CityResponse>> {
if (maxmindReader) return maxmindReader;
if (!maxmindReaderPromise) {
const dbPath = Config.geoip.maxmindDbPath;
if (!dbPath) {
return Promise.reject(new Error('Missing MaxMind DB path'));
throw new Error('Missing MaxMind DB path');
}
maxmindReaderPromise = maxmind
@@ -107,127 +117,79 @@ async function getMaxmindReader(): Promise<Reader<CityResponse>> {
return maxmindReaderPromise;
}
function getSubdivisionLabel(record?: CityResponse): string | null {
function stateLabel(record?: CityResponse): string | null {
const subdivision = record?.subdivisions?.[0];
if (!subdivision) return null;
return subdivision.iso_code || subdivision.names?.en || null;
return subdivision.names?.en || subdivision.iso_code || null;
}
async function lookupMaxmind(clean: string): Promise<GeoipResult> {
const dbPath = Config.geoip.maxmindDbPath;
if (!dbPath) {
return buildFallbackResult(clean, 'maxmind_db_missing');
return buildFallbackResult(clean);
}
try {
const reader = await getMaxmindReader();
const reader = await ensureMaxmindReader();
const record = reader.get(clean);
if (!record) {
return buildFallbackResult(clean, 'maxmind_not_found');
}
if (!record) return buildFallbackResult(clean);
const isoCode = record.country?.iso_code;
const countryCode = isoCode ? isoCode.toUpperCase() : null;
const countryCode = (record.country?.iso_code ?? DEFAULT_CC).toUpperCase();
return {
countryCode,
normalizedIp: clean,
reason: null,
city: record.city?.names?.en ?? null,
region: getSubdivisionLabel(record),
countryName: record.country?.names?.en ?? countryDisplayName(countryCode) ?? null,
region: stateLabel(record),
countryName: record.country?.names?.en ?? (countryCode ? countryDisplayName(countryCode) : null) ?? null,
};
} catch (error) {
const message = (error as Error)?.message ?? 'unknown';
Logger.warn({error, maxmind_db_path: dbPath}, 'MaxMind lookup failed');
return buildFallbackResult(clean, `maxmind_error:${message}`);
const message = (error as Error).message ?? 'unknown';
Logger.warn({error, maxmind_db_path: dbPath, message}, 'MaxMind lookup failed');
return buildFallbackResult(clean);
}
}
async function lookupIpinfo(clean: string): Promise<GeoipResult> {
const host = Config.geoip.host;
if (!host) {
return buildFallbackResult(clean, 'geoip_host_missing');
}
const url = `http://${host}/lookup?ip=${encodeURIComponent(clean)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await globalThis.fetch!(url, {
signal: controller.signal,
headers: {Accept: 'text/plain'},
});
if (!res.ok) {
return buildFallbackResult(clean, `non_ok:${res.status}`);
}
const text = (await res.text()).trim().toUpperCase();
const countryCode = isAsciiUpperAlpha2(text) ? text : DEFAULT_CC;
const reason = isAsciiUpperAlpha2(text) ? null : 'invalid_response';
return {
countryCode,
normalizedIp: clean,
reason,
city: null,
region: null,
countryName: countryDisplayName(countryCode),
};
} catch (error) {
const message = (error as Error)?.message ?? 'unknown';
return buildFallbackResult(clean, `error:${message}`);
} finally {
clearTimeout(timer);
}
}
export async function getCountryCodeDetailed(ip: string): Promise<GeoipResult> {
const clean = normalizeIpString(ip);
if (!isIPv4(clean) && !isIPv6(clean)) {
return buildFallbackResult(clean, 'invalid_ip');
}
const cached = cache.get(clean);
async function resolveGeoip(clean: string): Promise<GeoipResult> {
const now = Date.now();
if (cached && now < cached.exp) {
const cached = geoipCache.get(clean);
if (cached && now < cached.expiresAt) {
return cached.result;
}
const provider = Config.geoip.provider;
const result = provider === 'maxmind' ? await lookupMaxmind(clean) : await lookupIpinfo(clean);
cache.set(clean, {result, exp: now + CACHE_TTL_MS});
const result = await lookupMaxmind(clean);
geoipCache.set(clean, {result, expiresAt: now + CACHE_TTL_MS});
return result;
}
export async function getCountryCode(ip: string): Promise<string> {
const result = await getCountryCodeDetailed(ip);
return result.countryCode;
}
async function lookupGeoipFromString(value: string): Promise<GeoipResult> {
const clean = normalizeIpString(value);
if (!isIPv4(clean) && !isIPv6(clean)) {
return buildFallbackResult(clean);
}
export async function getCountryCodeFromReq(req: Request): Promise<string> {
const ip = extractClientIp(req);
if (!ip) return DEFAULT_CC;
return await getCountryCode(ip);
return resolveGeoip(clean);
}
function countryDisplayName(code: string, locale = 'en'): string | null {
const c = code.toUpperCase();
if (!isAsciiUpperAlpha2(c)) return null;
const upper = code.toUpperCase();
if (!isAsciiUpperAlpha2(upper)) return null;
const dn = new Intl.DisplayNames([locale], {type: 'region', fallback: 'none'});
return dn.of(c) ?? null;
return dn.of(upper) ?? null;
}
export function formatGeoipLocation(result: GeoipResult): string {
export function formatGeoipLocation(result: GeoipResult): string | null {
const parts: Array<string> = [];
if (result.city) parts.push(result.city);
if (result.region) parts.push(result.region);
const countryLabel = result.countryName ?? result.countryCode;
if (countryLabel) parts.push(countryLabel);
return parts.length > 0 ? parts.join(', ') : UNKNOWN_LOCATION;
return parts.length > 0 ? parts.join(', ') : null;
}
function stripBrackets(s: string): string {
return s.startsWith('[') && s.endsWith(']') ? s.slice(1, -1) : s;
function stripBrackets(value: string): string {
return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
}
export function normalizeIpString(value: string): string {
@@ -239,12 +201,9 @@ export function normalizeIpString(value: string): string {
export async function getIpAddressReverse(ip: string, cacheService?: ICacheService): Promise<string | null> {
const cacheKey = `${REVERSE_DNS_CACHE_PREFIX}${ip}`;
if (cacheService) {
const cached = await cacheService.get<string | null>(cacheKey);
if (cached !== null) {
return cached === '' ? null : cached;
}
if (cached !== null) return cached === '' ? null : cached;
}
let result: string | null = null;
@@ -262,9 +221,17 @@ export async function getIpAddressReverse(ip: string, cacheService?: ICacheServi
return result;
}
function isAsciiUpperAlpha2(s: string): boolean {
if (s.length !== 2) return false;
const a = s.charCodeAt(0);
const b = s.charCodeAt(1);
return a >= 65 && a <= 90 && b >= 65 && b <= 90;
export async function getLocationLabelFromIp(ip: string): Promise<string | null> {
const result = await lookupGeoip(ip);
return formatGeoipLocation(result);
}
function isAsciiUpperAlpha2(value: string): boolean {
return (
value.length === 2 &&
value.charCodeAt(0) >= 65 &&
value.charCodeAt(0) <= 90 &&
value.charCodeAt(1) >= 65 &&
value.charCodeAt(1) <= 90
);
}

View File

@@ -0,0 +1,61 @@
/*
* 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 Bowser from 'bowser';
import {Logger} from '~/Logger';
export interface UserAgentInfo {
clientOs: string;
detectedPlatform: string;
}
const UNKNOWN_LABEL = 'Unknown';
function formatName(name?: string | null): string {
const normalized = name?.trim();
return normalized || UNKNOWN_LABEL;
}
export function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo {
const ua = userAgentRaw.trim();
if (!ua) return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
try {
const parser = Bowser.getParser(ua);
return {
clientOs: formatName(parser.getOSName()),
detectedPlatform: formatName(parser.getBrowserName()),
};
} catch (error) {
Logger.warn({error}, 'Failed to parse user agent');
return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
}
}
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
clientOs: string;
clientPlatform: string;
} {
const parsed = parseUserAgentSafe(args.userAgent ?? '');
const clientPlatform = args.isDesktopClient ? 'Fluxer Desktop' : parsed.detectedPlatform;
return {
clientOs: parsed.clientOs,
clientPlatform,
};
}

View File

@@ -27,6 +27,7 @@ import {Config} from '~/Config';
import {makeAttachmentCdnUrl} from '~/channel/services/message/MessageHelpers';
import {Logger} from '~/Logger';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
import {resolveSessionClientInfo} from '~/utils/UserAgentUtils';
import {appendAssetToArchive, buildHashedAssetKey, getAnimatedAssetExtension} from '../utils/AssetArchiveHelpers';
import {getWorkerDependencies} from '../WorkerContext';
@@ -324,15 +325,20 @@ const harvestUserData: Task = async (payload, helpers) => {
mfa_enabled: user.authenticatorTypes.size > 0,
authenticator_types: Array.from(user.authenticatorTypes),
},
auth_sessions: authSessions.map((session) => ({
created_at: session.createdAt.toISOString(),
approx_last_used_at: session.approximateLastUsedAt?.toISOString() ?? null,
client_ip: session.clientIp,
client_os: session.clientOs,
client_platform: session.clientPlatform,
client_country: session.clientCountry,
client_location: session.clientLocation,
})),
auth_sessions: authSessions.map((session) => {
const {clientOs, clientPlatform} = resolveSessionClientInfo({
userAgent: session.clientUserAgent,
isDesktopClient: session.clientIsDesktop,
});
return {
created_at: session.createdAt.toISOString(),
approx_last_used_at: session.approximateLastUsedAt?.toISOString() ?? null,
client_ip: session.clientIp,
client_os: clientOs,
client_user_agent: session.clientUserAgent,
client_platform: clientPlatform,
};
}),
relationships: relationships.map((rel) => ({
target_user_id: rel.targetUserId.toString(),
type: rel.type,