diff --git a/fluxer_api/src/api/channel/tests/DMPrivacyBidirectional.test.ts b/fluxer_api/src/api/channel/tests/DMPrivacyBidirectional.test.ts index e958046b4..b91dbb6e0 100644 --- a/fluxer_api/src/api/channel/tests/DMPrivacyBidirectional.test.ts +++ b/fluxer_api/src/api/channel/tests/DMPrivacyBidirectional.test.ts @@ -101,12 +101,11 @@ describe('DM Privacy Bidirectional Enforcement', () => { .expect(HTTP_STATUS.OK) .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 target = await createTestAccount(harness); await ensureSessionStarted(harness, sender.token); await ensureSessionStarted(harness, target.token); - await updateUserSettings(harness, sender.token, {default_guilds_restricted: false}); await createFriendship(harness, sender, target); const guild = await createGuild(harness, sender.token, 'Default Community'); 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 createBuilder(harness, sender.token) .post(`/channels/${channel.id}/messages`) - .body({content: 'default restricted target'}) - .expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER') + .body({content: 'default open target'}) + .expect(HTTP_STATUS.OK) .execute(); }); it('blocks message when sender restricts the only mutual guild', async () => { diff --git a/fluxer_api/src/api/database/types/UserTypes.ts b/fluxer_api/src/api/database/types/UserTypes.ts index 2a0e89569..6886afa6d 100644 --- a/fluxer_api/src/api/database/types/UserTypes.ts +++ b/fluxer_api/src/api/database/types/UserTypes.ts @@ -274,6 +274,8 @@ export interface UserSettingsRow { synced_preferences: Nullish; profile_privacy: Nullish; default_share_voice_activity: Nullish; + privacy_setup_version: Nullish; + privacy_setup_completed_at: Nullish; version: number; } @@ -513,6 +515,8 @@ export const USER_SETTINGS_COLUMNS = [ 'synced_preferences', 'profile_privacy', 'default_share_voice_activity', + 'privacy_setup_version', + 'privacy_setup_completed_at', 'version', ] as const satisfies ReadonlyArray; export const USER_GUILD_SETTINGS_COLUMNS = [ diff --git a/fluxer_api/src/api/models/UserSettings.ts b/fluxer_api/src/api/models/UserSettings.ts index e22811423..14f2d9a2d 100644 --- a/fluxer_api/src/api/models/UserSettings.ts +++ b/fluxer_api/src/api/models/UserSettings.ts @@ -68,6 +68,8 @@ export class UserSettings { readonly syncedPreferences: string; readonly profilePrivacy: ProfilePrivacyLevel; readonly defaultShareVoiceActivity: boolean; + readonly privacySetupVersion: number; + readonly privacySetupCompletedAt: Date | null; readonly version: number; constructor(row: UserSettingsRow) { @@ -115,6 +117,8 @@ export class UserSettings { this.syncedPreferences = normalizeStoredSyncedPreferences(row.synced_preferences); this.profilePrivacy = (row.profile_privacy ?? ProfilePrivacyLevels.ALL_GUILDS) as ProfilePrivacyLevel; 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; } @@ -171,6 +175,8 @@ export class UserSettings { synced_preferences: this.syncedPreferences === '' ? null : this.syncedPreferences, profile_privacy: this.profilePrivacy, default_share_voice_activity: this.defaultShareVoiceActivity, + privacy_setup_version: this.privacySetupVersion, + privacy_setup_completed_at: this.privacySetupCompletedAt, version: this.version, }; } @@ -216,7 +222,7 @@ export class UserSettings { friend_source_flags: friendSourceFlags, incoming_call_flags: IncomingCallFlags.FRIENDS_ONLY, group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY, - default_guilds_restricted: true, + default_guilds_restricted: false, bot_default_guilds_restricted: false, restricted_guilds: new Set(), bot_restricted_guilds: new Set(), @@ -244,6 +250,8 @@ export class UserSettings { synced_preferences: null, profile_privacy: ProfilePrivacyLevels.ALL_GUILDS, default_share_voice_activity: true, + privacy_setup_version: 0, + privacy_setup_completed_at: null, version: 1, }; } diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 23ca0cd6c..5594f1911 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -24983,6 +24983,12 @@ "type": "boolean", "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": { "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"}] @@ -25136,6 +25142,23 @@ "default_share_voice_activity": { "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." + }, + "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": [ @@ -25173,7 +25196,9 @@ "staff_dm_access_user_ids", "synced_preferences", "profile_privacy", - "default_share_voice_activity" + "default_share_voice_activity", + "privacy_setup_version", + "privacy_setup_completed_at" ], "additionalProperties": false }, diff --git a/fluxer_api/src/api/user/UserMappers.ts b/fluxer_api/src/api/user/UserMappers.ts index ebd38d132..7933f8f79 100644 --- a/fluxer_api/src/api/user/UserMappers.ts +++ b/fluxer_api/src/api/user/UserMappers.ts @@ -353,6 +353,8 @@ export function mapUserSettingsToResponse(params: {settings: UserSettings}): Use synced_preferences: settings.syncedPreferences, profile_privacy: settings.profilePrivacy, default_share_voice_activity: settings.defaultShareVoiceActivity, + privacy_setup_version: settings.privacySetupVersion, + privacy_setup_completed_at: settings.privacySetupCompletedAt?.toISOString() ?? null, }; } diff --git a/fluxer_api/src/api/user/services/UserAccountSettingsService.ts b/fluxer_api/src/api/user/services/UserAccountSettingsService.ts index e926dbcfd..7172af81f 100644 --- a/fluxer_api/src/api/user/services/UserAccountSettingsService.ts +++ b/fluxer_api/src/api/user/services/UserAccountSettingsService.ts @@ -137,6 +137,10 @@ export class UserAccountSettingsService { if (data.bot_default_guilds_restricted !== undefined) { 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) { updatedRowData.inline_attachment_media = data.inline_attachment_media; } diff --git a/fluxer_api/src/api/user/tests/UserSettingsPrivacySetup.test.ts b/fluxer_api/src/api/user/tests/UserSettingsPrivacySetup.test.ts new file mode 100644 index 000000000..c6d5d01b6 --- /dev/null +++ b/fluxer_api/src/api/user/tests/UserSettingsPrivacySetup.test.ts @@ -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(); + }); +}); diff --git a/fluxer_api/src/api/worker/tasks/HarvestUserData.ts b/fluxer_api/src/api/worker/tasks/HarvestUserData.ts index 9e1f3129d..52da7ab6c 100644 --- a/fluxer_api/src/api/worker/tasks/HarvestUserData.ts +++ b/fluxer_api/src/api/worker/tasks/HarvestUserData.ts @@ -569,6 +569,8 @@ export function buildUserDataJson(params: UserDataJsonParams) { staff_dm_access_user_ids: Array.from(userSettings.staffDmAccessUserIds).map((id) => id.toString()), synced_preferences: syncedPreferencesToJson(decodeSyncedPreferencesLenient(userSettings.syncedPreferences)), profile_privacy: userSettings.profilePrivacy, + privacy_setup_version: userSettings.privacySetupVersion, + privacy_setup_completed_at: userSettings.privacySetupCompletedAt?.toISOString() ?? null, } : null, guild_memberships: guildMemberships diff --git a/fluxer_docs/src/content/docs/http-api/users.mdx b/fluxer_docs/src/content/docs/http-api/users.mdx index 2dd79487f..e5731118c 100644 --- a/fluxer_docs/src/content/docs/http-api/users.mdx +++ b/fluxer_docs/src/content/docs/http-api/users.mdx @@ -294,6 +294,8 @@ The [user settings update object](/http-api/users/settings/#user-settings-update | synced_preferences6 | string | The base64-encoded [synced preferences](/http-api/users/settings-protobuf/) message | | profile_privacy | integer | [Profile privacy level](#profile-privacy-levels) | | default_share_voice_activity7 | boolean | Whether a newly accepted friend relationship starts with voice activity sharing on | +| privacy_setup_version8 | 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 | 1 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 7 The field is read-only here and is changed by [Modify voice activity sharing](/http-api/users/settings/#modify-voice-activity-sharing) +8 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 A custom status is the text and emoji stored on the account's [user settings](#user-settings-object). diff --git a/fluxer_docs/src/content/docs/http-api/users/settings.mdx b/fluxer_docs/src/content/docs/http-api/users/settings.mdx index 54be57ce7..9221c6c44 100644 --- a/fluxer_docs/src/content/docs/http-api/users/settings.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/settings.mdx @@ -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?12 | ?array[snowflake] | The users exempt from mention suppression | | staff_dm_access_user_ids?12 | ?array[snowflake] | The users granted staff direct message access | | profile_privacy? | integer | [Profile privacy level](/http-api/users/#profile-privacy-levels) | +| privacy_setup_version?14 | integer | The privacy setup version the user just reviewed (1-1000) | | synced_preferences?13 | ?string | The base64-encoded `fluxer.user.preferences.v1.SyncedPreferences` snapshot, or null to clear it | 1 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 13 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` +14 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. :::caution[A rebuilt folder layout drops unresolved references] diff --git a/packages/schema/src/domains/user/UserRequestSchemas.ts b/packages/schema/src/domains/user/UserRequestSchemas.ts index ba02dc06e..dacdbbb1d 100644 --- a/packages/schema/src/domains/user/UserRequestSchemas.ts +++ b/packages/schema/src/domains/user/UserRequestSchemas.ts @@ -394,6 +394,14 @@ export const UserSettingsUpdateRequest = z default_share_voice_activity: z .boolean() .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 .string() .max(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH) diff --git a/packages/schema/src/domains/user/UserResponseSchemas.ts b/packages/schema/src/domains/user/UserResponseSchemas.ts index 15285db31..affa05563 100644 --- a/packages/schema/src/domains/user/UserResponseSchemas.ts +++ b/packages/schema/src/domains/user/UserResponseSchemas.ts @@ -375,6 +375,14 @@ export const UserSettingsResponse = z.object({ .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.', ), + 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; diff --git a/tools/dev/cassandra_target_schema.json b/tools/dev/cassandra_target_schema.json index ea939fad8..e9a496160 100644 --- a/tools/dev/cassandra_target_schema.json +++ b/tools/dev/cassandra_target_schema.json @@ -8356,6 +8356,14 @@ { "name": "default_share_voice_activity", "type": "boolean" + }, + { + "name": "privacy_setup_version", + "type": "int" + }, + { + "name": "privacy_setup_completed_at", + "type": "timestamp" } ], "primary_key": "((user_id))",