feat(api): derive stable placeholder names for hidden profiles (#3224)

This commit is contained in:
Hampus
2026-10-05 16:57:25 +02:00
committed by GitHub
parent 02c82f0038
commit 3093e7334b
30 changed files with 629 additions and 65 deletions
+1
View File
@@ -391,6 +391,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
auth: {
sudoModeSecret: master.auth.sudo_mode_secret,
connectionInitiationSecret: master.auth.connection_initiation_secret,
profilePseudonymSecret: master.auth.profile_pseudonym_secret,
ssoAllowPrivateAddresses: master.auth.sso_allow_private_addresses,
passkeys: {
rpName: master.auth.passkeys.rp_name,
+1
View File
@@ -249,6 +249,7 @@ export interface APIConfig {
auth: {
sudoModeSecret: string;
connectionInitiationSecret: string;
profilePseudonymSecret: string;
ssoAllowPrivateAddresses: boolean;
passkeys: {
rpName: string;
+1 -2
View File
@@ -31309,8 +31309,7 @@
"value": "32",
"description": "Bot requires manual approval for friend requests"
},
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"},
{"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden"}
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}
]
},
"RefreshedAttachmentUrl": {
+45 -16
View File
@@ -1,14 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHmac} from 'node:crypto';
import {Config} from '@app/api/Config';
import type {User} from '@app/api/models/User';
import {isTemporarilyBanned} from '@app/api/user/UserHelpers';
import {generateSeededUsername} from '@app/api/utils/UsernameGenerator';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {
HIDDEN_USER_DISCRIMINATOR,
HIDDEN_USER_USERNAME,
PublicUserFlags,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {NON_SELF_HOSTED_RESERVED_DISCRIMINATORS} from '@fluxer/constants/src/DiscriminatorConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
@@ -42,20 +41,50 @@ export function isProfileHidden(user: ProfileStanding, now = Date.now()): boolea
return (user.flags & UserFlags.PROFILE_HIDDEN) !== 0n || isUnderEnforcement(user, now);
}
export function hiddenUserPartial(partial: UserPartialResponse): UserPartialResponse {
const MAX_DISCRIMINATOR = 9999;
interface ProfilePseudonym {
username: string;
discriminator: string;
}
function pseudonymDiscriminator(seed: Buffer): number {
let value = (seed.readUInt32BE(seed.byteLength - 4) % MAX_DISCRIMINATOR) + 1;
while (NON_SELF_HOSTED_RESERVED_DISCRIMINATORS.has(value)) {
value = (value % MAX_DISCRIMINATOR) + 1;
}
return value;
}
export function profilePseudonym(
userId: string | bigint,
secret: string = Config.auth.profilePseudonymSecret,
): ProfilePseudonym {
const seed = createHmac('sha256', secret).update(userId.toString()).digest();
return {
...partial,
username: HIDDEN_USER_USERNAME,
discriminator: HIDDEN_USER_DISCRIMINATOR.toString().padStart(4, '0'),
global_name: null,
avatar: null,
avatar_color: null,
flags: partial.flags | PublicUserFlags.PROFILE_HIDDEN,
username: generateSeededUsername(seed),
discriminator: pseudonymDiscriminator(seed).toString().padStart(4, '0'),
};
}
export function isHiddenPartial(partial: Pick<UserPartialResponse, 'flags'>): boolean {
return (partial.flags & PublicUserFlags.PROFILE_HIDDEN) !== 0;
export function hiddenUserPartial(partial: UserPartialResponse): UserPartialResponse {
const pseudonym = profilePseudonym(partial.id);
return {
...partial,
username: pseudonym.username,
discriminator: pseudonym.discriminator,
global_name: null,
avatar: null,
avatar_color: null,
};
}
export function isHiddenPartial(
partial: Pick<UserPartialResponse, 'id' | 'username' | 'discriminator' | 'global_name' | 'avatar'>,
): boolean {
if (partial.global_name != null || partial.avatar != null) return false;
const pseudonym = profilePseudonym(partial.id);
return partial.username === pseudonym.username && partial.discriminator === pseudonym.discriminator;
}
export function hiddenGuildMember(member: GuildMemberResponse): GuildMemberResponse {
@@ -6,12 +6,15 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {profilePseudonym} from '@app/api/user/ProfileVisibility';
import {fetchUser, fetchUserMe, fetchUserProfile} from '@app/api/user/tests/UserTestUtils';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {PublicUserFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
const PROFILE_HIDDEN_BIT = Number(UserFlags.PROFILE_HIDDEN);
interface AdminUser {
username: string;
global_name: string | null;
@@ -27,6 +30,7 @@ describe('hidden profiles', () => {
let target: TestAccount;
let guildId: string;
let targetName: string;
let pseudonym: {username: string; discriminator: string};
beforeEach(async () => {
harness = await createApiTestHarness();
@@ -52,6 +56,7 @@ describe('hidden profiles', () => {
.expect(HTTP_STATUS.OK)
.execute();
targetName = (await fetchUser(harness, target.userId, viewer.token)).json.username;
pseudonym = profilePseudonym(target.userId);
});
afterEach(async () => {
@@ -62,7 +67,7 @@ describe('hidden profiles', () => {
async function expectShown(): Promise<void> {
const {json} = await fetchUser(harness, target.userId, viewer.token);
expect(json).toMatchObject({username: targetName, global_name: 'Shown Name'});
expect(json.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(0);
expect(json.flags & PROFILE_HIDDEN_BIT).toBe(0);
const profile = await fetchUserProfile(harness, target.userId, viewer.token);
expect(profile.json.user_profile).toMatchObject({bio: 'shown bio', pronouns: 'they/them'});
const member = await getMember(harness, viewer.token, guildId, target.userId);
@@ -71,13 +76,15 @@ describe('hidden profiles', () => {
async function expectHidden(): Promise<void> {
const {json} = await fetchUser(harness, target.userId, viewer.token);
expect(json).toMatchObject({username: 'HiddenUser', discriminator: '0000', global_name: null, avatar: null});
expect(json.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(PublicUserFlags.PROFILE_HIDDEN);
expect(json).toMatchObject({...pseudonym, global_name: null, avatar: null});
expect(json.username).not.toBe(targetName);
expect(json.flags & PROFILE_HIDDEN_BIT).toBe(0);
const profile = await fetchUserProfile(harness, target.userId, viewer.token);
expect(profile.json.user_profile).toMatchObject({bio: null, pronouns: null, banner: null, accent_color: null});
const member = await getMember(harness, viewer.token, guildId, target.userId);
expect(member).toMatchObject({nick: null, avatar: null, banner: null});
expect(member.user.username).toBe('HiddenUser');
expect(member.user).toMatchObject(pseudonym);
expect(member.user.flags & PROFILE_HIDDEN_BIT).toBe(0);
}
async function expectStaffSeeStoredProfile(): Promise<void> {
@@ -111,8 +118,15 @@ describe('hidden profiles', () => {
.execute();
await expectHidden();
await expectStaffSeeStoredProfile();
expect(memberUpdates().map((member) => [member.user.username, member.nick])).toEqual([['HiddenUser', null]]);
expect(presence.mock.calls.some(([params]) => params.event === 'USER_UPDATE')).toBe(true);
expect(memberUpdates().map((member) => [member.user.username, member.user.discriminator, member.nick])).toEqual([
[pseudonym.username, pseudonym.discriminator, null],
]);
const ownUpdates = presence.mock.calls
.map(([params]) => params)
.filter((params) => params.event === 'USER_UPDATE' && params.userId.toString() === target.userId)
.map((params) => params.data as {username: string});
expect(ownUpdates.length).toBeGreaterThan(0);
expect(ownUpdates.every((update) => update.username === targetName)).toBe(true);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/ban`)
.body({notify_user: false})
@@ -120,7 +134,7 @@ describe('hidden profiles', () => {
.execute();
await expectShown();
expect(memberUpdates().map((member) => [member.user.username, member.nick])).toEqual([
['HiddenUser', null],
[pseudonym.username, null],
[targetName, 'Shown Nick'],
]);
});
@@ -168,6 +182,7 @@ describe('hidden profiles', () => {
await expectHidden();
const own = await fetchUserMe(harness, target.token);
expect(own.json).toMatchObject({username: targetName, global_name: 'Shown Name', bio: 'shown bio'});
expect(own.json.flags & PROFILE_HIDDEN_BIT).toBe(0);
await createBuilder(harness, admin.token)
.patch(`/admin/users/${target.userId}/flags`)
.body({remove_flags: [UserFlags.PROFILE_HIDDEN.toString()]})
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {Config} from '@app/api/Config';
import {profilePseudonym} from '@app/api/user/ProfileVisibility';
import {NON_SELF_HOSTED_RESERVED_DISCRIMINATORS} from '@fluxer/constants/src/DiscriminatorConstants';
import {describe, expect, it} from 'vitest';
interface PseudonymVector {
secret: string;
user_id: string;
username: string;
discriminator: string;
}
interface PseudonymFixture {
development_secret: string;
reserved_discriminators: Array<number>;
vectors: Array<PseudonymVector>;
}
const VECTORS_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../../../fluxer_common/src/testdata/profile_pseudonym_vectors.json',
);
const fixture = JSON.parse(readFileSync(VECTORS_PATH, 'utf8')) as PseudonymFixture;
describe('profile pseudonym vectors shared with the users service', () => {
it('has vectors', () => {
expect(fixture.vectors.length).toBeGreaterThan(0);
});
it.each(fixture.vectors)('$user_id under $secret', (vector) => {
expect(profilePseudonym(vector.user_id, vector.secret)).toEqual({
username: vector.username,
discriminator: vector.discriminator,
});
});
it('pins the reserved discriminators and the development secret', () => {
expect([...NON_SELF_HOSTED_RESERVED_DISCRIMINATORS]).toEqual(fixture.reserved_discriminators);
expect(Config.auth.profilePseudonymSecret).toBe(fixture.development_secret);
});
});
@@ -9,7 +9,7 @@ import {LimitConfigService, resetGlobalLimitConfigServiceForTesting} from '@app/
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {GuildMember} from '@app/api/models/GuildMember';
import {User} from '@app/api/models/User';
import {isProfileHidden, isUnderEnforcement} from '@app/api/user/ProfileVisibility';
import {isHiddenPartial, isProfileHidden, isUnderEnforcement, profilePseudonym} from '@app/api/user/ProfileVisibility';
import {
hasPartialUserFieldsChanged,
mapGuildMemberToProfileResponse,
@@ -18,17 +18,21 @@ import {
mapUserToProfileResponse,
} from '@app/api/user/UserMappers';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {PublicUserFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {NON_SELF_HOSTED_RESERVED_DISCRIMINATORS} from '@fluxer/constants/src/DiscriminatorConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
const NOW = Date.now();
const HOUR = 3_600_000;
const USER_ID = 1174109840998400001n;
const PROFILE_HIDDEN_BIT = Number(UserFlags.PROFILE_HIDDEN);
const GENERATED_USERNAME = /^[A-Z][a-z0-9_]*[A-Z][a-z0-9_]*$/;
function user(overrides: Partial<UserRow> = {}): User {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(1174109840998400001n),
user_id: createUserID(USER_ID),
username: 'ada',
discriminator: 7,
global_name: 'Ada Lovelace',
@@ -96,16 +100,16 @@ describe('profile visibility', () => {
const subject = user(overrides);
expect(isProfileHidden(subject, NOW)).toBe(hidden);
const partial = mapUserToPartialResponse(subject);
expect(partial.flags & PROFILE_HIDDEN_BIT).toBe(0);
if (hidden) {
expect(partial).toMatchObject({
id: subject.id.toString(),
username: 'HiddenUser',
discriminator: '0000',
...profilePseudonym(USER_ID),
global_name: null,
avatar: null,
avatar_color: null,
});
expect(partial.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(PublicUserFlags.PROFILE_HIDDEN);
expect(isHiddenPartial(partial)).toBe(true);
expect(mapUserToProfileResponse(subject)).toEqual({
bio: null,
pronouns: null,
@@ -114,7 +118,7 @@ describe('profile visibility', () => {
accent_color: null,
});
} else {
expect(partial.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(0);
expect(isHiddenPartial(partial)).toBe(false);
expect(partial.username).toBe('ada');
expect(partial.global_name).toBe('Ada Lovelace');
expect(partial.avatar).toBe('a1b2c3');
@@ -126,12 +130,54 @@ describe('profile visibility', () => {
const own = mapUserToPrivateResponse(user({flags: UserFlags.PROFILE_HIDDEN}));
expect(own).toMatchObject({
username: 'ada',
discriminator: '0007',
global_name: 'Ada Lovelace',
avatar: 'a1b2c3',
bio: 'analytical engine enjoyer',
pronouns: 'she/her',
accent_color: 99,
});
expect(own.flags & PROFILE_HIDDEN_BIT).toBe(0);
});
it('gives each account one stable pseudonym in the generated username format', () => {
const first = profilePseudonym(USER_ID);
expect(profilePseudonym(USER_ID)).toEqual(first);
expect(mapUserToPartialResponse(user({flags: UserFlags.SPAMMER}))).toMatchObject(first);
expect(mapUserToPartialResponse(user({flags: UserFlags.PROFILE_HIDDEN}))).toMatchObject(first);
const seen = new Set<string>();
for (let offset = 0n; offset < 200n; offset++) {
const pseudonym = profilePseudonym(USER_ID + offset);
seen.add(`${pseudonym.username}#${pseudonym.discriminator}`);
expect(pseudonym.username).toMatch(GENERATED_USERNAME);
expect(pseudonym.username.length).toBeLessThanOrEqual(32);
expect(pseudonym.discriminator).toMatch(/^\d{4}$/);
const discriminator = Number(pseudonym.discriminator);
expect(discriminator).toBeGreaterThanOrEqual(1);
expect(discriminator).toBeLessThanOrEqual(9999);
expect(NON_SELF_HOSTED_RESERVED_DISCRIMINATORS.has(discriminator)).toBe(false);
}
expect(seen.size).toBe(200);
expect(profilePseudonym(USER_ID, 'another-secret')).not.toEqual(first);
});
it('restores the stored profile as soon as the mask lifts', () => {
const masked = mapUserToPartialResponse(user({flags: UserFlags.PROFILE_HIDDEN}));
const lifted = mapUserToPartialResponse(user({flags: 0n}));
expect(masked.username).not.toBe('ada');
expect(lifted).toMatchObject({
username: 'ada',
discriminator: '0007',
global_name: 'Ada Lovelace',
avatar: 'a1b2c3',
});
expect(isHiddenPartial(lifted)).toBe(false);
});
it('does not mistake a deleted account for a masked one', () => {
const deleted = mapUserToPartialResponse(user({flags: UserFlags.DELETED | UserFlags.SPAMMER}));
expect(deleted).toMatchObject({username: 'DeletedUser', discriminator: '0000', global_name: 'Deleted User'});
expect(isHiddenPartial(deleted)).toBe(false);
});
it('treats hiding and restoring as a partial change so clients are told both ways', () => {
@@ -150,7 +196,7 @@ describe('profile visibility', () => {
it('hides guild-specific profile details for a hidden member', async () => {
const member = new GuildMember({
guild_id: createGuildID(5n),
user_id: createUserID(1174109840998400001n),
user_id: createUserID(USER_ID),
joined_at: new Date(NOW - HOUR),
nick: 'Countess',
avatar_hash: 'm4v',
@@ -178,7 +224,7 @@ describe('profile visibility', () => {
createRequestCache(),
);
expect(hidden).toMatchObject({nick: null, avatar: null, banner: null, accent_color: null});
expect(hidden.user.username).toBe('HiddenUser');
expect(hidden.user).toMatchObject(profilePseudonym(USER_ID));
const shown = await mapGuildMemberToResponse(member, cache(mapUserToPartialResponse(user())), createRequestCache());
expect(shown).toMatchObject({nick: 'Countess', avatar: 'm4v', banner: 'm8n', accent_color: 12});
expect(mapGuildMemberToProfileResponse(member, {hidden: true})).toEqual({
+26 -6
View File
@@ -8,19 +8,17 @@ import {UsernameType} from '@fluxer/schema/src/primitives/UserValidators';
const scales = readFileSync(resolveAssetPath('words', 'scales.txt'), 'utf-8').trim().split('\n').filter(Boolean);
const tails = readFileSync(resolveAssetPath('words', 'tails.txt'), 'utf-8').trim().split('\n').filter(Boolean);
type IndexPicker = (size: number) => number;
function capitalize(word: string): string {
return word.charAt(0).toUpperCase() + word.slice(1);
}
function pickRandom(words: Array<string>): string {
return words[randomInt(words.length)];
}
export function generateRandomUsername(): string {
function generateUsername(pick: IndexPicker): string {
const MAX_LENGTH = 32;
const MAX_ATTEMPTS = 100;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
const username = capitalize(pickRandom(scales)) + capitalize(pickRandom(tails));
const username = capitalize(scales[pick(scales.length)]) + capitalize(tails[pick(tails.length)]);
if (username.length <= MAX_LENGTH && UsernameType.safeParse(username).success) {
return username;
}
@@ -33,3 +31,25 @@ export function generateRandomUsername(): string {
}
return 'BotUser';
}
function seededPicker(seed: Uint8Array): IndexPicker {
const view = new DataView(seed.buffer, seed.byteOffset, seed.byteLength);
const words = Math.floor(seed.byteLength / 4);
let next = 0;
return (size) => {
const value = view.getUint32((next % words) * 4);
next++;
return value % size;
};
}
export function generateRandomUsername(): string {
return generateUsername((size) => randomInt(size));
}
export function generateSeededUsername(seed: Uint8Array): string {
if (seed.byteLength < 4) {
throw new Error('Username seed must hold at least four bytes');
}
return generateUsername(seededPicker(seed));
}