feat(api): track privacy setup and open community DMs by default (#3322)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 20:47:22 +02:00
1 parent ae820ba4ea
commit e9a4f33a7e
13 files changed
+122 -6

No files matched your search

@@ -101,12 +101,11 @@ describe('DM Privacy Bidirectional Enforcement', () => {
.expect(HTTP_STATUS.OK) .expect(HTTP_STATUS.OK)
.execute(); .execute();
}); });
it('blocks message from a non-friend guild member to a new account by default', async () => { it('allows message from a non-friend guild member to a new account by default', async () => {
const sender = await createTestAccount(harness); const sender = await createTestAccount(harness);
const target = await createTestAccount(harness); const target = await createTestAccount(harness);
await ensureSessionStarted(harness, sender.token); await ensureSessionStarted(harness, sender.token);
await ensureSessionStarted(harness, target.token); await ensureSessionStarted(harness, target.token);
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
await createFriendship(harness, sender, target); await createFriendship(harness, sender, target);
const guild = await createGuild(harness, sender.token, 'Default Community'); const guild = await createGuild(harness, sender.token, 'Default Community');
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!); const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
@@ -116,8 +115,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
await removeRelationship(harness, sender.token, target.userId); await removeRelationship(harness, sender.token, target.userId);
await createBuilder(harness, sender.token) await createBuilder(harness, sender.token)
.post(`/channels/${channel.id}/messages`) .post(`/channels/${channel.id}/messages`)
.body({content: 'default restricted target'}) .body({content: 'default open target'})
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER') .expect(HTTP_STATUS.OK)
.execute(); .execute();
}); });
it('blocks message when sender restricts the only mutual guild', async () => { it('blocks message when sender restricts the only mutual guild', async () => {
@@ -274,6 +274,8 @@ export interface UserSettingsRow {
synced_preferences: Nullish<string>; synced_preferences: Nullish<string>;
profile_privacy: Nullish<number>; profile_privacy: Nullish<number>;
default_share_voice_activity: Nullish<boolean>; default_share_voice_activity: Nullish<boolean>;
privacy_setup_version: Nullish<number>;
privacy_setup_completed_at: Nullish<Date>;
version: number; version: number;
} }
@@ -513,6 +515,8 @@ export const USER_SETTINGS_COLUMNS = [
'synced_preferences', 'synced_preferences',
'profile_privacy', 'profile_privacy',
'default_share_voice_activity', 'default_share_voice_activity',
'privacy_setup_version',
'privacy_setup_completed_at',
'version', 'version',
] as const satisfies ReadonlyArray<keyof UserSettingsRow>; ] as const satisfies ReadonlyArray<keyof UserSettingsRow>;
export const USER_GUILD_SETTINGS_COLUMNS = [ export const USER_GUILD_SETTINGS_COLUMNS = [
+9 -1
View File
@@ -68,6 +68,8 @@ export class UserSettings {
readonly syncedPreferences: string; readonly syncedPreferences: string;
readonly profilePrivacy: ProfilePrivacyLevel; readonly profilePrivacy: ProfilePrivacyLevel;
readonly defaultShareVoiceActivity: boolean; readonly defaultShareVoiceActivity: boolean;
readonly privacySetupVersion: number;
readonly privacySetupCompletedAt: Date | null;
readonly version: number; readonly version: number;
constructor(row: UserSettingsRow) { constructor(row: UserSettingsRow) {
@@ -115,6 +117,8 @@ export class UserSettings {
this.syncedPreferences = normalizeStoredSyncedPreferences(row.synced_preferences); this.syncedPreferences = normalizeStoredSyncedPreferences(row.synced_preferences);
this.profilePrivacy = (row.profile_privacy ?? ProfilePrivacyLevels.ALL_GUILDS) as ProfilePrivacyLevel; this.profilePrivacy = (row.profile_privacy ?? ProfilePrivacyLevels.ALL_GUILDS) as ProfilePrivacyLevel;
this.defaultShareVoiceActivity = row.default_share_voice_activity ?? true; this.defaultShareVoiceActivity = row.default_share_voice_activity ?? true;
this.privacySetupVersion = row.privacy_setup_version ?? 0;
this.privacySetupCompletedAt = row.privacy_setup_completed_at ?? null;
this.version = row.version; this.version = row.version;
} }
@@ -171,6 +175,8 @@ export class UserSettings {
synced_preferences: this.syncedPreferences === '' ? null : this.syncedPreferences, synced_preferences: this.syncedPreferences === '' ? null : this.syncedPreferences,
profile_privacy: this.profilePrivacy, profile_privacy: this.profilePrivacy,
default_share_voice_activity: this.defaultShareVoiceActivity, default_share_voice_activity: this.defaultShareVoiceActivity,
privacy_setup_version: this.privacySetupVersion,
privacy_setup_completed_at: this.privacySetupCompletedAt,
version: this.version, version: this.version,
}; };
} }
@@ -216,7 +222,7 @@ export class UserSettings {
friend_source_flags: friendSourceFlags, friend_source_flags: friendSourceFlags,
incoming_call_flags: IncomingCallFlags.FRIENDS_ONLY, incoming_call_flags: IncomingCallFlags.FRIENDS_ONLY,
group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY, group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY,
default_guilds_restricted: true, default_guilds_restricted: false,
bot_default_guilds_restricted: false, bot_default_guilds_restricted: false,
restricted_guilds: new Set(), restricted_guilds: new Set(),
bot_restricted_guilds: new Set(), bot_restricted_guilds: new Set(),
@@ -244,6 +250,8 @@ export class UserSettings {
synced_preferences: null, synced_preferences: null,
profile_privacy: ProfilePrivacyLevels.ALL_GUILDS, profile_privacy: ProfilePrivacyLevels.ALL_GUILDS,
default_share_voice_activity: true, default_share_voice_activity: true,
privacy_setup_version: 0,
privacy_setup_completed_at: null,
version: 1, version: 1,
}; };
} }
+26 -1
View File
@@ -24983,6 +24983,12 @@
"type": "boolean", "type": "boolean",
"description": "Default share_voice_activity applied to new friend relationships" "description": "Default share_voice_activity applied to new friend relationships"
}, },
"privacy_setup_version": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"description": "Version of the privacy setup the user just reviewed. The server also records privacy_setup_completed_at as the current time."
},
"synced_preferences": { "synced_preferences": {
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it.", "description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it.",
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}] "anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}]
@@ -25136,6 +25142,23 @@
"default_share_voice_activity": { "default_share_voice_activity": {
"type": "boolean", "type": "boolean",
"description": "Default value of share_voice_activity applied to newly accepted friend relationships. Read-only here; mutated via PUT /users/@me/settings/voice-activity-sharing." "description": "Default value of share_voice_activity applied to newly accepted friend relationships. Read-only here; mutated via PUT /users/@me/settings/voice-activity-sharing."
},
"privacy_setup_version": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "Latest privacy setup version the user has reviewed, 0 if never reviewed"
},
"privacy_setup_completed_at": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
{"type": "null"}
],
"description": "When the user last completed the privacy setup, or null if never"
} }
}, },
"required": [ "required": [
@@ -25173,7 +25196,9 @@
"staff_dm_access_user_ids", "staff_dm_access_user_ids",
"synced_preferences", "synced_preferences",
"profile_privacy", "profile_privacy",
"default_share_voice_activity" "default_share_voice_activity",
"privacy_setup_version",
"privacy_setup_completed_at"
], ],
"additionalProperties": false "additionalProperties": false
}, },
+2
View File
@@ -353,6 +353,8 @@ export function mapUserSettingsToResponse(params: {settings: UserSettings}): Use
synced_preferences: settings.syncedPreferences, synced_preferences: settings.syncedPreferences,
profile_privacy: settings.profilePrivacy, profile_privacy: settings.profilePrivacy,
default_share_voice_activity: settings.defaultShareVoiceActivity, default_share_voice_activity: settings.defaultShareVoiceActivity,
privacy_setup_version: settings.privacySetupVersion,
privacy_setup_completed_at: settings.privacySetupCompletedAt?.toISOString() ?? null,
}; };
} }
@@ -137,6 +137,10 @@ export class UserAccountSettingsService {
if (data.bot_default_guilds_restricted !== undefined) { if (data.bot_default_guilds_restricted !== undefined) {
updatedRowData.bot_default_guilds_restricted = data.bot_default_guilds_restricted; updatedRowData.bot_default_guilds_restricted = data.bot_default_guilds_restricted;
} }
if (data.privacy_setup_version !== undefined) {
updatedRowData.privacy_setup_version = data.privacy_setup_version;
updatedRowData.privacy_setup_completed_at = new Date();
}
if (data.inline_attachment_media !== undefined) { if (data.inline_attachment_media !== undefined) {
updatedRowData.inline_attachment_media = data.inline_attachment_media; updatedRowData.inline_attachment_media = data.inline_attachment_media;
} }
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {fetchUserSettings, updateUserSettings} from '@app/api/user/tests/UserTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('User settings privacy setup', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('new accounts allow community DMs and have not reviewed the privacy setup', async () => {
const account = await createTestAccount(harness);
const {json} = await fetchUserSettings(harness, account.token);
expect(json.default_guilds_restricted).toBe(false);
expect(json.privacy_setup_version).toBe(0);
expect(json.privacy_setup_completed_at).toBeNull();
});
test('completing the privacy setup stores the version, the time and the chosen setting in one update', async () => {
const account = await createTestAccount(harness);
const before = Date.now();
const {json} = await updateUserSettings(harness, account.token, {
default_guilds_restricted: true,
privacy_setup_version: 1,
});
expect(json.default_guilds_restricted).toBe(true);
expect(json.privacy_setup_version).toBe(1);
expect(Date.parse(json.privacy_setup_completed_at ?? '')).toBeGreaterThanOrEqual(before - 1000);
const {json: reread} = await fetchUserSettings(harness, account.token);
expect(reread.privacy_setup_version).toBe(1);
expect(reread.privacy_setup_completed_at).toBe(json.privacy_setup_completed_at);
await createBuilder(harness, account.token)
.patch('/users/@me/settings')
.body({privacy_setup_version: 0})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});
@@ -569,6 +569,8 @@ export function buildUserDataJson(params: UserDataJsonParams) {
staff_dm_access_user_ids: Array.from(userSettings.staffDmAccessUserIds).map((id) => id.toString()), staff_dm_access_user_ids: Array.from(userSettings.staffDmAccessUserIds).map((id) => id.toString()),
synced_preferences: syncedPreferencesToJson(decodeSyncedPreferencesLenient(userSettings.syncedPreferences)), synced_preferences: syncedPreferencesToJson(decodeSyncedPreferencesLenient(userSettings.syncedPreferences)),
profile_privacy: userSettings.profilePrivacy, profile_privacy: userSettings.profilePrivacy,
privacy_setup_version: userSettings.privacySetupVersion,
privacy_setup_completed_at: userSettings.privacySetupCompletedAt?.toISOString() ?? null,
} }
: null, : null,
guild_memberships: guildMemberships guild_memberships: guildMemberships
@@ -294,6 +294,8 @@ The [user settings update object](/http-api/users/settings/#user-settings-update
| synced_preferences<sup>6</sup> | string | The base64-encoded [synced preferences](/http-api/users/settings-protobuf/) message | | synced_preferences<sup>6</sup> | string | The base64-encoded [synced preferences](/http-api/users/settings-protobuf/) message |
| profile_privacy | integer | [Profile privacy level](#profile-privacy-levels) | | profile_privacy | integer | [Profile privacy level](#profile-privacy-levels) |
| default_share_voice_activity<sup>7</sup> | boolean | Whether a newly accepted friend relationship starts with voice activity sharing on | | default_share_voice_activity<sup>7</sup> | boolean | Whether a newly accepted friend relationship starts with voice activity sharing on |
| privacy_setup_version<sup>8</sup> | integer | The latest privacy setup version the account has reviewed, 0 if it never has |
| privacy_setup_completed_at | ?ISO8601 timestamp | When the account last completed the privacy setup, or null if it never has |
<sup>1</sup> Duplicate identifiers are removed when the array is written <sup>1</sup> Duplicate identifiers are removed when the array is written
@@ -309,6 +311,8 @@ The [user settings update object](/http-api/users/settings/#user-settings-update
<sup>7</sup> The field is read-only here and is changed by [Modify voice activity sharing](/http-api/users/settings/#modify-voice-activity-sharing) <sup>7</sup> The field is read-only here and is changed by [Modify voice activity sharing](/http-api/users/settings/#modify-voice-activity-sharing)
<sup>8</sup> Fluxer only stores this number. The first-party client compares it with the privacy setup version it ships and asks the user to review their direct message settings while the stored number is lower
## Custom status object ## Custom status object
A custom status is the text and emoji stored on the account's [user settings](#user-settings-object). A custom status is the text and emoji stored on the account's [user settings](#user-settings-object).
@@ -228,6 +228,7 @@ Further members are accepted and ignored. `guild_positions` is an array of at mo
| suppress_unprivileged_self_mentions_bypass_user_ids?<sup>12</sup> | ?array[snowflake] | The users exempt from mention suppression | | suppress_unprivileged_self_mentions_bypass_user_ids?<sup>12</sup> | ?array[snowflake] | The users exempt from mention suppression |
| staff_dm_access_user_ids?<sup>12</sup> | ?array[snowflake] | The users granted staff direct message access | | staff_dm_access_user_ids?<sup>12</sup> | ?array[snowflake] | The users granted staff direct message access |
| profile_privacy? | integer | [Profile privacy level](/http-api/users/#profile-privacy-levels) | | profile_privacy? | integer | [Profile privacy level](/http-api/users/#profile-privacy-levels) |
| privacy_setup_version?<sup>14</sup> | integer | The privacy setup version the user just reviewed (1-1000) |
| synced_preferences?<sup>13</sup> | ?string | The base64-encoded `fluxer.user.preferences.v1.SyncedPreferences` snapshot, or null to clear it | | synced_preferences?<sup>13</sup> | ?string | The base64-encoded `fluxer.user.preferences.v1.SyncedPreferences` snapshot, or null to clear it |
<sup>1</sup> A string matches ISO 8601 and an integer is Unix milliseconds between 0 and 8640000000000000. Anything else is rejected with `INVALID_ISO_TIMESTAMP` <sup>1</sup> A string matches ISO 8601 and an integer is Unix milliseconds between 0 and 8640000000000000. Anything else is rejected with `INVALID_ISO_TIMESTAMP`
@@ -256,6 +257,8 @@ Further members are accepted and ignored. `guild_positions` is an array of at mo
<sup>13</sup> The value runs to at most 699052 characters of base64 and decodes to at most 524288 bytes, the 512 KiB ceiling. A longer string returns 400 `INVALID_FORM_BODY`, an oversized decode fails with `TOO_LARGE`, and an undecodable value fails with `INVALID_FORMAT` <sup>13</sup> The value runs to at most 699052 characters of base64 and decodes to at most 524288 bytes, the 512 KiB ceiling. A longer string returns 400 `INVALID_FORM_BODY`, an oversized decode fails with `TOO_LARGE`, and an undecodable value fails with `INVALID_FORMAT`
<sup>14</sup> Fluxer stores the value as given and sets `privacy_setup_completed_at` to the time of the update
Fluxer decodes an accepted `synced_preferences` value, re-encodes it canonically, and stores the result, so a snapshot with only zero values is stored as cleared and read back as the empty string. Fluxer decodes an accepted `synced_preferences` value, re-encodes it canonically, and stores the result, so a snapshot with only zero values is stored as cleared and read back as the empty string.
:::caution[A rebuilt folder layout drops unresolved references] :::caution[A rebuilt folder layout drops unresolved references]
@@ -394,6 +394,14 @@ export const UserSettingsUpdateRequest = z
default_share_voice_activity: z default_share_voice_activity: z
.boolean() .boolean()
.describe('Default share_voice_activity applied to new friend relationships'), .describe('Default share_voice_activity applied to new friend relationships'),
privacy_setup_version: z
.number()
.int()
.min(1)
.max(1000)
.describe(
'Version of the privacy setup the user just reviewed. The server also records privacy_setup_completed_at as the current time.',
),
synced_preferences: z synced_preferences: z
.string() .string()
.max(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH) .max(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH)
@@ -375,6 +375,14 @@ export const UserSettingsResponse = z.object({
.describe( .describe(
'Default value of share_voice_activity applied to newly accepted friend relationships. Read-only here; mutated via PUT /users/@me/settings/voice-activity-sharing.', 'Default value of share_voice_activity applied to newly accepted friend relationships. Read-only here; mutated via PUT /users/@me/settings/voice-activity-sharing.',
), ),
privacy_setup_version: z
.number()
.int()
.describe('Latest privacy setup version the user has reviewed, 0 if never reviewed'),
privacy_setup_completed_at: z.iso
.datetime()
.nullable()
.describe('When the user last completed the privacy setup, or null if never'),
}); });
export type UserSettingsResponse = z.infer<typeof UserSettingsResponse>; export type UserSettingsResponse = z.infer<typeof UserSettingsResponse>;
+8
View File
@@ -8356,6 +8356,14 @@
{ {
"name": "default_share_voice_activity", "name": "default_share_voice_activity",
"type": "boolean" "type": "boolean"
},
{
"name": "privacy_setup_version",
"type": "int"
},
{
"name": "privacy_setup_completed_at",
"type": "timestamp"
} }
], ],
"primary_key": "((user_id))", "primary_key": "((user_id))",