mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(api): add moderation events and visibility actions (#3219)
This commit is contained in:
@@ -9855,6 +9855,17 @@
|
||||
"default": true,
|
||||
"description": "Whether to notify the reporter by system DM and email",
|
||||
"type": "boolean"
|
||||
},
|
||||
"resolution": {
|
||||
"description": "How the report was resolved",
|
||||
"x-enumNames": ["actioned", "no_violation", "duplicate"],
|
||||
"x-enumDescriptions": [
|
||||
"The report was valid and action was taken",
|
||||
"The report was reviewed and no violation was found",
|
||||
"The report repeats one that was already handled"
|
||||
],
|
||||
"enum": ["actioned", "no_violation", "duplicate"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status"]
|
||||
@@ -13235,6 +13246,7 @@
|
||||
"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 from other users"},
|
||||
{"name": "DELETED", "value": "17179869184", "description": "User account has been deleted"},
|
||||
{"name": "SELF_DELETED", "value": "68719476736", "description": "User account was self-deleted"},
|
||||
{"name": "DISABLED", "value": "274877906944", "description": "User account is disabled"},
|
||||
@@ -14102,7 +14114,8 @@
|
||||
"value": "32",
|
||||
"description": "Bot requires manual approval for friend requests"
|
||||
},
|
||||
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}
|
||||
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"},
|
||||
{"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden"}
|
||||
]
|
||||
},
|
||||
"MessageEmbedChildResponse": {
|
||||
|
||||
@@ -178,7 +178,7 @@ export function ReportAdminController(app: HonoApp) {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const {public_comment, notify_reporter} = ctx.req.valid('json');
|
||||
const {public_comment, notify_reporter, resolution} = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.reportServiceAggregate.resolveReport(
|
||||
createReportID(report_id),
|
||||
@@ -186,6 +186,7 @@ export function ReportAdminController(app: HonoApp) {
|
||||
public_comment || null,
|
||||
auditLogReason,
|
||||
notify_reporter,
|
||||
resolution,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -261,7 +261,8 @@ export class AdminMessageService {
|
||||
|
||||
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
return {...access, includeHidden: true};
|
||||
}
|
||||
|
||||
private async listMessageResponsesForAdmin(params: {
|
||||
|
||||
@@ -34,11 +34,12 @@ import type {User} from '@app/api/models/User';
|
||||
import type {IARMessageContext, IARSubmission} from '@app/api/report/IReportRepository';
|
||||
import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
|
||||
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
|
||||
import {assertSafeByteSize} from '@app/api/utils/ByteSizeUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {SearchReportsRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {SearchReportsRequest, UpdateReportRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
import {seconds} from 'itty-time';
|
||||
@@ -56,6 +57,8 @@ interface AdminReportServiceDeps {
|
||||
ncmecSubmissionService: NcmecSubmissionService;
|
||||
}
|
||||
|
||||
type StaffReportResolution = NonNullable<UpdateReportRequest['resolution']>;
|
||||
|
||||
interface ReportNsfwLookupCache {
|
||||
channelNsfwByChannelId: Map<string, boolean | null>;
|
||||
guildNsfwLevelByGuildId: Map<string, number | null>;
|
||||
@@ -105,10 +108,14 @@ export class AdminReportService {
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
notifyReporter: boolean,
|
||||
resolution?: StaffReportResolution,
|
||||
) {
|
||||
const {reportService, auditService} = this.deps;
|
||||
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason, {
|
||||
outcome: resolution,
|
||||
resolvedBy: 'staff',
|
||||
});
|
||||
let reporterDmSent = false;
|
||||
let reporterEmailSent = false;
|
||||
const reporter =
|
||||
@@ -147,6 +154,7 @@ export class AdminReportService {
|
||||
['notify_reporter', notifyReporter ? 'true' : 'false'],
|
||||
['reporter_dm_sent', reporterDmSent ? 'true' : 'false'],
|
||||
['reporter_email_sent', reporterEmailSent ? 'true' : 'false'],
|
||||
...(resolution ? [['resolution', resolution] as [string, string]] : []),
|
||||
]),
|
||||
});
|
||||
return {
|
||||
@@ -418,7 +426,8 @@ export class AdminReportService {
|
||||
|
||||
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
return {...access, includeHidden: true};
|
||||
}
|
||||
|
||||
private async getMutualDmChannelId(report: IARSubmission): Promise<string | null> {
|
||||
@@ -617,7 +626,11 @@ export class AdminReportService {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const user = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
const cached = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
const stored = isHiddenPartial(cached) ? await this.deps.apiContext.services.users.findUnique(userId) : null;
|
||||
const user = stored
|
||||
? {username: stored.username, global_name: stored.globalName, discriminator: stored.discriminator.toString()}
|
||||
: cached;
|
||||
const discriminator = user.discriminator?.padStart(4, '0') ?? '0000';
|
||||
return {
|
||||
tag: `${user.username}#${discriminator}`,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
|
||||
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
|
||||
import {isEnforcementDeletionReason} from '@app/api/user/ProfileVisibility';
|
||||
import {clearPendingDeletion, reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
@@ -246,7 +247,7 @@ export class AdminUserDeletionService {
|
||||
let knownIps: ReadonlySet<string> = new Set();
|
||||
if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
|
||||
knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason});
|
||||
await this.resolvePendingReportsAgainstUser({user, adminUserId});
|
||||
await this.resolvePendingReportsAgainstUser({user, adminUserId, reasonCode: data.reason_code});
|
||||
}
|
||||
await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
@@ -381,8 +382,13 @@ export class AdminUserDeletionService {
|
||||
return knownIps;
|
||||
}
|
||||
|
||||
private async resolvePendingReportsAgainstUser(params: {user: User; adminUserId: UserID}): Promise<void> {
|
||||
const {user, adminUserId} = params;
|
||||
private async resolvePendingReportsAgainstUser(params: {
|
||||
user: User;
|
||||
adminUserId: UserID;
|
||||
reasonCode: number;
|
||||
}): Promise<void> {
|
||||
const {user, adminUserId, reasonCode} = params;
|
||||
const outcome = isEnforcementDeletionReason(reasonCode) ? 'actioned' : 'auto_resolved';
|
||||
const {reportService, auditService} = this.deps;
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
@@ -423,7 +429,10 @@ export class AdminUserDeletionService {
|
||||
for (const hitId of pendingReportIds) {
|
||||
const reportId = createReportID(BigInt(hitId));
|
||||
try {
|
||||
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason);
|
||||
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason, {
|
||||
outcome,
|
||||
resolvedBy: 'system',
|
||||
});
|
||||
resolvedCount++;
|
||||
} catch (error) {
|
||||
if (error instanceof ReportAlreadyResolvedError) continue;
|
||||
|
||||
@@ -78,6 +78,7 @@ export class AdminGuildMembershipService {
|
||||
reason: data.reason ?? undefined,
|
||||
banDurationSeconds: data.ban_duration_seconds ?? undefined,
|
||||
skipGuildAuditLog: true,
|
||||
by: 'staff',
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface MessageResponseAccessContext {
|
||||
sourceGuildId: GuildID | null;
|
||||
messageHistoryCutoff: string | null;
|
||||
canReadMessageHistory: boolean;
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
interface ExtractedMentions {
|
||||
@@ -105,6 +106,7 @@ export class MessageResponseDataService {
|
||||
? new Date(params.access.messageHistoryCutoff).getTime()
|
||||
: null,
|
||||
can_read_message_history: params.access.canReadMessageHistory,
|
||||
include_hidden: params.access.includeHidden ?? false,
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
@@ -146,6 +148,7 @@ export class MessageResponseDataService {
|
||||
? new Date(params.access.messageHistoryCutoff).getTime()
|
||||
: null,
|
||||
can_read_message_history: params.access.canReadMessageHistory,
|
||||
include_hidden: params.access.includeHidden ?? false,
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
@@ -177,6 +180,7 @@ export class MessageResponseDataService {
|
||||
? new Date(params.access.messageHistoryCutoff).getTime()
|
||||
: null,
|
||||
can_read_message_history: params.access.canReadMessageHistory,
|
||||
include_hidden: params.access.includeHidden ?? false,
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
@@ -245,6 +249,7 @@ export class MessageResponseDataService {
|
||||
? new Date(params.access.messageHistoryCutoff).getTime()
|
||||
: null,
|
||||
can_read_message_history: params.access.canReadMessageHistory,
|
||||
include_hidden: params.access.includeHidden ?? false,
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
|
||||
@@ -52,7 +52,7 @@ import type {Webhook} from '@app/api/models/Webhook';
|
||||
import {assertAccountNotLimited} from '@app/api/user/AccountLimit';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {assertMayStartConversation} from '@app/api/user/NewConversationLimit';
|
||||
import {isDirectDeliverySuppressed} from '@app/api/user/UserHelpers';
|
||||
import {isContentHidden, isDirectDeliverySuppressed} from '@app/api/user/UserHelpers';
|
||||
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
|
||||
import {
|
||||
ChannelTypes,
|
||||
@@ -942,6 +942,7 @@ export class MessageSendService {
|
||||
}
|
||||
}
|
||||
const suppressDmRecipientDelivery = dmRecipientId !== null && isDirectDeliverySuppressed(user);
|
||||
const suppressDelivery = suppressDmRecipientDelivery || isContentHidden(user, messageId);
|
||||
const channelHadMessages = channel.lastMessageId !== null;
|
||||
const {message, enqueueDeferredEmbeds} = await this.deps.persistenceService.createMessage({
|
||||
messageId,
|
||||
@@ -973,7 +974,7 @@ export class MessageSendService {
|
||||
messageId,
|
||||
mentionChannels: mentionData?.mentionChannels,
|
||||
});
|
||||
if (!suppressDmRecipientDelivery) {
|
||||
if (!suppressDelivery) {
|
||||
await this.settlePostCreateWork(messageId, [
|
||||
{
|
||||
step: 'update_dm_recipients',
|
||||
@@ -1000,7 +1001,7 @@ export class MessageSendService {
|
||||
await this.settlePostCreateWork(messageId, [
|
||||
{
|
||||
step: 'dispatch',
|
||||
promise: suppressDmRecipientDelivery
|
||||
promise: suppressDelivery
|
||||
? this.deps.dispatchService.dispatchMessageCreateToUser({
|
||||
channel,
|
||||
message,
|
||||
@@ -1031,7 +1032,7 @@ export class MessageSendService {
|
||||
guildOwnerId: guild?.owner_id ? createUserID(BigInt(guild.owner_id)) : null,
|
||||
dmRecipientId,
|
||||
channelHadMessages,
|
||||
delivered: !suppressDmRecipientDelivery,
|
||||
delivered: !suppressDelivery,
|
||||
userRepository: this.deps.userRepository,
|
||||
});
|
||||
void enqueueDeferredEmbeds().catch((error) => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {sendMessage} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
describe('messages from an author inside a hide window', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let author: TestAccount;
|
||||
let channelId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
const setup = await setupTestGuildWithMembers(harness, 1);
|
||||
author = setup.members[0]!;
|
||||
channelId = setup.channels[0]!.id;
|
||||
vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function setHiddenSince(since: Date | null): Promise<void> {
|
||||
await getUserRepository().patchUpsert(createUserID(BigInt(author.userId)), {content_hidden_since: since});
|
||||
}
|
||||
|
||||
function createEvents(messageId: string) {
|
||||
const guild = vi
|
||||
.mocked(NoopGatewayService.prototype.dispatchGuild)
|
||||
.mock.calls.filter(([call]) => call.event === 'MESSAGE_CREATE' && (call.data as {id: string}).id === messageId);
|
||||
const presence = vi
|
||||
.mocked(NoopGatewayService.prototype.dispatchPresence)
|
||||
.mock.calls.filter(([call]) => call.event === 'MESSAGE_CREATE' && (call.data as {id: string}).id === messageId);
|
||||
return {guild, presence: presence.map(([call]) => call.userId.toString())};
|
||||
}
|
||||
|
||||
test('reach only the author and fan out again once the window is cleared', async () => {
|
||||
await setHiddenSince(new Date(Date.now() - 60_000));
|
||||
const hidden = await sendMessage(harness, author.token, channelId, 'inside the window');
|
||||
expect(hidden.content).toBe('inside the window');
|
||||
expect(createEvents(hidden.id)).toEqual({guild: [], presence: [author.userId]});
|
||||
|
||||
await setHiddenSince(null);
|
||||
const shown = await sendMessage(harness, author.token, channelId, 'after restore');
|
||||
const events = createEvents(shown.id);
|
||||
expect(events.guild).toHaveLength(1);
|
||||
expect(events.presence).toEqual([]);
|
||||
});
|
||||
|
||||
test('a window that starts later leaves current messages alone', async () => {
|
||||
await setHiddenSince(new Date(Date.now() + 3_600_000));
|
||||
const message = await sendMessage(harness, author.token, channelId, 'before the window');
|
||||
expect(createEvents(message.id).guild).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,7 @@ export interface UserRow {
|
||||
deletion_audit_log_reason: Nullish<string>;
|
||||
deletion_scheduled_by?: Nullish<UserID>;
|
||||
deletion_scheduled_at?: Nullish<Date>;
|
||||
content_hidden_since?: Nullish<Date>;
|
||||
acls: Nullish<Set<string>>;
|
||||
traits: Nullish<Set<string>>;
|
||||
first_refund_at: Nullish<Date>;
|
||||
@@ -139,6 +140,7 @@ export const USER_COLUMNS = [
|
||||
'deletion_audit_log_reason',
|
||||
'deletion_scheduled_by',
|
||||
'deletion_scheduled_at',
|
||||
'content_hidden_since',
|
||||
'acls',
|
||||
'traits',
|
||||
'first_refund_at',
|
||||
@@ -202,6 +204,7 @@ export const EMPTY_USER_ROW: UserRow = {
|
||||
deletion_audit_log_reason: null,
|
||||
deletion_scheduled_by: null,
|
||||
deletion_scheduled_at: null,
|
||||
content_hidden_since: null,
|
||||
acls: null,
|
||||
traits: null,
|
||||
first_refund_at: null,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {GuildEmoji} from '@app/api/models/GuildEmoji';
|
||||
import type {GuildMember} from '@app/api/models/GuildMember';
|
||||
import type {GuildRole} from '@app/api/models/GuildRole';
|
||||
import type {GuildSticker} from '@app/api/models/GuildSticker';
|
||||
import {hiddenGuildMember, isHiddenPartial} from '@app/api/user/ProfileVisibility';
|
||||
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '@app/api/user/UserCacheHelpers';
|
||||
import type {
|
||||
GuildEmojiResponse,
|
||||
@@ -132,6 +133,11 @@ export function mapGuildStickerToResponse(sticker: GuildSticker): GuildStickerRe
|
||||
}
|
||||
|
||||
function mapMemberWithUser(member: GuildMember, userPartial: UserPartialResponse): GuildMemberResponse {
|
||||
const response = mapMemberFields(member, userPartial);
|
||||
return isHiddenPartial(userPartial) ? hiddenGuildMember(response) : response;
|
||||
}
|
||||
|
||||
function mapMemberFields(member: GuildMember, userPartial: UserPartialResponse): GuildMemberResponse {
|
||||
const now = Date.now();
|
||||
const isTimedOut = member.communicationDisabledUntil != null && member.communicationDisabledUntil.getTime() > now;
|
||||
return {
|
||||
|
||||
@@ -8,10 +8,13 @@ import {mapGuildBansToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
|
||||
import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/GuildMemberSearchIndexService';
|
||||
import type {BanBy} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {emitGuildMemberBanned, emitGuildMemberUnbanned} from '@app/api/infrastructure/activity/ModerationEvents';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Guild} from '@app/api/models/Guild';
|
||||
import type {GuildBan} from '@app/api/models/GuildBan';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
@@ -29,6 +32,8 @@ import type {GuildBanResponse} from '@fluxer/schema/src/domains/guild/GuildMembe
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
const GUILD_MODERATION_PERMISSIONS =
|
||||
Permissions.ADMINISTRATOR | Permissions.BAN_MEMBERS | Permissions.KICK_MEMBERS | Permissions.MANAGE_GUILD;
|
||||
|
||||
export class GuildModerationService {
|
||||
private readonly searchIndexService: GuildMemberSearchIndexService;
|
||||
@@ -67,6 +72,7 @@ export class GuildModerationService {
|
||||
reason?: string | null;
|
||||
banDurationSeconds?: number;
|
||||
skipGuildAuditLog?: boolean;
|
||||
by?: BanBy;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
@@ -79,6 +85,7 @@ export class GuildModerationService {
|
||||
reason,
|
||||
banDurationSeconds,
|
||||
skipGuildAuditLog,
|
||||
by = 'moderator',
|
||||
} = params;
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
if (userId === targetId) throw new UnknownGuildMemberError();
|
||||
@@ -106,6 +113,8 @@ export class GuildModerationService {
|
||||
if (banDurationSeconds && banDurationSeconds > 0) {
|
||||
expiresAt = new Date(Date.now() + banDurationSeconds * 1000);
|
||||
}
|
||||
const guildBeforeBan = await this.guildRepository.findUnique(guildId);
|
||||
const targetModerator = await this.isGuildModerator(guildId, targetId, guildBeforeBan, targetMember !== null);
|
||||
const ban = await this.guildRepository.upsertBan({
|
||||
guild_id: guildId,
|
||||
user_id: targetId,
|
||||
@@ -131,6 +140,16 @@ export class GuildModerationService {
|
||||
changes: this.guildAuditLogService.computeChanges(null, this.serializeBanForAudit(ban)),
|
||||
});
|
||||
}
|
||||
await emitGuildMemberBanned({
|
||||
guildId,
|
||||
userId: targetId,
|
||||
moderatorId: userId,
|
||||
by,
|
||||
memberCount: guildBeforeBan?.memberCount ?? 0,
|
||||
targetModerator,
|
||||
bannedAt: ban.bannedAt,
|
||||
expiresAt: ban.expiresAt,
|
||||
});
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_BAN_ADD',
|
||||
@@ -177,16 +196,18 @@ export class GuildModerationService {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
by?: BanBy;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, targetId} = params;
|
||||
const {userId, guildId, targetId, by = 'moderator'} = params;
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
const ban = await this.guildRepository.getBan(guildId, targetId);
|
||||
if (!ban) {
|
||||
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
|
||||
}
|
||||
await this.guildRepository.deleteBan(guildId, targetId);
|
||||
await emitGuildMemberUnbanned({guildId, userId: targetId, moderatorId: userId, by});
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
@@ -225,6 +246,23 @@ export class GuildModerationService {
|
||||
}
|
||||
}
|
||||
|
||||
private async isGuildModerator(
|
||||
guildId: GuildID,
|
||||
targetId: UserID,
|
||||
guild: Guild | null,
|
||||
isMember: boolean,
|
||||
): Promise<boolean> {
|
||||
if (guild?.ownerId === targetId) return true;
|
||||
if (!isMember) return false;
|
||||
try {
|
||||
const permissions = await this.gatewayService.getUserPermissions({guildId, userId: targetId});
|
||||
return (permissions & GUILD_MODERATION_PERMISSIONS) !== 0n;
|
||||
} catch (error) {
|
||||
Logger.debug({error, guildId: guildId.toString()}, 'Could not read target permissions for a guild ban');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private serializeBanForAudit(ban: GuildBan): Record<string, unknown> {
|
||||
return {
|
||||
user_id: ban.userId.toString(),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ export class User {
|
||||
readonly deletionAuditLogReason: string | null;
|
||||
readonly deletionScheduledBy: UserID | null;
|
||||
readonly deletionScheduledAt: Date | null;
|
||||
readonly contentHiddenSince: Date | null;
|
||||
readonly acls: Set<string>;
|
||||
private readonly _traits: Set<string>;
|
||||
readonly firstRefundAt: Date | null;
|
||||
@@ -132,6 +133,7 @@ export class User {
|
||||
this.deletionAuditLogReason = row.deletion_audit_log_reason ?? null;
|
||||
this.deletionScheduledBy = row.deletion_scheduled_by ?? null;
|
||||
this.deletionScheduledAt = row.deletion_scheduled_at ?? null;
|
||||
this.contentHiddenSince = row.content_hidden_since ?? null;
|
||||
this.acls = row.acls ?? new Set();
|
||||
this._traits = row.traits ?? new Set();
|
||||
this.firstRefundAt = row.first_refund_at ?? null;
|
||||
@@ -223,6 +225,7 @@ export class User {
|
||||
deletion_audit_log_reason: this.deletionAuditLogReason,
|
||||
deletion_scheduled_by: this.deletionScheduledBy,
|
||||
deletion_scheduled_at: this.deletionScheduledAt,
|
||||
content_hidden_since: this.contentHiddenSince,
|
||||
acls: this.acls.size > 0 ? this.acls : null,
|
||||
traits: this._traits.size > 0 ? this._traits : null,
|
||||
first_refund_at: this.firstRefundAt,
|
||||
|
||||
@@ -30759,7 +30759,8 @@
|
||||
"value": "32",
|
||||
"description": "Bot requires manual approval for friend requests"
|
||||
},
|
||||
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}
|
||||
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"},
|
||||
{"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden"}
|
||||
]
|
||||
},
|
||||
"RefreshedAttachmentUrl": {
|
||||
|
||||
@@ -27,7 +27,8 @@ import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
||||
import type {DSAReportTicketRow} from '@app/api/database/types/ReportTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import type {ReportTarget} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import type {ReportOutcome, ReportTarget, ResolvedBy} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {emitReportResolved} from '@app/api/infrastructure/activity/ModerationEvents';
|
||||
import type {IEmailDnsValidationService} from '@app/api/infrastructure/IEmailDnsValidationService';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
import {ReportStatus, ReportType} from '@app/api/report/IReportRepository';
|
||||
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {isUnderEnforcement} from '@app/api/user/ProfileVisibility';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {InviteTypes, MessageFlags, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
@@ -901,6 +903,7 @@ export class ReportService {
|
||||
adminUserId: UserID,
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
resolution: {outcome?: ReportOutcome; resolvedBy?: ResolvedBy} = {},
|
||||
): Promise<IARSubmission> {
|
||||
const report = await this.reportRepository.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
if (this.reportSearchService && 'updateReport' in this.reportSearchService) {
|
||||
@@ -908,9 +911,22 @@ export class ReportService {
|
||||
Logger.error({error, reportId: report.reportId}, 'Failed to update report in search index');
|
||||
});
|
||||
}
|
||||
const outcome = resolution.outcome ?? (await this.observedOutcome(report));
|
||||
await emitReportResolved(report, outcome, resolution.resolvedBy ?? 'staff');
|
||||
return report;
|
||||
}
|
||||
|
||||
private async observedOutcome(report: IARSubmission): Promise<ReportOutcome> {
|
||||
if (!report.reportedUserId) return 'unspecified';
|
||||
try {
|
||||
const reported = await this.userRepository.findUnique(report.reportedUserId);
|
||||
return reported && isUnderEnforcement(reported) ? 'actioned' : 'unspecified';
|
||||
} catch (error) {
|
||||
Logger.warn({error, reportId: report.reportId}, 'Could not read the reported account for a resolved report');
|
||||
return 'unspecified';
|
||||
}
|
||||
}
|
||||
|
||||
private async gatherMessageContext(
|
||||
channelId: ChannelID,
|
||||
targetMessageId: MessageID,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {isTemporarilyBanned} from '@app/api/user/UserHelpers';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {
|
||||
HIDDEN_USER_DISCRIMINATOR,
|
||||
HIDDEN_USER_USERNAME,
|
||||
PublicUserFlags,
|
||||
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';
|
||||
|
||||
type ProfileStanding = Pick<
|
||||
User,
|
||||
'flags' | 'isSystem' | 'tempBannedUntil' | 'pendingDeletionAt' | 'deletionReasonCode'
|
||||
>;
|
||||
|
||||
const NON_ENFORCEMENT_DELETION_REASONS: ReadonlySet<number> = new Set([
|
||||
DeletionReasons.USER_REQUESTED,
|
||||
DeletionReasons.OTHER,
|
||||
DeletionReasons.INACTIVITY,
|
||||
]);
|
||||
|
||||
export function isEnforcementDeletionReason(code: number | null): boolean {
|
||||
return code != null && !NON_ENFORCEMENT_DELETION_REASONS.has(code);
|
||||
}
|
||||
|
||||
function isPendingEnforcementDeletion(user: Pick<User, 'pendingDeletionAt' | 'deletionReasonCode'>): boolean {
|
||||
return user.pendingDeletionAt != null && isEnforcementDeletionReason(user.deletionReasonCode);
|
||||
}
|
||||
|
||||
export function isUnderEnforcement(user: Omit<ProfileStanding, 'isSystem'>, now = Date.now()): boolean {
|
||||
return (
|
||||
(user.flags & UserFlags.SPAMMER) !== 0n || isTemporarilyBanned(user, now) || isPendingEnforcementDeletion(user)
|
||||
);
|
||||
}
|
||||
|
||||
export function isProfileHidden(user: ProfileStanding, now = Date.now()): boolean {
|
||||
if (user.isSystem) return false;
|
||||
return (user.flags & UserFlags.PROFILE_HIDDEN) !== 0n || isUnderEnforcement(user, now);
|
||||
}
|
||||
|
||||
export function hiddenUserPartial(partial: UserPartialResponse): UserPartialResponse {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function isHiddenPartial(partial: Pick<UserPartialResponse, 'flags'>): boolean {
|
||||
return (partial.flags & PublicUserFlags.PROFILE_HIDDEN) !== 0;
|
||||
}
|
||||
|
||||
export function hiddenGuildMember(member: GuildMemberResponse): GuildMemberResponse {
|
||||
return {...member, nick: null, avatar: null, banner: null, accent_color: null};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
export function isAccountClosed(user: Pick<User, 'flags' | 'deletionStartedAt'>): boolean {
|
||||
return (user.flags & UserFlags.DELETED) !== 0n || user.deletionStartedAt != null;
|
||||
@@ -35,6 +36,10 @@ export function canOwnerRunBots(owner: Pick<User, 'flags' | 'deletionStartedAt'
|
||||
return !isAccountClosed(owner) && !isAccountDisabled(owner);
|
||||
}
|
||||
|
||||
export function isContentHidden(user: Pick<User, 'contentHiddenSince'>, messageId: bigint): boolean {
|
||||
return user.contentHiddenSince !== null && snowflakeToDate(messageId) >= user.contentHiddenSince;
|
||||
}
|
||||
|
||||
export function isDirectDeliverySuppressed(user: Pick<User, 'isBot' | 'flags'>): boolean {
|
||||
return !user.isBot && (user.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@app/api/models/UserSettings';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {isAccountLimited} from '@app/api/user/AccountLimit';
|
||||
import {hiddenUserPartial, isProfileHidden} from '@app/api/user/ProfileVisibility';
|
||||
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
|
||||
import type {ChannelMessageNotifications} from '@fluxer/constants/src/NotificationConstants';
|
||||
import {
|
||||
@@ -70,10 +71,18 @@ function sortUserIds(userIds: Iterable<UserID>): Array<string> {
|
||||
}
|
||||
|
||||
export function mapUserToPartialResponse(user: User): UserPartialResponse {
|
||||
const partial = mapUserToOwnPartialResponse(user);
|
||||
return isProfileHidden(user) && !isDeletedForDisplay(user) ? hiddenUserPartial(partial) : partial;
|
||||
}
|
||||
|
||||
function isDeletedForDisplay(user: User): boolean {
|
||||
return (user.flags & UserFlags.DELETED) !== 0n && user.pendingDeletionAt === null && !user.isSystem;
|
||||
}
|
||||
|
||||
function mapUserToOwnPartialResponse(user: User): UserPartialResponse {
|
||||
const isBot = user.isBot;
|
||||
const avatarHash = stripAvatarForUser(user);
|
||||
const isDeleted = (user.flags & UserFlags.DELETED) !== 0n && user.pendingDeletionAt === null && !user.isSystem;
|
||||
if (isDeleted) {
|
||||
if (isDeletedForDisplay(user)) {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: DELETED_USER_USERNAME,
|
||||
@@ -119,7 +128,7 @@ export function hasPartialUserFieldsChanged(oldUser: User, newUser: User): boole
|
||||
|
||||
export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
|
||||
const isStaff = (user.flags & UserFlags.STAFF) !== 0n;
|
||||
const partialResponse = mapUserToPartialResponse(user);
|
||||
const partialResponse = mapUserToOwnPartialResponse(user);
|
||||
const isActuallyPremium = user.isPremium();
|
||||
const traitSet = new Set<string>();
|
||||
for (const trait of user.traits ?? []) {
|
||||
@@ -193,6 +202,9 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
|
||||
}
|
||||
|
||||
export function mapUserToProfileResponse(user: User, options?: {restrictProfile?: boolean}): UserProfileResponse {
|
||||
if (isProfileHidden(user)) {
|
||||
return {bio: null, pronouns: null, banner: null, banner_color: null, accent_color: null};
|
||||
}
|
||||
if (options?.restrictProfile) {
|
||||
return {
|
||||
bio: null,
|
||||
@@ -236,9 +248,12 @@ export function mapUserToOAuthResponse(
|
||||
|
||||
export function mapGuildMemberToProfileResponse(
|
||||
guildMember: GuildMember | null | undefined,
|
||||
options?: {restrictProfile?: boolean},
|
||||
options?: {restrictProfile?: boolean; hidden?: boolean},
|
||||
): UserProfileResponse | null {
|
||||
if (!guildMember) return null;
|
||||
if (options?.hidden) {
|
||||
return {bio: null, pronouns: null, banner: null, accent_color: null};
|
||||
}
|
||||
if (options?.restrictProfile) {
|
||||
return {
|
||||
bio: null,
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import type {AdminMessageDeletionService} from '@app/api/admin/services/AdminMessageDeletionService';
|
||||
import {type ChannelID, createUserID, type MessageID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import {IP_BAN_REFRESH_CHANNEL} from '@app/api/constants/IpBan';
|
||||
import {withAccountChangeSource} from '@app/api/infrastructure/activity/ActivityMeta';
|
||||
import type {
|
||||
@@ -21,42 +25,85 @@ import {
|
||||
isNewConversationLimitExempt,
|
||||
setNewConversationLimit,
|
||||
} from '@app/api/user/NewConversationLimit';
|
||||
import {
|
||||
type PartialUserChangePropagationDeps,
|
||||
propagatePartialUserChange,
|
||||
} from '@app/api/user/services/PartialUserChangePropagation';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {getSameIpDecisionKey, isPublicIpAddress, parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
export type ActionOf<T extends ActionEnvelope['type']> = Extract<ActionEnvelope, {type: T}>;
|
||||
|
||||
export interface AccountUpdateDispatch {
|
||||
userUpdated(user: User): Promise<void>;
|
||||
profileChanged(user: User): Promise<void>;
|
||||
contentVisibilityChanged(user: User): Promise<void>;
|
||||
messagesRemoved(channelId: ChannelID, authorId: UserID, messageIds: Array<MessageID>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AccountStateDeps {
|
||||
users: Pick<IUserRepository, 'findUnique' | 'compareAndSetFlags'>;
|
||||
users: Pick<IUserRepository, 'findUnique' | 'compareAndSetFlags' | 'patchUpsert'>;
|
||||
dispatch: AccountUpdateDispatch;
|
||||
ipBans: Pick<AdminRepository, 'isIpBanned' | 'banIpTemp'>;
|
||||
cache: Pick<ICacheService, 'publish' | 'get' | 'set' | 'delete'>;
|
||||
messages: Pick<AdminMessageDeletionService, 'deleteAllUserMessages'>;
|
||||
authored: Pick<IChannelRepository, 'listMessagesByAuthor'>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const FLAGS_WRITE_ATTEMPTS = 3;
|
||||
const AUTHORED_PAGE_SIZE = 200;
|
||||
const MIN_TEMP_BAN_SECONDS = 60;
|
||||
|
||||
function gatewayDispatch(gateway: Pick<IGatewayService, 'dispatchPresence'>): AccountUpdateDispatch {
|
||||
type ProfilePropagation = Omit<PartialUserChangePropagationDeps, 'gatewayService'>;
|
||||
|
||||
function gatewayDispatch(
|
||||
gateway: IGatewayService,
|
||||
profile: ProfilePropagation,
|
||||
channels: Pick<IChannelRepository, 'findUnique'>,
|
||||
): AccountUpdateDispatch {
|
||||
return {
|
||||
async userUpdated(user) {
|
||||
await gateway.dispatchPresence({userId: user.id, event: 'USER_UPDATE', data: mapUserToPrivateResponse(user)});
|
||||
},
|
||||
async profileChanged(user) {
|
||||
await propagatePartialUserChange({...profile, gatewayService: gateway}, user);
|
||||
},
|
||||
async contentVisibilityChanged(user) {
|
||||
await profile.userCacheService.invalidateUserCache(user.id);
|
||||
},
|
||||
async messagesRemoved(channelId, authorId, messageIds) {
|
||||
const channel = await channels.findUnique(channelId);
|
||||
if (!channel) return;
|
||||
for (const messageId of messageIds) {
|
||||
await dispatchChannelEvent({
|
||||
gatewayService: gateway,
|
||||
channel,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {channel_id: channelId.toString(), id: messageId.toString(), author_id: authorId.toString()},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function accountStateDepsFromContext(ctx: ApiContext, ipBans: AccountStateDeps['ipBans']): AccountStateDeps {
|
||||
export function accountStateDepsFromContext(
|
||||
ctx: ApiContext,
|
||||
ipBans: AccountStateDeps['ipBans'],
|
||||
profile: Omit<ProfilePropagation, 'userRepository'>,
|
||||
messages: AccountStateDeps['messages'],
|
||||
channels: Pick<IChannelRepository, 'findUnique' | 'listMessagesByAuthor'>,
|
||||
): AccountStateDeps {
|
||||
return {
|
||||
users: ctx.services.users,
|
||||
dispatch: gatewayDispatch(ctx.services.gateway),
|
||||
dispatch: gatewayDispatch(ctx.services.gateway, {...profile, userRepository: ctx.services.users}, channels),
|
||||
ipBans,
|
||||
cache: ctx.services.cache,
|
||||
messages,
|
||||
authored: channels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +160,95 @@ export async function applySetAccountLimit(
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyHideProfile(deps: AccountStateDeps, env: ActionOf<'hide_profile'>): Promise<ActionOutcome> {
|
||||
return withAccountChangeSource('action', async () => {
|
||||
const userId = createUserID(BigInt(env.user_id));
|
||||
let user = await deps.users.findUnique(userId);
|
||||
for (let attempt = 0; attempt < FLAGS_WRITE_ATTEMPTS; attempt++) {
|
||||
if (!user) return outcomeOf(env, 'ineligible');
|
||||
if (isIneligible(user)) return outcomeOf(env, 'ineligible', user);
|
||||
if (env.on && isAccountLimitExempt(user)) return outcomeOf(env, 'exempt', user);
|
||||
const hidden = (user.flags & UserFlags.PROFILE_HIDDEN) !== 0n;
|
||||
if (hidden === env.on) return outcomeOf(env, 'noop', user);
|
||||
const target = env.on ? user.flags | UserFlags.PROFILE_HIDDEN : user.flags & ~UserFlags.PROFILE_HIDDEN;
|
||||
const updated = await deps.users.compareAndSetFlags(user, target);
|
||||
if (updated) {
|
||||
await deps.dispatch.userUpdated(updated);
|
||||
await deps.dispatch.profileChanged(updated);
|
||||
return outcomeOf(env, 'applied', updated);
|
||||
}
|
||||
user = await deps.users.findUnique(userId);
|
||||
}
|
||||
throw new Error('User flags kept changing during apply');
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyHideRecentMessages(
|
||||
deps: AccountStateDeps,
|
||||
env: ActionOf<'hide_recent_messages'>,
|
||||
): Promise<ActionOutcome> {
|
||||
return withAccountChangeSource('action', async () => {
|
||||
const user = await deps.users.findUnique(createUserID(BigInt(env.user_id)));
|
||||
if (!user) return outcomeOf(env, 'ineligible');
|
||||
if (isIneligible(user)) return outcomeOf(env, 'ineligible', user);
|
||||
const current = user.contentHiddenSince?.getTime() ?? null;
|
||||
if (!env.on) {
|
||||
if (current === null) return outcomeOf(env, 'noop', user);
|
||||
const shown = await deps.users.patchUpsert(user.id, {content_hidden_since: null}, user.toRow());
|
||||
await deps.dispatch.contentVisibilityChanged(shown);
|
||||
return outcomeOf(env, 'applied', shown);
|
||||
}
|
||||
if (isAccountLimitExempt(user)) return outcomeOf(env, 'exempt', user);
|
||||
const since = current === null ? env.since_ms : Math.min(current, env.since_ms);
|
||||
let hidden = user;
|
||||
if (since !== current) {
|
||||
hidden = await deps.users.patchUpsert(user.id, {content_hidden_since: new Date(since)}, user.toRow());
|
||||
await deps.dispatch.contentVisibilityChanged(hidden);
|
||||
}
|
||||
const removed = await removeAuthoredMessagesSince(deps, user.id, since);
|
||||
return outcomeOf(env, since === current ? 'noop' : 'applied', hidden, `messages=${removed}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function removeAuthoredMessagesSince(deps: AccountStateDeps, authorId: UserID, sinceMs: number): Promise<number> {
|
||||
let cursor: MessageID | undefined;
|
||||
let removed = 0;
|
||||
while (true) {
|
||||
const refs = await deps.authored.listMessagesByAuthor(authorId, AUTHORED_PAGE_SIZE, cursor);
|
||||
const inWindow = refs.filter(({messageId}) => snowflakeToDate(messageId).getTime() >= sinceMs);
|
||||
const byChannel = new Map<ChannelID, Array<MessageID>>();
|
||||
for (const {channelId, messageId} of inWindow) {
|
||||
const ids = byChannel.get(channelId);
|
||||
if (ids) ids.push(messageId);
|
||||
else byChannel.set(channelId, [messageId]);
|
||||
}
|
||||
for (const [channelId, messageIds] of byChannel) {
|
||||
await deps.dispatch.messagesRemoved(channelId, authorId, messageIds);
|
||||
}
|
||||
removed += inWindow.length;
|
||||
if (inWindow.length < refs.length || refs.length < AUTHORED_PAGE_SIZE) return removed;
|
||||
cursor = refs[refs.length - 1].messageId;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyDeleteUserMessages(
|
||||
deps: AccountStateDeps,
|
||||
env: ActionOf<'delete_user_messages'>,
|
||||
): Promise<ActionOutcome> {
|
||||
if (!env.on) return outcomeOf(env, 'unsupported', null, 'a message purge cannot be reversed');
|
||||
const user = await deps.users.findUnique(createUserID(BigInt(env.user_id)));
|
||||
if (!user) return outcomeOf(env, 'ineligible');
|
||||
if (user.isBot) return outcomeOf(env, 'ineligible', user);
|
||||
if (isAccountLimitExempt(user)) return outcomeOf(env, 'exempt', user);
|
||||
const purge = await deps.messages.deleteAllUserMessages(
|
||||
{user_id: user.id, dry_run: false},
|
||||
SYSTEM_USER_ID,
|
||||
`Automated action ${env.id}`,
|
||||
);
|
||||
if (purge.message_count === 0) return outcomeOf(env, 'noop', user);
|
||||
return outcomeOf(env, 'applied', user, `messages=${purge.message_count} job=${purge.job_id ?? ''}`);
|
||||
}
|
||||
|
||||
function parseBanTarget(value: string): ReturnType<typeof parseIpAddress> {
|
||||
const direct = parseIpAddress(value);
|
||||
if (direct) return direct;
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {User} from '@app/api/models/User';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {assertAccountNotLimited} from '@app/api/user/AccountLimit';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {isProfileHidden} from '@app/api/user/ProfileVisibility';
|
||||
import type {EmailChangeService} from '@app/api/user/services/EmailChangeService';
|
||||
import type {UserAccountService} from '@app/api/user/services/UserAccountService';
|
||||
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
|
||||
@@ -301,8 +302,12 @@ export class UserAccountRequestService {
|
||||
}
|
||||
}
|
||||
const restrictProfile = profile.restrictProfile;
|
||||
const hidden = isProfileHidden(profileUser);
|
||||
const userProfile = mapUserToProfileResponse(profileUser, {restrictProfile});
|
||||
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null, {restrictProfile});
|
||||
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null, {
|
||||
restrictProfile,
|
||||
hidden,
|
||||
});
|
||||
const timezoneOffset = profile.timezoneVisible ? getCurrentTimeZoneOffsetMinutes(profileUser.timezone) : null;
|
||||
const mutualFriends = profile.mutualFriends
|
||||
? profile.mutualFriends.map((user) =>
|
||||
@@ -313,7 +318,8 @@ export class UserAccountRequestService {
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
const connectedAccounts = profile.connections ? this.mapConnectionsToResponse(profile.connections) : undefined;
|
||||
const connectedAccounts =
|
||||
profile.connections && !hidden ? this.mapConnectionsToResponse(profile.connections) : undefined;
|
||||
return {
|
||||
user: mapUserToPartialResponseWithCache({
|
||||
user: profileUser,
|
||||
|
||||
@@ -821,6 +821,7 @@ export class UserContentService {
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const data = (await this.buildMessageResponsesForUser(userId, [message]))[0];
|
||||
if (!data) return;
|
||||
await this.gatewayService
|
||||
.dispatchPresence({
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getMember, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
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 type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
interface AdminUser {
|
||||
username: string;
|
||||
global_name: string | null;
|
||||
bio: string | null;
|
||||
pronouns: string | null;
|
||||
pending_deletion_at: string | null;
|
||||
}
|
||||
|
||||
describe('hidden profiles', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
let viewer: TestAccount;
|
||||
let target: TestAccount;
|
||||
let guildId: string;
|
||||
let targetName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'user:lookup',
|
||||
'user:temp_ban',
|
||||
'user:delete',
|
||||
'user:update:flags',
|
||||
]);
|
||||
const setup = await setupTestGuildWithMembers(harness, 1);
|
||||
viewer = setup.owner;
|
||||
target = setup.members[0]!;
|
||||
guildId = setup.guild.id;
|
||||
await createBuilder(harness, target.token)
|
||||
.patch('/users/@me')
|
||||
.body({global_name: 'Shown Name', bio: 'shown bio', pronouns: 'they/them'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, target.token)
|
||||
.patch(`/guilds/${guildId}/members/@me`)
|
||||
.body({nick: 'Shown Nick'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
targetName = (await fetchUser(harness, target.userId, viewer.token)).json.username;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
expect(member.nick).toBe('Shown Nick');
|
||||
}
|
||||
|
||||
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);
|
||||
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');
|
||||
}
|
||||
|
||||
async function expectStaffSeeStoredProfile(): Promise<void> {
|
||||
const {users} = await createBuilder<{users: Array<AdminUser>}>(harness, admin.token)
|
||||
.get(`/admin/users/${target.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(users[0]).toMatchObject({username: targetName, global_name: 'Shown Name', bio: 'shown bio'});
|
||||
}
|
||||
|
||||
function memberUpdates(): Array<GuildMemberResponse> {
|
||||
const dispatch = vi.mocked(NoopGatewayService.prototype.dispatchGuild);
|
||||
return dispatch.mock.calls
|
||||
.map(([params]) => params)
|
||||
.filter((params) => params.event === 'GUILD_MEMBER_UPDATE' && params.guildId.toString() === guildId)
|
||||
.map((params) => params.data as GuildMemberResponse)
|
||||
.filter((member) => member.user.id === target.userId);
|
||||
}
|
||||
|
||||
test('a normal account shows its stored profile', async () => {
|
||||
await expectShown();
|
||||
});
|
||||
|
||||
test('a staff ban hides the profile until the unban restores it, and clients are told both ways', async () => {
|
||||
vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
const presence = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
|
||||
await createBuilder(harness, admin.token)
|
||||
.put(`/admin/users/${target.userId}/ban`)
|
||||
.body({duration_hours: 24, notify_user: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.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);
|
||||
await createBuilder(harness, admin.token)
|
||||
.delete(`/admin/users/${target.userId}/ban`)
|
||||
.body({notify_user: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectShown();
|
||||
expect(memberUpdates().map((member) => [member.user.username, member.nick])).toEqual([
|
||||
['HiddenUser', null],
|
||||
[targetName, 'Shown Nick'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the spammer flag hides the profile and clearing it restores it', async () => {
|
||||
const flags = (body: Record<string, Array<string>>) =>
|
||||
createBuilder(harness, admin.token)
|
||||
.patch(`/admin/users/${target.userId}/flags`)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await flags({add_flags: [UserFlags.SPAMMER.toString()]});
|
||||
await expectHidden();
|
||||
await expectStaffSeeStoredProfile();
|
||||
await flags({remove_flags: [UserFlags.SPAMMER.toString()]});
|
||||
await expectShown();
|
||||
});
|
||||
|
||||
test('a pending deletion for abuse hides the profile and cancelling it restores it', async () => {
|
||||
const {user} = await createBuilder<{user: AdminUser}>(harness, admin.token)
|
||||
.put(`/admin/users/${target.userId}/deletion`)
|
||||
.body({
|
||||
reason_code: DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT,
|
||||
days_until_deletion: 30,
|
||||
notify_user: false,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectHidden();
|
||||
await expectStaffSeeStoredProfile();
|
||||
await createBuilder(harness, admin.token)
|
||||
.delete(`/admin/users/${target.userId}/deletion`)
|
||||
.body({expected_pending_deletion_at: user.pending_deletion_at})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectShown();
|
||||
});
|
||||
|
||||
test('the hidden profile flag hides the profile from others while the owner keeps seeing it', async () => {
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/users/${target.userId}/flags`)
|
||||
.body({add_flags: [UserFlags.PROFILE_HIDDEN.toString()]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectHidden();
|
||||
const own = await fetchUserMe(harness, target.token);
|
||||
expect(own.json).toMatchObject({username: targetName, global_name: 'Shown Name', bio: 'shown bio'});
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/users/${target.userId}/flags`)
|
||||
.body({remove_flags: [UserFlags.PROFILE_HIDDEN.toString()]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectShown();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {EMPTY_USER_ROW, type UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {LimitConfigService, resetGlobalLimitConfigServiceForTesting} from '@app/api/limits/LimitConfigService';
|
||||
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 {
|
||||
hasPartialUserFieldsChanged,
|
||||
mapGuildMemberToProfileResponse,
|
||||
mapUserToPartialResponse,
|
||||
mapUserToPrivateResponse,
|
||||
mapUserToProfileResponse,
|
||||
} from '@app/api/user/UserMappers';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {PublicUserFlags, 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;
|
||||
|
||||
function user(overrides: Partial<UserRow> = {}): User {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(1174109840998400001n),
|
||||
username: 'ada',
|
||||
discriminator: 7,
|
||||
global_name: 'Ada Lovelace',
|
||||
avatar_hash: 'a1b2c3',
|
||||
avatar_color: 42,
|
||||
banner_hash: 'b4n',
|
||||
banner_color: 7,
|
||||
bio: 'analytical engine enjoyer',
|
||||
pronouns: 'she/her',
|
||||
accent_color: 99,
|
||||
flags: 0n,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const STATES: Array<[string, Partial<UserRow>, boolean]> = [
|
||||
['a normal account', {}, false],
|
||||
['a staff ban', {flags: UserFlags.DISABLED, temp_banned_until: new Date(NOW + HOUR)}, true],
|
||||
['a lifted ban', {flags: 0n, temp_banned_until: null}, false],
|
||||
['a ban that ran out', {flags: UserFlags.DISABLED, temp_banned_until: new Date(NOW - HOUR)}, false],
|
||||
['a self-disabled account', {flags: UserFlags.DISABLED}, false],
|
||||
['the spammer flag', {flags: UserFlags.SPAMMER}, true],
|
||||
[
|
||||
'a pending deletion for abuse',
|
||||
{
|
||||
flags: UserFlags.DELETED,
|
||||
pending_deletion_at: new Date(NOW + 30 * 24 * HOUR),
|
||||
deletion_reason_code: DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
'a self-requested deletion',
|
||||
{
|
||||
flags: UserFlags.SELF_DELETED,
|
||||
pending_deletion_at: new Date(NOW + 14 * 24 * HOUR),
|
||||
deletion_reason_code: DeletionReasons.USER_REQUESTED,
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
'an inactivity deletion',
|
||||
{
|
||||
flags: UserFlags.DELETED,
|
||||
pending_deletion_at: new Date(NOW + 30 * 24 * HOUR),
|
||||
deletion_reason_code: DeletionReasons.INACTIVITY,
|
||||
},
|
||||
false,
|
||||
],
|
||||
['a cancelled deletion', {flags: 0n, pending_deletion_at: null, deletion_reason_code: null}, false],
|
||||
['a hidden profile', {flags: UserFlags.PROFILE_HIDDEN}, true],
|
||||
['a hidden system account', {flags: UserFlags.PROFILE_HIDDEN, system: true}, false],
|
||||
];
|
||||
|
||||
describe('profile visibility', () => {
|
||||
beforeAll(() => {
|
||||
new LimitConfigService(new InstanceConfigRepository(), new InMemoryProvider()).setAsGlobalInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetGlobalLimitConfigServiceForTesting();
|
||||
});
|
||||
|
||||
it.each(STATES)('%s', (_name, overrides, hidden) => {
|
||||
const subject = user(overrides);
|
||||
expect(isProfileHidden(subject, NOW)).toBe(hidden);
|
||||
const partial = mapUserToPartialResponse(subject);
|
||||
if (hidden) {
|
||||
expect(partial).toMatchObject({
|
||||
id: subject.id.toString(),
|
||||
username: 'HiddenUser',
|
||||
discriminator: '0000',
|
||||
global_name: null,
|
||||
avatar: null,
|
||||
avatar_color: null,
|
||||
});
|
||||
expect(partial.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(PublicUserFlags.PROFILE_HIDDEN);
|
||||
expect(mapUserToProfileResponse(subject)).toEqual({
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
banner: null,
|
||||
banner_color: null,
|
||||
accent_color: null,
|
||||
});
|
||||
} else {
|
||||
expect(partial.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(0);
|
||||
expect(partial.username).toBe('ada');
|
||||
expect(partial.global_name).toBe('Ada Lovelace');
|
||||
expect(partial.avatar).toBe('a1b2c3');
|
||||
expect(mapUserToProfileResponse(subject)).toMatchObject({bio: 'analytical engine enjoyer', pronouns: 'she/her'});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the stored profile for the account owner', () => {
|
||||
const own = mapUserToPrivateResponse(user({flags: UserFlags.PROFILE_HIDDEN}));
|
||||
expect(own).toMatchObject({
|
||||
username: 'ada',
|
||||
global_name: 'Ada Lovelace',
|
||||
avatar: 'a1b2c3',
|
||||
bio: 'analytical engine enjoyer',
|
||||
pronouns: 'she/her',
|
||||
accent_color: 99,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats hiding and restoring as a partial change so clients are told both ways', () => {
|
||||
const open = user();
|
||||
const banned = user({flags: UserFlags.DISABLED, temp_banned_until: new Date(NOW + HOUR)});
|
||||
expect(hasPartialUserFieldsChanged(open, banned)).toBe(true);
|
||||
expect(hasPartialUserFieldsChanged(banned, open)).toBe(true);
|
||||
expect(hasPartialUserFieldsChanged(open, user({flags: UserFlags.HAS_SESSION_STARTED}))).toBe(false);
|
||||
});
|
||||
|
||||
it('only counts staff enforcement as enforcement', () => {
|
||||
expect(isUnderEnforcement(user({flags: UserFlags.PROFILE_HIDDEN}), NOW)).toBe(false);
|
||||
expect(isUnderEnforcement(user({flags: UserFlags.SPAMMER}), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('hides guild-specific profile details for a hidden member', async () => {
|
||||
const member = new GuildMember({
|
||||
guild_id: createGuildID(5n),
|
||||
user_id: createUserID(1174109840998400001n),
|
||||
joined_at: new Date(NOW - HOUR),
|
||||
nick: 'Countess',
|
||||
avatar_hash: 'm4v',
|
||||
banner_hash: 'm8n',
|
||||
bio: 'member bio',
|
||||
pronouns: 'she/her',
|
||||
accent_color: 12,
|
||||
join_source_type: null,
|
||||
source_invite_code: null,
|
||||
inviter_id: null,
|
||||
deaf: false,
|
||||
mute: false,
|
||||
communication_disabled_until: null,
|
||||
role_ids: null,
|
||||
is_premium_sanitized: false,
|
||||
temporary: false,
|
||||
profile_flags: null,
|
||||
version: 1,
|
||||
});
|
||||
const cache = (partial: ReturnType<typeof mapUserToPartialResponse>) =>
|
||||
({getUserPartialResponse: async () => partial}) as unknown as Pick<UserCacheService, 'getUserPartialResponse'>;
|
||||
const hidden = await mapGuildMemberToResponse(
|
||||
member,
|
||||
cache(mapUserToPartialResponse(user({flags: UserFlags.SPAMMER}))),
|
||||
createRequestCache(),
|
||||
);
|
||||
expect(hidden).toMatchObject({nick: null, avatar: null, banner: null, accent_color: null});
|
||||
expect(hidden.user.username).toBe('HiddenUser');
|
||||
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({
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
});
|
||||
expect(mapGuildMemberToProfileResponse(member)).toMatchObject({bio: 'member bio', pronouns: 'she/her'});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {
|
||||
type AccountStateDeps,
|
||||
applyDeleteUserMessages,
|
||||
applyHideProfile,
|
||||
applyHideRecentMessages,
|
||||
applyLimitNewConversations,
|
||||
applySetAccountLimit,
|
||||
applyTempBanIp,
|
||||
@@ -70,6 +73,12 @@ export function applyAction(deps: AccountActionDeps, env: ActionEnvelope): Promi
|
||||
return applyTempBanIp(deps.state, env);
|
||||
case 'limit_new_conversations':
|
||||
return applyLimitNewConversations(deps.state, env);
|
||||
case 'hide_profile':
|
||||
return applyHideProfile(deps.state, env);
|
||||
case 'hide_recent_messages':
|
||||
return applyHideRecentMessages(deps.state, env);
|
||||
case 'delete_user_messages':
|
||||
return applyDeleteUserMessages(deps.state, env);
|
||||
default:
|
||||
return Promise.resolve(outcomeOf(env as ActionEnvelope, 'unsupported'));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {AdminMessageDeletionService} from '@app/api/admin/services/AdminMessageDeletionService';
|
||||
import {AdminMessageShredService} from '@app/api/admin/services/AdminMessageShredService';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createApiContext} from '@app/api/CreateApiContext';
|
||||
import {setDatabaseQueryExecutor} from '@app/api/database/CassandraQueryExecution';
|
||||
@@ -282,9 +285,25 @@ export async function startWorkerMain(): Promise<void> {
|
||||
});
|
||||
startSharedListWatch(jsConnectionManager.getJetStreamClient());
|
||||
if (activeWorkerLanes.some((lane) => lane.name === 'lifecycle')) {
|
||||
const apiContext = createApiContext();
|
||||
const auditService = new AdminAuditService(getAdminRepository(), apiContext.services.snowflake);
|
||||
const messagePurge = new AdminMessageDeletionService({
|
||||
channelRepository: dependencies.channelRepository,
|
||||
messageShredService: new AdminMessageShredService({apiContext, auditService}),
|
||||
auditService,
|
||||
});
|
||||
startAccountActionConsumer({
|
||||
js: jsConnectionManager.getJetStreamClient(),
|
||||
state: accountStateDepsFromContext(createApiContext(), getAdminRepository()),
|
||||
state: accountStateDepsFromContext(
|
||||
apiContext,
|
||||
getAdminRepository(),
|
||||
{
|
||||
userCacheService: dependencies.userCacheService,
|
||||
guildRepository: dependencies.guildRepository,
|
||||
},
|
||||
messagePurge,
|
||||
dependencies.channelRepository,
|
||||
),
|
||||
});
|
||||
Logger.info('Account action consumer started');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {AdminMessageDeletionService} from '@app/api/admin/services/AdminMessageDeletionService';
|
||||
import {
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createMessageID,
|
||||
createUserID,
|
||||
type MessageID,
|
||||
type UserID,
|
||||
} from '@app/api/BrandedTypes';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes';
|
||||
import {
|
||||
@@ -20,6 +29,7 @@ import {
|
||||
stopAccountActionConsumer,
|
||||
} from '@app/api/worker/AccountActionConsumer';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {createSnowflakeFromTimestamp} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {AckPolicy, DeliverPolicy, type JsMsg, jetstream, jetstreamManager} from '@nats-io/jetstream';
|
||||
import {connect} from '@nats-io/transport-node';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -80,14 +90,26 @@ class FakeUsers {
|
||||
this.rows.set(user.id.toString(), next);
|
||||
return new User(next);
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patch: Partial<UserRow>): Promise<User> {
|
||||
const next = {...this.rows.get(userId.toString())!, ...patch};
|
||||
this.rows.set(userId.toString(), next);
|
||||
return new User(next);
|
||||
}
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
users: FakeUsers;
|
||||
cached: Map<string, unknown>;
|
||||
presence: Array<unknown>;
|
||||
profiles: Array<unknown>;
|
||||
bans: Array<{ip: string; ttl: number}>;
|
||||
refreshes: number;
|
||||
authored: Array<{channelId: ChannelID; messageId: MessageID}>;
|
||||
visibility: Array<UserID>;
|
||||
removed: Array<{channelId: ChannelID; authorId: UserID; messageIds: Array<MessageID>}>;
|
||||
audits: Array<{adminUserId: UserID; action: string; targetId: bigint; auditLogReason: string | null}>;
|
||||
shreds: Array<{userId: bigint; entries: number; adminUserId: UserID; auditLogReason: string | null}>;
|
||||
deps: AccountActionDeps;
|
||||
}
|
||||
|
||||
@@ -97,16 +119,66 @@ function harness(): Harness {
|
||||
users,
|
||||
cached: new Map(),
|
||||
presence: [],
|
||||
profiles: [],
|
||||
bans: [],
|
||||
refreshes: 0,
|
||||
authored: [],
|
||||
visibility: [],
|
||||
removed: [],
|
||||
audits: [],
|
||||
shreds: [],
|
||||
deps: null as never,
|
||||
};
|
||||
const authored = {
|
||||
listMessagesByAuthor: async (_authorId: UserID, limit: number, before?: MessageID) =>
|
||||
[...h.authored]
|
||||
.sort((a, b) => (a.messageId > b.messageId ? -1 : 1))
|
||||
.filter((m) => before === undefined || m.messageId < before)
|
||||
.slice(0, limit),
|
||||
};
|
||||
const messages = new AdminMessageDeletionService({
|
||||
channelRepository: authored,
|
||||
messageShredService: {
|
||||
queueMessageShred: async (
|
||||
data: {user_id: bigint; entries: Array<unknown>},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) => {
|
||||
h.shreds.push({userId: data.user_id, entries: data.entries.length, adminUserId, auditLogReason});
|
||||
return {success: true, job_id: '77', requested: data.entries.length};
|
||||
},
|
||||
},
|
||||
auditService: {
|
||||
createAuditLog: async (log: {
|
||||
adminUserId: UserID;
|
||||
action: string;
|
||||
targetId: bigint;
|
||||
auditLogReason: string | null;
|
||||
}) => {
|
||||
h.audits.push({
|
||||
adminUserId: log.adminUserId,
|
||||
action: log.action,
|
||||
targetId: log.targetId,
|
||||
auditLogReason: log.auditLogReason,
|
||||
});
|
||||
},
|
||||
},
|
||||
} as unknown as ConstructorParameters<typeof AdminMessageDeletionService>[0]);
|
||||
const state: AccountStateDeps = {
|
||||
users: users as unknown as AccountStateDeps['users'],
|
||||
dispatch: {
|
||||
userUpdated: async (user) => {
|
||||
h.presence.push(user.id);
|
||||
},
|
||||
profileChanged: async (user) => {
|
||||
h.profiles.push(user.id);
|
||||
},
|
||||
contentVisibilityChanged: async (user) => {
|
||||
h.visibility.push(user.id);
|
||||
},
|
||||
messagesRemoved: async (channelId, authorId, messageIds) => {
|
||||
h.removed.push({channelId, authorId, messageIds});
|
||||
},
|
||||
},
|
||||
ipBans: {
|
||||
isIpBanned: async () => false,
|
||||
@@ -126,6 +198,8 @@ function harness(): Harness {
|
||||
h.cached.delete(key);
|
||||
},
|
||||
} as unknown as AccountStateDeps['cache'],
|
||||
messages,
|
||||
authored: authored as unknown as AccountStateDeps['authored'],
|
||||
now: () => NOW,
|
||||
};
|
||||
h.deps = {js: {} as AccountActionDeps['js'], state, now: () => NOW};
|
||||
@@ -310,6 +384,245 @@ describe('account action apply', () => {
|
||||
expect(h.bans).toEqual([]);
|
||||
});
|
||||
|
||||
it('hides the profile reversibly and tells clients both ways', async () => {
|
||||
h.users.put({flags: UserFlags.ACCOUNT_LIMITED});
|
||||
const hide = envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: true});
|
||||
expect(await applyAction(h.deps, hide)).toMatchObject({status: 'applied', action_type: 'hide_profile'});
|
||||
expect(h.users.current().flags).toBe(UserFlags.ACCOUNT_LIMITED | UserFlags.PROFILE_HIDDEN);
|
||||
expect((await applyAction(h.deps, hide)).status).toBe('noop');
|
||||
const show = envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: false});
|
||||
expect((await applyAction(h.deps, show)).status).toBe('applied');
|
||||
expect(h.users.current().flags).toBe(UserFlags.ACCOUNT_LIMITED);
|
||||
expect((await applyAction(h.deps, show)).status).toBe('noop');
|
||||
expect(h.presence).toHaveLength(2);
|
||||
expect(h.profiles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('never hides staff or trusted profiles and skips bots and deleted accounts', async () => {
|
||||
const hide = envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: true});
|
||||
for (const flags of [UserFlags.STAFF, UserFlags.LIMIT_EXEMPT]) {
|
||||
h.users.put({flags});
|
||||
expect((await applyAction(h.deps, hide)).status).toBe('exempt');
|
||||
}
|
||||
for (const overrides of [{bot: true}, {flags: UserFlags.DELETED}] satisfies Array<Partial<UserRow>>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, hide)).status).toBe('ineligible');
|
||||
}
|
||||
h.users.put({flags: UserFlags.STAFF | UserFlags.PROFILE_HIDDEN});
|
||||
const show = envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: false});
|
||||
expect((await applyAction(h.deps, show)).status).toBe('applied');
|
||||
expect(h.profiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
function hideEnvelope(on: boolean, sinceMs = NOW - 86_400_000) {
|
||||
return envelope<'hide_recent_messages'>({type: 'hide_recent_messages', user_id: USER_ID, since_ms: sinceMs, on});
|
||||
}
|
||||
|
||||
function authorAt(timestampMs: number, channel: number): MessageID {
|
||||
const messageId = createMessageID(createSnowflakeFromTimestamp(timestampMs) + BigInt(h.authored.length));
|
||||
h.authored.push({channelId: createChannelID(BigInt(channel)), messageId});
|
||||
return messageId;
|
||||
}
|
||||
|
||||
function removedIds(): Array<MessageID> {
|
||||
return h.removed.flatMap((entry) => entry.messageIds).sort((a, b) => (a < b ? -1 : 1));
|
||||
}
|
||||
|
||||
it('hides every message from the window onward and tells each channel they are gone', async () => {
|
||||
const since = NOW - 86_400_000;
|
||||
const before = [authorAt(since - 60_000, 100), authorAt(since - 1, 101)];
|
||||
const inside: Array<MessageID> = [authorAt(since, 100)];
|
||||
for (let i = 0; i < 250; i++) inside.push(authorAt(since + 1_000 + i, 100 + (i % 3)));
|
||||
const outcome = await applyAction(h.deps, hideEnvelope(true, since));
|
||||
expect(outcome).toEqual({
|
||||
action_id: 'a:07:4242:0',
|
||||
action_type: 'hide_recent_messages',
|
||||
status: 'applied',
|
||||
detail: 'messages=251',
|
||||
observed: {flags: '0', deleted: false},
|
||||
user_id: USER_ID,
|
||||
});
|
||||
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since);
|
||||
expect(h.visibility).toEqual([createUserID(BigInt(USER_ID))]);
|
||||
expect(removedIds()).toEqual([...inside].sort((a, b) => (a < b ? -1 : 1)));
|
||||
for (const id of before) expect(removedIds()).not.toContain(id);
|
||||
for (const entry of h.removed) {
|
||||
expect(entry.authorId).toBe(createUserID(BigInt(USER_ID)));
|
||||
for (const id of entry.messageIds) {
|
||||
expect(h.authored.find((m) => m.messageId === id)?.channelId).toBe(entry.channelId);
|
||||
}
|
||||
}
|
||||
expect(h.users.current().flags).toBe(0n);
|
||||
expect(h.shreds).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the wider window when asked again and resends the deletes as a noop', async () => {
|
||||
const since = NOW - 86_400_000;
|
||||
authorAt(since + 5_000, 100);
|
||||
await applyAction(h.deps, hideEnvelope(true, since));
|
||||
const later = await applyAction(h.deps, hideEnvelope(true, since + 60_000));
|
||||
expect(later).toMatchObject({status: 'noop', detail: 'messages=1'});
|
||||
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since);
|
||||
expect(h.visibility).toHaveLength(1);
|
||||
const wider = await applyAction(h.deps, hideEnvelope(true, since - 60_000));
|
||||
expect(wider.status).toBe('applied');
|
||||
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since - 60_000);
|
||||
expect(h.visibility).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reports a hide with nothing in the window as applied with no deletes', async () => {
|
||||
authorAt(NOW - 2 * 86_400_000, 100);
|
||||
const outcome = await applyAction(h.deps, hideEnvelope(true));
|
||||
expect(outcome).toMatchObject({status: 'applied', detail: 'messages=0'});
|
||||
expect(h.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it('restores by clearing the window and sends no gateway events', async () => {
|
||||
const since = NOW - 86_400_000;
|
||||
authorAt(since + 5_000, 100);
|
||||
h.users.put({content_hidden_since: new Date(since)});
|
||||
const outcome = await applyAction(h.deps, hideEnvelope(false));
|
||||
expect(outcome).toMatchObject({status: 'applied', detail: null, observed: {flags: '0', deleted: false}});
|
||||
expect(h.users.current().contentHiddenSince).toBeNull();
|
||||
expect(h.removed).toEqual([]);
|
||||
expect(h.presence).toHaveLength(0);
|
||||
expect(h.visibility).toHaveLength(1);
|
||||
expect((await applyAction(h.deps, hideEnvelope(false))).status).toBe('noop');
|
||||
expect(h.visibility).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never hides staff, trusted or system messages and skips bots and deleted accounts', async () => {
|
||||
authorAt(NOW - 1_000, 100);
|
||||
for (const overrides of [{flags: UserFlags.STAFF}, {flags: UserFlags.LIMIT_EXEMPT}, {system: true}] satisfies Array<
|
||||
Partial<UserRow>
|
||||
>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, hideEnvelope(true))).status).toBe('exempt');
|
||||
expect(h.users.current().contentHiddenSince).toBeNull();
|
||||
}
|
||||
for (const overrides of [{bot: true}, {flags: UserFlags.DELETED}] satisfies Array<Partial<UserRow>>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, hideEnvelope(true))).status).toBe('ineligible');
|
||||
}
|
||||
h.users.rows.clear();
|
||||
expect(await applyAction(h.deps, hideEnvelope(true))).toMatchObject({status: 'ineligible', observed: null});
|
||||
expect(h.removed).toEqual([]);
|
||||
expect(h.visibility).toEqual([]);
|
||||
h.users.put({flags: UserFlags.STAFF, content_hidden_since: new Date(NOW - 1_000)});
|
||||
expect((await applyAction(h.deps, hideEnvelope(false))).status).toBe('applied');
|
||||
expect(h.users.current().contentHiddenSince).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the message hide and the profile mask independent', async () => {
|
||||
const since = NOW - 86_400_000;
|
||||
authorAt(since + 5_000, 100);
|
||||
await applyAction(h.deps, hideEnvelope(true, since));
|
||||
await applyAction(h.deps, envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: true}));
|
||||
expect(h.users.current().flags).toBe(UserFlags.PROFILE_HIDDEN);
|
||||
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since);
|
||||
await applyAction(h.deps, hideEnvelope(false));
|
||||
expect(h.users.current().flags).toBe(UserFlags.PROFILE_HIDDEN);
|
||||
expect(h.users.current().contentHiddenSince).toBeNull();
|
||||
await applyAction(h.deps, hideEnvelope(true, since));
|
||||
await applyAction(h.deps, envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: false}));
|
||||
expect(h.users.current().flags).toBe(0n);
|
||||
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since);
|
||||
});
|
||||
|
||||
it('still purges a hidden account through the admin purge', async () => {
|
||||
const since = NOW - 86_400_000;
|
||||
authorAt(since - 5_000, 100);
|
||||
authorAt(since + 5_000, 101);
|
||||
await applyAction(h.deps, hideEnvelope(true, since));
|
||||
const purge = await applyAction(
|
||||
h.deps,
|
||||
envelope<'delete_user_messages'>({type: 'delete_user_messages', user_id: USER_ID, on: true}),
|
||||
);
|
||||
expect(purge).toMatchObject({status: 'applied', detail: 'messages=2 job=77'});
|
||||
expect(h.shreds).toEqual([expect.objectContaining({entries: 2})]);
|
||||
});
|
||||
|
||||
function purgeEnvelope(on = true) {
|
||||
return envelope<'delete_user_messages'>({type: 'delete_user_messages', user_id: USER_ID, on});
|
||||
}
|
||||
|
||||
function author(count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
h.authored.push({
|
||||
channelId: createChannelID(BigInt(100 + (i % 3))),
|
||||
messageId: createMessageID(BigInt(1000 + i)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it('purges every message through the admin purge and audits it as the system', async () => {
|
||||
author(450);
|
||||
const outcome = await applyAction(h.deps, purgeEnvelope());
|
||||
expect(outcome).toEqual({
|
||||
action_id: 'a:07:4242:0',
|
||||
action_type: 'delete_user_messages',
|
||||
status: 'applied',
|
||||
detail: 'messages=450 job=77',
|
||||
observed: {flags: '0', deleted: false},
|
||||
user_id: USER_ID,
|
||||
});
|
||||
expect(h.shreds).toEqual([
|
||||
{userId: BigInt(USER_ID), entries: 450, adminUserId: SYSTEM_USER_ID, auditLogReason: 'Automated action a:07:4242:0'},
|
||||
]);
|
||||
expect(h.audits).toEqual([
|
||||
{
|
||||
adminUserId: SYSTEM_USER_ID,
|
||||
action: 'delete_all_user_messages',
|
||||
targetId: BigInt(USER_ID),
|
||||
auditLogReason: 'Automated action a:07:4242:0',
|
||||
},
|
||||
]);
|
||||
expect(h.users.current().flags).toBe(0n);
|
||||
expect(h.presence).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('purges accounts already scheduled for deletion and paid accounts', async () => {
|
||||
author(2);
|
||||
for (const overrides of [
|
||||
{flags: UserFlags.DELETED | UserFlags.SPAMMER, pending_deletion_at: new Date(NOW + 86_400_000)},
|
||||
{has_ever_purchased: true, premium_type: 2},
|
||||
] satisfies Array<Partial<UserRow>>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('applied');
|
||||
}
|
||||
expect(h.shreds).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reports a noop when nothing is left to purge', async () => {
|
||||
const outcome = await applyAction(h.deps, purgeEnvelope());
|
||||
expect(outcome).toMatchObject({status: 'noop', detail: null});
|
||||
expect(h.shreds).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('never purges staff, trusted, system, bot or missing accounts', async () => {
|
||||
author(3);
|
||||
for (const overrides of [{flags: UserFlags.STAFF}, {flags: UserFlags.LIMIT_EXEMPT}, {system: true}] satisfies Array<
|
||||
Partial<UserRow>
|
||||
>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('exempt');
|
||||
}
|
||||
h.users.put({bot: true});
|
||||
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('ineligible');
|
||||
h.users.rows.clear();
|
||||
expect(await applyAction(h.deps, purgeEnvelope())).toMatchObject({status: 'ineligible', observed: null});
|
||||
expect(h.shreds).toHaveLength(0);
|
||||
expect(h.audits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('answers a purge with on false as unsupported, since it cannot be reversed', async () => {
|
||||
author(3);
|
||||
const outcome = await applyAction(h.deps, purgeEnvelope(false));
|
||||
expect(outcome).toMatchObject({status: 'unsupported', detail: 'a message purge cannot be reversed'});
|
||||
expect(h.shreds).toHaveLength(0);
|
||||
expect(h.audits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('answers expired actions and unknown shapes without touching the account', async () => {
|
||||
const expired = await applyAction(h.deps, limitEnvelope({expires_at_ms: NOW}));
|
||||
expect(expired.status).toBe('expired');
|
||||
|
||||
@@ -4,14 +4,19 @@ const USER_FLAG_BUG_HUNTER: i64 = 1 << 3;
|
||||
const USER_FLAG_FRIENDLY_BOT: i64 = 1 << 4;
|
||||
const USER_FLAG_FRIENDLY_BOT_MANUAL_APPROVAL: i64 = 1 << 5;
|
||||
const USER_FLAG_SPAMMER: i64 = 1 << 6;
|
||||
pub const USER_FLAG_PROFILE_HIDDEN: i64 = 1 << 7;
|
||||
const USER_FLAG_DELETED: i64 = 1 << 34;
|
||||
const USER_FLAG_DISABLED: i64 = 1 << 38;
|
||||
pub const USER_FLAG_STAFF_HIDDEN: i64 = 1 << 57;
|
||||
const PUBLIC_USER_FLAGS: i64 = USER_FLAG_STAFF
|
||||
| USER_FLAG_PARTNER
|
||||
| USER_FLAG_BUG_HUNTER
|
||||
| USER_FLAG_FRIENDLY_BOT
|
||||
| USER_FLAG_FRIENDLY_BOT_MANUAL_APPROVAL
|
||||
| USER_FLAG_SPAMMER;
|
||||
| USER_FLAG_SPAMMER
|
||||
| USER_FLAG_PROFILE_HIDDEN;
|
||||
const PUBLIC_USER_FLAGS_WITHOUT_STAFF: i64 = PUBLIC_USER_FLAGS & !USER_FLAG_STAFF;
|
||||
const NON_ENFORCEMENT_DELETION_REASONS: [i32; 3] = [1, 2, 19];
|
||||
|
||||
pub fn visible_user_flags(flags: i64) -> i32 {
|
||||
let visible_flags = if (flags & USER_FLAG_STAFF_HIDDEN) != 0 {
|
||||
@@ -21,3 +26,79 @@ pub fn visible_user_flags(flags: i64) -> i32 {
|
||||
};
|
||||
(flags & visible_flags) as i32
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AccountStanding {
|
||||
pub flags: i64,
|
||||
pub temp_banned_until_ms: Option<i64>,
|
||||
pub pending_deletion_at_ms: Option<i64>,
|
||||
pub deletion_reason_code: Option<i32>,
|
||||
}
|
||||
|
||||
impl AccountStanding {
|
||||
pub fn deleted_for_display(&self) -> bool {
|
||||
self.flags & USER_FLAG_DELETED != 0 && self.pending_deletion_at_ms.is_none()
|
||||
}
|
||||
|
||||
pub fn profile_hidden(&self, now_ms: i64) -> bool {
|
||||
let banned = self.flags & USER_FLAG_DISABLED != 0
|
||||
&& self
|
||||
.temp_banned_until_ms
|
||||
.is_some_and(|until| until > now_ms);
|
||||
let deleting = self.pending_deletion_at_ms.is_some()
|
||||
&& self
|
||||
.deletion_reason_code
|
||||
.is_some_and(|code| !NON_ENFORCEMENT_DELETION_REASONS.contains(&code));
|
||||
!self.deleted_for_display()
|
||||
&& (self.flags & (USER_FLAG_PROFILE_HIDDEN | USER_FLAG_SPAMMER) != 0
|
||||
|| banned
|
||||
|| deleting)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NOW: i64 = 1_790_000_000_000;
|
||||
|
||||
fn standing(flags: i64) -> AccountStanding {
|
||||
AccountStanding {
|
||||
flags,
|
||||
..AccountStanding::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_hidden_follows_enforcement_state() {
|
||||
assert!(!standing(0).profile_hidden(NOW));
|
||||
assert!(standing(USER_FLAG_PROFILE_HIDDEN).profile_hidden(NOW));
|
||||
assert!(standing(USER_FLAG_SPAMMER).profile_hidden(NOW));
|
||||
assert!(!standing(USER_FLAG_DISABLED).profile_hidden(NOW));
|
||||
let banned = AccountStanding {
|
||||
temp_banned_until_ms: Some(NOW + 1),
|
||||
..standing(USER_FLAG_DISABLED)
|
||||
};
|
||||
assert!(banned.profile_hidden(NOW));
|
||||
assert!(!banned.profile_hidden(NOW + 1));
|
||||
let deleting = |code| AccountStanding {
|
||||
pending_deletion_at_ms: Some(NOW),
|
||||
deletion_reason_code: Some(code),
|
||||
..standing(USER_FLAG_DELETED)
|
||||
};
|
||||
assert!(deleting(3).profile_hidden(NOW));
|
||||
assert!(deleting(20).profile_hidden(NOW));
|
||||
for code in NON_ENFORCEMENT_DELETION_REASONS {
|
||||
assert!(!deleting(code).profile_hidden(NOW), "{code}");
|
||||
}
|
||||
assert!(!standing(USER_FLAG_DELETED | USER_FLAG_SPAMMER).profile_hidden(NOW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hidden_bit_is_public() {
|
||||
assert_eq!(
|
||||
visible_user_flags(USER_FLAG_PROFILE_HIDDEN | USER_FLAG_DISABLED),
|
||||
USER_FLAG_PROFILE_HIDDEN as i32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,10 @@ impl RouterService for MessagesRouter {
|
||||
include_reactions,
|
||||
nonce,
|
||||
tts,
|
||||
include_hidden,
|
||||
..
|
||||
} => Some(format!(
|
||||
"api-get:{channel_id}:{message_id}:{viewer_user_id}:{source_guild_id:?}:{message_history_cutoff_ms:?}:{can_read_message_history}:{media_endpoint}:{include_reactions:?}:{nonce:?}:{tts:?}"
|
||||
"api-get:{channel_id}:{message_id}:{viewer_user_id}:{source_guild_id:?}:{message_history_cutoff_ms:?}:{can_read_message_history}:{media_endpoint}:{include_reactions:?}:{nonce:?}:{tts:?}:{include_hidden}"
|
||||
)),
|
||||
MessageRequest::BuildResponse { .. } => None,
|
||||
MessageRequest::BuildResponses { .. } => None,
|
||||
@@ -92,9 +93,10 @@ impl RouterService for MessagesRouter {
|
||||
can_read_message_history,
|
||||
media_endpoint,
|
||||
include_reactions,
|
||||
include_hidden,
|
||||
..
|
||||
} => Some(format!(
|
||||
"api-list:{channel_id}:{viewer_user_id}:{limit}:{before_id:?}:{after_id:?}:{around_id:?}:{source_guild_id:?}:{message_history_cutoff_ms:?}:{can_read_message_history}:{media_endpoint}:{include_reactions:?}"
|
||||
"api-list:{channel_id}:{viewer_user_id}:{limit}:{before_id:?}:{after_id:?}:{around_id:?}:{source_guild_id:?}:{message_history_cutoff_ms:?}:{can_read_message_history}:{media_endpoint}:{include_reactions:?}:{include_hidden}"
|
||||
)),
|
||||
MessageRequest::ExtractMentions { .. } => None,
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ const BUCKET_DURATION_MS: i64 = 864_000_000;
|
||||
const FLUXER_EPOCH_MS: i64 = 1_420_070_400_000;
|
||||
const SERVICE_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const MESSAGE_REFERENCE_TYPE_DEFAULT: i32 = 0;
|
||||
const MESSAGE_REFERENCE_TYPE_FORWARD: i32 = 1;
|
||||
const HIDDEN_REFILL_ROUNDS: usize = 5;
|
||||
const HIDDEN_REFILL_MAX_PAGE: u32 = 800;
|
||||
const MESSAGE_FLAG_IS_CROSSPOST: i64 = 1 << 1;
|
||||
|
||||
fn effective_reference_type(reference: &MessageReference) -> i32 {
|
||||
@@ -75,6 +78,16 @@ fn reply_target(message: &Message) -> Option<(i64, i64)> {
|
||||
Some((reference.channel_id?, reference.message_id?))
|
||||
}
|
||||
|
||||
fn copy_source(message: &Message) -> Option<(i64, i64)> {
|
||||
let reference = message.message_reference.as_ref()?;
|
||||
if !is_crosspost_copy(message)
|
||||
&& effective_reference_type(reference) != MESSAGE_REFERENCE_TYPE_FORWARD
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((reference.channel_id?, reference.message_id?))
|
||||
}
|
||||
|
||||
const MESSAGE_FLAG_SUPPRESS_EMBEDS: i64 = 1 << 2;
|
||||
const EMBED_MEDIA_OWNED_ATTACHMENT_FLAG: i32 = 1 << 30;
|
||||
#[cfg(test)]
|
||||
@@ -124,6 +137,62 @@ enum MessagesStorage {
|
||||
Scylla(Box<ScyllaMessagesStorage>),
|
||||
#[cfg(test)]
|
||||
Deletions(DeletedMessageKeys),
|
||||
#[cfg(test)]
|
||||
Memory(std::sync::Arc<Vec<Message>>),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn memory_buckets(
|
||||
messages: &[Message],
|
||||
channel_id: i64,
|
||||
min_bucket: i32,
|
||||
max_bucket: i32,
|
||||
limit: u32,
|
||||
descending: bool,
|
||||
) -> Vec<i32> {
|
||||
let mut buckets = messages
|
||||
.iter()
|
||||
.filter(|message| message.channel_id == channel_id)
|
||||
.map(|message| snowflake_to_bucket(message.message_id))
|
||||
.filter(|bucket| (min_bucket..=max_bucket).contains(bucket))
|
||||
.collect::<Vec<_>>();
|
||||
buckets.sort_unstable();
|
||||
buckets.dedup();
|
||||
if descending {
|
||||
buckets.reverse();
|
||||
}
|
||||
buckets.truncate(limit as usize);
|
||||
buckets
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn memory_bucket(
|
||||
messages: &[Message],
|
||||
channel_id: i64,
|
||||
bucket: i32,
|
||||
bound: Option<BucketBound>,
|
||||
limit: i32,
|
||||
) -> Vec<Message> {
|
||||
let mut rows = messages
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
message.channel_id == channel_id
|
||||
&& snowflake_to_bucket(message.message_id) == bucket
|
||||
&& match bound {
|
||||
Some(BucketBound::Before(id)) => message.message_id < id,
|
||||
Some(BucketBound::After(id)) => message.message_id > id,
|
||||
None => true,
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if matches!(bound, Some(BucketBound::After(_))) {
|
||||
rows.sort_unstable_by_key(|message| message.message_id);
|
||||
} else {
|
||||
rows.sort_unstable_by_key(|message| std::cmp::Reverse(message.message_id));
|
||||
}
|
||||
rows.truncate(limit.max(0) as usize);
|
||||
rows
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -244,6 +313,45 @@ struct UserPartialServiceResponse {
|
||||
flags: Option<i64>,
|
||||
avatar_color: Option<i32>,
|
||||
mention_flags: Option<i32>,
|
||||
#[serde(default)]
|
||||
content_hidden_since: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct UserLookup {
|
||||
partials: HashMap<i64, UserPartialServiceResponse>,
|
||||
requested: HashSet<i64>,
|
||||
}
|
||||
|
||||
impl UserLookup {
|
||||
fn hides(&self, author_id: Option<i64>, message_id: i64) -> bool {
|
||||
author_id
|
||||
.and_then(|author_id| self.partials.get(&author_id))
|
||||
.and_then(|partial| partial.content_hidden_since)
|
||||
.is_some_and(|since| snowflake_to_epoch_millis(message_id) >= since)
|
||||
}
|
||||
|
||||
fn merge(&mut self, other: UserLookup) {
|
||||
self.requested.extend(other.requested);
|
||||
self.partials.extend(other.partials);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum PageCursor {
|
||||
Latest,
|
||||
Before(i64),
|
||||
After(i64),
|
||||
}
|
||||
|
||||
impl PageCursor {
|
||||
fn next(self, page: &[Message]) -> Option<Self> {
|
||||
let ids = page.iter().map(|message| message.message_id);
|
||||
match self {
|
||||
PageCursor::Latest | PageCursor::Before(_) => ids.min().map(PageCursor::Before),
|
||||
PageCursor::After(_) => ids.max().map(PageCursor::After),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -288,6 +396,7 @@ struct ResponseBuildOptions {
|
||||
include_reactions: bool,
|
||||
nonce: Option<String>,
|
||||
tts: bool,
|
||||
include_hidden: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -583,21 +692,112 @@ impl<T: Transport> MessagesShard<T> {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_around(
|
||||
async fn fetch_page(
|
||||
&self,
|
||||
channel_id: i64,
|
||||
cursor: PageCursor,
|
||||
limit: u32,
|
||||
) -> anyhow::Result<Vec<Message>> {
|
||||
match cursor {
|
||||
PageCursor::Latest => self.get_latest(channel_id, limit).await,
|
||||
PageCursor::Before(before_id) => self.get_before(channel_id, before_id, limit).await,
|
||||
PageCursor::After(after_id) => self.get_after(channel_id, after_id, limit).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn visible_page(
|
||||
&self,
|
||||
channel_id: i64,
|
||||
start: PageCursor,
|
||||
limit: u32,
|
||||
floor: Option<i64>,
|
||||
options: &ResponseBuildOptions,
|
||||
users: &mut UserLookup,
|
||||
) -> anyhow::Result<Vec<Message>> {
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = start;
|
||||
let mut page_size = limit;
|
||||
for _ in 0..HIDDEN_REFILL_ROUNDS {
|
||||
if limit == 0 {
|
||||
break;
|
||||
}
|
||||
let page = self.fetch_page(channel_id, cursor, page_size).await?;
|
||||
let exhausted = page.len() < page_size as usize;
|
||||
let reached_floor =
|
||||
floor.is_some_and(|floor| page.iter().any(|message| message.message_id <= floor));
|
||||
let next = cursor.next(&page);
|
||||
let hidden = self.hidden_message_ids(&page, options, users).await;
|
||||
let refill = !hidden.is_empty();
|
||||
out.extend(
|
||||
page.into_iter()
|
||||
.filter(|message| !hidden.contains(&message.message_id)),
|
||||
);
|
||||
let Some(next) = next else {
|
||||
break;
|
||||
};
|
||||
if !refill || exhausted || reached_floor || out.len() >= limit as usize {
|
||||
break;
|
||||
}
|
||||
cursor = next;
|
||||
page_size = page_size
|
||||
.saturating_mul(2)
|
||||
.min(HIDDEN_REFILL_MAX_PAGE.max(limit));
|
||||
}
|
||||
if matches!(start, PageCursor::After(_)) {
|
||||
out.sort_unstable_by_key(|message| message.message_id);
|
||||
} else {
|
||||
out.sort_unstable_by_key(|message| std::cmp::Reverse(message.message_id));
|
||||
}
|
||||
out.truncate(limit as usize);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn visible_around(
|
||||
&self,
|
||||
channel_id: i64,
|
||||
around_id: i64,
|
||||
limit: u32,
|
||||
options: &ResponseBuildOptions,
|
||||
users: &mut UserLookup,
|
||||
) -> anyhow::Result<Vec<Message>> {
|
||||
if limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (newer_limit, older_limit) = around_window_limits(limit);
|
||||
let mut target_users = UserLookup::default();
|
||||
let mut newer_users = UserLookup::default();
|
||||
let mut older_users = UserLookup::default();
|
||||
let (target, newer, older) = tokio::try_join!(
|
||||
self.get_by_id(channel_id, around_id),
|
||||
self.get_after(channel_id, around_id, newer_limit),
|
||||
self.get_before(channel_id, around_id, older_limit)
|
||||
async {
|
||||
let target = self.get_by_id(channel_id, around_id).await?;
|
||||
let Some(target) = target else {
|
||||
return anyhow::Ok(None);
|
||||
};
|
||||
let hidden = self
|
||||
.hidden_message_ids(std::slice::from_ref(&target), options, &mut target_users)
|
||||
.await;
|
||||
Ok((!hidden.contains(&target.message_id)).then_some(target))
|
||||
},
|
||||
self.visible_page(
|
||||
channel_id,
|
||||
PageCursor::After(around_id),
|
||||
newer_limit,
|
||||
None,
|
||||
options,
|
||||
&mut newer_users,
|
||||
),
|
||||
self.visible_page(
|
||||
channel_id,
|
||||
PageCursor::Before(around_id),
|
||||
older_limit,
|
||||
None,
|
||||
options,
|
||||
&mut older_users,
|
||||
)
|
||||
)?;
|
||||
users.merge(target_users);
|
||||
users.merge(newer_users);
|
||||
users.merge(older_users);
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for message in newer.into_iter().rev() {
|
||||
@@ -631,18 +831,31 @@ impl<T: Transport> MessagesShard<T> {
|
||||
if !options.can_read_message_history && options.message_history_cutoff_ms.is_none() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut users = UserLookup::default();
|
||||
let mut messages = if let Some(around_id) = around_id {
|
||||
self.get_around(channel_id, around_id, limit).await?
|
||||
self.visible_around(channel_id, around_id, limit, &options, &mut users)
|
||||
.await?
|
||||
} else if let (Some(before_id), Some(after_id)) = (before_id, after_id) {
|
||||
let mut before = self.get_before(channel_id, before_id, limit).await?;
|
||||
let mut before = self
|
||||
.visible_page(
|
||||
channel_id,
|
||||
PageCursor::Before(before_id),
|
||||
limit,
|
||||
Some(after_id),
|
||||
&options,
|
||||
&mut users,
|
||||
)
|
||||
.await?;
|
||||
before.retain(|message| message.message_id > after_id);
|
||||
before
|
||||
} else if let Some(before_id) = before_id {
|
||||
self.get_before(channel_id, before_id, limit).await?
|
||||
} else if let Some(after_id) = after_id {
|
||||
self.get_after(channel_id, after_id, limit).await?
|
||||
} else {
|
||||
self.get_latest(channel_id, limit).await?
|
||||
let cursor = match (before_id, after_id) {
|
||||
(Some(before_id), _) => PageCursor::Before(before_id),
|
||||
(None, Some(after_id)) => PageCursor::After(after_id),
|
||||
(None, None) => PageCursor::Latest,
|
||||
};
|
||||
self.visible_page(channel_id, cursor, limit, None, &options, &mut users)
|
||||
.await?
|
||||
};
|
||||
messages
|
||||
.retain(|message| self.is_message_visible_to_requester(message.message_id, &options));
|
||||
@@ -652,7 +865,7 @@ impl<T: Transport> MessagesShard<T> {
|
||||
self.cleanup_orphaned_messages(orphaned_messages).await;
|
||||
messages.sort_unstable_by_key(|message| std::cmp::Reverse(message.message_id));
|
||||
let context = self
|
||||
.build_response_context(&messages, &options, true)
|
||||
.build_response_context(&messages, &options, true, users)
|
||||
.await?;
|
||||
Ok(messages
|
||||
.iter()
|
||||
@@ -676,8 +889,15 @@ impl<T: Transport> MessagesShard<T> {
|
||||
self.cleanup_orphaned_messages(vec![message]).await;
|
||||
return Ok(None);
|
||||
}
|
||||
let mut users = UserLookup::default();
|
||||
let hidden = self
|
||||
.hidden_message_ids(std::slice::from_ref(&message), &options, &mut users)
|
||||
.await;
|
||||
if !hidden.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let context = self
|
||||
.build_response_context(std::slice::from_ref(&message), &options, true)
|
||||
.build_response_context(std::slice::from_ref(&message), &options, true, users)
|
||||
.await?;
|
||||
Ok(Some(
|
||||
self.map_message_response(&message, &options, &context, true),
|
||||
@@ -697,7 +917,12 @@ impl<T: Transport> MessagesShard<T> {
|
||||
return Ok(None);
|
||||
}
|
||||
let context = self
|
||||
.build_response_context(std::slice::from_ref(&message), &options, true)
|
||||
.build_response_context(
|
||||
std::slice::from_ref(&message),
|
||||
&options,
|
||||
true,
|
||||
UserLookup::default(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(
|
||||
self.map_message_response(&message, &options, &context, true),
|
||||
@@ -714,8 +939,16 @@ impl<T: Transport> MessagesShard<T> {
|
||||
.filter(|message| self.is_message_visible_to_requester(message.message_id, &options))
|
||||
.partition(|message| message.author_id.is_some() || message.webhook_id.is_some());
|
||||
self.cleanup_orphaned_messages(orphaned_messages).await;
|
||||
let mut users = UserLookup::default();
|
||||
let hidden = self
|
||||
.hidden_message_ids(&messages, &options, &mut users)
|
||||
.await;
|
||||
let messages = messages
|
||||
.into_iter()
|
||||
.filter(|message| !hidden.contains(&message.message_id))
|
||||
.collect::<Vec<_>>();
|
||||
let context = self
|
||||
.build_response_context(&messages, &options, true)
|
||||
.build_response_context(&messages, &options, true, users)
|
||||
.await?;
|
||||
Ok(messages
|
||||
.iter()
|
||||
@@ -723,6 +956,53 @@ impl<T: Transport> MessagesShard<T> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn hidden_message_ids(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
options: &ResponseBuildOptions,
|
||||
users: &mut UserLookup,
|
||||
) -> HashSet<i64> {
|
||||
if options.include_hidden || messages.is_empty() {
|
||||
return HashSet::new();
|
||||
}
|
||||
let copies = messages
|
||||
.iter()
|
||||
.filter_map(|message| copy_source(message).map(|source| (message.message_id, source)))
|
||||
.collect::<Vec<_>>();
|
||||
let sources = stream::iter(copies)
|
||||
.map(|(message_id, (channel_id, source_id))| async move {
|
||||
match self.get_by_id(channel_id, source_id).await {
|
||||
Ok(Some(source)) => Some((message_id, (source.author_id, source.message_id))),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.buffer_unordered(ENRICHMENT_QUERY_CONCURRENCY)
|
||||
.filter_map(|source| async move { source })
|
||||
.collect::<HashMap<_, _>>()
|
||||
.await;
|
||||
let user_ids = messages
|
||||
.iter()
|
||||
.flat_map(|message| {
|
||||
message
|
||||
.author_id
|
||||
.into_iter()
|
||||
.chain(message.mention_users.iter().copied())
|
||||
})
|
||||
.chain(sources.values().filter_map(|(author_id, _)| *author_id))
|
||||
.collect::<HashSet<_>>();
|
||||
self.load_user_partials(user_ids, users).await;
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
users.hides(message.author_id, message.message_id)
|
||||
|| sources
|
||||
.get(&message.message_id)
|
||||
.is_some_and(|(author_id, source_id)| users.hides(*author_id, *source_id))
|
||||
})
|
||||
.map(|message| message.message_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_message_visible_to_requester(
|
||||
&self,
|
||||
message_id: i64,
|
||||
@@ -773,8 +1053,9 @@ impl<T: Transport> MessagesShard<T> {
|
||||
messages: &[Message],
|
||||
options: &ResponseBuildOptions,
|
||||
include_referenced_messages: bool,
|
||||
mut users: UserLookup,
|
||||
) -> anyhow::Result<ResponseContext> {
|
||||
let referenced_messages = if include_referenced_messages {
|
||||
let mut referenced_messages = if include_referenced_messages {
|
||||
self.fetch_referenced_messages(messages, options).await
|
||||
} else {
|
||||
HashMap::new()
|
||||
@@ -790,14 +1071,23 @@ impl<T: Transport> MessagesShard<T> {
|
||||
let reactions_future = self.fetch_reactions_for_messages(messages, options);
|
||||
let attachment_decay_future = self.fetch_attachment_decay(attachment_ids);
|
||||
let channel_mentions_future = self.resolve_channel_mentions(channel_ids, options);
|
||||
let users_future = self.fetch_user_partials(user_ids);
|
||||
let (reactions, attachment_decay, channel_mentions, users) = tokio::join!(
|
||||
let users_future = self.load_user_partials(user_ids, &mut users);
|
||||
let (reactions, attachment_decay, channel_mentions, ()) = tokio::join!(
|
||||
reactions_future,
|
||||
attachment_decay_future,
|
||||
channel_mentions_future,
|
||||
users_future
|
||||
);
|
||||
let attachment_decay = attachment_decay?;
|
||||
if !options.include_hidden {
|
||||
referenced_messages
|
||||
.retain(|_, referenced| !users.hides(referenced.author_id, referenced.message_id));
|
||||
}
|
||||
let users = users
|
||||
.partials
|
||||
.into_values()
|
||||
.map(|partial| (partial.user_id, map_user_partial(partial)))
|
||||
.collect();
|
||||
Ok(ResponseContext {
|
||||
users,
|
||||
reactions,
|
||||
@@ -938,19 +1228,18 @@ impl<T: Transport> MessagesShard<T> {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_user_partials(
|
||||
&self,
|
||||
user_ids: HashSet<i64>,
|
||||
) -> HashMap<i64, ApiUserPartialResponse> {
|
||||
if user_ids.is_empty() {
|
||||
return HashMap::new();
|
||||
async fn load_user_partials(&self, user_ids: HashSet<i64>, users: &mut UserLookup) {
|
||||
let mut missing = user_ids
|
||||
.into_iter()
|
||||
.filter(|user_id| users.requested.insert(*user_id))
|
||||
.collect::<Vec<_>>();
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut user_ids: Vec<i64> = user_ids.into_iter().collect();
|
||||
user_ids.sort_unstable();
|
||||
user_ids.dedup();
|
||||
missing.sort_unstable();
|
||||
let payload = serde_json::json!({
|
||||
"op": "GetPartialsByIds",
|
||||
"user_ids": user_ids,
|
||||
"user_ids": missing,
|
||||
});
|
||||
let payload_bytes = serde_json::to_vec(&payload).unwrap_or_default();
|
||||
let response = self
|
||||
@@ -968,13 +1257,11 @@ impl<T: Transport> MessagesShard<T> {
|
||||
Some(UserServiceResponse::FoundPartial(partial)) => vec![partial],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
partials
|
||||
.into_iter()
|
||||
.map(|partial| {
|
||||
let id = partial.user_id;
|
||||
(id, map_user_partial(partial))
|
||||
})
|
||||
.collect()
|
||||
users.partials.extend(
|
||||
partials
|
||||
.into_iter()
|
||||
.map(|partial| (partial.user_id, partial)),
|
||||
);
|
||||
}
|
||||
|
||||
async fn resolve_channel_mentions(
|
||||
@@ -1497,6 +1784,13 @@ impl MessagesStorage {
|
||||
}
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Deletions(_) => Ok(None),
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Memory(messages) => Ok(messages
|
||||
.iter()
|
||||
.find(|message| {
|
||||
message.channel_id == channel_id && message.message_id == message_id
|
||||
})
|
||||
.cloned()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1521,6 +1815,10 @@ impl MessagesStorage {
|
||||
}
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Deletions(_) => Ok(Vec::new()),
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Memory(messages) => Ok(memory_buckets(
|
||||
messages, channel_id, min_bucket, max_bucket, limit, true,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1545,6 +1843,10 @@ impl MessagesStorage {
|
||||
}
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Deletions(_) => Ok(Vec::new()),
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Memory(messages) => Ok(memory_buckets(
|
||||
messages, channel_id, min_bucket, max_bucket, limit, false,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1566,6 +1868,10 @@ impl MessagesStorage {
|
||||
}
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Deletions(_) => Ok(Vec::new()),
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Memory(messages) => {
|
||||
Ok(memory_bucket(messages, channel_id, bucket, None, limit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1596,6 +1902,14 @@ impl MessagesStorage {
|
||||
}
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Deletions(_) => Ok(Vec::new()),
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Memory(messages) => Ok(memory_bucket(
|
||||
messages,
|
||||
channel_id,
|
||||
bucket,
|
||||
Some(BucketBound::Before(before_id)),
|
||||
limit,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1626,6 +1940,14 @@ impl MessagesStorage {
|
||||
}
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Deletions(_) => Ok(Vec::new()),
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Memory(messages) => Ok(memory_bucket(
|
||||
messages,
|
||||
channel_id,
|
||||
bucket,
|
||||
Some(BucketBound::After(after_id)),
|
||||
limit,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1651,6 +1973,8 @@ impl MessagesStorage {
|
||||
.push((channel_id, bucket, message_id));
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Memory(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1684,7 +2008,7 @@ impl MessagesStorage {
|
||||
.await
|
||||
}
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Deletions(_) => HashMap::new(),
|
||||
MessagesStorage::Deletions(_) | MessagesStorage::Memory(_) => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1701,7 +2025,7 @@ impl MessagesStorage {
|
||||
storage.fetch_attachment_decay_batch(attachment_ids).await
|
||||
}
|
||||
#[cfg(test)]
|
||||
MessagesStorage::Deletions(_) => Ok(HashMap::new()),
|
||||
MessagesStorage::Deletions(_) | MessagesStorage::Memory(_) => Ok(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2174,6 +2498,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
include_reactions,
|
||||
nonce,
|
||||
tts,
|
||||
include_hidden,
|
||||
} => {
|
||||
let channel_id = parse_i64(&channel_id, "channel_id")?;
|
||||
let message_id = parse_i64(&message_id, "message_id")?;
|
||||
@@ -2199,6 +2524,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
include_reactions: include_reactions.unwrap_or(true),
|
||||
nonce,
|
||||
tts: tts.unwrap_or(false),
|
||||
include_hidden,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -2219,6 +2545,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
include_reactions,
|
||||
nonce,
|
||||
tts,
|
||||
include_hidden,
|
||||
} => {
|
||||
let viewer_user_id = parse_i64(&viewer_user_id, "viewer_user_id")?;
|
||||
let source_guild_id = source_guild_id
|
||||
@@ -2241,6 +2568,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
include_reactions: include_reactions.unwrap_or(true),
|
||||
nonce,
|
||||
tts: tts.unwrap_or(false),
|
||||
include_hidden,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -2259,6 +2587,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
media_proxy_secret_key,
|
||||
attachment_url_secret_base64,
|
||||
include_reactions,
|
||||
include_hidden,
|
||||
} => {
|
||||
let viewer_user_id = parse_i64(&viewer_user_id, "viewer_user_id")?;
|
||||
let source_guild_id = source_guild_id
|
||||
@@ -2281,6 +2610,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
include_reactions: include_reactions.unwrap_or(true),
|
||||
nonce: None,
|
||||
tts: false,
|
||||
include_hidden,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -2300,6 +2630,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
media_proxy_secret_key,
|
||||
attachment_url_secret_base64,
|
||||
include_reactions,
|
||||
include_hidden,
|
||||
} => {
|
||||
let channel_id = parse_i64(&channel_id, "channel_id")?;
|
||||
let viewer_user_id = parse_i64(&viewer_user_id, "viewer_user_id")?;
|
||||
@@ -2339,6 +2670,7 @@ impl<T: Transport> ShardService for MessagesShard<T> {
|
||||
include_reactions: include_reactions.unwrap_or(true),
|
||||
nonce: None,
|
||||
tts: false,
|
||||
include_hidden,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -3265,7 +3597,9 @@ impl From<MessageDbRow> for Message {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fluxer_svc::transport::{InMemoryTransport, TransportSubscriber, reply_message};
|
||||
use fluxer_svc::transport::{
|
||||
InMemoryTransport, TransportMessage, TransportSubscriber, reply_message,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -3281,6 +3615,7 @@ mod tests {
|
||||
flags: Some(USER_FLAG_DELETED),
|
||||
avatar_color: None,
|
||||
mention_flags: None,
|
||||
content_hidden_since: None,
|
||||
});
|
||||
|
||||
assert_eq!(mapped.id, "0");
|
||||
@@ -3330,6 +3665,7 @@ mod tests {
|
||||
flags: Some(USER_FLAG_DELETED),
|
||||
avatar_color: Some(0x336699),
|
||||
mention_flags: None,
|
||||
content_hidden_since: None,
|
||||
});
|
||||
|
||||
assert_eq!(mapped.id, "42");
|
||||
@@ -3773,6 +4109,7 @@ mod tests {
|
||||
include_reactions: false,
|
||||
nonce: None,
|
||||
tts: false,
|
||||
include_hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4864,4 +5201,260 @@ mod tests {
|
||||
|
||||
assert!(mapped.is_none());
|
||||
}
|
||||
|
||||
const HIDDEN_AUTHOR: i64 = 1_472_426_752_046_002_301;
|
||||
const OTHER_AUTHOR: i64 = 1_472_426_752_046_002_302;
|
||||
const CHANNEL: i64 = 10;
|
||||
const WINDOW_MS: i64 = 1_790_000_000_000;
|
||||
|
||||
fn snowflake_at(epoch_millis: i64, sequence: i64) -> i64 {
|
||||
((epoch_millis - FLUXER_EPOCH_MS) << 22) | sequence
|
||||
}
|
||||
|
||||
fn message_by(author_id: i64, message_id: i64, extra: serde_json::Value) -> Message {
|
||||
let mut row = json!({
|
||||
"channel_id": {"__fluxer_type": "bigint", "value": CHANNEL.to_string()},
|
||||
"bucket": snowflake_to_bucket(message_id),
|
||||
"message_id": {"__fluxer_type": "bigint", "value": message_id.to_string()},
|
||||
"author_id": {"__fluxer_type": "bigint", "value": author_id.to_string()},
|
||||
"content": format!("message {message_id}"),
|
||||
});
|
||||
row.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(extra.as_object().unwrap().clone());
|
||||
decode_postgres_message(row).unwrap()
|
||||
}
|
||||
|
||||
async fn user_service(
|
||||
transport: &InMemoryTransport,
|
||||
hidden_since: Option<i64>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let mut subscriber = transport.subscribe("svc.users").await.unwrap();
|
||||
let transport = transport.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(message) = subscriber.next().await {
|
||||
let request: serde_json::Value = serde_json::from_slice(message.payload()).unwrap();
|
||||
let partials = request["user_ids"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let id = id.as_i64().unwrap();
|
||||
json!({
|
||||
"user_id": id,
|
||||
"username": format!("user{id}"),
|
||||
"discriminator": 1,
|
||||
"content_hidden_since": (id == HIDDEN_AUTHOR).then_some(hidden_since).flatten(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let reply = serde_json::to_vec(&json!({"FoundPartials": partials})).unwrap();
|
||||
let _ = reply_message(&message, &transport, &reply).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn memory_shard(messages: Vec<Message>) -> MessagesShard<InMemoryTransport> {
|
||||
MessagesShard {
|
||||
storage: MessagesStorage::Memory(std::sync::Arc::new(messages)),
|
||||
transport: InMemoryTransport::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_history() -> (Vec<Message>, Vec<i64>, Vec<i64>) {
|
||||
let mut messages = Vec::new();
|
||||
let mut hidden = Vec::new();
|
||||
let mut visible = Vec::new();
|
||||
for offset in 0..3 {
|
||||
let id = snowflake_at(WINDOW_MS - 60_000 + offset, 0);
|
||||
messages.push(message_by(HIDDEN_AUTHOR, id, json!({})));
|
||||
visible.push(id);
|
||||
}
|
||||
for offset in 0..2 {
|
||||
let id = snowflake_at(WINDOW_MS - 30_000 + offset, 0);
|
||||
messages.push(message_by(OTHER_AUTHOR, id, json!({})));
|
||||
visible.push(id);
|
||||
}
|
||||
hidden.push(snowflake_at(WINDOW_MS, 0));
|
||||
messages.push(message_by(HIDDEN_AUTHOR, hidden[0], json!({})));
|
||||
for offset in 1..7 {
|
||||
let id = snowflake_at(WINDOW_MS + offset * 1_000, 0);
|
||||
messages.push(message_by(HIDDEN_AUTHOR, id, json!({})));
|
||||
hidden.push(id);
|
||||
}
|
||||
visible.sort_unstable_by_key(|id| std::cmp::Reverse(*id));
|
||||
(messages, hidden, visible)
|
||||
}
|
||||
|
||||
fn ids(responses: &[ApiMessageResponse]) -> Vec<i64> {
|
||||
responses
|
||||
.iter()
|
||||
.map(|response| response.id.parse().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hidden_window_drops_messages_from_every_list_shape_and_refills_the_page() {
|
||||
let (messages, hidden, visible) = channel_history();
|
||||
let shard = memory_shard(messages);
|
||||
let users = user_service(&shard.transport, Some(WINDOW_MS)).await;
|
||||
|
||||
let latest = shard
|
||||
.list_api_responses(CHANNEL, 3, None, None, None, build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ids(&latest), visible[..3].to_vec());
|
||||
|
||||
let before = shard
|
||||
.list_api_responses(CHANNEL, 50, Some(hidden[6]), None, None, build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ids(&before), visible);
|
||||
|
||||
let after = shard
|
||||
.list_api_responses(CHANNEL, 50, None, Some(visible[1]), None, build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ids(&after), vec![visible[0]]);
|
||||
|
||||
let around = shard
|
||||
.list_api_responses(CHANNEL, 4, None, None, Some(hidden[0]), build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ids(&around).iter().all(|id| visible.contains(id)));
|
||||
assert!(!around.is_empty());
|
||||
|
||||
let mut staff_view = build_options();
|
||||
staff_view.include_hidden = true;
|
||||
let all = shard
|
||||
.list_api_responses(CHANNEL, 50, None, None, None, staff_view)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(all.len(), visible.len() + hidden.len());
|
||||
users.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_the_window_restores_every_message() {
|
||||
let (messages, hidden, visible) = channel_history();
|
||||
let shard = memory_shard(messages);
|
||||
let users = user_service(&shard.transport, None).await;
|
||||
|
||||
let latest = shard
|
||||
.list_api_responses(CHANNEL, 50, None, None, None, build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(latest.len(), visible.len() + hidden.len());
|
||||
let single = shard
|
||||
.get_api_response(CHANNEL, hidden[0], build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(single.unwrap().id, hidden[0].to_string());
|
||||
users.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_fetch_hides_messages_inside_the_window_only() {
|
||||
let (messages, hidden, visible) = channel_history();
|
||||
let shard = memory_shard(messages);
|
||||
let users = user_service(&shard.transport, Some(WINDOW_MS)).await;
|
||||
|
||||
for id in &hidden {
|
||||
let response = shard
|
||||
.get_api_response(CHANNEL, *id, build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.is_none(), "{id}");
|
||||
}
|
||||
let earlier = visible.last().copied().unwrap();
|
||||
let response = shard
|
||||
.get_api_response(CHANNEL, earlier, build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.unwrap().author.id, HIDDEN_AUTHOR.to_string());
|
||||
|
||||
let mut staff_view = build_options();
|
||||
staff_view.include_hidden = true;
|
||||
assert!(
|
||||
shard
|
||||
.get_api_response(CHANNEL, hidden[0], staff_view)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
users.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_pins_and_saved_builds_drop_hidden_messages() {
|
||||
let (messages, hidden, visible) = channel_history();
|
||||
let shard = memory_shard(messages.clone());
|
||||
let users = user_service(&shard.transport, Some(WINDOW_MS)).await;
|
||||
|
||||
let built = shard
|
||||
.build_api_responses_from_messages(messages.clone(), build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut built_ids = ids(&built);
|
||||
built_ids.sort_unstable_by_key(|id| std::cmp::Reverse(*id));
|
||||
assert_eq!(built_ids, visible);
|
||||
assert!(built_ids.iter().all(|id| !hidden.contains(id)));
|
||||
users.abort();
|
||||
|
||||
let shard = memory_shard(messages.clone());
|
||||
let users = user_service(&shard.transport, None).await;
|
||||
let restored = shard
|
||||
.build_api_responses_from_messages(messages, build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(restored.len(), visible.len() + hidden.len());
|
||||
users.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replies_lose_their_preview_and_forwards_vanish_when_the_source_is_hidden() {
|
||||
let (mut messages, hidden, _) = channel_history();
|
||||
let reply_id = snowflake_at(WINDOW_MS + 20_000, 0);
|
||||
let forward_id = snowflake_at(WINDOW_MS + 21_000, 0);
|
||||
let reference = |kind: i32| {
|
||||
json!({"message_reference": {
|
||||
"channel_id": {"__fluxer_type": "bigint", "value": CHANNEL.to_string()},
|
||||
"message_id": {"__fluxer_type": "bigint", "value": hidden[1].to_string()},
|
||||
"type": kind,
|
||||
}})
|
||||
};
|
||||
messages.push(message_by(OTHER_AUTHOR, reply_id, reference(0)));
|
||||
let mut forward = reference(1);
|
||||
forward["message_snapshots"] = json!([{"content": "copied", "type": 0}]);
|
||||
forward["content"] = json!("");
|
||||
messages.push(message_by(OTHER_AUTHOR, forward_id, forward));
|
||||
let shard = memory_shard(messages);
|
||||
let users = user_service(&shard.transport, Some(WINDOW_MS)).await;
|
||||
|
||||
let reply = shard
|
||||
.get_api_response(CHANNEL, reply_id, build_options())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let reply = serde_json::to_value(&reply).unwrap();
|
||||
assert!(reply["referenced_message"].is_null());
|
||||
assert_eq!(
|
||||
reply["message_reference"]["message_id"],
|
||||
hidden[1].to_string()
|
||||
);
|
||||
assert!(
|
||||
shard
|
||||
.get_api_response(CHANNEL, forward_id, build_options())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let latest = shard
|
||||
.list_api_responses(CHANNEL, 2, None, None, None, build_options())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ids(&latest)[0], reply_id);
|
||||
assert!(!ids(&latest).contains(&forward_id));
|
||||
users.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ pub enum MessageRequest {
|
||||
include_reactions: Option<bool>,
|
||||
nonce: Option<String>,
|
||||
tts: Option<bool>,
|
||||
#[serde(default)]
|
||||
include_hidden: bool,
|
||||
},
|
||||
BuildResponse {
|
||||
message: Message,
|
||||
@@ -117,6 +119,8 @@ pub enum MessageRequest {
|
||||
include_reactions: Option<bool>,
|
||||
nonce: Option<String>,
|
||||
tts: Option<bool>,
|
||||
#[serde(default)]
|
||||
include_hidden: bool,
|
||||
},
|
||||
BuildResponses {
|
||||
messages: Vec<Message>,
|
||||
@@ -129,6 +133,8 @@ pub enum MessageRequest {
|
||||
#[serde(default)]
|
||||
attachment_url_secret_base64: Option<String>,
|
||||
include_reactions: Option<bool>,
|
||||
#[serde(default)]
|
||||
include_hidden: bool,
|
||||
},
|
||||
ListResponses {
|
||||
channel_id: String,
|
||||
@@ -145,6 +151,8 @@ pub enum MessageRequest {
|
||||
#[serde(default)]
|
||||
attachment_url_secret_base64: Option<String>,
|
||||
include_reactions: Option<bool>,
|
||||
#[serde(default)]
|
||||
include_hidden: bool,
|
||||
},
|
||||
ExtractMentions {
|
||||
contents: Vec<String>,
|
||||
|
||||
+234
-10
@@ -1,8 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::types::{ApiUserPartial, User, UserPartial, UserRequest, UserResponse};
|
||||
use crate::types::{ApiUserPartial, User, UserPartial, UserRequest, UserResponse, now_ms};
|
||||
#[cfg(feature = "scylla")]
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use fluxer_common::user_flags::AccountStanding;
|
||||
use fluxer_svc::shard::ShardService;
|
||||
use fluxer_svc::{postgres, postgres::KeyPart};
|
||||
use futures::stream::{self, StreamExt};
|
||||
@@ -54,13 +55,14 @@ const FULL_USER_COLUMNS: &str = "\
|
||||
first_refund_at, version, \
|
||||
premium_grace_ends_at, mention_flags, \
|
||||
last_voice_activity_sharing_change_at, \
|
||||
timezone, timezone_privacy_flags";
|
||||
timezone, timezone_privacy_flags, content_hidden_since";
|
||||
#[cfg(feature = "scylla")]
|
||||
const PARTIAL_USER_COLUMNS: &str = "\
|
||||
user_id, username, discriminator, global_name, \
|
||||
avatar_hash, bot, system, flags, \
|
||||
banner_hash, banner_color, accent_color, avatar_color, \
|
||||
mention_flags";
|
||||
mention_flags, temp_banned_until, pending_deletion_at, deletion_reason_code, \
|
||||
content_hidden_since";
|
||||
const USER_BATCH_SIZE: usize = 128;
|
||||
const USER_BATCH_CONCURRENCY: usize = 8;
|
||||
const USER_CACHE_MIN_GENERATION_STRIPES: usize = 4096;
|
||||
@@ -167,10 +169,10 @@ struct FullUserDbRow {
|
||||
last_voice_activity_sharing_change_at: OptionalTimestamp,
|
||||
timezone: Option<String>,
|
||||
timezone_privacy_flags: Option<i32>,
|
||||
content_hidden_since: OptionalTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "scylla", derive(DeserializeRow))]
|
||||
struct PartialUserDbRow {
|
||||
user_id: i64,
|
||||
username: String,
|
||||
@@ -185,6 +187,61 @@ struct PartialUserDbRow {
|
||||
accent_color: Option<i32>,
|
||||
avatar_color: Option<i32>,
|
||||
mention_flags: Option<i32>,
|
||||
#[serde(default)]
|
||||
temp_banned_until: Option<i64>,
|
||||
#[serde(default)]
|
||||
pending_deletion_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
deletion_reason_code: Option<i32>,
|
||||
#[serde(default)]
|
||||
content_hidden_since: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "scylla")]
|
||||
#[derive(Debug, DeserializeRow)]
|
||||
struct PartialUserScyllaRow {
|
||||
user_id: i64,
|
||||
username: String,
|
||||
discriminator: i32,
|
||||
global_name: Option<String>,
|
||||
avatar_hash: Option<String>,
|
||||
bot: Option<bool>,
|
||||
system: Option<bool>,
|
||||
flags: Option<i64>,
|
||||
banner_hash: Option<String>,
|
||||
banner_color: Option<i32>,
|
||||
accent_color: Option<i32>,
|
||||
avatar_color: Option<i32>,
|
||||
mention_flags: Option<i32>,
|
||||
temp_banned_until: OptionalTimestamp,
|
||||
pending_deletion_at: OptionalTimestamp,
|
||||
deletion_reason_code: Option<i32>,
|
||||
content_hidden_since: OptionalTimestamp,
|
||||
}
|
||||
|
||||
#[cfg(feature = "scylla")]
|
||||
impl From<PartialUserScyllaRow> for PartialUserDbRow {
|
||||
fn from(row: PartialUserScyllaRow) -> Self {
|
||||
Self {
|
||||
user_id: row.user_id,
|
||||
username: row.username,
|
||||
discriminator: row.discriminator,
|
||||
global_name: row.global_name,
|
||||
avatar_hash: row.avatar_hash,
|
||||
bot: row.bot,
|
||||
system: row.system,
|
||||
flags: row.flags,
|
||||
banner_hash: row.banner_hash,
|
||||
banner_color: row.banner_color,
|
||||
accent_color: row.accent_color,
|
||||
avatar_color: row.avatar_color,
|
||||
mention_flags: row.mention_flags,
|
||||
temp_banned_until: optional_timestamp_millis(row.temp_banned_until),
|
||||
pending_deletion_at: optional_timestamp_millis(row.pending_deletion_at),
|
||||
deletion_reason_code: row.deletion_reason_code,
|
||||
content_hidden_since: optional_timestamp_millis(row.content_hidden_since),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -245,6 +302,7 @@ struct FullUserKvRow {
|
||||
last_voice_activity_sharing_change_at: Option<i64>,
|
||||
timezone: Option<String>,
|
||||
timezone_privacy_flags: Option<i32>,
|
||||
content_hidden_since: Option<i64>,
|
||||
}
|
||||
|
||||
fn generation_stripes(max_entries: u64) -> usize {
|
||||
@@ -589,7 +647,9 @@ impl ScyllaUsersStorage {
|
||||
.execute_unpaged(&self.stmt_partial, (user_id,))
|
||||
.await?;
|
||||
let rows = result.into_rows_result()?;
|
||||
let partial = rows.maybe_first_row::<PartialUserDbRow>()?.map(Into::into);
|
||||
let partial = rows
|
||||
.maybe_first_row::<PartialUserScyllaRow>()?
|
||||
.map(|row| UserPartial::from(PartialUserDbRow::from(row)));
|
||||
Ok(partial)
|
||||
}
|
||||
|
||||
@@ -599,9 +659,13 @@ impl ScyllaUsersStorage {
|
||||
.execute_unpaged(&self.stmt_partial_batch, (user_ids,))
|
||||
.await?;
|
||||
let rows = result.into_rows_result()?;
|
||||
let rows: Vec<PartialUserDbRow> =
|
||||
rows.rows::<PartialUserDbRow>()?.collect::<Result<_, _>>()?;
|
||||
Ok(rows.into_iter().map(UserPartial::from).collect::<Vec<_>>())
|
||||
let rows: Vec<PartialUserScyllaRow> = rows
|
||||
.rows::<PartialUserScyllaRow>()?
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| UserPartial::from(PartialUserDbRow::from(row)))
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,6 +739,7 @@ fn fluxer_system_user() -> User {
|
||||
last_voice_activity_sharing_change_at: None,
|
||||
timezone: None,
|
||||
timezone_privacy_flags: None,
|
||||
content_hidden_since: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,6 +824,12 @@ fn optional_date_string(value: OptionalDate) -> Option<String> {
|
||||
|
||||
impl From<PartialUserDbRow> for UserPartial {
|
||||
fn from(row: PartialUserDbRow) -> Self {
|
||||
let standing = AccountStanding {
|
||||
flags: row.flags.unwrap_or_default(),
|
||||
temp_banned_until_ms: row.temp_banned_until,
|
||||
pending_deletion_at_ms: row.pending_deletion_at,
|
||||
deletion_reason_code: row.deletion_reason_code,
|
||||
};
|
||||
Self {
|
||||
user_id: row.user_id,
|
||||
username: row.username,
|
||||
@@ -773,7 +844,9 @@ impl From<PartialUserDbRow> for UserPartial {
|
||||
accent_color: row.accent_color,
|
||||
avatar_color: row.avatar_color,
|
||||
mention_flags: row.mention_flags,
|
||||
content_hidden_since: row.content_hidden_since,
|
||||
}
|
||||
.visible_to_others(&standing, now_ms())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,6 +929,7 @@ impl From<FullUserDbRow> for User {
|
||||
),
|
||||
timezone: row.timezone,
|
||||
timezone_privacy_flags: row.timezone_privacy_flags,
|
||||
content_hidden_since: optional_timestamp_millis(row.content_hidden_since),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -921,6 +995,7 @@ impl From<FullUserKvRow> for User {
|
||||
last_voice_activity_sharing_change_at: row.last_voice_activity_sharing_change_at,
|
||||
timezone: row.timezone,
|
||||
timezone_privacy_flags: row.timezone_privacy_flags,
|
||||
content_hidden_since: row.content_hidden_since,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1087,13 +1162,20 @@ mod tests {
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let partial = serde_json::to_value(test_user(42).to_partial()).unwrap();
|
||||
let fields = partial
|
||||
let mut user = test_user(42);
|
||||
user.content_hidden_since = Some(1);
|
||||
let partial = serde_json::to_value(user.to_partial()).unwrap();
|
||||
let mut fields = partial
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<BTreeSet<_>>();
|
||||
fields.extend([
|
||||
"temp_banned_until",
|
||||
"pending_deletion_at",
|
||||
"deletion_reason_code",
|
||||
]);
|
||||
let full_columns = FULL_USER_COLUMNS
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
@@ -1146,4 +1228,146 @@ mod tests {
|
||||
assert_eq!(user.premium_since, Some(1_781_526_896_789));
|
||||
assert_eq!(user.version, 3);
|
||||
}
|
||||
|
||||
const SPAMMER: i64 = 1 << 6;
|
||||
const DISABLED: i64 = 1 << 38;
|
||||
const PROFILE_HIDDEN: i64 = 1 << 7;
|
||||
|
||||
fn styled(user_id: i64, flags: i64) -> User {
|
||||
let mut user = test_user(user_id);
|
||||
user.flags = Some(flags);
|
||||
user.banner_hash = Some("banner_hash".to_owned());
|
||||
user.banner_color = Some(3);
|
||||
user.accent_color = Some(5);
|
||||
user.avatar_color = Some(9);
|
||||
user
|
||||
}
|
||||
|
||||
fn assert_hidden(partial: &UserPartial) {
|
||||
assert_eq!(partial.username, "HiddenUser");
|
||||
assert_eq!(partial.discriminator, 0);
|
||||
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.as_str(), api.discriminator.as_str()),
|
||||
("HiddenUser", "0000")
|
||||
);
|
||||
assert_ne!(api.flags & PROFILE_HIDDEN as i32, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_and_enforced_accounts_get_a_neutral_partial_and_keep_their_stored_profile() {
|
||||
let now = now_ms();
|
||||
let mut banned = styled(43, DISABLED);
|
||||
banned.temp_banned_until = Some(now + 60_000);
|
||||
let mut deleting = styled(44, 0);
|
||||
deleting.pending_deletion_at = Some(now + 60_000);
|
||||
deleting.deletion_reason_code = Some(20);
|
||||
for user in [
|
||||
styled(41, PROFILE_HIDDEN),
|
||||
styled(42, SPAMMER),
|
||||
banned,
|
||||
deleting,
|
||||
] {
|
||||
assert_hidden(&user.to_partial());
|
||||
assert_eq!(user.username, "Ada");
|
||||
assert_eq!(user.avatar_hash.as_deref(), Some("avatar_hash"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifted_bans_self_disables_and_normal_accounts_keep_their_partial() {
|
||||
let now = now_ms();
|
||||
let mut expired = styled(45, DISABLED);
|
||||
expired.temp_banned_until = Some(now - 60_000);
|
||||
let mut self_deleting = styled(46, 0);
|
||||
self_deleting.pending_deletion_at = Some(now + 60_000);
|
||||
self_deleting.deletion_reason_code = Some(1);
|
||||
for user in [styled(47, 0), styled(48, DISABLED), expired, self_deleting] {
|
||||
let partial = user.to_partial();
|
||||
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.flags.unwrap_or_default() & PROFILE_HIDDEN, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_partial_rows_apply_the_mask_from_ban_state() {
|
||||
let until = |ms: i64| {
|
||||
let at = chrono::DateTime::from_timestamp_millis(ms).unwrap();
|
||||
json!({"__fluxer_type": "date", "value": at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)})
|
||||
};
|
||||
let row = |ms: i64| {
|
||||
json!({
|
||||
"user_id": {"__fluxer_type": "bigint", "value": "42"},
|
||||
"username": "ada",
|
||||
"discriminator": 7,
|
||||
"global_name": "Ada",
|
||||
"avatar_hash": "avatar_hash",
|
||||
"flags": {"__fluxer_type": "bigint", "value": DISABLED.to_string()},
|
||||
"temp_banned_until": until(ms),
|
||||
})
|
||||
};
|
||||
let now = now_ms();
|
||||
assert_hidden(&decode_postgres_user_partial(row(now + 60_000)).unwrap());
|
||||
let lifted = decode_postgres_user_partial(row(now - 60_000)).unwrap();
|
||||
assert_eq!(lifted.username, "ada");
|
||||
assert_eq!(lifted.avatar_hash.as_deref(), Some("avatar_hash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_message_hide_window_reaches_the_partial_independently_of_the_profile_mask() {
|
||||
let since = 1_790_000_000_000;
|
||||
let mut hidden = styled(49, 0);
|
||||
hidden.content_hidden_since = Some(since);
|
||||
let partial = hidden.to_partial();
|
||||
assert_eq!(partial.content_hidden_since, Some(since));
|
||||
assert_eq!(partial.username, "Ada");
|
||||
let mut masked = styled(50, PROFILE_HIDDEN);
|
||||
masked.content_hidden_since = Some(since);
|
||||
let partial = masked.to_partial();
|
||||
assert_hidden(&partial);
|
||||
assert_eq!(partial.content_hidden_since, Some(since));
|
||||
assert_eq!(
|
||||
styled(51, PROFILE_HIDDEN).to_partial().content_hidden_since,
|
||||
None
|
||||
);
|
||||
let wire = serde_json::to_value(styled(52, 0).to_partial()).unwrap();
|
||||
assert!(wire.get("content_hidden_since").is_none());
|
||||
assert!(
|
||||
serde_json::to_value(hidden.to_partial().to_api_partial())
|
||||
.unwrap()
|
||||
.get("content_hidden_since")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_partial_rows_read_the_message_hide_window() {
|
||||
let since = 1_790_000_000_000_i64;
|
||||
let at = chrono::DateTime::from_timestamp_millis(since).unwrap();
|
||||
let row = json!({
|
||||
"user_id": {"__fluxer_type": "bigint", "value": "42"},
|
||||
"username": "ada",
|
||||
"discriminator": 7,
|
||||
"content_hidden_since": {"__fluxer_type": "date", "value": at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)},
|
||||
});
|
||||
let partial = decode_postgres_user_partial(row).unwrap();
|
||||
assert_eq!(partial.content_hidden_since, Some(since));
|
||||
assert_eq!(partial.username, "ada");
|
||||
let cleared = decode_postgres_user_partial(json!({
|
||||
"user_id": {"__fluxer_type": "bigint", "value": "42"},
|
||||
"username": "ada",
|
||||
"discriminator": 7,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(cleared.content_hidden_since, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_common::user_flags::{
|
||||
AccountStanding, USER_FLAG_PROFILE_HIDDEN, USER_FLAG_STAFF, visible_user_flags,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use fluxer_common::user_flags::{USER_FLAG_PARTNER, USER_FLAG_STAFF_HIDDEN};
|
||||
use fluxer_common::user_flags::{USER_FLAG_STAFF, visible_user_flags};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -99,6 +101,8 @@ pub struct User {
|
||||
pub last_voice_activity_sharing_change_at: Option<i64>,
|
||||
pub timezone: Option<String>,
|
||||
pub timezone_privacy_flags: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub content_hidden_since: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -116,6 +120,8 @@ pub struct UserPartial {
|
||||
pub accent_color: Option<i32>,
|
||||
pub avatar_color: Option<i32>,
|
||||
pub mention_flags: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content_hidden_since: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -139,8 +145,30 @@ 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)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn standing(&self) -> AccountStanding {
|
||||
AccountStanding {
|
||||
flags: self.flags.unwrap_or_default(),
|
||||
temp_banned_until_ms: self.temp_banned_until,
|
||||
pending_deletion_at_ms: self.pending_deletion_at,
|
||||
deletion_reason_code: self.deletion_reason_code,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_partial(&self) -> UserPartial {
|
||||
self.to_own_partial()
|
||||
.visible_to_others(&self.standing(), now_ms())
|
||||
}
|
||||
|
||||
fn to_own_partial(&self) -> UserPartial {
|
||||
UserPartial {
|
||||
user_id: self.user_id,
|
||||
username: self.username.clone(),
|
||||
@@ -155,11 +183,30 @@ impl User {
|
||||
accent_color: self.accent_color,
|
||||
avatar_color: self.avatar_color,
|
||||
mention_flags: self.mention_flags,
|
||||
content_hidden_since: self.content_hidden_since,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserPartial {
|
||||
pub fn visible_to_others(self, standing: &AccountStanding, now_ms: i64) -> UserPartial {
|
||||
if self.user_id == FLUXER_SYSTEM_USER_ID || !standing.profile_hidden(now_ms) {
|
||||
return self;
|
||||
}
|
||||
UserPartial {
|
||||
username: HIDDEN_USER_USERNAME.to_owned(),
|
||||
discriminator: 0,
|
||||
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,
|
||||
avatar_color: None,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_api_partial(&self) -> ApiUserPartial {
|
||||
if self.user_id == FLUXER_SYSTEM_USER_ID {
|
||||
return fluxer_system_user();
|
||||
@@ -233,6 +280,7 @@ mod tests {
|
||||
accent_color: None,
|
||||
avatar_color: Some(0x336699),
|
||||
mention_flags: Some(0),
|
||||
content_hidden_since: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +342,7 @@ mod tests {
|
||||
last_voice_activity_sharing_change_at: None,
|
||||
timezone: Some("Europe/London".to_owned()),
|
||||
timezone_privacy_flags: Some(1),
|
||||
content_hidden_since: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const UserFlags = {
|
||||
FRIENDLY_BOT: 1n << 4n,
|
||||
FRIENDLY_BOT_MANUAL_APPROVAL: 1n << 5n,
|
||||
SPAMMER: 1n << 6n,
|
||||
PROFILE_HIDDEN: 1n << 7n,
|
||||
DELETED: 1n << 34n,
|
||||
SELF_DELETED: 1n << 36n,
|
||||
DISABLED: 1n << 38n,
|
||||
@@ -79,6 +80,7 @@ export const UserFlagsDescriptions: Record<keyof typeof UserFlags, string> = {
|
||||
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 from other users',
|
||||
DELETED: 'User account has been deleted',
|
||||
SELF_DELETED: 'User account was self-deleted',
|
||||
DISABLED: 'User account is disabled',
|
||||
@@ -153,6 +155,8 @@ 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),
|
||||
@@ -160,6 +164,7 @@ 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',
|
||||
@@ -168,6 +173,7 @@ 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',
|
||||
|
||||
@@ -228,10 +228,20 @@ export const ListReportsQuery = z.object({
|
||||
|
||||
export type ListReportsQuery = z.infer<typeof ListReportsQuery>;
|
||||
|
||||
const ReportResolutionEnum = createNamedStringLiteralUnion(
|
||||
[
|
||||
['actioned', 'actioned', 'The report was valid and action was taken'],
|
||||
['no_violation', 'no_violation', 'The report was reviewed and no violation was found'],
|
||||
['duplicate', 'duplicate', 'The report repeats one that was already handled'],
|
||||
],
|
||||
'How the report was resolved',
|
||||
);
|
||||
|
||||
export const UpdateReportRequest = z.object({
|
||||
status: z.literal('resolved').describe('The status to move the report to'),
|
||||
public_comment: createStringType(0, 512).optional().describe('Public comment to include with the resolution'),
|
||||
notify_reporter: z.boolean().default(true).describe('Whether to notify the reporter by system DM and email'),
|
||||
resolution: ReportResolutionEnum.optional().describe('How the report was resolved'),
|
||||
});
|
||||
|
||||
export type UpdateReportRequest = z.infer<typeof UpdateReportRequest>;
|
||||
|
||||
@@ -8218,6 +8218,10 @@
|
||||
{
|
||||
"name": "timezone_privacy_flags",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "content_hidden_since",
|
||||
"type": "timestamp"
|
||||
}
|
||||
],
|
||||
"primary_key": "((user_id))",
|
||||
|
||||
Reference in New Issue
Block a user