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:
Generated
+2
@@ -1986,11 +1986,13 @@ dependencies = [
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
Vendored
+1
@@ -89,6 +89,7 @@ FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
|
||||
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET=fluxer-dev-profile-pseudonym-secret
|
||||
FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w
|
||||
FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw
|
||||
FLUXER_VAPID_EMAIL=dev@localhost
|
||||
|
||||
@@ -184,6 +184,7 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
|
||||
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET=CHANGE_ME
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
|
||||
FLUXER_ERLANG_COOKIE=CHANGE_ME
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
|
||||
|
||||
@@ -177,6 +177,7 @@ x-fluxer-env: &fluxer-env
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env}
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET: ${FLUXER_PROFILE_PSEUDONYM_SECRET:?set FLUXER_PROFILE_PSEUDONYM_SECRET in .env}
|
||||
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-}
|
||||
FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env}
|
||||
FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env}
|
||||
|
||||
@@ -14265,8 +14265,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"}
|
||||
]
|
||||
},
|
||||
"MessageEmbedChildResponse": {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"version": 1,
|
||||
"development_secret": "fluxer-dev-profile-pseudonym-secret",
|
||||
"reserved_discriminators": [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 67, 69, 404, 420, 666, 911, 1000, 1111, 1234, 1337, 2000, 2025, 2026, 2027, 2222, 2345,
|
||||
3000, 3333, 3456, 4000, 4321, 4444, 4567, 5000, 5432, 5555, 5678, 6000, 6543, 6666, 6789, 6969, 7000, 7654, 7777,
|
||||
7890, 8000, 8008, 8055, 8080, 8765, 8888, 9000, 9001, 9876, 9999
|
||||
],
|
||||
"vectors": [
|
||||
{
|
||||
"secret": "fluxer-profile-pseudonym-test-vector-secret",
|
||||
"user_id": "1",
|
||||
"username": "VelociraptorAbyssinian",
|
||||
"discriminator": "5640"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-profile-pseudonym-test-vector-secret",
|
||||
"user_id": "1174109840998400001",
|
||||
"username": "HarmonicGate",
|
||||
"discriminator": "1839"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-profile-pseudonym-test-vector-secret",
|
||||
"user_id": "1472906353451343872",
|
||||
"username": "DeltaBombay",
|
||||
"discriminator": "8696"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-profile-pseudonym-test-vector-secret",
|
||||
"user_id": "1544725486800732163",
|
||||
"username": "AlteredBeaver",
|
||||
"discriminator": "6114"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-profile-pseudonym-test-vector-secret",
|
||||
"user_id": "9223372036854775807",
|
||||
"username": "SalmonPolydactyl",
|
||||
"discriminator": "2936"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-dev-profile-pseudonym-secret",
|
||||
"user_id": "1",
|
||||
"username": "SqueakerWeasel",
|
||||
"discriminator": "2226"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-dev-profile-pseudonym-secret",
|
||||
"user_id": "1174109840998400001",
|
||||
"username": "MambaEgret",
|
||||
"discriminator": "6542"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-dev-profile-pseudonym-secret",
|
||||
"user_id": "1472906353451343872",
|
||||
"username": "MenkentBrown",
|
||||
"discriminator": "8304"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-dev-profile-pseudonym-secret",
|
||||
"user_id": "1544725486800732163",
|
||||
"username": "HamalMacaroni",
|
||||
"discriminator": "1160"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-dev-profile-pseudonym-secret",
|
||||
"user_id": "9223372036854775807",
|
||||
"username": "HerringGrolar",
|
||||
"discriminator": "2718"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-profile-pseudonym-test-vector-secret",
|
||||
"user_id": "1500000000000000461",
|
||||
"username": "IdeRoyal",
|
||||
"discriminator": "7655"
|
||||
},
|
||||
{
|
||||
"secret": "fluxer-profile-pseudonym-test-vector-secret",
|
||||
"user_id": "1500000000000002226",
|
||||
"username": "OpaleyeMara",
|
||||
"discriminator": "0010"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,8 +13,7 @@ const PUBLIC_USER_FLAGS: i64 = USER_FLAG_STAFF
|
||||
| USER_FLAG_BUG_HUNTER
|
||||
| USER_FLAG_FRIENDLY_BOT
|
||||
| USER_FLAG_FRIENDLY_BOT_MANUAL_APPROVAL
|
||||
| USER_FLAG_SPAMMER
|
||||
| USER_FLAG_PROFILE_HIDDEN;
|
||||
| USER_FLAG_SPAMMER;
|
||||
const PUBLIC_USER_FLAGS_WITHOUT_STAFF: i64 = PUBLIC_USER_FLAGS & !USER_FLAG_STAFF;
|
||||
const NON_ENFORCEMENT_DELETION_REASONS: [i32; 3] = [1, 2, 19];
|
||||
|
||||
@@ -95,10 +94,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hidden_bit_is_public() {
|
||||
fn the_hidden_bit_is_never_public() {
|
||||
assert_eq!(
|
||||
visible_user_flags(USER_FLAG_PROFILE_HIDDEN | USER_FLAG_DISABLED),
|
||||
USER_FLAG_PROFILE_HIDDEN as i32
|
||||
visible_user_flags(USER_FLAG_PROFILE_HIDDEN | USER_FLAG_DISABLED | USER_FLAG_PARTNER),
|
||||
USER_FLAG_PARTNER as i32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ Sudo mode JWTs, as a raw HS256 key. Changing it ends every active sudo mode sess
|
||||
|
||||
Connection initiation tokens and harvest download links. Changing it invalidates connection initiation tokens not yet verified and harvest download links already issued.
|
||||
|
||||
#### `FLUXER_PROFILE_PSEUDONYM_SECRET`
|
||||
|
||||
Derives the stable placeholder name and tag that other users see while an account's profile is hidden. Must be byte-identical on `api`, `worker`, and `users-shard`, or the same account shows two different names. Changing it gives every hidden profile a new placeholder. `api` and `worker` refuse to start without it when `FLUXER_ENV` is `production`. `users-shard` refuses to start without it unless `FLUXER_ENV` is `development` or `test`.
|
||||
|
||||
#### `FLUXER_GATEWAY_RPC_AUTH_TOKEN`
|
||||
|
||||
Internal RPC between the API and the Gateway. Must be byte-identical on `api`, `worker`, and `gateway`.
|
||||
|
||||
@@ -75,6 +75,14 @@ To write it by hand, run the generator in a shell the same way:
|
||||
printf '\nFLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=%s\n' "$(openssl rand -base64 32)" >> .env
|
||||
```
|
||||
|
||||
The refreshed stack requires `FLUXER_PROFILE_PSEUDONYM_SECRET` on `api`, `worker`, and `users-shard`. `--update` replaces a missing or `CHANGE_ME` value with 64 hex characters. Without it, Compose commands fail with `set FLUXER_PROFILE_PSEUDONYM_SECRET in .env`.
|
||||
|
||||
To write it by hand, run the generator in a shell the same way:
|
||||
|
||||
```bash
|
||||
printf '\nFLUXER_PROFILE_PSEUDONYM_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
## The script is the reference
|
||||
|
||||
Read the [Linux and macOS installer](https://fluxer.dev/install.sh) or [Windows installer](https://fluxer.dev/install.ps1) before running it.
|
||||
|
||||
@@ -173,6 +173,7 @@ $FluxerBackupVolumes = @(
|
||||
$FluxerUpgradeSecretKeys = @(
|
||||
@{Name = 'FLUXER_ERLANG_COOKIE'; Kind = 'hex'}
|
||||
@{Name = 'FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64'; Kind = 'base64'}
|
||||
@{Name = 'FLUXER_PROFILE_PSEUDONYM_SECRET'; Kind = 'hex'}
|
||||
)
|
||||
|
||||
$FluxerSecretKeys = @(
|
||||
@@ -181,6 +182,7 @@ $FluxerSecretKeys = @(
|
||||
@{Name = 'FLUXER_S3_SECRET_KEY'; Kind = 'hex'}
|
||||
@{Name = 'FLUXER_SUDO_MODE_SECRET'; Kind = 'hex'}
|
||||
@{Name = 'FLUXER_CONNECTION_INITIATION_SECRET'; Kind = 'hex'}
|
||||
@{Name = 'FLUXER_PROFILE_PSEUDONYM_SECRET'; Kind = 'hex'}
|
||||
@{Name = 'FLUXER_GATEWAY_RPC_AUTH_TOKEN'; Kind = 'hex'}
|
||||
@{Name = 'FLUXER_ERLANG_COOKIE'; Kind = 'hex'}
|
||||
@{Name = 'FLUXER_MEDIA_PROXY_SECRET_KEY'; Kind = 'hex'}
|
||||
|
||||
@@ -110,6 +110,7 @@ MEILI_MASTER_KEY hex
|
||||
FLUXER_S3_SECRET_KEY hex
|
||||
FLUXER_SUDO_MODE_SECRET hex
|
||||
FLUXER_CONNECTION_INITIATION_SECRET hex
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET hex
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN hex
|
||||
FLUXER_ERLANG_COOKIE hex
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY hex
|
||||
@@ -1124,6 +1125,7 @@ fluxer_upgrade_secret_keys() {
|
||||
cat <<'KEYS'
|
||||
FLUXER_ERLANG_COOKIE hex
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 base64
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET hex
|
||||
KEYS
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ chrono = { version = "0.4", default-features = false }
|
||||
fluxer_common = { path = "../fluxer_common" }
|
||||
fluxer-svc = { path = "../fluxer_svc" }
|
||||
futures = "0.3.34"
|
||||
hmac = "0.13.0"
|
||||
moka = { version = "0.12.16", features = ["future"] }
|
||||
rmp-serde = "1.3"
|
||||
scylla = { version = "1.9.0", features = ["chrono-04"], optional = true }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
sha2 = "0.11.0"
|
||||
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal"] }
|
||||
tracing = "0.1.44"
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
mod pseudonym;
|
||||
mod router_impl;
|
||||
mod shard_impl;
|
||||
mod types;
|
||||
@@ -28,6 +29,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
match config.mode {
|
||||
Mode::Router => fluxer_svc::router::run_router(&config, UsersRouter, transport).await,
|
||||
Mode::Shard => {
|
||||
pseudonym::configure_from_env()?;
|
||||
let shard = match config.database_backend {
|
||||
DatabaseBackend::Postgres => {
|
||||
let postgres_config =
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use hmac::{KeyInit, Mac};
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
|
||||
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
|
||||
|
||||
const SECRET_ENV: &str = "FLUXER_PROFILE_PSEUDONYM_SECRET";
|
||||
const DEVELOPMENT_SECRET: &str = "fluxer-dev-profile-pseudonym-secret";
|
||||
const SCALES_TEXT: &str = include_str!("../../fluxer_api/src/api/words/scales.txt");
|
||||
const TAILS_TEXT: &str = include_str!("../../fluxer_api/src/api/words/tails.txt");
|
||||
const MAX_USERNAME_LENGTH: usize = 32;
|
||||
const MAX_ATTEMPTS: usize = 100;
|
||||
const FALLBACK_USERNAME: &str = "BotUser";
|
||||
const MAX_DISCRIMINATOR: u32 = 9999;
|
||||
const RESERVED_DISCRIMINATORS: [u32; 55] = [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 67, 69, 404, 420, 666, 911, 1000, 1111, 1234, 1337, 2000, 2025,
|
||||
2026, 2027, 2222, 2345, 3000, 3333, 3456, 4000, 4321, 4444, 4567, 5000, 5432, 5555, 5678, 6000,
|
||||
6543, 6666, 6789, 6969, 7000, 7654, 7777, 7890, 8000, 8008, 8055, 8080, 8765, 8888, 9000, 9001,
|
||||
9876, 9999,
|
||||
];
|
||||
|
||||
static SECRET: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
static SCALES: LazyLock<Vec<&'static str>> = LazyLock::new(|| word_list(SCALES_TEXT));
|
||||
static TAILS: LazyLock<Vec<&'static str>> = LazyLock::new(|| word_list(TAILS_TEXT));
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Pseudonym {
|
||||
pub username: String,
|
||||
pub discriminator: i32,
|
||||
}
|
||||
|
||||
pub fn configure(secret: Option<String>, environment: Option<&str>) -> anyhow::Result<()> {
|
||||
let secret = match secret.filter(|value| !value.trim().is_empty()) {
|
||||
Some(value) => value,
|
||||
None if matches!(environment, Some("development" | "test")) => {
|
||||
tracing::warn!("{SECRET_ENV} is not set, using the development secret");
|
||||
DEVELOPMENT_SECRET.to_owned()
|
||||
}
|
||||
None => anyhow::bail!("{SECRET_ENV} is required"),
|
||||
};
|
||||
SECRET
|
||||
.set(secret.into_bytes())
|
||||
.map_err(|_| anyhow::anyhow!("{SECRET_ENV} was already configured"))
|
||||
}
|
||||
|
||||
pub fn configure_from_env() -> anyhow::Result<()> {
|
||||
configure(
|
||||
std::env::var(SECRET_ENV).ok(),
|
||||
std::env::var("FLUXER_ENV").ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn pseudonym(user_id: i64) -> Pseudonym {
|
||||
let secret = SECRET
|
||||
.get()
|
||||
.map_or(DEVELOPMENT_SECRET.as_bytes(), Vec::as_slice);
|
||||
pseudonym_with_secret(secret, user_id)
|
||||
}
|
||||
|
||||
pub fn pseudonym_with_secret(secret: &[u8], user_id: i64) -> Pseudonym {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).expect("hmac accepts any key length");
|
||||
mac.update(user_id.to_string().as_bytes());
|
||||
let seed: [u8; 32] = mac.finalize().into_bytes().into();
|
||||
Pseudonym {
|
||||
username: seeded_username(&seed),
|
||||
discriminator: seeded_discriminator(&seed),
|
||||
}
|
||||
}
|
||||
|
||||
fn word_list(text: &'static str) -> Vec<&'static str> {
|
||||
text.trim()
|
||||
.split('\n')
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn capitalize(word: &str) -> String {
|
||||
let mut chars = word.chars();
|
||||
chars.next().map_or_else(String::new, |first| {
|
||||
first.to_uppercase().chain(chars).collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn is_valid_username(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
let lower = trimmed.to_lowercase();
|
||||
!trimmed.is_empty()
|
||||
&& trimmed.encode_utf16().count() <= MAX_USERNAME_LENGTH
|
||||
&& trimmed
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
&& lower != "everyone"
|
||||
&& lower != "here"
|
||||
&& !lower.contains("fluxer")
|
||||
&& !lower.contains("system message")
|
||||
}
|
||||
|
||||
fn seed_word(seed: &[u8; 32], index: usize) -> u32 {
|
||||
let offset = (index % (seed.len() / 4)) * 4;
|
||||
u32::from_be_bytes([
|
||||
seed[offset],
|
||||
seed[offset + 1],
|
||||
seed[offset + 2],
|
||||
seed[offset + 3],
|
||||
])
|
||||
}
|
||||
|
||||
fn seeded_username(seed: &[u8; 32]) -> String {
|
||||
let scales = &*SCALES;
|
||||
let tails = &*TAILS;
|
||||
let mut picks = 0;
|
||||
let mut pick = |size: usize| {
|
||||
let value = seed_word(seed, picks) as usize % size;
|
||||
picks += 1;
|
||||
value
|
||||
};
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
let scale = scales[pick(scales.len())];
|
||||
let tail = tails[pick(tails.len())];
|
||||
let candidate = capitalize(scale) + &capitalize(tail);
|
||||
if is_valid_username(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
tails
|
||||
.iter()
|
||||
.map(|tail| capitalize(tail))
|
||||
.find(|candidate| is_valid_username(candidate))
|
||||
.unwrap_or_else(|| FALLBACK_USERNAME.to_owned())
|
||||
}
|
||||
|
||||
fn seeded_discriminator(seed: &[u8; 32]) -> i32 {
|
||||
let mut value = seed_word(seed, seed.len() / 4 - 1) % MAX_DISCRIMINATOR + 1;
|
||||
while RESERVED_DISCRIMINATORS.contains(&value) {
|
||||
value = value % MAX_DISCRIMINATOR + 1;
|
||||
}
|
||||
value as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
const VECTORS: &str =
|
||||
include_str!("../../fluxer_common/src/testdata/profile_pseudonym_vectors.json");
|
||||
|
||||
fn fixture() -> Value {
|
||||
serde_json::from_str(VECTORS).expect("the pseudonym vectors parse as json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_the_shared_vectors() {
|
||||
let fixture = fixture();
|
||||
let vectors = fixture["vectors"].as_array().expect("a vector list");
|
||||
assert!(!vectors.is_empty());
|
||||
for vector in vectors {
|
||||
let secret = vector["secret"].as_str().expect("a secret");
|
||||
let user_id = vector["user_id"]
|
||||
.as_str()
|
||||
.expect("a user id")
|
||||
.parse::<i64>()
|
||||
.expect("a numeric user id");
|
||||
let expected = Pseudonym {
|
||||
username: vector["username"].as_str().expect("a username").to_owned(),
|
||||
discriminator: vector["discriminator"]
|
||||
.as_str()
|
||||
.expect("a discriminator")
|
||||
.parse()
|
||||
.expect("a numeric discriminator"),
|
||||
};
|
||||
assert_eq!(
|
||||
pseudonym_with_secret(secret.as_bytes(), user_id),
|
||||
expected,
|
||||
"{user_id}"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{:04}", expected.discriminator),
|
||||
vector["discriminator"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shares_the_reserved_list_and_development_secret() {
|
||||
let fixture = fixture();
|
||||
let reserved = fixture["reserved_discriminators"]
|
||||
.as_array()
|
||||
.expect("a reserved list")
|
||||
.iter()
|
||||
.map(|value| value.as_u64().expect("a number") as u32)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(reserved, RESERVED_DISCRIMINATORS.to_vec());
|
||||
assert_eq!(fixture["development_secret"], DEVELOPMENT_SECRET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_stable_per_account_and_differs_across_accounts() {
|
||||
let secret = b"stable-secret";
|
||||
let first = pseudonym_with_secret(secret, 1_174_109_840_998_400_001);
|
||||
assert_eq!(
|
||||
first,
|
||||
pseudonym_with_secret(secret, 1_174_109_840_998_400_001)
|
||||
);
|
||||
let names = (0..200)
|
||||
.map(|offset| pseudonym_with_secret(secret, 1_174_109_840_998_400_001 + offset))
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
assert_eq!(names.len(), 200);
|
||||
assert_ne!(
|
||||
first,
|
||||
pseudonym_with_secret(b"other-secret", 1_174_109_840_998_400_001)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_a_generated_account() {
|
||||
for user_id in 0..500 {
|
||||
let generated = pseudonym_with_secret(b"format-secret", user_id);
|
||||
assert!(
|
||||
is_valid_username(&generated.username),
|
||||
"{}",
|
||||
generated.username
|
||||
);
|
||||
assert!(
|
||||
SCALES.iter().any(|scale| {
|
||||
generated
|
||||
.username
|
||||
.strip_prefix(&capitalize(scale))
|
||||
.is_some_and(|rest| TAILS.iter().any(|tail| rest == capitalize(tail)))
|
||||
}),
|
||||
"{}",
|
||||
generated.username
|
||||
);
|
||||
let discriminator = generated.discriminator as u32;
|
||||
assert!((1..=MAX_DISCRIMINATOR).contains(&discriminator));
|
||||
assert!(!RESERVED_DISCRIMINATORS.contains(&discriminator));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_requires_a_secret_outside_development() {
|
||||
assert!(configure(None, None).is_err());
|
||||
assert!(configure(Some(" ".to_owned()), Some("production")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1244,21 +1244,44 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_hidden(partial: &UserPartial) {
|
||||
assert_eq!(partial.username, "HiddenUser");
|
||||
assert_eq!(partial.discriminator, 0);
|
||||
let expected = crate::pseudonym::pseudonym(partial.user_id);
|
||||
assert_eq!(partial.username, expected.username);
|
||||
assert_eq!(partial.discriminator, expected.discriminator);
|
||||
assert_ne!(partial.username, "Ada");
|
||||
assert_eq!(partial.global_name, None);
|
||||
assert_eq!(partial.avatar_hash, None);
|
||||
assert_eq!(partial.avatar_color, None);
|
||||
assert_eq!(partial.banner_hash, None);
|
||||
assert_eq!(partial.banner_color, None);
|
||||
assert_eq!(partial.accent_color, None);
|
||||
assert_ne!(partial.flags.unwrap_or_default() & PROFILE_HIDDEN, 0);
|
||||
let api = partial.to_api_partial();
|
||||
assert_eq!(api.username, expected.username);
|
||||
assert_eq!(api.discriminator, format!("{:04}", expected.discriminator));
|
||||
assert_eq!(api.flags & PROFILE_HIDDEN as i32, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_masked_partial_matches_the_shared_development_vector() {
|
||||
let api = styled(1_174_109_840_998_400_001, SPAMMER)
|
||||
.to_partial()
|
||||
.to_api_partial();
|
||||
assert_eq!(
|
||||
(api.username.as_str(), api.discriminator.as_str()),
|
||||
("HiddenUser", "0000")
|
||||
("MambaEgret", "6542")
|
||||
);
|
||||
assert_eq!(api.flags & PROFILE_HIDDEN as i32, 0);
|
||||
assert_eq!(api.global_name, None);
|
||||
assert_eq!(api.avatar, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_masked_partial_is_stable_across_reads() {
|
||||
let first = styled(53, PROFILE_HIDDEN).to_partial();
|
||||
let second = styled(53, PROFILE_HIDDEN).to_partial();
|
||||
assert_eq!(
|
||||
(first.username, first.discriminator),
|
||||
(second.username, second.discriminator)
|
||||
);
|
||||
assert_ne!(api.flags & PROFILE_HIDDEN as i32, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1294,6 +1317,7 @@ mod tests {
|
||||
assert_eq!(partial.username, "Ada", "{}", user.user_id);
|
||||
assert_eq!(partial.global_name.as_deref(), Some("Ada Lovelace"));
|
||||
assert_eq!(partial.banner_hash.as_deref(), Some("banner_hash"));
|
||||
assert_eq!(partial.discriminator, 7);
|
||||
assert_eq!(partial.flags.unwrap_or_default() & PROFILE_HIDDEN, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_common::user_flags::{
|
||||
AccountStanding, USER_FLAG_PROFILE_HIDDEN, USER_FLAG_STAFF, visible_user_flags,
|
||||
};
|
||||
use crate::pseudonym::pseudonym;
|
||||
use fluxer_common::user_flags::{AccountStanding, USER_FLAG_STAFF, visible_user_flags};
|
||||
#[cfg(test)]
|
||||
use fluxer_common::user_flags::{USER_FLAG_PARTNER, USER_FLAG_STAFF_HIDDEN};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
@@ -145,8 +144,6 @@ const FLUXER_SYSTEM_USER_ID: i64 = 0;
|
||||
const FLUXER_SYSTEM_USERNAME: &str = "Fluxer";
|
||||
const FLUXER_SYSTEM_DISCRIMINATOR: &str = "0000";
|
||||
|
||||
const HIDDEN_USER_USERNAME: &str = "HiddenUser";
|
||||
|
||||
pub fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -193,12 +190,12 @@ impl UserPartial {
|
||||
if self.user_id == FLUXER_SYSTEM_USER_ID || !standing.profile_hidden(now_ms) {
|
||||
return self;
|
||||
}
|
||||
let pseudonym = pseudonym(self.user_id);
|
||||
UserPartial {
|
||||
username: HIDDEN_USER_USERNAME.to_owned(),
|
||||
discriminator: 0,
|
||||
username: pseudonym.username,
|
||||
discriminator: pseudonym.discriminator,
|
||||
global_name: None,
|
||||
avatar_hash: None,
|
||||
flags: Some(self.flags.unwrap_or_default() | USER_FLAG_PROFILE_HIDDEN),
|
||||
banner_hash: None,
|
||||
banner_color: None,
|
||||
accent_color: None,
|
||||
|
||||
@@ -143,6 +143,7 @@ function defaultConfig(): MasterConfig {
|
||||
auth: {
|
||||
sudo_mode_secret: '',
|
||||
connection_initiation_secret: '',
|
||||
profile_pseudonym_secret: '',
|
||||
sso_allow_private_addresses: false,
|
||||
passkeys: {
|
||||
rp_name: 'Fluxer',
|
||||
@@ -302,6 +303,18 @@ function requireString(value: string | undefined, envName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
const DEVELOPMENT_PROFILE_PSEUDONYM_SECRET = 'fluxer-dev-profile-pseudonym-secret';
|
||||
|
||||
function applyProfilePseudonymSecret(config: MasterConfig): void {
|
||||
if (config.auth.profile_pseudonym_secret.trim().length > 0) {
|
||||
return;
|
||||
}
|
||||
if (config.env === 'production') {
|
||||
throw new Error('FLUXER_PROFILE_PSEUDONYM_SECRET is required');
|
||||
}
|
||||
config.auth.profile_pseudonym_secret = DEVELOPMENT_PROFILE_PSEUDONYM_SECRET;
|
||||
}
|
||||
|
||||
function validateReplyToEmail(value: string): void {
|
||||
if (value !== '' && !/^[^\s@<>,;"]+@[^\s@<>,;"]+$/.test(value)) {
|
||||
throw new Error('FLUXER_EMAIL_REPLY_TO_EMAIL must be a single email address such as [email protected]');
|
||||
@@ -600,6 +613,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
|
||||
}
|
||||
requireString(config.auth.sudo_mode_secret, 'FLUXER_SUDO_MODE_SECRET');
|
||||
requireString(config.auth.connection_initiation_secret, 'FLUXER_CONNECTION_INITIATION_SECRET');
|
||||
applyProfilePseudonymSecret(config);
|
||||
validateVapidConfig(config);
|
||||
requireString(config.s3?.access_key_id, 'FLUXER_S3_ACCESS_KEY_ID');
|
||||
requireString(config.s3?.secret_access_key, 'FLUXER_S3_SECRET_ACCESS_KEY');
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface MasterConfig {
|
||||
auth: {
|
||||
sudo_mode_secret: string;
|
||||
connection_initiation_secret: string;
|
||||
profile_pseudonym_secret: string;
|
||||
sso_allow_private_addresses: boolean;
|
||||
passkeys: {
|
||||
rp_name: string;
|
||||
|
||||
@@ -25,6 +25,7 @@ const MINIMAL_ENV: Record<string, string> = {
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'test-gateway-token',
|
||||
FLUXER_SUDO_MODE_SECRET: 'test-sudo-secret',
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: 'test-connection-secret',
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET: 'test-profile-pseudonym-secret',
|
||||
FLUXER_VAPID_PUBLIC_KEY: 'BB76bTFIuoqmxJtTfZX0yGTn1f_qu9H03B_nkj8OyExJFkN7Y-HBZZzShnHZoEhXKc5ZRy3jFu7OkBbnaQG-4aw',
|
||||
FLUXER_VAPID_PRIVATE_KEY: 'Xgi-3P8J-I3Q6U1HlCcXMuc_tKLGAM9nIfznX3Hz68o',
|
||||
};
|
||||
@@ -475,6 +476,25 @@ describe('ConfigLoader', () => {
|
||||
await expect(loadConfig()).rejects.toThrow('Invalid FLUXER_KV_MODE: sentinel');
|
||||
});
|
||||
|
||||
test('reads the profile pseudonym secret and requires it in production', async () => {
|
||||
stubMinimalEnv();
|
||||
expect((await loadConfig()).auth.profile_pseudonym_secret).toBe('test-profile-pseudonym-secret');
|
||||
resetConfig();
|
||||
stubMinimalEnv({FLUXER_PROFILE_PSEUDONYM_SECRET: ''});
|
||||
expect((await loadConfig()).auth.profile_pseudonym_secret).toBe('fluxer-dev-profile-pseudonym-secret');
|
||||
resetConfig();
|
||||
stubMinimalEnv({
|
||||
FLUXER_ENV: 'production',
|
||||
FLUXER_POSTGRES_HOST: 'postgres.internal',
|
||||
FLUXER_POSTGRES_DATABASE: 'fluxer_prod',
|
||||
FLUXER_POSTGRES_USERNAME: 'fluxer_app',
|
||||
FLUXER_POSTGRES_PASSWORD: 'prod-postgres-secret',
|
||||
FLUXER_POSTGRES_SSL: 'true',
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET: '',
|
||||
});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_PROFILE_PSEUDONYM_SECRET is required');
|
||||
});
|
||||
|
||||
test('rejects unsafe production Postgres defaults', async () => {
|
||||
stubMinimalEnv({FLUXER_ENV: 'production'});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_HOST');
|
||||
|
||||
@@ -21,6 +21,7 @@ const SECRETS: Record<string, string> = {
|
||||
FLUXER_ERLANG_COOKIE: 'erlang-cookie',
|
||||
FLUXER_SUDO_MODE_SECRET: 'sudo-mode-secret',
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: 'connection-initiation-secret',
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET: 'profile-pseudonym-secret',
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'gateway-rpc-auth-token',
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY: 'media-proxy-secret-key',
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=',
|
||||
|
||||
@@ -128,6 +128,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: {path: ['services', 'gateway', 'rpc_auth_token']},
|
||||
FLUXER_SUDO_MODE_SECRET: {path: ['auth', 'sudo_mode_secret']},
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: {path: ['auth', 'connection_initiation_secret']},
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET: {path: ['auth', 'profile_pseudonym_secret']},
|
||||
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: {path: ['auth', 'sso_allow_private_addresses'], parse: parseBoolean},
|
||||
FLUXER_VAPID_PUBLIC_KEY: {path: ['auth', 'vapid', 'public_key']},
|
||||
FLUXER_VAPID_PRIVATE_KEY: {path: ['auth', 'vapid', 'private_key']},
|
||||
|
||||
@@ -155,8 +155,6 @@ export const DELETED_USER_USERNAME = 'DeletedUser';
|
||||
export const DELETED_USER_GLOBAL_NAME = 'Deleted User';
|
||||
export const DELETED_USER_DISCRIMINATOR = 0;
|
||||
export const DELETED_USER_ID = 1n;
|
||||
export const HIDDEN_USER_USERNAME = 'HiddenUser';
|
||||
export const HIDDEN_USER_DISCRIMINATOR = 0;
|
||||
export const PublicUserFlags = {
|
||||
STAFF: Number(UserFlags.STAFF),
|
||||
PARTNER: Number(UserFlags.PARTNER),
|
||||
@@ -164,7 +162,6 @@ export const PublicUserFlags = {
|
||||
FRIENDLY_BOT: Number(UserFlags.FRIENDLY_BOT),
|
||||
FRIENDLY_BOT_MANUAL_APPROVAL: Number(UserFlags.FRIENDLY_BOT_MANUAL_APPROVAL),
|
||||
SPAMMER: Number(UserFlags.SPAMMER),
|
||||
PROFILE_HIDDEN: Number(UserFlags.PROFILE_HIDDEN),
|
||||
} as const;
|
||||
export const PublicUserFlagsDescriptions: Record<keyof typeof PublicUserFlags, string> = {
|
||||
STAFF: 'User is a staff member',
|
||||
@@ -173,7 +170,6 @@ export const PublicUserFlagsDescriptions: Record<keyof typeof PublicUserFlags, s
|
||||
FRIENDLY_BOT: 'Bot accepts friend requests from users',
|
||||
FRIENDLY_BOT_MANUAL_APPROVAL: 'Bot requires manual approval for friend requests',
|
||||
SPAMMER: 'User is flagged as a spammer',
|
||||
PROFILE_HIDDEN: 'User profile details are hidden',
|
||||
};
|
||||
export const ThemeTypes = {
|
||||
DARK: 'dark',
|
||||
|
||||
Reference in New Issue
Block a user