mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(api): add moderation events and visibility actions (#3219)
This commit is contained in:
1 parent
532e828fe6
commit
05257d6439
39 files changed
+2464
-88
No files matched your search
@@ -57,6 +57,15 @@ export function currentAccountChangeSource(): ChangeSource {
|
||||
return sourceContext.getStore() ?? requestContext.getStore()?.source ?? 'other';
|
||||
}
|
||||
|
||||
export function anonymousActivityMeta(): Meta {
|
||||
const context = requestContext.getStore();
|
||||
return {
|
||||
...workerMeta(),
|
||||
channel: context?.channel ?? processChannel,
|
||||
request_id: context?.requestId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function workerMeta(): Meta {
|
||||
return {ip: null, country: null, ua: null, locale: null, channel: 'worker', request_id: null};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ export type Id = string;
|
||||
export type Flags64 = string;
|
||||
export type Channel = "stable" | "canary" | "worker" | "internal" | "import" | "other";
|
||||
export type Meta = { ip: string | null, country: string | null, ua: string | null, locale: string | null, channel: Channel, request_id: string | null, };
|
||||
export type Kind = "registration" | "email_changed" | "profile_updated" | "account_changed" | "admin_action" | "account_deleted" | "report_filed" | "email_bounced" | "action_outcome" | "login" | "session_started" | "guild_joined" | "dm_opened" | "message_created" | "message_updated" | "friend_request" | "http_errors" | "user_blocked";
|
||||
export type Event = { v: number, id: string, at_ms: number, key: string, meta: Meta, } & ({ "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked });
|
||||
export type Body = { "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked };
|
||||
export type Kind = "registration" | "email_changed" | "profile_updated" | "account_changed" | "admin_action" | "account_deleted" | "report_filed" | "email_bounced" | "action_outcome" | "login" | "session_started" | "guild_joined" | "dm_opened" | "message_created" | "message_updated" | "friend_request" | "http_errors" | "user_blocked" | "guild_member_banned" | "guild_member_unbanned" | "report_resolved";
|
||||
export type Event = { v: number, id: string, at_ms: number, key: string, meta: Meta, } & ({ "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked } | { "kind": "guild_member_banned", "data": GuildMemberBanned } | { "kind": "guild_member_unbanned", "data": GuildMemberUnbanned } | { "kind": "report_resolved", "data": ReportResolved });
|
||||
export type Body = { "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked } | { "kind": "guild_member_banned", "data": GuildMemberBanned } | { "kind": "guild_member_unbanned", "data": GuildMemberUnbanned } | { "kind": "report_resolved", "data": ReportResolved };
|
||||
export type Registration = { user_id: Id, method: RegMethod, email: string | null, username: string, username_user_chosen: boolean, global_name: string | null, locale: string | null, timezone: string | null, invite_code: string | null, flags: Flags64, };
|
||||
export type RegMethod = "password" | "unclaimed" | "oauth" | "other";
|
||||
export type EmailChanged = { user_id: Id, new_email: string, was_unclaimed: boolean, has_ever_purchased: boolean, };
|
||||
@@ -32,8 +32,14 @@ export type ChannelType = "dm" | "group_dm" | "guild";
|
||||
export type FriendRequest = { user_id: Id, target_id: Id, delivered: boolean, };
|
||||
export type UserBlocked = { blocker_id: Id, blocked_id: Id, };
|
||||
export type HttpErrors = { ip: string, window_ms: number, s401: number, s403: number, s404: number, s429: number, other_4xx: number, auth_failures: number, token_hashes: Array<string>, };
|
||||
export type ActionEnvelope = { v: number, id: string, key: string, issued_at_ms: number, expires_at_ms: number, } & ({ "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, });
|
||||
export type Action = { "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, };
|
||||
export type GuildMemberBanned = { guild_id: Id, user_id: Id, moderator_id: Id, by: BanBy, guild_member_count: number, target_moderator: boolean, expires_at_ms: number | null, };
|
||||
export type GuildMemberUnbanned = { guild_id: Id, user_id: Id, moderator_id: Id, by: BanBy, };
|
||||
export type BanBy = "moderator" | "staff";
|
||||
export type ReportResolved = { report_id: Id, reporter_id: Id, category: string, target_type: ReportTarget, reported_user_id: Id | null, outcome: ReportOutcome, resolved_by: ResolvedBy, };
|
||||
export type ReportOutcome = "actioned" | "no_violation" | "duplicate" | "auto_resolved" | "unspecified";
|
||||
export type ResolvedBy = "staff" | "system";
|
||||
export type ActionEnvelope = { v: number, id: string, key: string, issued_at_ms: number, expires_at_ms: number, } & ({ "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, } | { "type": "hide_recent_messages", user_id: Id, since_ms: number, on: boolean, } | { "type": "hide_profile", user_id: Id, on: boolean, } | { "type": "delete_user_messages", user_id: Id, on: boolean, });
|
||||
export type Action = { "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, } | { "type": "hide_recent_messages", user_id: Id, since_ms: number, on: boolean, } | { "type": "hide_profile", user_id: Id, on: boolean, } | { "type": "delete_user_messages", user_id: Id, on: boolean, };
|
||||
export type ActionOutcome = { action_id: string, action_type: string, status: OutcomeStatus, detail: string | null, observed: Observed | null, user_id?: Id, };
|
||||
export type OutcomeStatus = "applied" | "noop" | "conflict" | "expired" | "ineligible" | "exempt" | "unsupported" | "failed";
|
||||
export type Observed = { flags: Flags64, deleted: boolean, };
|
||||
@@ -71,6 +77,9 @@ export const EVENT_KINDS: ReadonlyArray<EventKind> = [
|
||||
'friend_request',
|
||||
'http_errors',
|
||||
'user_blocked',
|
||||
'guild_member_banned',
|
||||
'guild_member_unbanned',
|
||||
'report_resolved',
|
||||
];
|
||||
export const EVENT_MAJOR: Record<EventKind, number> = {
|
||||
registration: 1,
|
||||
@@ -91,6 +100,9 @@ export const EVENT_MAJOR: Record<EventKind, number> = {
|
||||
friend_request: 1,
|
||||
http_errors: 1,
|
||||
user_blocked: 1,
|
||||
guild_member_banned: 1,
|
||||
guild_member_unbanned: 1,
|
||||
report_resolved: 1,
|
||||
};
|
||||
export const EVENT_TTL: Record<EventKind, string> = {
|
||||
registration: 'never',
|
||||
@@ -111,6 +123,9 @@ export const EVENT_TTL: Record<EventKind, string> = {
|
||||
friend_request: '259200',
|
||||
http_errors: '3600',
|
||||
user_blocked: '259200',
|
||||
guild_member_banned: '3024000',
|
||||
guild_member_unbanned: '3024000',
|
||||
report_resolved: 'never',
|
||||
};
|
||||
export const EVENT_CLASS: Record<EventKind, 'fact' | 'signal'> = {
|
||||
registration: 'fact',
|
||||
@@ -131,6 +146,9 @@ export const EVENT_CLASS: Record<EventKind, 'fact' | 'signal'> = {
|
||||
friend_request: 'signal',
|
||||
http_errors: 'signal',
|
||||
user_blocked: 'signal',
|
||||
guild_member_banned: 'fact',
|
||||
guild_member_unbanned: 'fact',
|
||||
report_resolved: 'fact',
|
||||
};
|
||||
export const effectsConsumer = (p: number): string => `effects-${String(p).padStart(2, '0')}`;
|
||||
export function keyToken(key: string): string {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import {anonymousActivityMeta} from '@app/api/infrastructure/activity/ActivityMeta';
|
||||
import type {BanBy, ReportOutcome, ReportTarget, ResolvedBy} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import type {IARSubmission} from '@app/api/report/IReportRepository';
|
||||
import {ReportType} from '@app/api/report/IReportRepository';
|
||||
|
||||
interface GuildBanFacts {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
moderatorId: UserID;
|
||||
by: BanBy;
|
||||
memberCount: number;
|
||||
targetModerator: boolean;
|
||||
bannedAt: Date;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
export async function emitGuildMemberBanned(facts: GuildBanFacts): Promise<void> {
|
||||
const target = facts.userId.toString();
|
||||
await emitActivity(
|
||||
'guild_member_banned',
|
||||
target,
|
||||
{
|
||||
guild_id: facts.guildId.toString(),
|
||||
user_id: target,
|
||||
moderator_id: facts.moderatorId.toString(),
|
||||
by: facts.by,
|
||||
guild_member_count: Math.max(0, Math.trunc(facts.memberCount)),
|
||||
target_moderator: facts.targetModerator,
|
||||
expires_at_ms: facts.expiresAt?.getTime() ?? null,
|
||||
},
|
||||
anonymousActivityMeta(),
|
||||
`${facts.guildId}:${target}:${facts.bannedAt.getTime()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function emitGuildMemberUnbanned(facts: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
moderatorId: UserID;
|
||||
by: BanBy;
|
||||
}): Promise<void> {
|
||||
const target = facts.userId.toString();
|
||||
await emitActivity(
|
||||
'guild_member_unbanned',
|
||||
target,
|
||||
{
|
||||
guild_id: facts.guildId.toString(),
|
||||
user_id: target,
|
||||
moderator_id: facts.moderatorId.toString(),
|
||||
by: facts.by,
|
||||
},
|
||||
anonymousActivityMeta(),
|
||||
);
|
||||
}
|
||||
|
||||
const REPORT_TARGETS: Record<number, ReportTarget> = {
|
||||
[ReportType.MESSAGE]: 'message',
|
||||
[ReportType.USER]: 'user',
|
||||
[ReportType.GUILD]: 'guild',
|
||||
};
|
||||
|
||||
export async function emitReportResolved(
|
||||
report: Pick<IARSubmission, 'reportId' | 'reporterId' | 'reportedUserId' | 'category' | 'reportType'>,
|
||||
outcome: ReportOutcome,
|
||||
resolvedBy: ResolvedBy,
|
||||
): Promise<void> {
|
||||
const key = report.reportedUserId ?? report.reporterId;
|
||||
const targetType = REPORT_TARGETS[report.reportType];
|
||||
if (key === null || targetType === undefined) return;
|
||||
await emitActivity(
|
||||
'report_resolved',
|
||||
key.toString(),
|
||||
{
|
||||
report_id: report.reportId.toString(),
|
||||
reporter_id: (report.reporterId ?? 0n).toString(),
|
||||
category: report.category,
|
||||
target_type: targetType,
|
||||
reported_user_id: report.reportedUserId?.toString() ?? null,
|
||||
outcome,
|
||||
resolved_by: resolvedBy,
|
||||
},
|
||||
anonymousActivityMeta(),
|
||||
report.reportId.toString(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {addMemberRole, createGuild, createRole, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {resetActivityEventsForTests, startActivityEvents} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import type {ActivityPublisher} from '@app/api/infrastructure/activity/ActivitySpool';
|
||||
import type {Event} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
class CapturingPublisher implements ActivityPublisher {
|
||||
readonly events: Array<Event> = [];
|
||||
|
||||
async publish(_subject: string, payload: string): Promise<void> {
|
||||
this.events.push(JSON.parse(payload) as Event);
|
||||
}
|
||||
|
||||
of<K extends Event['kind']>(kind: K): Array<Extract<Event, {kind: K}>> {
|
||||
return this.events.filter((event): event is Extract<Event, {kind: K}> => event.kind === kind);
|
||||
}
|
||||
}
|
||||
|
||||
describe('moderation activity events', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let publisher: CapturingPublisher;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
publisher = new CapturingPublisher();
|
||||
await startActivityEvents({publisher, kv: new MockKVProvider()});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetActivityEventsForTests();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function ban(token: string, path: string): Promise<void> {
|
||||
await createBuilder(harness, token).put(path).body({}).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
}
|
||||
|
||||
test('a moderator ban and unban publish facts about the target without addresses', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const target = members[0]!;
|
||||
await ban(owner.token, `/guilds/${guild.id}/bans/${target.userId}`);
|
||||
const [banned] = publisher.of('guild_member_banned');
|
||||
expect(banned?.key).toBe(target.userId);
|
||||
expect(banned?.data).toEqual({
|
||||
guild_id: guild.id,
|
||||
user_id: target.userId,
|
||||
moderator_id: owner.userId,
|
||||
by: 'moderator',
|
||||
guild_member_count: 2,
|
||||
target_moderator: false,
|
||||
expires_at_ms: null,
|
||||
});
|
||||
expect(banned?.meta).toMatchObject({ip: null, country: null, ua: null, locale: null});
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/guilds/${guild.id}/bans/${target.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const [unbanned] = publisher.of('guild_member_unbanned');
|
||||
expect(unbanned?.data).toEqual({
|
||||
guild_id: guild.id,
|
||||
user_id: target.userId,
|
||||
moderator_id: owner.userId,
|
||||
by: 'moderator',
|
||||
});
|
||||
expect(unbanned?.meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
test('banning a member who can moderate the guild marks the target as a moderator', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const target = members[0]!;
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Mods',
|
||||
permissions: Permissions.BAN_MEMBERS.toString(),
|
||||
});
|
||||
await addMemberRole(harness, owner.token, guild.id, target.userId, role.id);
|
||||
await ban(owner.token, `/guilds/${guild.id}/bans/${target.userId}`);
|
||||
expect(publisher.of('guild_member_banned')[0]?.data.target_moderator).toBe(true);
|
||||
});
|
||||
|
||||
test('a staff ban says it came from staff', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'guild:ban_member',
|
||||
]);
|
||||
const target = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, admin.token, 'Staff ban guild');
|
||||
await ban(admin.token, `/admin/guilds/${guild.id}/bans/${target.userId}`);
|
||||
expect(publisher.of('guild_member_banned')[0]?.data).toMatchObject({
|
||||
user_id: target.userId,
|
||||
by: 'staff',
|
||||
target_moderator: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('report resolution', () => {
|
||||
let admin: TestAccount;
|
||||
|
||||
beforeEach(async () => {
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'report:resolve',
|
||||
'user:temp_ban',
|
||||
]);
|
||||
});
|
||||
|
||||
async function fileReport(): Promise<{reporter: TestAccount; reported: TestAccount; reportId: string}> {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const reported = await createTestAccount(harness);
|
||||
const report = await createBuilder<{report_id: string}>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({user_id: reported.userId, category: 'harassment'})
|
||||
.execute();
|
||||
return {reporter, reported, reportId: report.report_id};
|
||||
}
|
||||
|
||||
async function resolve(reportId: string, body: Record<string, unknown>): Promise<void> {
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/reports/${reportId}`)
|
||||
.body({status: 'resolved', notify_reporter: false, ...body})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
test('a staff dismissal publishes the outcome staff chose', async () => {
|
||||
const {reporter, reported, reportId} = await fileReport();
|
||||
await resolve(reportId, {resolution: 'no_violation'});
|
||||
const [resolved] = publisher.of('report_resolved');
|
||||
expect(resolved?.key).toBe(reported.userId);
|
||||
expect(resolved?.data).toEqual({
|
||||
report_id: reportId,
|
||||
reporter_id: reporter.userId,
|
||||
category: 'harassment',
|
||||
target_type: 'user',
|
||||
reported_user_id: reported.userId,
|
||||
outcome: 'no_violation',
|
||||
resolved_by: 'staff',
|
||||
});
|
||||
expect(resolved?.meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
test('without a chosen outcome the reported account state decides between actioned and unspecified', async () => {
|
||||
const first = await fileReport();
|
||||
await resolve(first.reportId, {});
|
||||
const second = await fileReport();
|
||||
await createBuilder(harness, admin.token)
|
||||
.put(`/admin/users/${second.reported.userId}/ban`)
|
||||
.body({duration_hours: 24, notify_user: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await resolve(second.reportId, {});
|
||||
expect(publisher.of('report_resolved').map((event) => event.data.outcome)).toEqual(['unspecified', 'actioned']);
|
||||
});
|
||||
|
||||
test('an unknown resolution is rejected', async () => {
|
||||
const {reportId} = await fileReport();
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/reports/${reportId}`)
|
||||
.body({status: 'resolved', resolution: 'maybe'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(publisher.of('report_resolved')).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user