mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(api): track privacy setup and open community DMs by default (#3322)
This commit is contained in:
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)
|
||||
.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 () => {
|
||||
|
||||
@@ -274,6 +274,8 @@ export interface UserSettingsRow {
|
||||
synced_preferences: Nullish<string>;
|
||||
profile_privacy: Nullish<number>;
|
||||
default_share_voice_activity: Nullish<boolean>;
|
||||
privacy_setup_version: Nullish<number>;
|
||||
privacy_setup_completed_at: Nullish<Date>;
|
||||
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<keyof UserSettingsRow>;
|
||||
export const USER_GUILD_SETTINGS_COLUMNS = [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -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>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
|
||||
|
||||
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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
|
||||
<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>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.
|
||||
|
||||
:::caution[A rebuilt folder layout drops unresolved references]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof UserSettingsResponse>;
|
||||
|
||||
@@ -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))",
|
||||
|
||||
Reference in new issue
Block a user