refactor(geoip): reconcile geoip system (#31)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
61
fluxer_api/src/utils/UserAgentUtils.ts
Normal file
61
fluxer_api/src/utils/UserAgentUtils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user