mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(api): derive stable placeholder names for hidden profiles (#3224)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -249,6 +249,7 @@ export interface APIConfig {
|
||||
auth: {
|
||||
sudoModeSecret: string;
|
||||
connectionInitiationSecret: string;
|
||||
profilePseudonymSecret: string;
|
||||
ssoAllowPrivateAddresses: boolean;
|
||||
passkeys: {
|
||||
rpName: string;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user