refactor: switch to bowser for user agent parsing (#30)

This commit is contained in:
hampus-fluxer 2026-01-05 14:32:55 +01:00 committed by GitHub
parent a9da71c7d7
commit 2cd7aa5863
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 396 additions and 326 deletions

View File

@ -54,7 +54,7 @@
"transliteration": "2.6.0", "transliteration": "2.6.0",
"tsx": "4.21.0", "tsx": "4.21.0",
"twilio": "5.11.1", "twilio": "5.11.1",
"ua-parser-js": "2.0.7", "bowser": "2.13.1",
"uint8array-extras": "1.5.0", "uint8array-extras": "1.5.0",
"undici": "7.16.0", "undici": "7.16.0",
"unique-names-generator": "4.7.1", "unique-names-generator": "4.7.1",

View File

@ -44,6 +44,9 @@ importers:
argon2: argon2:
specifier: 0.44.0 specifier: 0.44.0
version: 0.44.0 version: 0.44.0
bowser:
specifier: 2.13.1
version: 2.13.1
cassandra-driver: cassandra-driver:
specifier: 4.8.0 specifier: 4.8.0
version: 4.8.0 version: 4.8.0
@ -131,9 +134,6 @@ importers:
twilio: twilio:
specifier: 5.11.1 specifier: 5.11.1
version: 5.11.1 version: 5.11.1
ua-parser-js:
specifier: 2.0.7
version: 2.0.7
uint8array-extras: uint8array-extras:
specifier: 1.5.0 specifier: 1.5.0
version: 1.5.0 version: 1.5.0
@ -1948,9 +1948,6 @@ packages:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
detect-europe-js@0.1.2:
resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2260,9 +2257,6 @@ packages:
is-potential-custom-element-name@1.0.1: is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-standalone-pwa@0.1.1:
resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==}
is-stream@2.0.1: is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -3001,13 +2995,6 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
ua-is-frozen@0.1.2:
resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
ua-parser-js@2.0.7:
resolution: {integrity: sha512-CFdHVHr+6YfbktNZegH3qbYvYgC7nRNEUm2tk7nSFXSODUu4tDBpaFpP1jdXBUOKKwapVlWRfTtS8bCPzsQ47w==}
hasBin: true
uint8array-extras@1.5.0: uint8array-extras@1.5.0:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -5439,8 +5426,6 @@ snapshots:
denque@2.1.0: {} denque@2.1.0: {}
detect-europe-js@0.1.2: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dom-serializer@2.0.0: dom-serializer@2.0.0:
@ -5796,8 +5781,6 @@ snapshots:
is-potential-custom-element-name@1.0.1: {} is-potential-custom-element-name@1.0.1: {}
is-standalone-pwa@0.1.1: {}
is-stream@2.0.1: {} is-stream@2.0.1: {}
is-stream@3.0.0: {} is-stream@3.0.0: {}
@ -6587,14 +6570,6 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
ua-is-frozen@0.1.2: {}
ua-parser-js@2.0.7:
dependencies:
detect-europe-js: 0.1.2
is-standalone-pwa: 0.1.1
ua-is-frozen: 0.1.2
uint8array-extras@1.5.0: {} uint8array-extras@1.5.0: {}
uint8arrays@3.0.0: uint8arrays@3.0.0:

View File

@ -18,8 +18,8 @@
*/ */
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import Bowser from 'bowser';
import {types} from 'cassandra-driver'; import {types} from 'cassandra-driver';
import {UAParser} from 'ua-parser-js';
import type {RegisterRequest} from '~/auth/AuthModel'; import type {RegisterRequest} from '~/auth/AuthModel';
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '~/BrandedTypes'; import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '~/BrandedTypes';
import {Config} from '~/Config'; import {Config} from '~/Config';
@ -81,9 +81,10 @@ const MINIMUM_AGE_BY_COUNTRY: Record<string, number> = {
}; };
const DEFAULT_MINIMUM_AGE = 13; const DEFAULT_MINIMUM_AGE = 13;
const USER_AGENT_TRUNCATE_LENGTH = 512; const USER_AGENT_TRUNCATE_LENGTH = 512;
type CountryResultDetailed = Awaited<ReturnType<typeof IpUtils.getCountryCodeDetailed>>;
interface RegistrationMetadataContext { interface RegistrationMetadataContext {
metadata: Map<string, string>; metadata: Map<string, string>;
clientIp: string; clientIp: string;
@ -112,19 +113,43 @@ const AGE_BUCKETS: Array<{label: string; min: number; max: number}> = [
]; ];
function determineAgeGroup(age: number | null): string { function determineAgeGroup(age: number | null): string {
if (age === null || age < 0) { if (age === null || age < 0) return 'unknown';
return 'unknown';
}
for (const bucket of AGE_BUCKETS) { for (const bucket of AGE_BUCKETS) {
if (age >= bucket.min && age <= bucket.max) { if (age >= bucket.min && age <= bucket.max) return bucket.label;
return bucket.label;
}
} }
return '65+'; 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(':');
}
function rateLimitError(message: string): FluxerAPIError {
return new FluxerAPIError({code: APIErrorCodes.RATE_LIMITED, message, status: 429});
}
function parseDobLocalDate(dateOfBirth: string): types.LocalDate {
try {
return types.LocalDate.fromString(dateOfBirth);
} catch {
throw InputValidationError.create('date_of_birth', 'Invalid date of birth format');
}
}
function safeJsonParse<T>(value: string): T | null {
try {
return JSON.parse(value) as T;
} catch (error) {
Logger.warn({error}, 'Failed to parse JSON from environment variable');
return null;
}
}
interface RegisterParams { interface RegisterParams {
data: RegisterRequest; data: RegisterRequest;
request: Request; request: Request;
@ -132,9 +157,11 @@ interface RegisterParams {
} }
export class AuthRegistrationService { export class AuthRegistrationService {
private instanceConfigRepository = new InstanceConfigRepository();
constructor( constructor(
private repository: IUserRepository, private repository: IUserRepository,
private inviteService: InviteService, private inviteService: InviteService | null,
private rateLimitService: IRateLimitService, private rateLimitService: IRateLimitService,
private emailService: IEmailService, private emailService: IEmailService,
private snowflakeService: SnowflakeService, private snowflakeService: SnowflakeService,
@ -158,9 +185,13 @@ export class AuthRegistrationService {
throw InputValidationError.create('consent', 'You must agree to the Terms of Service and Privacy Policy'); throw InputValidationError.create('consent', 'You must agree to the Terms of Service and Privacy Policy');
} }
const countryCode = await IpUtils.getCountryCodeFromReq(request); const now = new Date();
const metrics = getMetricsService();
const clientIp = IpUtils.requireClientIp(request); const clientIp = IpUtils.requireClientIp(request);
const countryCode = await IpUtils.getCountryCodeFromReq(request);
const countryResultDetailed = await IpUtils.getCountryCodeDetailed(clientIp); const countryResultDetailed = await IpUtils.getCountryCodeDetailed(clientIp);
const minAge = (countryCode && MINIMUM_AGE_BY_COUNTRY[countryCode]) || DEFAULT_MINIMUM_AGE; const minAge = (countryCode && MINIMUM_AGE_BY_COUNTRY[countryCode]) || DEFAULT_MINIMUM_AGE;
if (!this.validateAge({dateOfBirth: data.date_of_birth, minAge})) { if (!this.validateAge({dateOfBirth: data.date_of_birth, minAge})) {
throw InputValidationError.create( throw InputValidationError.create(
@ -173,109 +204,43 @@ export class AuthRegistrationService {
throw InputValidationError.create('password', 'Password is too common'); throw InputValidationError.create('password', 'Password is too common');
} }
const {raw: rawEmail, key: emailKey} = sanitizeEmail(data.email);
const enforceRateLimits = !Config.dev.relaxRegistrationRateLimits; const enforceRateLimits = !Config.dev.relaxRegistrationRateLimits;
await this.enforceRegistrationRateLimits({enforceRateLimits, clientIp, emailKey});
if (enforceRateLimits && data.email) { const {betaCode, hasValidBetaCode} = await this.resolveBetaCode(data.beta_code ?? null);
const emailRateLimit = await this.rateLimitService.checkLimit({
identifier: `registration:email:${data.email}`,
maxAttempts: 3,
windowMs: 15 * 60 * 1000,
});
if (!emailRateLimit.allowed) { if (rawEmail) {
throw new FluxerAPIError({ const emailTaken = await this.repository.findByEmail(rawEmail);
code: APIErrorCodes.RATE_LIMITED, if (emailTaken) throw InputValidationError.create('email', 'Email already in use');
message: 'Too many registration attempts. Please try again later.',
status: 429,
});
}
}
if (enforceRateLimits) {
const ipRateLimit = await this.rateLimitService.checkLimit({
identifier: `registration:ip:${clientIp}`,
maxAttempts: 5,
windowMs: 30 * 60 * 1000,
});
if (!ipRateLimit.allowed) {
throw new FluxerAPIError({
code: APIErrorCodes.RATE_LIMITED,
message: 'Too many registration attempts from this IP. Please try again later.',
status: 429,
});
}
}
let betaCode = null;
let hasValidBetaCode = false;
if (data.beta_code) {
if (Config.nodeEnv === 'development' && data.beta_code === 'NOVERIFY') {
hasValidBetaCode = false;
} else {
betaCode = await this.repository.getBetaCode(data.beta_code);
if (betaCode && !betaCode.redeemerId) {
hasValidBetaCode = true;
}
}
}
const rawEmail = data.email?.trim() || null;
const normalizedEmail = rawEmail?.toLowerCase() || null;
if (normalizedEmail) {
const emailTaken = await this.repository.findByEmail(normalizedEmail);
if (emailTaken) {
throw InputValidationError.create('email', 'Email already in use');
}
} }
const username = data.username || generateRandomUsername(); const username = data.username || generateRandomUsername();
const discriminator = await this.allocateDiscriminator(username);
const discriminatorResult = await this.discriminatorService.generateDiscriminator({ const userId = this.generateUserId(emailKey);
username,
isPremium: false,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
throw InputValidationError.create('username', 'Too many users with this username');
}
const discriminator = discriminatorResult.discriminator;
let userId: UserID;
if (normalizedEmail && process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE) {
const mapping = JSON.parse(process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE) as Record<string, string>;
const emailHash = crypto.createHash('sha256').update(normalizedEmail).digest('hex');
const mappedUserId = mapping[emailHash];
userId = mappedUserId ? createUserID(BigInt(mappedUserId)) : createUserID(this.snowflakeService.generate());
} else {
userId = createUserID(this.snowflakeService.generate());
}
const acceptLanguage = request.headers.get('accept-language'); const acceptLanguage = request.headers.get('accept-language');
const userLocale = parseAcceptLanguage(acceptLanguage); const userLocale = parseAcceptLanguage(acceptLanguage);
const passwordHash = data.password ? await this.hashPassword(data.password) : null; const passwordHash = data.password ? await this.hashPassword(data.password) : null;
const instanceConfigRepository = new InstanceConfigRepository(); const instanceConfig = await this.instanceConfigRepository.getInstanceConfig();
const instanceConfig = await instanceConfigRepository.getInstanceConfig(); const isManualReviewActive = this.instanceConfigRepository.isManualReviewActiveNow(instanceConfig);
const isManualReviewActive = instanceConfigRepository.isManualReviewActiveNow(instanceConfig);
const shouldRequireVerification = const shouldRequireVerification =
(isManualReviewActive && Config.nodeEnv === 'production') || (isManualReviewActive && Config.nodeEnv === 'production') ||
(Config.nodeEnv === 'development' && data.beta_code === 'NOVERIFY'); (Config.nodeEnv === 'development' && data.beta_code === 'NOVERIFY');
const isPendingVerification = shouldRequireVerification && !hasValidBetaCode; const isPendingVerification = shouldRequireVerification && !hasValidBetaCode;
let baseFlags = Config.nodeEnv === 'development' ? UserFlags.STAFF : 0n; let flags = Config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
if (isPendingVerification) { if (isPendingVerification) flags |= UserFlags.PENDING_MANUAL_VERIFICATION;
baseFlags |= UserFlags.PENDING_MANUAL_VERIFICATION;
}
const now = new Date();
const user = await this.repository.create({ const user = await this.repository.create({
user_id: userId, user_id: userId,
username, username,
discriminator: discriminator, discriminator,
global_name: data.global_name || null, global_name: data.global_name || null,
bot: false, bot: false,
system: false, system: false,
@ -284,7 +249,7 @@ export class AuthRegistrationService {
email_bounced: false, email_bounced: false,
phone: null, phone: null,
password_hash: passwordHash, password_hash: passwordHash,
password_last_changed_at: passwordHash ? new Date() : null, password_last_changed_at: passwordHash ? now : null,
totp_secret: null, totp_secret: null,
authenticator_types: new Set(), authenticator_types: new Set(),
avatar_hash: null, avatar_hash: null,
@ -294,9 +259,9 @@ export class AuthRegistrationService {
bio: null, bio: null,
pronouns: null, pronouns: null,
accent_color: null, accent_color: null,
date_of_birth: types.LocalDate.fromString(data.date_of_birth), date_of_birth: parseDobLocalDate(data.date_of_birth),
locale: userLocale, locale: userLocale,
flags: baseFlags, flags,
premium_type: null, premium_type: null,
premium_since: null, premium_since: null,
premium_until: null, premium_until: null,
@ -307,8 +272,8 @@ export class AuthRegistrationService {
stripe_customer_id: null, stripe_customer_id: null,
has_ever_purchased: null, has_ever_purchased: null,
suspicious_activity_flags: null, suspicious_activity_flags: null,
terms_agreed_at: new Date(), terms_agreed_at: now,
privacy_agreed_at: new Date(), privacy_agreed_at: now,
last_active_at: now, last_active_at: now,
last_active_ip: clientIp, last_active_ip: clientIp,
temp_banned_until: null, temp_banned_until: null,
@ -331,17 +296,17 @@ export class AuthRegistrationService {
await this.redisActivityTracker.updateActivity(user.id, now); await this.redisActivityTracker.updateActivity(user.id, now);
getMetricsService().counter({ metrics.counter({
name: 'user.registration', name: 'user.registration',
dimensions: { dimensions: {
country: countryCode ?? 'unknown', country: countryCode ?? 'unknown',
state: countryResultDetailed.region ?? 'unknown', state: countryResultDetailed.region ?? 'unknown',
ip_version: clientIp.includes(':') ? 'v6' : 'v4', ip_version: isIpv6(clientIp) ? 'v6' : 'v4',
}, },
}); });
const age = data.date_of_birth ? AgeUtils.calculateAge(data.date_of_birth) : null; const age = data.date_of_birth ? AgeUtils.calculateAge(data.date_of_birth) : null;
getMetricsService().counter({ metrics.counter({
name: 'user.age', name: 'user.age',
dimensions: { dimensions: {
country: countryCode ?? 'unknown', country: countryCode ?? 'unknown',
@ -359,52 +324,29 @@ export class AuthRegistrationService {
}), }),
); );
const userSearchService = getUserSearchService(); await this.maybeIndexUser(user);
if (userSearchService) {
await userSearchService.indexUser(user).catch((error) => {
Logger.error({userId: user.id, error}, 'Failed to index user in search');
});
}
if (rawEmail) { if (rawEmail) await this.maybeSendVerificationEmail({user, email: rawEmail});
const emailVerifyToken = createEmailVerificationToken(await this.generateSecureToken()); if (betaCode) await this.repository.updateBetaCodeRedeemed(betaCode.code, userId, now);
await this.repository.createEmailVerificationToken({
token_: emailVerifyToken,
user_id: userId,
email: rawEmail,
});
await this.emailService.sendEmailVerification(rawEmail, user.username, emailVerifyToken, user.locale); const registrationMetadata = await this.buildRegistrationMetadataContext({
} user,
clientIp,
request,
countryResultDetailed,
});
if (betaCode) { if (isPendingVerification)
await this.repository.updateBetaCodeRedeemed(betaCode.code, userId, new Date()); await this.repository.createPendingVerification(userId, now, registrationMetadata.metadata);
}
const registrationMetadata = await this.buildRegistrationMetadataContext(user, clientIp, request);
if (isPendingVerification) {
await this.repository.createPendingVerification(userId, new Date(), registrationMetadata.metadata);
}
await this.repository.createAuthorizedIp(userId, clientIp); await this.repository.createAuthorizedIp(userId, clientIp);
const inviteCodeToJoin = data.invite_code || Config.instance.autoJoinInviteCode; await this.maybeAutoJoinInvite({
if (inviteCodeToJoin != null) { userId,
if (isPendingVerification) { inviteCode: data.invite_code || Config.instance.autoJoinInviteCode,
await this.pendingJoinInviteStore.setPendingInvite(userId, inviteCodeToJoin); isPendingVerification,
} else if (this.inviteService) { requestCache,
try { });
await this.inviteService.acceptInvite({
userId,
inviteCode: createInviteCode(inviteCodeToJoin),
requestCache,
});
} catch (error) {
Logger.warn({inviteCode: inviteCodeToJoin, error}, 'Failed to auto-join invite on registration');
}
}
}
const [token] = await this.createAuthSession({user, request}); const [token] = await this.createAuthSession({user, request});
@ -421,36 +363,206 @@ export class AuthRegistrationService {
}; };
} }
private async buildRegistrationMetadataContext( private async maybeIndexUser(user: User): Promise<void> {
user: User, const userSearchService = getUserSearchService();
clientIp: string, if (!userSearchService) return;
request: Request,
): Promise<RegistrationMetadataContext> {
const countryResult = await IpUtils.getCountryCodeDetailed(clientIp);
const userAgentHeader = request.headers.get('user-agent') ?? '';
const trimmedUserAgent = userAgentHeader.trim();
const parsedUserAgent = new UAParser(trimmedUserAgent).getResult();
try {
await userSearchService.indexUser(user);
} catch (error) {
Logger.error({userId: user.id, error}, 'Failed to index user in search');
}
}
private async maybeSendVerificationEmail(params: {user: User; email: string}): Promise<void> {
const {user, email} = params;
const token = createEmailVerificationToken(await this.generateSecureToken());
await this.repository.createEmailVerificationToken({
token_: token,
user_id: user.id,
email,
});
await this.emailService.sendEmailVerification(email, user.username, token, user.locale);
}
private async maybeAutoJoinInvite(params: {
userId: UserID;
inviteCode: string | null | undefined;
isPendingVerification: boolean;
requestCache: RequestCache;
}): Promise<void> {
const {userId, inviteCode, isPendingVerification, requestCache} = params;
if (inviteCode == null) return;
if (isPendingVerification) {
await this.pendingJoinInviteStore.setPendingInvite(userId, inviteCode);
return;
}
if (!this.inviteService) return;
try {
await this.inviteService.acceptInvite({
userId,
inviteCode: createInviteCode(inviteCode),
requestCache,
});
} catch (error) {
Logger.warn({inviteCode, error}, 'Failed to auto-join invite on registration');
}
}
private async enforceRegistrationRateLimits(params: {
enforceRateLimits: boolean;
clientIp: string;
emailKey: string | null;
}): Promise<void> {
const {enforceRateLimits, clientIp, emailKey} = params;
if (!enforceRateLimits) return;
if (emailKey) {
const emailRateLimit = await this.rateLimitService.checkLimit({
identifier: `registration:email:${emailKey}`,
maxAttempts: 3,
windowMs: 15 * 60 * 1000,
});
if (!emailRateLimit.allowed) throw rateLimitError('Too many registration attempts. Please try again later.');
}
const ipRateLimit = await this.rateLimitService.checkLimit({
identifier: `registration:ip:${clientIp}`,
maxAttempts: 5,
windowMs: 30 * 60 * 1000,
});
if (!ipRateLimit.allowed)
throw rateLimitError('Too many registration attempts from this IP. Please try again later.');
}
private async resolveBetaCode(betaCodeInput: string | null): Promise<{
betaCode: Awaited<ReturnType<IUserRepository['getBetaCode']>> | null;
hasValidBetaCode: boolean;
}> {
if (!betaCodeInput) return {betaCode: null, hasValidBetaCode: false};
if (Config.nodeEnv === 'development' && betaCodeInput === 'NOVERIFY')
return {betaCode: null, hasValidBetaCode: false};
const betaCode = await this.repository.getBetaCode(betaCodeInput);
return {betaCode, hasValidBetaCode: Boolean(betaCode && !betaCode.redeemerId)};
}
private async allocateDiscriminator(username: string): Promise<number> {
const result = await this.discriminatorService.generateDiscriminator({username, isPremium: false});
if (!result.available || result.discriminator === -1) {
throw InputValidationError.create('username', 'Too many users with this username');
}
return result.discriminator;
}
private generateUserId(emailKey: string | null): UserID {
const mappingJson = process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE;
if (emailKey && mappingJson) {
const mapping = safeJsonParse<Record<string, string>>(mappingJson);
if (mapping) {
const emailHash = crypto.createHash('sha256').update(emailKey).digest('hex');
const mapped = mapping[emailHash];
if (mapped) {
try {
return createUserID(BigInt(mapped));
} catch (error) {
Logger.warn({error}, 'Invalid snowflake mapping value; falling back to generated ID');
}
}
}
}
return createUserID(this.snowflakeService.generate());
}
private truncateUserAgent(userAgent: string): string {
if (userAgent.length <= USER_AGENT_TRUNCATE_LENGTH) return userAgent;
return `${userAgent.slice(0, USER_AGENT_TRUNCATE_LENGTH)}...`;
}
private parseUserAgentSafe(userAgent: string): {osInfo: string; browserInfo: string; deviceInfo: string} {
try {
const result = Bowser.parse(userAgent);
return {
osInfo: this.formatOsInfo(result.os) ?? 'Unknown',
browserInfo: this.formatNameVersion(result.browser?.name, result.browser?.version) ?? 'Unknown',
deviceInfo: this.formatDeviceInfo(result.platform),
};
} catch (error) {
Logger.warn({error}, 'Failed to parse user agent with Bowser');
return {osInfo: 'Unknown', browserInfo: 'Unknown', deviceInfo: 'Desktop/Unknown'};
}
}
private formatNameVersion(name?: string, version?: string): string | null {
if (!name) return null;
return version ? `${name} ${version}` : name;
}
private formatOsInfo(os?: {name?: string; version?: string; versionName?: string}): string | null {
if (!os?.name) return null;
if (os.versionName && os.version) return `${os.name} ${os.versionName} (${os.version})`;
if (os.versionName) return `${os.name} ${os.versionName}`;
if (os.version) return `${os.name} ${os.version}`;
return os.name;
}
private formatDeviceInfo(platform?: {type?: string; vendor?: string; model?: string}): string {
const type = this.formatPlatformType(platform?.type);
const vendorModel = [platform?.vendor, platform?.model].filter(Boolean).join(' ').trim();
if (vendorModel && type) return `${vendorModel} (${type})`;
if (vendorModel) return vendorModel;
if (type) return type;
return 'Desktop/Unknown';
}
private formatPlatformType(type?: string): string | null {
switch ((type ?? '').toLowerCase()) {
case 'mobile':
return 'Mobile';
case 'tablet':
return 'Tablet';
case 'desktop':
return 'Desktop';
default:
return null;
}
}
private async buildRegistrationMetadataContext(params: {
user: User;
clientIp: string;
request: Request;
countryResultDetailed: CountryResultDetailed;
}): Promise<RegistrationMetadataContext> {
const {user, clientIp, request, countryResultDetailed} = params;
const userAgentHeader = (request.headers.get('user-agent') ?? '').trim();
const fluxerTag = `${user.username}#${user.discriminator.toString().padStart(4, '0')}`; const fluxerTag = `${user.username}#${user.discriminator.toString().padStart(4, '0')}`;
const displayName = user.globalName || user.username; const displayName = user.globalName || user.username;
const emailDisplay = user.email || 'Not provided'; const emailDisplay = user.email || 'Not provided';
const normalizedUserAgent = trimmedUserAgent.length > 0 ? trimmedUserAgent : 'Not provided';
const truncatedUserAgent = this.truncateUserAgent(normalizedUserAgent);
const normalizedIp = countryResult.normalizedIp ?? clientIp;
const geoipReason = countryResult.reason ?? 'none';
const osInfo = parsedUserAgent.os.name const hasUserAgent = userAgentHeader.length > 0;
? `${parsedUserAgent.os.name}${parsedUserAgent.os.version ? ` ${parsedUserAgent.os.version}` : ''}` const userAgentForDisplay = hasUserAgent ? userAgentHeader : 'Not provided';
: 'Unknown'; const truncatedUserAgent = this.truncateUserAgent(userAgentForDisplay);
const browserInfo = parsedUserAgent.browser.name
? `${parsedUserAgent.browser.name}${parsedUserAgent.browser.version ? ` ${parsedUserAgent.browser.version}` : ''}`
: 'Unknown';
const deviceInfo = parsedUserAgent.device.vendor
? `${parsedUserAgent.device.vendor} ${parsedUserAgent.device.model || ''}`.trim()
: 'Desktop/Unknown';
const uaInfo = hasUserAgent
? 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 ipAddressReverse = await IpUtils.getIpAddressReverse(normalizedIp, this.cacheService); const ipAddressReverse = await IpUtils.getIpAddressReverse(normalizedIp, this.cacheService);
const locationLabel = IpUtils.formatGeoipLocation(countryResult);
const metadataEntries: Array<[string, string]> = [ const metadataEntries: Array<[string, string]> = [
['fluxer_tag', fluxerTag], ['fluxer_tag', fluxerTag],
@ -458,38 +570,30 @@ export class AuthRegistrationService {
['email', emailDisplay], ['email', emailDisplay],
['ip_address', clientIp], ['ip_address', clientIp],
['normalized_ip', normalizedIp], ['normalized_ip', normalizedIp],
['country_code', countryResult.countryCode], ['country_code', countryResultDetailed.countryCode],
['location', locationLabel], ['location', locationLabel],
['geoip_reason', geoipReason], ['geoip_reason', geoipReason],
['os', osInfo], ['os', uaInfo.osInfo],
['browser', browserInfo], ['browser', uaInfo.browserInfo],
['device', deviceInfo], ['device', uaInfo.deviceInfo],
['user_agent', truncatedUserAgent], ['user_agent', truncatedUserAgent],
]; ];
if (countryResult.city) { if (countryResultDetailed.city) metadataEntries.push(['city', countryResultDetailed.city]);
metadataEntries.push(['city', countryResult.city]); if (countryResultDetailed.region) metadataEntries.push(['region', countryResultDetailed.region]);
} if (countryResultDetailed.countryName) metadataEntries.push(['country_name', countryResultDetailed.countryName]);
if (countryResult.region) { if (ipAddressReverse) metadataEntries.push(['ip_address_reverse', ipAddressReverse]);
metadataEntries.push(['region', countryResult.region]);
}
if (countryResult.countryName) {
metadataEntries.push(['country_name', countryResult.countryName]);
}
if (ipAddressReverse) {
metadataEntries.push(['ip_address_reverse', ipAddressReverse]);
}
return { return {
metadata: new Map(metadataEntries), metadata: new Map(metadataEntries),
clientIp, clientIp,
countryCode: countryResult.countryCode, countryCode: countryResultDetailed.countryCode,
location: locationLabel, location: locationLabel,
city: countryResult.city, city: countryResultDetailed.city,
region: countryResult.region, region: countryResultDetailed.region,
osInfo, osInfo: uaInfo.osInfo,
browserInfo, browserInfo: uaInfo.browserInfo,
deviceInfo, deviceInfo: uaInfo.deviceInfo,
truncatedUserAgent, truncatedUserAgent,
fluxerTag, fluxerTag,
displayName, displayName,
@ -498,14 +602,6 @@ export class AuthRegistrationService {
}; };
} }
private truncateUserAgent(userAgent: string): string {
if (userAgent.length <= USER_AGENT_TRUNCATE_LENGTH) {
return userAgent;
}
return `${userAgent.slice(0, USER_AGENT_TRUNCATE_LENGTH)}...`;
}
private async sendRegistrationWebhook( private async sendRegistrationWebhook(
user: User, user: User,
context: RegistrationMetadataContext, context: RegistrationMetadataContext,
@ -513,40 +609,22 @@ export class AuthRegistrationService {
): Promise<void> { ): Promise<void> {
if (!webhookUrl) return; if (!webhookUrl) return;
const { const locationDisplay = context.city ? context.location : context.countryCode;
clientIp,
countryCode,
location,
city,
osInfo,
browserInfo,
deviceInfo,
truncatedUserAgent,
fluxerTag,
displayName,
email,
ipAddressReverse,
} = context;
const locationDisplay = city ? location : countryCode;
const embedFields = [ const embedFields = [
{name: 'User ID', value: user.id.toString(), inline: true}, {name: 'User ID', value: user.id.toString(), inline: true},
{name: 'FluxerTag', value: fluxerTag, inline: true}, {name: 'FluxerTag', value: context.fluxerTag, inline: true},
{name: 'Display Name', value: displayName, inline: true}, {name: 'Display Name', value: context.displayName, inline: true},
{name: 'Email', value: email, inline: true}, {name: 'Email', value: context.email, inline: true},
{name: 'IP Address', value: clientIp, inline: true}, {name: 'IP Address', value: context.clientIp, inline: true},
...(context.ipAddressReverse ? [{name: 'Reverse DNS', value: context.ipAddressReverse, inline: true}] : []),
{name: 'Location', value: locationDisplay, inline: true}, {name: 'Location', value: locationDisplay, inline: true},
{name: 'OS', value: osInfo, inline: true}, {name: 'OS', value: context.osInfo, inline: true},
{name: 'Browser', value: browserInfo, inline: true}, {name: 'Browser', value: context.browserInfo, inline: true},
{name: 'Device', value: deviceInfo, inline: true}, {name: 'Device', value: context.deviceInfo, inline: true},
{name: 'User Agent', value: truncatedUserAgent, inline: false}, {name: 'User Agent', value: context.truncatedUserAgent, inline: false},
]; ];
if (ipAddressReverse) {
embedFields.splice(6, 0, {name: 'Reverse DNS', value: ipAddressReverse, inline: true});
}
const payload = { const payload = {
username: 'Registration Monitor', username: 'Registration Monitor',
embeds: [ embeds: [

View File

@ -17,11 +17,12 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>. * along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {UAParser} from 'ua-parser-js'; import Bowser from 'bowser';
import {type AuthSessionResponse, mapAuthSessionsToResponse} from '~/auth/AuthModel'; import {type AuthSessionResponse, mapAuthSessionsToResponse} from '~/auth/AuthModel';
import type {UserID} from '~/BrandedTypes'; import type {UserID} from '~/BrandedTypes';
import {AccessDeniedError} from '~/Errors'; import {AccessDeniedError} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService'; import type {IGatewayService} from '~/infrastructure/IGatewayService';
import {Logger} from '~/Logger';
import type {AuthSession, User} from '~/Models'; import type {AuthSession, User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository'; import type {IUserRepository} from '~/user/IUserRepository';
import * as IpUtils from '~/utils/IpUtils'; import * as IpUtils from '~/utils/IpUtils';
@ -41,6 +42,44 @@ interface UpdateUserActivityParams {
clientIp: string; 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 { export class AuthSessionService {
constructor( constructor(
private repository: IUserRepository, private repository: IUserRepository,
@ -50,33 +89,30 @@ export class AuthSessionService {
) {} ) {}
async createAuthSession({user, request}: CreateAuthSessionParams): Promise<[token: string, AuthSession]> { async createAuthSession({user, request}: CreateAuthSessionParams): Promise<[token: string, AuthSession]> {
if (user.isBot) { if (user.isBot) throw new AccessDeniedError('Bot users cannot create auth sessions');
throw new AccessDeniedError('Bot users cannot create auth sessions');
}
const now = new Date();
const token = await this.generateAuthToken(); const token = await this.generateAuthToken();
const ip = IpUtils.requireClientIp(request); const ip = IpUtils.requireClientIp(request);
const userAgent = request.headers.get('user-agent') || '';
const platformHeader = request.headers.get('x-fluxer-platform')?.toLowerCase() ?? null; const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null;
const parsedUserAgent = new UAParser(userAgent).getResult(); const uaRaw = request.headers.get('user-agent') ?? '';
const geoipResult = await IpUtils.getCountryCodeDetailed(ip); const uaInfo = parseUserAgent(uaRaw);
const clientLocationLabel = IpUtils.formatGeoipLocation(geoipResult);
const detectedPlatform = parsedUserAgent.browser.name ?? 'Unknown'; const geoip = await IpUtils.getCountryCodeDetailed(ip);
const clientPlatform = platformHeader === 'desktop' ? 'Fluxer Desktop' : detectedPlatform; const locationLabel = nullIfUnknown(IpUtils.formatGeoipLocation(geoip));
const countryLabel = nullIfUnknown(geoip.countryName ?? geoip.countryCode ?? null);
const authSession = await this.repository.createAuthSession({ const authSession = await this.repository.createAuthSession({
user_id: user.id, user_id: user.id,
session_id_hash: Buffer.from(this.getTokenIdHash(token)), session_id_hash: Buffer.from(this.getTokenIdHash(token)),
created_at: new Date(), created_at: now,
approx_last_used_at: new Date(), approx_last_used_at: now,
client_ip: ip, client_ip: ip,
client_os: parsedUserAgent.os.name ?? 'Unknown', client_os: uaInfo.clientOs,
client_platform: clientPlatform, client_platform: resolveClientPlatform(platformHeader, uaInfo.detectedPlatform),
client_country: client_country: countryLabel,
(geoipResult.countryName ?? geoipResult.countryCode) === IpUtils.UNKNOWN_LOCATION client_location: locationLabel,
? null
: (geoipResult.countryName ?? geoipResult.countryCode),
client_location: clientLocationLabel === IpUtils.UNKNOWN_LOCATION ? null : clientLocationLabel,
version: 1, version: 1,
}); });
@ -101,50 +137,49 @@ export class AuthSessionService {
} }
async revokeToken(token: string): Promise<void> { async revokeToken(token: string): Promise<void> {
const tokenHash = this.getTokenIdHash(token); const tokenHash = Buffer.from(this.getTokenIdHash(token));
const authSession = await this.repository.getAuthSessionByToken(Buffer.from(tokenHash)); const authSession = await this.repository.getAuthSessionByToken(tokenHash);
if (!authSession) return;
if (authSession) { await this.repository.revokeAuthSession(tokenHash);
await this.repository.revokeAuthSession(Buffer.from(tokenHash));
await this.gatewayService.terminateSession({ await this.gatewayService.terminateSession({
userId: authSession.userId, userId: authSession.userId,
sessionIdHashes: [Buffer.from(authSession.sessionIdHash).toString('base64url')], sessionIdHashes: [Buffer.from(authSession.sessionIdHash).toString('base64url')],
}); });
}
} }
async logoutAuthSessions({user, sessionIdHashes}: LogoutAuthSessionsParams): Promise<void> { async logoutAuthSessions({user, sessionIdHashes}: LogoutAuthSessionsParams): Promise<void> {
const hashes = sessionIdHashes.map((hash) => Buffer.from(hash, 'base64url')); const hashes = sessionIdHashes.map((hash) => Buffer.from(hash, 'base64url'));
await this.repository.deleteAuthSessions(user.id, hashes); await this.repository.deleteAuthSessions(user.id, hashes);
await this.gatewayService.terminateSession({ await this.gatewayService.terminateSession({
userId: user.id, userId: user.id,
sessionIdHashes: sessionIdHashes, sessionIdHashes,
}); });
} }
async terminateAllUserSessions(userId: UserID): Promise<void> { async terminateAllUserSessions(userId: UserID): Promise<void> {
const authSessions = await this.repository.listAuthSessions(userId); const authSessions = await this.repository.listAuthSessions(userId);
await this.repository.deleteAuthSessions( if (authSessions.length === 0) return;
userId,
authSessions.map((session) => session.sessionIdHash), const hashes = authSessions.map((s) => s.sessionIdHash);
); await this.repository.deleteAuthSessions(userId, hashes);
await this.gatewayService.terminateSession({ await this.gatewayService.terminateSession({
userId, userId,
sessionIdHashes: authSessions.map((session) => Buffer.from(session.sessionIdHash).toString('base64url')), sessionIdHashes: authSessions.map((s) => Buffer.from(s.sessionIdHash).toString('base64url')),
}); });
} }
async dispatchAuthSessionChange({ async dispatchAuthSessionChange(params: {
userId,
oldAuthSessionIdHash,
newAuthSessionIdHash,
newToken,
}: {
userId: UserID; userId: UserID;
oldAuthSessionIdHash: string; oldAuthSessionIdHash: string;
newAuthSessionIdHash: string; newAuthSessionIdHash: string;
newToken: string; newToken: string;
}): Promise<void> { }): Promise<void> {
const {userId, oldAuthSessionIdHash, newAuthSessionIdHash, newToken} = params;
await this.gatewayService.dispatchPresence({ await this.gatewayService.dispatchPresence({
userId, userId,
event: 'AUTH_SESSION_CHANGE', event: 'AUTH_SESSION_CHANGE',

View File

@ -59,6 +59,7 @@
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@sentry/react": "10.32.1", "@sentry/react": "10.32.1",
"@simplewebauthn/browser": "13.2.2", "@simplewebauthn/browser": "13.2.2",
"bowser": "2.13.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"colorjs.io": "0.6.0", "colorjs.io": "0.6.0",
"combokeys": "3.0.1", "combokeys": "3.0.1",
@ -97,7 +98,6 @@
"react-zoom-pan-pinch": "3.7.0", "react-zoom-pan-pinch": "3.7.0",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"thumbhash": "0.1.1", "thumbhash": "0.1.1",
"ua-parser-js": "2.0.7",
"undici": "7.16.0", "undici": "7.16.0",
"unique-names-generator": "4.7.1", "unique-names-generator": "4.7.1",
"update-electron-app": "3.1.2", "update-electron-app": "3.1.2",

View File

@ -73,6 +73,9 @@ importers:
'@simplewebauthn/browser': '@simplewebauthn/browser':
specifier: 13.2.2 specifier: 13.2.2
version: 13.2.2 version: 13.2.2
bowser:
specifier: 2.13.1
version: 2.13.1
clsx: clsx:
specifier: 2.1.1 specifier: 2.1.1
version: 2.1.1 version: 2.1.1
@ -187,9 +190,6 @@ importers:
thumbhash: thumbhash:
specifier: 0.1.1 specifier: 0.1.1
version: 0.1.1 version: 0.1.1
ua-parser-js:
specifier: 2.0.7
version: 2.0.7
undici: undici:
specifier: 7.16.0 specifier: 7.16.0
version: 7.16.0 version: 7.16.0
@ -3737,6 +3737,9 @@ packages:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
bowser@2.13.1:
resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==}
brace-expansion@1.1.12: brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@ -4159,9 +4162,6 @@ packages:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
detect-europe-js@0.1.2:
resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -4892,9 +4892,6 @@ packages:
is-potential-custom-element-name@1.0.1: is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-standalone-pwa@0.1.1:
resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==}
is-there@4.5.2: is-there@4.5.2:
resolution: {integrity: sha512-ixMkfz3rtS1vEsLf0TjgjqUn96Q0ukpUVDMnPYVocJyTzu2G/QgEtqYddcHZawHO+R31cKVPggJmBLrm1vJCOg==} resolution: {integrity: sha512-ixMkfz3rtS1vEsLf0TjgjqUn96Q0ukpUVDMnPYVocJyTzu2G/QgEtqYddcHZawHO+R31cKVPggJmBLrm1vJCOg==}
@ -6609,13 +6606,6 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
ua-is-frozen@0.1.2:
resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
ua-parser-js@2.0.7:
resolution: {integrity: sha512-CFdHVHr+6YfbktNZegH3qbYvYgC7nRNEUm2tk7nSFXSODUu4tDBpaFpP1jdXBUOKKwapVlWRfTtS8bCPzsQ47w==}
hasBin: true
uiohook-napi@1.5.4: uiohook-napi@1.5.4:
resolution: {integrity: sha512-7vPVDNwgb6MwTgviA/dnF2MrW0X5xm76fAqaOAC3cEKkswqAZOPw1USu14Sr6383s5qhXegcJaR63CpJOPCNAg==} resolution: {integrity: sha512-7vPVDNwgb6MwTgviA/dnF2MrW0X5xm76fAqaOAC3cEKkswqAZOPw1USu14Sr6383s5qhXegcJaR63CpJOPCNAg==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
@ -11098,6 +11088,8 @@ snapshots:
boolean@3.2.0: boolean@3.2.0:
optional: true optional: true
bowser@2.13.1: {}
brace-expansion@1.1.12: brace-expansion@1.1.12:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
@ -11543,8 +11535,6 @@ snapshots:
destroy@1.2.0: {} destroy@1.2.0: {}
detect-europe-js@0.1.2: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
detect-node@2.1.0: {} detect-node@2.1.0: {}
@ -12432,8 +12422,6 @@ snapshots:
is-potential-custom-element-name@1.0.1: {} is-potential-custom-element-name@1.0.1: {}
is-standalone-pwa@0.1.1: {}
is-there@4.5.2: {} is-there@4.5.2: {}
is-unicode-supported@0.1.0: {} is-unicode-supported@0.1.0: {}
@ -14339,14 +14327,6 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
ua-is-frozen@0.1.2: {}
ua-parser-js@2.0.7:
dependencies:
detect-europe-js: 0.1.2
is-standalone-pwa: 0.1.1
ua-is-frozen: 0.1.2
uiohook-napi@1.5.4: uiohook-napi@1.5.4:
dependencies: dependencies:
node-gyp-build: 4.8.4 node-gyp-build: 4.8.4

View File

@ -344,7 +344,7 @@ export default () => {
reuseExistingChunk: true, reuseExistingChunk: true,
}, },
utils: { utils: {
test: /[\\/]node_modules[\\/](lodash|clsx|qrcode|thumbhash|ua-parser-js|match-sorter)[\\/]/, test: /[\\/]node_modules[\\/](lodash|clsx|qrcode|thumbhash|bowser|match-sorter)[\\/]/,
name: 'utils', name: 'utils',
priority: 28, priority: 28,
reuseExistingChunk: true, reuseExistingChunk: true,

View File

@ -17,7 +17,7 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>. * along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {UAParser} from 'ua-parser-js'; import Bowser from 'bowser';
import Config from '~/Config'; import Config from '~/Config';
import {getElectronAPI, isDesktop} from '~/utils/NativeUtils'; import {getElectronAPI, isDesktop} from '~/utils/NativeUtils';
@ -39,14 +39,16 @@ let cachedClientInfo: ClientInfo | null = null;
let preloadPromise: Promise<ClientInfo> | null = null; let preloadPromise: Promise<ClientInfo> | null = null;
const parseUserAgent = (): ClientInfo => { const parseUserAgent = (): ClientInfo => {
const parser = new UAParser(); const hasNavigator = typeof navigator !== 'undefined';
const userAgent = hasNavigator ? navigator.userAgent : '';
const parser = Bowser.getParser(userAgent);
const result = parser.getResult(); const result = parser.getResult();
return { return {
browserName: normalize(result.browser.name), browserName: normalize(result.browser.name),
browserVersion: normalize(result.browser.version), browserVersion: normalize(result.browser.version),
osName: normalize(result.os.name), osName: normalize(result.os.name),
osVersion: normalize(result.os.version), osVersion: normalize(result.os.version),
arch: normalize(result.cpu.architecture), arch: normalize(hasNavigator ? navigator.platform : undefined),
}; };
}; };