refactor progress
This commit is contained in:
162
packages/api/src/report/IReportRepository.tsx
Normal file
162
packages/api/src/report/IReportRepository.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, MessageID, ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageAttachment, MessageEmbed, MessageStickerItem} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {DSAReportEmailVerificationRow, DSAReportTicketRow} from '@fluxer/api/src/database/types/ReportTypes';
|
||||
|
||||
export enum ReportStatus {
|
||||
PENDING = 0,
|
||||
RESOLVED = 1,
|
||||
}
|
||||
|
||||
export enum ReportType {
|
||||
MESSAGE = 0,
|
||||
USER = 1,
|
||||
GUILD = 2,
|
||||
}
|
||||
|
||||
const REPORT_STATUS_STRINGS: Record<ReportStatus, string> = {
|
||||
[ReportStatus.PENDING]: 'pending',
|
||||
[ReportStatus.RESOLVED]: 'resolved',
|
||||
};
|
||||
|
||||
export function reportStatusToString(status: ReportStatus | number): string {
|
||||
return REPORT_STATUS_STRINGS[status as ReportStatus] ?? 'unknown';
|
||||
}
|
||||
|
||||
type MentionCollection = ReadonlyArray<bigint> | Set<bigint> | null | undefined;
|
||||
|
||||
export interface IARMessageContextRow {
|
||||
message_id: bigint;
|
||||
channel_id: bigint | null;
|
||||
author_id: bigint;
|
||||
author_username: string;
|
||||
author_discriminator: number;
|
||||
author_avatar_hash: string | null;
|
||||
content: string | null;
|
||||
timestamp: Date;
|
||||
edited_timestamp: Date | null;
|
||||
type: number;
|
||||
flags: number;
|
||||
mention_everyone: boolean;
|
||||
mention_users: MentionCollection;
|
||||
mention_roles: MentionCollection;
|
||||
mention_channels: MentionCollection;
|
||||
attachments: Array<MessageAttachment> | null;
|
||||
embeds: Array<MessageEmbed> | null;
|
||||
sticker_items: Array<MessageStickerItem> | null;
|
||||
}
|
||||
|
||||
export interface IARMessageContext {
|
||||
messageId: MessageID;
|
||||
channelId: ChannelID | null;
|
||||
authorId: UserID;
|
||||
authorUsername: string;
|
||||
authorDiscriminator: number;
|
||||
authorAvatarHash: string | null;
|
||||
content: string | null;
|
||||
timestamp: Date;
|
||||
editedTimestamp: Date | null;
|
||||
type: number;
|
||||
flags: number;
|
||||
mentionEveryone: boolean;
|
||||
mentionUsers: Array<bigint>;
|
||||
mentionRoles: Array<bigint>;
|
||||
mentionChannels: Array<bigint>;
|
||||
attachments: Array<MessageAttachment>;
|
||||
embeds: Array<MessageEmbed>;
|
||||
stickers: Array<MessageStickerItem>;
|
||||
}
|
||||
|
||||
export interface IARSubmissionRow {
|
||||
report_id: bigint;
|
||||
reporter_id: bigint | null;
|
||||
reporter_email: string | null;
|
||||
reporter_full_legal_name: string | null;
|
||||
reporter_country_of_residence: string | null;
|
||||
reported_at: Date;
|
||||
status: number;
|
||||
report_type: number;
|
||||
category: string;
|
||||
additional_info: string | null;
|
||||
reported_user_id: bigint | null;
|
||||
reported_user_avatar_hash: string | null;
|
||||
reported_guild_id: bigint | null;
|
||||
reported_guild_name: string | null;
|
||||
reported_guild_icon_hash: string | null;
|
||||
reported_message_id: bigint | null;
|
||||
reported_channel_id: bigint | null;
|
||||
reported_channel_name: string | null;
|
||||
message_context: Array<IARMessageContextRow> | null;
|
||||
guild_context_id: bigint | null;
|
||||
resolved_at: Date | null;
|
||||
resolved_by_admin_id: bigint | null;
|
||||
public_comment: string | null;
|
||||
audit_log_reason: string | null;
|
||||
reported_guild_invite_code: string | null;
|
||||
}
|
||||
|
||||
export interface IARSubmission {
|
||||
reportId: ReportID;
|
||||
reporterId: UserID | null;
|
||||
reporterEmail: string | null;
|
||||
reporterFullLegalName: string | null;
|
||||
reporterCountryOfResidence: string | null;
|
||||
reportedAt: Date;
|
||||
status: number;
|
||||
reportType: number;
|
||||
category: string;
|
||||
additionalInfo: string | null;
|
||||
reportedUserId: UserID | null;
|
||||
reportedUserAvatarHash: string | null;
|
||||
reportedGuildId: GuildID | null;
|
||||
reportedGuildName: string | null;
|
||||
reportedGuildIconHash: string | null;
|
||||
reportedMessageId: MessageID | null;
|
||||
reportedChannelId: ChannelID | null;
|
||||
reportedChannelName: string | null;
|
||||
messageContext: Array<IARMessageContext> | null;
|
||||
guildContextId: GuildID | null;
|
||||
resolvedAt: Date | null;
|
||||
resolvedByAdminId: UserID | null;
|
||||
publicComment: string | null;
|
||||
auditLogReason: string | null;
|
||||
reportedGuildInviteCode: string | null;
|
||||
}
|
||||
|
||||
export abstract class IReportRepository {
|
||||
abstract createReport(data: IARSubmissionRow): Promise<IARSubmission>;
|
||||
abstract getReport(reportId: ReportID): Promise<IARSubmission | null>;
|
||||
|
||||
abstract resolveReport(
|
||||
reportId: ReportID,
|
||||
resolvedByAdminId: UserID,
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
): Promise<IARSubmission>;
|
||||
|
||||
abstract listAllReportsPaginated(limit: number, lastReportId?: ReportID): Promise<Array<IARSubmission>>;
|
||||
abstract upsertDsaEmailVerification(row: DSAReportEmailVerificationRow): Promise<void>;
|
||||
abstract deleteDsaEmailVerification(emailLower: string): Promise<void>;
|
||||
abstract getDsaEmailVerification(emailLower: string): Promise<DSAReportEmailVerificationRow | null>;
|
||||
abstract createDsaTicket(row: DSAReportTicketRow): Promise<void>;
|
||||
abstract getDsaTicket(ticket: string): Promise<DSAReportTicketRow | null>;
|
||||
abstract deleteDsaTicket(ticket: string): Promise<void>;
|
||||
}
|
||||
168
packages/api/src/report/ReportController.tsx
Normal file
168
packages/api/src/report/ReportController.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {
|
||||
DsaReportEmailSendRequest,
|
||||
DsaReportEmailVerifyRequest,
|
||||
DsaReportRequest,
|
||||
OkResponse,
|
||||
ReportGuildRequest,
|
||||
ReportMessageRequest,
|
||||
ReportResponse,
|
||||
ReportUserRequest,
|
||||
TicketResponse,
|
||||
} from '@fluxer/schema/src/domains/report/ReportSchemas';
|
||||
|
||||
export function ReportController(app: HonoApp) {
|
||||
app.post(
|
||||
'/reports/message',
|
||||
RateLimitMiddleware(RateLimitConfigs.REPORT_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'report_message',
|
||||
summary: 'Report message',
|
||||
description: 'Submits a report about a message to moderators for content violation review.',
|
||||
responseSchema: ReportResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Reports',
|
||||
}),
|
||||
Validator('json', ReportMessageRequest),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await ctx.get('reportRequestService').reportMessage({
|
||||
user: ctx.get('user'),
|
||||
data: ctx.req.valid('json'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/reports/user',
|
||||
RateLimitMiddleware(RateLimitConfigs.REPORT_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'report_user',
|
||||
summary: 'Report user',
|
||||
description: 'Submits a report about a user to moderators for content violation or behaviour review.',
|
||||
responseSchema: ReportResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Reports',
|
||||
}),
|
||||
Validator('json', ReportUserRequest),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await ctx.get('reportRequestService').reportUser({
|
||||
user: ctx.get('user'),
|
||||
data: ctx.req.valid('json'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/reports/guild',
|
||||
RateLimitMiddleware(RateLimitConfigs.REPORT_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'report_guild',
|
||||
summary: 'Report guild',
|
||||
description: 'Submits a report about a guild to moderators for policy violation review.',
|
||||
responseSchema: ReportResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Reports',
|
||||
}),
|
||||
Validator('json', ReportGuildRequest),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await ctx.get('reportRequestService').reportGuild({
|
||||
user: ctx.get('user'),
|
||||
data: ctx.req.valid('json'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/reports/dsa/email/send',
|
||||
RateLimitMiddleware(RateLimitConfigs.DSA_REPORT_EMAIL_SEND),
|
||||
OpenAPI({
|
||||
operationId: 'send_dsa_report_email',
|
||||
summary: 'Send DSA report email',
|
||||
description: 'Initiates DSA (Digital Services Act) report submission by sending verification email to reporter.',
|
||||
responseSchema: OkResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: 'Reports',
|
||||
}),
|
||||
Validator('json', DsaReportEmailSendRequest),
|
||||
async (ctx) => {
|
||||
await ctx.get('reportRequestService').sendDsaReportVerificationEmail({data: ctx.req.valid('json')});
|
||||
return ctx.json({ok: true});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/reports/dsa/email/verify',
|
||||
RateLimitMiddleware(RateLimitConfigs.DSA_REPORT_EMAIL_VERIFY),
|
||||
OpenAPI({
|
||||
operationId: 'verify_dsa_report_email',
|
||||
summary: 'Verify DSA report email',
|
||||
description: 'Verifies the DSA report email and creates a report ticket for legal compliance.',
|
||||
responseSchema: TicketResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: 'Reports',
|
||||
}),
|
||||
Validator('json', DsaReportEmailVerifyRequest),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('reportRequestService').verifyDsaReportEmail({data: ctx.req.valid('json')}));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/reports/dsa',
|
||||
RateLimitMiddleware(RateLimitConfigs.DSA_REPORT_CREATE),
|
||||
OpenAPI({
|
||||
operationId: 'create_dsa_report',
|
||||
summary: 'Create DSA report',
|
||||
description: 'Creates a DSA complaint report with verified email for Digital Services Act compliance.',
|
||||
responseSchema: ReportResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: 'Reports',
|
||||
}),
|
||||
Validator('json', DsaReportRequest),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('reportRequestService').createDsaReport({data: ctx.req.valid('json')}));
|
||||
},
|
||||
);
|
||||
}
|
||||
215
packages/api/src/report/ReportRepository.tsx
Normal file
215
packages/api/src/report/ReportRepository.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
createChannelID,
|
||||
createGuildID,
|
||||
createMessageID,
|
||||
createReportID,
|
||||
createUserID,
|
||||
} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Db, executeConditional, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {DSAReportEmailVerificationRow, DSAReportTicketRow} from '@fluxer/api/src/database/types/ReportTypes';
|
||||
import type {
|
||||
IARMessageContext,
|
||||
IARMessageContextRow,
|
||||
IARSubmission,
|
||||
IARSubmissionRow,
|
||||
IReportRepository,
|
||||
} from '@fluxer/api/src/report/IReportRepository';
|
||||
import {DSAReportEmailVerifications, DSAReportTickets, IARSubmissions} from '@fluxer/api/src/Tables';
|
||||
import {ReportAlreadyResolvedError} from '@fluxer/errors/src/domains/moderation/ReportAlreadyResolvedError';
|
||||
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
|
||||
|
||||
const GET_REPORT_QUERY = IARSubmissions.select({
|
||||
where: IARSubmissions.where.eq('report_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const createFetchAllReportsPaginatedQuery = (limit: number) =>
|
||||
IARSubmissions.select({
|
||||
where: IARSubmissions.where.tokenGt('report_id', 'last_report_id'),
|
||||
limit,
|
||||
});
|
||||
|
||||
const GET_DSA_EMAIL_VERIFICATION_QUERY = DSAReportEmailVerifications.select({
|
||||
where: DSAReportEmailVerifications.where.eq('email_lower'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const GET_DSA_REPORT_TICKET_QUERY = DSAReportTickets.select({
|
||||
where: DSAReportTickets.where.eq('ticket'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
function createFetchAllReportsFirstPageQuery(limit: number) {
|
||||
return IARSubmissions.select({limit});
|
||||
}
|
||||
|
||||
export class ReportRepository implements IReportRepository {
|
||||
async createReport(data: IARSubmissionRow): Promise<IARSubmission> {
|
||||
await upsertOne(IARSubmissions.insert(data));
|
||||
return this.mapRowToSubmission(data);
|
||||
}
|
||||
|
||||
async getReport(reportId: ReportID): Promise<IARSubmission | null> {
|
||||
const row = await fetchOne<IARSubmissionRow>(GET_REPORT_QUERY.bind({report_id: reportId}));
|
||||
return row ? this.mapRowToSubmission(row) : null;
|
||||
}
|
||||
|
||||
async resolveReport(
|
||||
reportId: ReportID,
|
||||
resolvedByAdminId: UserID,
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
): Promise<IARSubmission> {
|
||||
const report = await this.getReport(reportId);
|
||||
if (!report) {
|
||||
throw new UnknownReportError();
|
||||
}
|
||||
|
||||
const resolvedAt = new Date();
|
||||
const newStatus = 1;
|
||||
|
||||
const q = IARSubmissions.patchByPkIf(
|
||||
{report_id: reportId},
|
||||
{
|
||||
resolved_at: Db.set(resolvedAt),
|
||||
resolved_by_admin_id: Db.set(resolvedByAdminId),
|
||||
public_comment: Db.set(publicComment),
|
||||
audit_log_reason: Db.set(auditLogReason),
|
||||
status: Db.set(newStatus),
|
||||
},
|
||||
{col: 'status', expectedParam: 'expected_status', expectedValue: 0},
|
||||
);
|
||||
|
||||
const result = await executeConditional(q);
|
||||
if (!result.applied) {
|
||||
throw new ReportAlreadyResolvedError();
|
||||
}
|
||||
|
||||
return {
|
||||
...report,
|
||||
resolvedAt,
|
||||
resolvedByAdminId,
|
||||
publicComment,
|
||||
auditLogReason,
|
||||
status: newStatus,
|
||||
};
|
||||
}
|
||||
|
||||
private mapRowToSubmission(row: IARSubmissionRow): IARSubmission {
|
||||
return {
|
||||
reportId: createReportID(row.report_id),
|
||||
reporterId: row.reporter_id ? createUserID(row.reporter_id) : null,
|
||||
reporterEmail: row.reporter_email,
|
||||
reporterFullLegalName: row.reporter_full_legal_name,
|
||||
reporterCountryOfResidence: row.reporter_country_of_residence,
|
||||
reportedAt: row.reported_at,
|
||||
status: row.status,
|
||||
reportType: row.report_type,
|
||||
category: row.category,
|
||||
additionalInfo: row.additional_info,
|
||||
reportedUserId: row.reported_user_id ? createUserID(row.reported_user_id) : null,
|
||||
reportedUserAvatarHash: row.reported_user_avatar_hash,
|
||||
reportedGuildId: row.reported_guild_id ? createGuildID(row.reported_guild_id) : null,
|
||||
reportedGuildName: row.reported_guild_name,
|
||||
reportedGuildIconHash: row.reported_guild_icon_hash,
|
||||
reportedMessageId: row.reported_message_id ? createMessageID(row.reported_message_id) : null,
|
||||
reportedChannelId: row.reported_channel_id ? createChannelID(row.reported_channel_id) : null,
|
||||
reportedChannelName: row.reported_channel_name,
|
||||
messageContext: row.message_context ? this.mapMessageContext(row.message_context) : null,
|
||||
guildContextId: row.guild_context_id ? createGuildID(row.guild_context_id) : null,
|
||||
resolvedAt: row.resolved_at,
|
||||
resolvedByAdminId: row.resolved_by_admin_id ? createUserID(row.resolved_by_admin_id) : null,
|
||||
publicComment: row.public_comment,
|
||||
auditLogReason: row.audit_log_reason,
|
||||
reportedGuildInviteCode: row.reported_guild_invite_code,
|
||||
};
|
||||
}
|
||||
|
||||
async listAllReportsPaginated(limit: number, lastReportId?: ReportID): Promise<Array<IARSubmission>> {
|
||||
let reports: Array<IARSubmissionRow>;
|
||||
|
||||
if (lastReportId) {
|
||||
const query = createFetchAllReportsPaginatedQuery(limit);
|
||||
reports = await fetchMany<IARSubmissionRow>(query.bind({last_report_id: lastReportId}));
|
||||
} else {
|
||||
const query = createFetchAllReportsFirstPageQuery(limit);
|
||||
reports = await fetchMany<IARSubmissionRow>(query.bind({}));
|
||||
}
|
||||
|
||||
return reports.map((report) => this.mapRowToSubmission(report));
|
||||
}
|
||||
|
||||
async upsertDsaEmailVerification(row: DSAReportEmailVerificationRow): Promise<void> {
|
||||
await upsertOne(DSAReportEmailVerifications.insert(row));
|
||||
}
|
||||
|
||||
async getDsaEmailVerification(emailLower: string): Promise<DSAReportEmailVerificationRow | null> {
|
||||
const row = await fetchOne<DSAReportEmailVerificationRow>(
|
||||
GET_DSA_EMAIL_VERIFICATION_QUERY.bind({email_lower: emailLower}),
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async deleteDsaEmailVerification(emailLower: string): Promise<void> {
|
||||
await DSAReportEmailVerifications.deleteByPk({email_lower: emailLower});
|
||||
}
|
||||
|
||||
async createDsaTicket(row: DSAReportTicketRow): Promise<void> {
|
||||
await upsertOne(DSAReportTickets.insert(row));
|
||||
}
|
||||
|
||||
async getDsaTicket(ticket: string): Promise<DSAReportTicketRow | null> {
|
||||
const row = await fetchOne<DSAReportTicketRow>(GET_DSA_REPORT_TICKET_QUERY.bind({ticket}));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async deleteDsaTicket(ticket: string): Promise<void> {
|
||||
await DSAReportTickets.deleteByPk({ticket});
|
||||
}
|
||||
|
||||
private mapMessageContext(rawContext: Array<IARMessageContextRow>): Array<IARMessageContext> {
|
||||
const toBigintArray = (collection: ReadonlyArray<bigint> | Set<bigint> | null | undefined): Array<bigint> =>
|
||||
collection ? Array.from(collection) : [];
|
||||
|
||||
return rawContext.map((msg) => ({
|
||||
messageId: createMessageID(msg.message_id),
|
||||
authorId: createUserID(msg.author_id),
|
||||
channelId: msg.channel_id ? createChannelID(msg.channel_id) : null,
|
||||
authorUsername: msg.author_username,
|
||||
authorDiscriminator: msg.author_discriminator,
|
||||
authorAvatarHash: msg.author_avatar_hash,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
editedTimestamp: msg.edited_timestamp,
|
||||
type: msg.type,
|
||||
flags: msg.flags,
|
||||
mentionEveryone: msg.mention_everyone,
|
||||
mentionUsers: toBigintArray(msg.mention_users),
|
||||
mentionRoles: toBigintArray(msg.mention_roles),
|
||||
mentionChannels: toBigintArray(msg.mention_channels),
|
||||
attachments: msg.attachments ?? [],
|
||||
embeds: msg.embeds ?? [],
|
||||
stickers: msg.sticker_items ?? [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
115
packages/api/src/report/ReportRequestService.tsx
Normal file
115
packages/api/src/report/ReportRequestService.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createChannelID, createGuildID, createMessageID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {type ReportStatus, reportStatusToString} from '@fluxer/api/src/report/IReportRepository';
|
||||
import type {ReportService} from '@fluxer/api/src/report/ReportService';
|
||||
import type {
|
||||
DsaReportEmailSendRequest,
|
||||
DsaReportEmailVerifyRequest,
|
||||
DsaReportRequest,
|
||||
ReportGuildRequest,
|
||||
ReportMessageRequest,
|
||||
ReportResponse,
|
||||
ReportUserRequest,
|
||||
TicketResponse,
|
||||
} from '@fluxer/schema/src/domains/report/ReportSchemas';
|
||||
|
||||
interface ReportUserRequestContext<T> {
|
||||
user: User;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface ReportDsaRequestContext<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface ReportRecord {
|
||||
reportId: bigint;
|
||||
status: ReportStatus;
|
||||
reportedAt: Date;
|
||||
}
|
||||
|
||||
export class ReportRequestService {
|
||||
constructor(private reportService: ReportService) {}
|
||||
|
||||
async reportMessage({user, data}: ReportUserRequestContext<ReportMessageRequest>): Promise<ReportResponse> {
|
||||
const report = await this.reportService.reportMessage(
|
||||
this.createReporter(user),
|
||||
createChannelID(data.channel_id),
|
||||
createMessageID(data.message_id),
|
||||
data.category,
|
||||
data.additional_info,
|
||||
);
|
||||
return this.toReportResponse(report);
|
||||
}
|
||||
|
||||
async reportUser({user, data}: ReportUserRequestContext<ReportUserRequest>): Promise<ReportResponse> {
|
||||
const report = await this.reportService.reportUser(
|
||||
this.createReporter(user),
|
||||
createUserID(data.user_id),
|
||||
data.category,
|
||||
data.additional_info,
|
||||
data.guild_id ? createGuildID(data.guild_id) : undefined,
|
||||
);
|
||||
return this.toReportResponse(report);
|
||||
}
|
||||
|
||||
async reportGuild({user, data}: ReportUserRequestContext<ReportGuildRequest>): Promise<ReportResponse> {
|
||||
const report = await this.reportService.reportGuild(
|
||||
this.createReporter(user),
|
||||
createGuildID(data.guild_id),
|
||||
data.category,
|
||||
data.additional_info,
|
||||
);
|
||||
return this.toReportResponse(report);
|
||||
}
|
||||
|
||||
async sendDsaReportVerificationEmail({data}: ReportDsaRequestContext<DsaReportEmailSendRequest>): Promise<void> {
|
||||
await this.reportService.sendDsaReportVerificationCode(data.email);
|
||||
}
|
||||
|
||||
async verifyDsaReportEmail({data}: ReportDsaRequestContext<DsaReportEmailVerifyRequest>): Promise<TicketResponse> {
|
||||
const ticket = await this.reportService.verifyDsaReportEmail(data.email, data.code);
|
||||
return {ticket};
|
||||
}
|
||||
|
||||
async createDsaReport({data}: ReportDsaRequestContext<DsaReportRequest>): Promise<ReportResponse> {
|
||||
const report = await this.reportService.createDsaReport(data);
|
||||
return this.toReportResponse(report);
|
||||
}
|
||||
|
||||
private createReporter(user: User) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullLegalName: null,
|
||||
countryOfResidence: null,
|
||||
};
|
||||
}
|
||||
|
||||
private toReportResponse(report: ReportRecord): ReportResponse {
|
||||
return {
|
||||
report_id: report.reportId.toString(),
|
||||
status: reportStatusToString(report.status),
|
||||
reported_at: report.reportedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
946
packages/api/src/report/ReportService.tsx
Normal file
946
packages/api/src/report/ReportService.tsx
Normal file
@@ -0,0 +1,946 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createHash, randomBytes} from 'node:crypto';
|
||||
import type {ChannelID, GuildID, MessageID, ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
createChannelID,
|
||||
createGuildID,
|
||||
createInviteCode,
|
||||
createMessageID,
|
||||
createReportID,
|
||||
createUserID,
|
||||
} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import * as MessageHelpers from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {MessageAttachment} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {DSAReportTicketRow} from '@fluxer/api/src/database/types/ReportTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {IInviteRepository} from '@fluxer/api/src/invite/IInviteRepository';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {Attachment} from '@fluxer/api/src/models/Attachment';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {
|
||||
IARMessageContextRow,
|
||||
IARSubmission,
|
||||
IARSubmissionRow,
|
||||
IReportRepository,
|
||||
} from '@fluxer/api/src/report/IReportRepository';
|
||||
import {ReportStatus, ReportType} from '@fluxer/api/src/report/IReportRepository';
|
||||
import type {IReportSearchService} from '@fluxer/api/src/search/IReportSearchService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {CannotReportOwnMessageError} from '@fluxer/errors/src/domains/channel/CannotReportOwnMessageError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import {CannotReportOwnGuildError} from '@fluxer/errors/src/domains/guild/CannotReportOwnGuildError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {UnknownInviteError} from '@fluxer/errors/src/domains/invite/UnknownInviteError';
|
||||
import {CannotReportYourselfError} from '@fluxer/errors/src/domains/moderation/CannotReportYourselfError';
|
||||
import {InvalidDsaReportTargetError} from '@fluxer/errors/src/domains/moderation/InvalidDsaReportTargetError';
|
||||
import {InvalidDsaTicketError} from '@fluxer/errors/src/domains/moderation/InvalidDsaTicketError';
|
||||
import {InvalidDsaVerificationCodeError} from '@fluxer/errors/src/domains/moderation/InvalidDsaVerificationCodeError';
|
||||
import {ReportBannedError} from '@fluxer/errors/src/domains/moderation/ReportBannedError';
|
||||
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {DsaReportRequest} from '@fluxer/schema/src/domains/report/ReportSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
interface ReporterMetadata {
|
||||
id: UserID | null;
|
||||
email: string | null;
|
||||
fullLegalName: string | null;
|
||||
countryOfResidence: string | null;
|
||||
}
|
||||
|
||||
const REPORT_RATE_LIMIT_WINDOW = ms('1 hour');
|
||||
const REPORT_RATE_LIMIT_MAX = 5;
|
||||
const MESSAGE_CONTEXT_WINDOW = 25;
|
||||
|
||||
const DSA_CODE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const DSA_CODE_SEGMENT_LENGTH = 4;
|
||||
const DSA_CODE_SEPARATOR = '-';
|
||||
const DSA_TICKET_BYTES = 32;
|
||||
|
||||
export class ReportService {
|
||||
private reportRateLimitMap = new Map<string, Array<number>>();
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private reportRepository: IReportRepository,
|
||||
private channelRepository: IChannelRepository,
|
||||
private guildRepository: IGuildRepositoryAggregate,
|
||||
private userRepository: IUserRepository,
|
||||
private inviteRepository: IInviteRepository,
|
||||
private emailService: IEmailService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private storageService: IStorageService,
|
||||
private reportSearchService: IReportSearchService | null = null,
|
||||
) {
|
||||
this.cleanupInterval = setInterval(() => this.cleanupRateLimitMap(), ms('5 minutes'));
|
||||
}
|
||||
|
||||
async reportMessage(
|
||||
reporter: ReporterMetadata,
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
category: string,
|
||||
additionalInfo?: string,
|
||||
): Promise<IARSubmission> {
|
||||
await this.checkReportBan(reporter.id);
|
||||
const reporterKey = this.getReporterRateLimitKey(reporter);
|
||||
await this.checkRateLimit(reporterKey);
|
||||
|
||||
const channel = await this.channelRepository.findUnique(channelId);
|
||||
if (!channel) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
|
||||
const message = await this.channelRepository.getMessage(channelId, messageId);
|
||||
if (!message) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
if (reporter.id && message.authorId === reporter.id) {
|
||||
throw new CannotReportOwnMessageError();
|
||||
}
|
||||
|
||||
const [reportedUser, messageContext] = await Promise.all([
|
||||
this.userRepository.findUnique(message.authorId!),
|
||||
this.gatherMessageContext(channelId, messageId),
|
||||
]);
|
||||
|
||||
if (!reportedUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const reportId = createReportID(await this.snowflakeService.generate());
|
||||
const reportData: IARSubmissionRow = {
|
||||
report_id: reportId,
|
||||
reporter_id: reporter.id,
|
||||
reporter_email: reporter.email,
|
||||
reporter_full_legal_name: reporter.fullLegalName,
|
||||
reporter_country_of_residence: reporter.countryOfResidence,
|
||||
reported_at: new Date(),
|
||||
status: ReportStatus.PENDING,
|
||||
report_type: ReportType.MESSAGE,
|
||||
category,
|
||||
additional_info: additionalInfo || null,
|
||||
reported_user_id: message.authorId,
|
||||
reported_user_avatar_hash: reportedUser.avatarHash || null,
|
||||
reported_guild_id: channel.guildId || null,
|
||||
reported_guild_name: null,
|
||||
reported_guild_icon_hash: null,
|
||||
reported_message_id: messageId,
|
||||
reported_channel_id: channelId,
|
||||
reported_channel_name: channel.name || null,
|
||||
message_context: messageContext,
|
||||
guild_context_id: channel.guildId || null,
|
||||
resolved_at: null,
|
||||
resolved_by_admin_id: null,
|
||||
public_comment: null,
|
||||
audit_log_reason: null,
|
||||
reported_guild_invite_code: null,
|
||||
};
|
||||
|
||||
if (channel.guildId) {
|
||||
const guild = await this.guildRepository.findUnique(channel.guildId);
|
||||
if (guild) {
|
||||
reportData.reported_guild_name = guild.name;
|
||||
reportData.reported_guild_icon_hash = guild.iconHash || null;
|
||||
}
|
||||
}
|
||||
|
||||
this.trackRateLimit(reporterKey);
|
||||
|
||||
const report = await this.reportRepository.createReport(reportData);
|
||||
|
||||
emitReportMetric('reports.iar.created', report);
|
||||
|
||||
if (this.reportSearchService && 'indexReport' in this.reportSearchService) {
|
||||
await this.reportSearchService.indexReport(report).catch((error) => {
|
||||
Logger.error({error, reportId: report.reportId}, 'Failed to index message report in search');
|
||||
});
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
async reportUser(
|
||||
reporter: ReporterMetadata,
|
||||
reportedUserId: UserID,
|
||||
category: string,
|
||||
additionalInfo?: string,
|
||||
guildId?: GuildID,
|
||||
): Promise<IARSubmission> {
|
||||
await this.checkReportBan(reporter.id);
|
||||
const reporterKey = this.getReporterRateLimitKey(reporter);
|
||||
await this.checkRateLimit(reporterKey);
|
||||
|
||||
if (reporter.id && reportedUserId === reporter.id) {
|
||||
throw new CannotReportYourselfError();
|
||||
}
|
||||
|
||||
const reportedUser = await this.userRepository.findUnique(reportedUserId);
|
||||
if (!reportedUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const reportId = createReportID(await this.snowflakeService.generate());
|
||||
const reportData: IARSubmissionRow = {
|
||||
report_id: reportId,
|
||||
reporter_id: reporter.id,
|
||||
reporter_email: reporter.email,
|
||||
reporter_full_legal_name: reporter.fullLegalName,
|
||||
reporter_country_of_residence: reporter.countryOfResidence,
|
||||
reported_at: new Date(),
|
||||
status: ReportStatus.PENDING,
|
||||
report_type: ReportType.USER,
|
||||
category,
|
||||
additional_info: additionalInfo || null,
|
||||
reported_user_id: reportedUserId,
|
||||
reported_user_avatar_hash: reportedUser.avatarHash || null,
|
||||
reported_guild_id: guildId || null,
|
||||
reported_guild_name: null,
|
||||
reported_guild_icon_hash: null,
|
||||
reported_message_id: null,
|
||||
reported_channel_id: null,
|
||||
reported_channel_name: null,
|
||||
message_context: null,
|
||||
guild_context_id: guildId || null,
|
||||
resolved_at: null,
|
||||
resolved_by_admin_id: null,
|
||||
public_comment: null,
|
||||
audit_log_reason: null,
|
||||
reported_guild_invite_code: null,
|
||||
};
|
||||
|
||||
if (guildId) {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (guild) {
|
||||
reportData.reported_guild_name = guild.name;
|
||||
reportData.reported_guild_icon_hash = guild.iconHash || null;
|
||||
}
|
||||
}
|
||||
|
||||
this.trackRateLimit(reporterKey);
|
||||
|
||||
const report = await this.reportRepository.createReport(reportData);
|
||||
|
||||
emitReportMetric('reports.iar.created', report);
|
||||
|
||||
if (this.reportSearchService && 'indexReport' in this.reportSearchService) {
|
||||
await this.reportSearchService.indexReport(report).catch((error) => {
|
||||
Logger.error({error, reportId: report.reportId}, 'Failed to index user report in search');
|
||||
});
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
async reportGuild(
|
||||
reporter: ReporterMetadata,
|
||||
guildId: GuildID,
|
||||
category: string,
|
||||
additionalInfo?: string,
|
||||
): Promise<IARSubmission> {
|
||||
await this.checkReportBan(reporter.id);
|
||||
const reporterKey = this.getReporterRateLimitKey(reporter);
|
||||
await this.checkRateLimit(reporterKey);
|
||||
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
if (reporter.id && guild.ownerId === reporter.id) {
|
||||
throw new CannotReportOwnGuildError();
|
||||
}
|
||||
|
||||
const reportId = createReportID(await this.snowflakeService.generate());
|
||||
const reportData: IARSubmissionRow = {
|
||||
report_id: reportId,
|
||||
reporter_id: reporter.id,
|
||||
reporter_email: reporter.email,
|
||||
reporter_full_legal_name: reporter.fullLegalName,
|
||||
reporter_country_of_residence: reporter.countryOfResidence,
|
||||
reported_at: new Date(),
|
||||
status: ReportStatus.PENDING,
|
||||
report_type: ReportType.GUILD,
|
||||
category,
|
||||
additional_info: additionalInfo || null,
|
||||
reported_user_id: null,
|
||||
reported_user_avatar_hash: null,
|
||||
reported_guild_id: guildId,
|
||||
reported_guild_name: guild.name,
|
||||
reported_guild_icon_hash: guild.iconHash || null,
|
||||
reported_message_id: null,
|
||||
reported_channel_id: null,
|
||||
reported_channel_name: null,
|
||||
message_context: null,
|
||||
guild_context_id: guildId,
|
||||
resolved_at: null,
|
||||
resolved_by_admin_id: null,
|
||||
public_comment: null,
|
||||
audit_log_reason: null,
|
||||
reported_guild_invite_code: null,
|
||||
};
|
||||
|
||||
this.trackRateLimit(reporterKey);
|
||||
|
||||
const report = await this.reportRepository.createReport(reportData);
|
||||
|
||||
emitReportMetric('reports.iar.created', report);
|
||||
|
||||
if (this.reportSearchService && 'indexReport' in this.reportSearchService) {
|
||||
await this.reportSearchService.indexReport(report).catch((error) => {
|
||||
Logger.error({error, reportId: report.reportId}, 'Failed to index guild report in search');
|
||||
});
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
async sendDsaReportVerificationCode(email: string): Promise<void> {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const verificationCode = this.generateDsaVerificationCode();
|
||||
const expiresAt = new Date(Date.now() + ms('10 minutes'));
|
||||
|
||||
await this.reportRepository.upsertDsaEmailVerification({
|
||||
email_lower: normalizedEmail,
|
||||
code_hash: this.hashVerificationCode(verificationCode),
|
||||
expires_at: expiresAt,
|
||||
last_sent_at: new Date(),
|
||||
});
|
||||
|
||||
await this.emailService.sendDsaReportVerificationCode(normalizedEmail, verificationCode, expiresAt);
|
||||
}
|
||||
|
||||
async verifyDsaReportEmail(email: string, code: string): Promise<string> {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const verificationRow = await this.reportRepository.getDsaEmailVerification(normalizedEmail);
|
||||
if (!verificationRow || verificationRow.expires_at.getTime() < Date.now()) {
|
||||
throw new InvalidDsaVerificationCodeError();
|
||||
}
|
||||
|
||||
if (this.hashVerificationCode(code) !== verificationRow.code_hash) {
|
||||
throw new InvalidDsaVerificationCodeError();
|
||||
}
|
||||
|
||||
await this.reportRepository.deleteDsaEmailVerification(normalizedEmail);
|
||||
|
||||
const ticket = this.generateDsaTicket();
|
||||
await this.reportRepository.createDsaTicket({
|
||||
ticket,
|
||||
email_lower: normalizedEmail,
|
||||
expires_at: new Date(Date.now() + ms('1 hour')),
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async createDsaReport(report: DsaReportRequest): Promise<IARSubmission> {
|
||||
const ticket = await this.consumeDsaTicket(report.ticket);
|
||||
const reporterMeta: ReporterMetadata = {
|
||||
id: null,
|
||||
email: ticket.email_lower,
|
||||
fullLegalName: report.reporter_full_legal_name,
|
||||
countryOfResidence: report.reporter_country_of_residence,
|
||||
};
|
||||
|
||||
await this.checkReportBan(null);
|
||||
const reporterKey = this.getReporterRateLimitKey(reporterMeta);
|
||||
await this.checkRateLimit(reporterKey);
|
||||
|
||||
const reportId = createReportID(await this.snowflakeService.generate());
|
||||
const reportRow = await this.buildDsaReportRow(reportId, report, reporterMeta);
|
||||
|
||||
this.trackRateLimit(reporterKey);
|
||||
|
||||
const createdReport = await this.reportRepository.createReport(reportRow);
|
||||
|
||||
emitReportMetric('reports.iar.created', createdReport);
|
||||
|
||||
if (this.reportSearchService && 'indexReport' in this.reportSearchService) {
|
||||
await this.reportSearchService.indexReport(createdReport).catch((error) => {
|
||||
Logger.error({error, reportId: createdReport.reportId}, 'Failed to index DSA report in search');
|
||||
});
|
||||
}
|
||||
|
||||
return createdReport;
|
||||
}
|
||||
|
||||
private async buildDsaReportRow(
|
||||
reportId: ReportID,
|
||||
report: DsaReportRequest,
|
||||
reporter: ReporterMetadata,
|
||||
): Promise<IARSubmissionRow> {
|
||||
switch (report.report_type) {
|
||||
case 'message':
|
||||
return this.buildDsaMessageReportRow(reportId, report, reporter);
|
||||
case 'user':
|
||||
return this.buildDsaUserReportRow(reportId, report, reporter);
|
||||
case 'guild':
|
||||
return this.buildDsaGuildReportRow(reportId, report, reporter);
|
||||
default:
|
||||
throw new InvalidDsaReportTargetError();
|
||||
}
|
||||
}
|
||||
|
||||
private async buildDsaMessageReportRow(
|
||||
reportId: ReportID,
|
||||
report: Extract<DsaReportRequest, {report_type: 'message'}>,
|
||||
reporter: ReporterMetadata,
|
||||
): Promise<IARSubmissionRow> {
|
||||
const {channelId, messageId} = this.extractChannelAndMessageFromLink(report.message_link);
|
||||
const channel = await this.channelRepository.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
const message = await this.channelRepository.getMessage(channelId, messageId);
|
||||
if (!message) throw new UnknownMessageError();
|
||||
|
||||
if (message.authorId == null) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (report.reported_user_tag) {
|
||||
const tagged = await this.findUserByTag(report.reported_user_tag);
|
||||
if (tagged.id !== message.authorId) {
|
||||
throw new InvalidDsaReportTargetError();
|
||||
}
|
||||
}
|
||||
|
||||
const reportedUser = await this.userRepository.findUnique(message.authorId);
|
||||
if (!reportedUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const messageContext = await this.gatherMessageContext(channelId, messageId);
|
||||
const guild = channel.guildId ? await this.guildRepository.findUnique(channel.guildId) : null;
|
||||
|
||||
return {
|
||||
report_id: reportId,
|
||||
reporter_id: null,
|
||||
reporter_email: reporter.email,
|
||||
reporter_full_legal_name: reporter.fullLegalName,
|
||||
reporter_country_of_residence: reporter.countryOfResidence,
|
||||
reported_at: new Date(),
|
||||
status: ReportStatus.PENDING,
|
||||
report_type: ReportType.MESSAGE,
|
||||
category: report.category,
|
||||
additional_info: report.additional_info ?? null,
|
||||
reported_user_id: message.authorId,
|
||||
reported_user_avatar_hash: reportedUser.avatarHash || null,
|
||||
reported_guild_id: channel.guildId || null,
|
||||
reported_guild_name: guild?.name ?? null,
|
||||
reported_guild_icon_hash: guild?.iconHash ?? null,
|
||||
reported_message_id: messageId,
|
||||
reported_channel_id: channelId,
|
||||
reported_channel_name: channel.name || null,
|
||||
message_context: messageContext,
|
||||
guild_context_id: channel.guildId || null,
|
||||
resolved_at: null,
|
||||
resolved_by_admin_id: null,
|
||||
public_comment: null,
|
||||
audit_log_reason: null,
|
||||
reported_guild_invite_code: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildDsaUserReportRow(
|
||||
reportId: ReportID,
|
||||
report: Extract<DsaReportRequest, {report_type: 'user'}>,
|
||||
reporter: ReporterMetadata,
|
||||
): Promise<IARSubmissionRow> {
|
||||
const target = await this.resolveDsaUser(report.user_id ?? undefined, report.user_tag ?? undefined);
|
||||
|
||||
return {
|
||||
report_id: reportId,
|
||||
reporter_id: null,
|
||||
reporter_email: reporter.email,
|
||||
reporter_full_legal_name: reporter.fullLegalName,
|
||||
reporter_country_of_residence: reporter.countryOfResidence,
|
||||
reported_at: new Date(),
|
||||
status: ReportStatus.PENDING,
|
||||
report_type: ReportType.USER,
|
||||
category: report.category,
|
||||
additional_info: report.additional_info ?? null,
|
||||
reported_user_id: target.id,
|
||||
reported_user_avatar_hash: target.avatarHash || null,
|
||||
reported_guild_id: null,
|
||||
reported_guild_name: null,
|
||||
reported_guild_icon_hash: null,
|
||||
reported_message_id: null,
|
||||
reported_channel_id: null,
|
||||
reported_channel_name: null,
|
||||
message_context: null,
|
||||
guild_context_id: null,
|
||||
resolved_at: null,
|
||||
resolved_by_admin_id: null,
|
||||
public_comment: null,
|
||||
audit_log_reason: null,
|
||||
reported_guild_invite_code: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildDsaGuildReportRow(
|
||||
reportId: ReportID,
|
||||
report: Extract<DsaReportRequest, {report_type: 'guild'}>,
|
||||
reporter: ReporterMetadata,
|
||||
): Promise<IARSubmissionRow> {
|
||||
const guildId = createGuildID(report.guild_id);
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
let inviteCode: string | null = null;
|
||||
if (report.invite_code) {
|
||||
inviteCode = this.sanitizeInviteCode(report.invite_code);
|
||||
if (!inviteCode) {
|
||||
throw new InvalidDsaReportTargetError();
|
||||
}
|
||||
await this.validateInviteForGuild(inviteCode, guildId);
|
||||
}
|
||||
|
||||
return {
|
||||
report_id: reportId,
|
||||
reporter_id: null,
|
||||
reporter_email: reporter.email,
|
||||
reporter_full_legal_name: reporter.fullLegalName,
|
||||
reporter_country_of_residence: reporter.countryOfResidence,
|
||||
reported_at: new Date(),
|
||||
status: ReportStatus.PENDING,
|
||||
report_type: ReportType.GUILD,
|
||||
category: report.category,
|
||||
additional_info: report.additional_info ?? null,
|
||||
reported_user_id: null,
|
||||
reported_user_avatar_hash: null,
|
||||
reported_guild_id: guildId,
|
||||
reported_guild_name: guild.name,
|
||||
reported_guild_icon_hash: guild.iconHash || null,
|
||||
reported_message_id: null,
|
||||
reported_channel_id: null,
|
||||
reported_channel_name: null,
|
||||
message_context: null,
|
||||
guild_context_id: guildId,
|
||||
resolved_at: null,
|
||||
resolved_by_admin_id: null,
|
||||
public_comment: null,
|
||||
audit_log_reason: null,
|
||||
reported_guild_invite_code: inviteCode,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveDsaUser(userId?: bigint, userTag?: string | null): Promise<User> {
|
||||
if (userId != null) {
|
||||
const user = await this.userRepository.findUnique(createUserID(userId));
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (userTag) {
|
||||
const taggedUser = await this.findUserByTag(userTag);
|
||||
if (taggedUser.id !== user.id) {
|
||||
throw new InvalidDsaReportTargetError();
|
||||
}
|
||||
}
|
||||
return user;
|
||||
}
|
||||
if (userTag) {
|
||||
return this.findUserByTag(userTag);
|
||||
}
|
||||
throw new InvalidDsaReportTargetError();
|
||||
}
|
||||
|
||||
private async findUserByTag(tag: string): Promise<User> {
|
||||
const parsed = this.parseFluxerTag(tag);
|
||||
if (!parsed) {
|
||||
throw new InvalidDsaReportTargetError();
|
||||
}
|
||||
const user = await this.userRepository.findByUsernameDiscriminator(parsed.username, parsed.discriminator);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async consumeDsaTicket(ticket: string): Promise<DSAReportTicketRow> {
|
||||
const ticketRow = await this.reportRepository.getDsaTicket(ticket);
|
||||
if (!ticketRow || ticketRow.expires_at.getTime() < Date.now()) {
|
||||
throw new InvalidDsaTicketError();
|
||||
}
|
||||
await this.reportRepository.deleteDsaTicket(ticket);
|
||||
return ticketRow;
|
||||
}
|
||||
|
||||
private generateDsaVerificationCode(): string {
|
||||
const segments: Array<string> = [];
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
let segment = '';
|
||||
for (let j = 0; j < DSA_CODE_SEGMENT_LENGTH; j += 1) {
|
||||
const index = randomBytes(1)[0] % DSA_CODE_CHARSET.length;
|
||||
segment += DSA_CODE_CHARSET[index];
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join(DSA_CODE_SEPARATOR);
|
||||
}
|
||||
|
||||
private hashVerificationCode(code: string): string {
|
||||
return createHash('sha256').update(code).digest('hex');
|
||||
}
|
||||
|
||||
private generateDsaTicket(): string {
|
||||
return randomBytes(DSA_TICKET_BYTES).toString('hex');
|
||||
}
|
||||
|
||||
private normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private parseFluxerTag(tag: string): {username: string; discriminator: number} | null {
|
||||
const trimmed = tag.trim();
|
||||
const match = /^(.+)#(\d{4})$/.exec(trimmed);
|
||||
if (!match) return null;
|
||||
return {
|
||||
username: match[1],
|
||||
discriminator: Number.parseInt(match[2], 10),
|
||||
};
|
||||
}
|
||||
|
||||
private extractChannelAndMessageFromLink(link: string): {channelId: ChannelID; messageId: MessageID} {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(link);
|
||||
} catch {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const segments = parsed.pathname.split('/').filter((segment) => segment.length > 0);
|
||||
if (segments.length < 4 || segments[0] !== 'channels') {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const channelIdSegment = segments[2];
|
||||
const messageIdSegment = segments[3];
|
||||
return {
|
||||
channelId: createChannelID(BigInt(channelIdSegment)),
|
||||
messageId: createMessageID(BigInt(messageIdSegment)),
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizeInviteCode(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const segments = trimmed.split('/').filter((segment) => segment.length > 0);
|
||||
const candidate = segments.length > 0 ? segments[segments.length - 1] : trimmed;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private async validateInviteForGuild(code: string, guildId: GuildID): Promise<void> {
|
||||
const invite = await this.inviteRepository.findUnique(createInviteCode(code));
|
||||
if (!invite) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
if (invite.type !== InviteTypes.GUILD || !invite.guildId || invite.guildId !== guildId) {
|
||||
throw new InvalidDsaReportTargetError();
|
||||
}
|
||||
}
|
||||
|
||||
async getReport(reportId: ReportID): Promise<IARSubmission> {
|
||||
const report = await this.reportRepository.getReport(reportId);
|
||||
if (!report) {
|
||||
throw new UnknownReportError();
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
async listMyReports(reporterId: UserID, limit?: number, offset?: number): Promise<Array<IARSubmission>> {
|
||||
if (!this.reportSearchService) {
|
||||
throw new Error('Search service not available');
|
||||
}
|
||||
|
||||
const {hits} = await this.reportSearchService.listReportsByReporter(reporterId, limit, offset);
|
||||
|
||||
const reportIds = hits.map((hit) => createReportID(BigInt(hit.id)));
|
||||
const reports = await Promise.all(reportIds.map((id) => this.reportRepository.getReport(id)));
|
||||
|
||||
return reports.filter((report): report is IARSubmission => report !== null);
|
||||
}
|
||||
|
||||
async listReportsByStatus(status: number, limit?: number, offset?: number): Promise<Array<IARSubmission>> {
|
||||
if (!this.reportSearchService) {
|
||||
throw new Error('Search service not available');
|
||||
}
|
||||
|
||||
const {hits} = await this.reportSearchService.listReportsByStatus(status, limit, offset);
|
||||
|
||||
const reportIds = hits.map((hit) => createReportID(BigInt(hit.id)));
|
||||
const reports = await Promise.all(reportIds.map((id) => this.reportRepository.getReport(id)));
|
||||
|
||||
return reports.filter((report): report is IARSubmission => report !== null);
|
||||
}
|
||||
|
||||
async listAllReportsPaginated(limit: number, lastReportId?: ReportID): Promise<Array<IARSubmission>> {
|
||||
return this.reportRepository.listAllReportsPaginated(limit, lastReportId);
|
||||
}
|
||||
|
||||
async resolveReport(
|
||||
reportId: ReportID,
|
||||
adminUserId: UserID,
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
): Promise<IARSubmission> {
|
||||
const report = await this.reportRepository.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
|
||||
emitReportMetric('reports.iar.resolved', report, {status: 'resolved'});
|
||||
|
||||
if (this.reportSearchService && 'updateReport' in this.reportSearchService) {
|
||||
await this.reportSearchService.updateReport(report).catch((error) => {
|
||||
Logger.error({error, reportId: report.reportId}, 'Failed to update report in search index');
|
||||
});
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
private async gatherMessageContext(
|
||||
channelId: ChannelID,
|
||||
targetMessageId: MessageID,
|
||||
): Promise<Array<IARMessageContextRow>> {
|
||||
const messagesBefore = await this.channelRepository.listMessages(
|
||||
channelId,
|
||||
targetMessageId,
|
||||
MESSAGE_CONTEXT_WINDOW,
|
||||
);
|
||||
|
||||
const messagesAfter = await this.channelRepository.listMessages(
|
||||
channelId,
|
||||
undefined,
|
||||
MESSAGE_CONTEXT_WINDOW,
|
||||
targetMessageId,
|
||||
);
|
||||
|
||||
const targetMessage = await this.channelRepository.getMessage(channelId, targetMessageId);
|
||||
if (!targetMessage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
messagesBefore.reverse();
|
||||
const allMessages = [...messagesBefore, targetMessage, ...messagesAfter];
|
||||
|
||||
const userIds = new Set<UserID>();
|
||||
for (const msg of allMessages) {
|
||||
if (msg.authorId) {
|
||||
userIds.add(msg.authorId);
|
||||
}
|
||||
}
|
||||
|
||||
const users = new Map<UserID, User>();
|
||||
for (const userId of userIds) {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (user) {
|
||||
users.set(userId, user);
|
||||
}
|
||||
}
|
||||
|
||||
const context: Array<IARMessageContextRow> = [];
|
||||
for (const message of allMessages) {
|
||||
const author = message.authorId != null ? users.get(message.authorId) : null;
|
||||
if (!author) continue;
|
||||
|
||||
const clonedAttachments = message.attachments
|
||||
? await this.cloneAttachmentsForReport(message.attachments, channelId)
|
||||
: [];
|
||||
|
||||
context.push({
|
||||
message_id: message.id,
|
||||
channel_id: channelId,
|
||||
author_id: message.authorId!,
|
||||
author_username: author.username,
|
||||
author_discriminator: author.discriminator,
|
||||
author_avatar_hash: author.avatarHash || null,
|
||||
content: message.content || null,
|
||||
timestamp: snowflakeToDate(message.id),
|
||||
edited_timestamp: message.editedTimestamp || null,
|
||||
type: message.type,
|
||||
flags: message.flags,
|
||||
mention_everyone: message.mentionEveryone,
|
||||
mention_users: message.mentionedUserIds.size > 0 ? Array.from(message.mentionedUserIds) : null,
|
||||
mention_roles: message.mentionedRoleIds.size > 0 ? Array.from(message.mentionedRoleIds) : null,
|
||||
mention_channels: message.mentionedChannelIds.size > 0 ? Array.from(message.mentionedChannelIds) : null,
|
||||
attachments: clonedAttachments.length > 0 ? clonedAttachments : null,
|
||||
embeds: message.embeds.length > 0 ? message.embeds.map((embed) => embed.toMessageEmbed()) : null,
|
||||
sticker_items:
|
||||
message.stickers.length > 0 ? message.stickers.map((sticker) => sticker.toMessageStickerItem()) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private async cloneAttachmentsForReport(
|
||||
attachments: Array<Attachment>,
|
||||
sourceChannelId: ChannelID,
|
||||
): Promise<Array<MessageAttachment>> {
|
||||
const clonedAttachments: Array<MessageAttachment> = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const sourceKey = MessageHelpers.makeAttachmentCdnKey(sourceChannelId, attachment.id, attachment.filename);
|
||||
|
||||
try {
|
||||
await this.storageService.copyObject({
|
||||
sourceBucket: Config.s3.buckets.cdn,
|
||||
sourceKey,
|
||||
destinationBucket: Config.s3.buckets.reports,
|
||||
destinationKey: sourceKey,
|
||||
newContentType: attachment.contentType,
|
||||
});
|
||||
|
||||
const clonedAttachment: MessageAttachment = {
|
||||
attachment_id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
size: BigInt(attachment.size),
|
||||
title: attachment.title,
|
||||
description: attachment.description,
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
content_type: attachment.contentType,
|
||||
content_hash: attachment.contentHash,
|
||||
placeholder: attachment.placeholder,
|
||||
flags: attachment.flags ?? 0,
|
||||
duration: attachment.duration,
|
||||
nsfw: attachment.nsfw,
|
||||
waveform: attachment.waveform ?? null,
|
||||
};
|
||||
clonedAttachments.push(clonedAttachment);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, attachmentId: attachment.id, filename: attachment.filename, sourceChannelId},
|
||||
'Failed to clone attachment for report',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return clonedAttachments;
|
||||
}
|
||||
|
||||
private async checkReportBan(userId: UserID | null): Promise<void> {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (user && (user.flags & UserFlags.REPORT_BANNED) !== 0n) {
|
||||
throw new ReportBannedError();
|
||||
}
|
||||
}
|
||||
|
||||
private getReporterRateLimitKey(reporter: ReporterMetadata): string {
|
||||
if (reporter.id) {
|
||||
return `user:${reporter.id.toString()}`;
|
||||
}
|
||||
if (reporter.email) {
|
||||
return `email:${reporter.email.toLowerCase()}`;
|
||||
}
|
||||
return 'anonymous';
|
||||
}
|
||||
|
||||
private async checkRateLimit(key: string): Promise<void> {
|
||||
const now = Date.now();
|
||||
const userReports = this.reportRateLimitMap.get(key) || [];
|
||||
const recentReports = userReports.filter((timestamp) => now - timestamp < REPORT_RATE_LIMIT_WINDOW);
|
||||
|
||||
if (recentReports.length >= REPORT_RATE_LIMIT_MAX) {
|
||||
const oldestReport = Math.min(...recentReports);
|
||||
const retryAfter = Math.ceil((oldestReport + REPORT_RATE_LIMIT_WINDOW - now) / 1000);
|
||||
const resetTime = new Date(oldestReport + REPORT_RATE_LIMIT_WINDOW);
|
||||
throw new RateLimitError({
|
||||
message: `Too many reports. Try again in ${retryAfter} seconds.`,
|
||||
retryAfter,
|
||||
limit: REPORT_RATE_LIMIT_MAX,
|
||||
resetTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private trackRateLimit(key: string): void {
|
||||
const now = Date.now();
|
||||
const userReports = this.reportRateLimitMap.get(key) || [];
|
||||
const recentReports = userReports.filter((timestamp) => now - timestamp < REPORT_RATE_LIMIT_WINDOW);
|
||||
recentReports.push(now);
|
||||
this.reportRateLimitMap.set(key, recentReports);
|
||||
}
|
||||
|
||||
private cleanupRateLimitMap(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamps] of this.reportRateLimitMap.entries()) {
|
||||
const recentReports = timestamps.filter((timestamp) => now - timestamp < REPORT_RATE_LIMIT_WINDOW);
|
||||
if (recentReports.length === 0) {
|
||||
this.reportRateLimitMap.delete(key);
|
||||
} else {
|
||||
this.reportRateLimitMap.set(key, recentReports);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitReportMetric(metricName: string, report: IARSubmission, extra: Record<string, string> = {}): void {
|
||||
const dimensions: Record<string, string> = {
|
||||
report_type: reportTypeLabel(report.reportType),
|
||||
category: report.category || 'unknown',
|
||||
...extra,
|
||||
};
|
||||
|
||||
if (report.reportedGuildId) {
|
||||
dimensions['reported_guild_id'] = report.reportedGuildId.toString();
|
||||
}
|
||||
if (report.reportedUserId) {
|
||||
dimensions['reported_user_id'] = report.reportedUserId.toString();
|
||||
}
|
||||
|
||||
recordCounter({
|
||||
name: metricName,
|
||||
dimensions,
|
||||
});
|
||||
}
|
||||
|
||||
function reportTypeLabel(type: number): string {
|
||||
switch (type) {
|
||||
case ReportType.MESSAGE:
|
||||
return 'message';
|
||||
case ReportType.USER:
|
||||
return 'user';
|
||||
case ReportType.GUILD:
|
||||
return 'guild';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
837
packages/api/src/report/tests/ContentReporting.test.tsx
Normal file
837
packages/api/src/report/tests/ContentReporting.test.tsx
Normal file
@@ -0,0 +1,837 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
clearTestEmails,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
findLastTestEmail,
|
||||
listTestEmails,
|
||||
setUserACLs,
|
||||
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createGuild,
|
||||
getChannel,
|
||||
sendChannelMessage,
|
||||
setupTestGuildWithMembers,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface ReportResponse {
|
||||
report_id: string;
|
||||
status: string;
|
||||
reported_at: string;
|
||||
}
|
||||
|
||||
describe('Content Reporting', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('Report User', () => {
|
||||
test('should report a user with valid category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'harassment',
|
||||
additional_info: 'User is harassing me in DMs',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.reported_at).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should report a user with spam category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'spam_account',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should report a user with guild context', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetUser = members[0];
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, owner.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'harassment',
|
||||
additional_info: 'User is harassing members in guild',
|
||||
guild_id: guild.id,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should reject report with invalid category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId.toString(),
|
||||
category: 'invalid_category',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject report without authentication', async () => {
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId.toString(),
|
||||
category: 'harassment',
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should report user with impersonation category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'impersonation',
|
||||
additional_info: 'User is impersonating a celebrity',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('admin report detail includes mutual DM channel when present', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'report:view']);
|
||||
|
||||
await createFriendship(harness, reporter, targetUser);
|
||||
const mutualDm = await createDmChannel(harness, reporter.token, targetUser.userId);
|
||||
|
||||
const report = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'harassment',
|
||||
additional_info: 'User is harassing me in DMs',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const reportDetail = await createBuilder<{
|
||||
report_id: string;
|
||||
mutual_dm_channel_id?: string | null;
|
||||
}>(harness, `Bearer ${admin.token}`)
|
||||
.get(`/admin/reports/${report.report_id}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(reportDetail.report_id).toBe(report.report_id);
|
||||
expect(reportDetail.mutual_dm_channel_id).toBe(mutualDm.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Report Message', () => {
|
||||
test('should report a message with valid category', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetUser = members[0];
|
||||
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Offensive content');
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, owner.token)
|
||||
.post('/reports/message')
|
||||
.body({
|
||||
channel_id: channel.id,
|
||||
message_id: message.id,
|
||||
category: 'harassment',
|
||||
additional_info: 'This message is harassing me',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should report message with spam category', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetUser = members[0];
|
||||
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Buy now! Click link!');
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, owner.token)
|
||||
.post('/reports/message')
|
||||
.body({
|
||||
channel_id: channel.id,
|
||||
message_id: message.id,
|
||||
category: 'spam',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should report message with hate_speech category', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetUser = members[0];
|
||||
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Test message');
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, owner.token)
|
||||
.post('/reports/message')
|
||||
.body({
|
||||
channel_id: channel.id,
|
||||
message_id: message.id,
|
||||
category: 'hate_speech',
|
||||
additional_info: 'Contains hate speech',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should report message with illegal_activity category', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetUser = members[0];
|
||||
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Test message');
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, owner.token)
|
||||
.post('/reports/message')
|
||||
.body({
|
||||
channel_id: channel.id,
|
||||
message_id: message.id,
|
||||
category: 'illegal_activity',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should reject message report without authentication', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetUser = members[0];
|
||||
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Test message');
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/message')
|
||||
.body({
|
||||
channel_id: channel.id,
|
||||
message_id: message.id,
|
||||
category: 'harassment',
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Report Guild', () => {
|
||||
test('should report a guild with valid category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Problematic Guild');
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/guild')
|
||||
.body({
|
||||
guild_id: guild.id,
|
||||
category: 'harassment',
|
||||
additional_info: 'Guild promotes harassment',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should report guild with extremist_community category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/guild')
|
||||
.body({
|
||||
guild_id: guild.id,
|
||||
category: 'extremist_community',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should report guild with raid_coordination category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/guild')
|
||||
.body({
|
||||
guild_id: guild.id,
|
||||
category: 'raid_coordination',
|
||||
additional_info: 'Guild is coordinating raids',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should report guild with malware_distribution category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/guild')
|
||||
.body({
|
||||
guild_id: guild.id,
|
||||
category: 'malware_distribution',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should reject guild report with invalid category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, reporter.token)
|
||||
.post('/reports/guild')
|
||||
.body({
|
||||
guild_id: guild.id,
|
||||
category: 'invalid_category',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject guild report without authentication', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/guild')
|
||||
.body({
|
||||
guild_id: guild.id,
|
||||
category: 'harassment',
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Report Validation', () => {
|
||||
test('should reject report with additional_info exceeding max length', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
const longInfo = 'a'.repeat(1001);
|
||||
|
||||
await createBuilder(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId.toString(),
|
||||
category: 'harassment',
|
||||
additional_info: longInfo,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept report with maximum length additional_info', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
const maxLengthInfo = 'a'.repeat(1000);
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'harassment',
|
||||
additional_info: maxLengthInfo,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should accept report without additional_info', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'spam_account',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Report Requires Category', () => {
|
||||
test('should reject user report without category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId.toString(),
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject message report without category', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetUser = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, targetUser.token);
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Test message');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/reports/message')
|
||||
.body({
|
||||
channel_id: channel.id,
|
||||
message_id: message.id,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject guild report without category', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, reporter.token)
|
||||
.post('/reports/guild')
|
||||
.body({
|
||||
guild_id: guild.id,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Duplicate Reports', () => {
|
||||
test('should allow user to report same user multiple times', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
const firstReport = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'harassment',
|
||||
additional_info: 'First report',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(firstReport.report_id).toBeTruthy();
|
||||
|
||||
const secondReport = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'spam_account',
|
||||
additional_info: 'Second report with different category',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(secondReport.report_id).toBeTruthy();
|
||||
expect(secondReport.report_id).not.toBe(firstReport.report_id);
|
||||
});
|
||||
|
||||
test('should allow user to report same message multiple times', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetUser = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, targetUser.token);
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Problematic message');
|
||||
|
||||
const firstReport = await createBuilder<ReportResponse>(harness, owner.token)
|
||||
.post('/reports/message')
|
||||
.body({
|
||||
channel_id: channel.id,
|
||||
message_id: message.id,
|
||||
category: 'harassment',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(firstReport.report_id).toBeTruthy();
|
||||
|
||||
const secondReport = await createBuilder<ReportResponse>(harness, owner.token)
|
||||
.post('/reports/message')
|
||||
.body({
|
||||
channel_id: channel.id,
|
||||
message_id: message.id,
|
||||
category: 'spam',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(secondReport.report_id).toBeTruthy();
|
||||
expect(secondReport.report_id).not.toBe(firstReport.report_id);
|
||||
});
|
||||
|
||||
test('should allow user to report same guild multiple times', async () => {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Problematic Guild');
|
||||
|
||||
const firstReport = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/guild')
|
||||
.body({
|
||||
guild_id: guild.id,
|
||||
category: 'harassment',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(firstReport.report_id).toBeTruthy();
|
||||
|
||||
const secondReport = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/guild')
|
||||
.body({
|
||||
guild_id: guild.id,
|
||||
category: 'extremist_community',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(secondReport.report_id).toBeTruthy();
|
||||
expect(secondReport.report_id).not.toBe(firstReport.report_id);
|
||||
});
|
||||
|
||||
test('should allow different users to report same content', async () => {
|
||||
const reporter1 = await createTestAccount(harness);
|
||||
const reporter2 = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
const report1 = await createBuilder<ReportResponse>(harness, reporter1.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'harassment',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const report2 = await createBuilder<ReportResponse>(harness, reporter2.token)
|
||||
.post('/reports/user')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
category: 'harassment',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(report1.report_id).toBeTruthy();
|
||||
expect(report2.report_id).toBeTruthy();
|
||||
expect(report1.report_id).not.toBe(report2.report_id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DSA Report Flow', () => {
|
||||
test('should send DSA verification email', async () => {
|
||||
await clearTestEmails(harness);
|
||||
const email = createUniqueEmail('dsa-reporter');
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/dsa/email/send')
|
||||
.body({email})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const emails = await listTestEmails(harness);
|
||||
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
|
||||
expect(dsaEmail).toBeTruthy();
|
||||
expect(dsaEmail!.to).toBe(email.toLowerCase());
|
||||
expect(dsaEmail!.metadata.code).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should verify DSA email and return ticket', async () => {
|
||||
await clearTestEmails(harness);
|
||||
const email = createUniqueEmail('dsa-reporter');
|
||||
|
||||
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
|
||||
|
||||
const emails = await listTestEmails(harness);
|
||||
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
|
||||
expect(dsaEmail).toBeTruthy();
|
||||
|
||||
const code = dsaEmail!.metadata.code;
|
||||
|
||||
const verifyResponse = await createBuilder<{ticket: string}>(harness, '')
|
||||
.post('/reports/dsa/email/verify')
|
||||
.body({email, code})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(verifyResponse.ticket).toBeTruthy();
|
||||
expect(verifyResponse.ticket.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should reject DSA email verification with invalid code', async () => {
|
||||
await clearTestEmails(harness);
|
||||
const email = createUniqueEmail('dsa-reporter');
|
||||
|
||||
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/dsa/email/verify')
|
||||
.body({email, code: 'XXXX-XXXX'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should create DSA user report with valid ticket', async () => {
|
||||
await clearTestEmails(harness);
|
||||
const email = createUniqueEmail('dsa-reporter');
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
|
||||
|
||||
const emails = await listTestEmails(harness);
|
||||
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
|
||||
const code = dsaEmail!.metadata.code;
|
||||
|
||||
const verifyResponse = await createBuilder<{ticket: string}>(harness, '')
|
||||
.post('/reports/dsa/email/verify')
|
||||
.body({email, code})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, '')
|
||||
.post('/reports/dsa')
|
||||
.body({
|
||||
ticket: verifyResponse.ticket,
|
||||
report_type: 'user',
|
||||
category: 'harassment',
|
||||
user_id: targetUser.userId,
|
||||
reporter_full_legal_name: 'John Doe',
|
||||
reporter_country_of_residence: 'DE',
|
||||
additional_info: 'DSA report for harassment',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should create DSA guild report with valid ticket', async () => {
|
||||
await clearTestEmails(harness);
|
||||
const email = createUniqueEmail('dsa-reporter');
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'DSA Test Guild');
|
||||
|
||||
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
|
||||
|
||||
const emails = await listTestEmails(harness);
|
||||
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
|
||||
const code = dsaEmail!.metadata.code;
|
||||
|
||||
const verifyResponse = await createBuilder<{ticket: string}>(harness, '')
|
||||
.post('/reports/dsa/email/verify')
|
||||
.body({email, code})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const result = await createBuilder<ReportResponse>(harness, '')
|
||||
.post('/reports/dsa')
|
||||
.body({
|
||||
ticket: verifyResponse.ticket,
|
||||
report_type: 'guild',
|
||||
category: 'illegal_activity',
|
||||
guild_id: guild.id,
|
||||
reporter_full_legal_name: 'Jane Doe',
|
||||
reporter_country_of_residence: 'FR',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.report_id).toBeTruthy();
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should reject DSA report with invalid ticket', async () => {
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/dsa')
|
||||
.body({
|
||||
ticket: 'invalid-ticket-value',
|
||||
report_type: 'user',
|
||||
category: 'harassment',
|
||||
user_id: targetUser.userId.toString(),
|
||||
reporter_full_legal_name: 'John Doe',
|
||||
reporter_country_of_residence: 'DE',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject DSA report with malformed ticket', async () => {
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/dsa')
|
||||
.body({
|
||||
ticket: '',
|
||||
report_type: 'user',
|
||||
category: 'harassment',
|
||||
user_id: targetUser.userId,
|
||||
reporter_full_legal_name: 'John Doe',
|
||||
reporter_country_of_residence: 'DE',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require reporter_full_legal_name for DSA report', async () => {
|
||||
await clearTestEmails(harness);
|
||||
const email = createUniqueEmail('dsa-reporter');
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
|
||||
|
||||
const emails = await listTestEmails(harness);
|
||||
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
|
||||
const code = dsaEmail!.metadata.code;
|
||||
|
||||
const verifyResponse = await createBuilderWithoutAuth<{ticket: string}>(harness)
|
||||
.post('/reports/dsa/email/verify')
|
||||
.body({email, code})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/dsa')
|
||||
.body({
|
||||
ticket: verifyResponse.ticket,
|
||||
report_type: 'user',
|
||||
category: 'harassment',
|
||||
user_id: targetUser.userId.toString(),
|
||||
reporter_country_of_residence: 'DE',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require EU country for DSA report', async () => {
|
||||
await clearTestEmails(harness);
|
||||
const email = createUniqueEmail('dsa-reporter');
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
|
||||
|
||||
const emails = await listTestEmails(harness);
|
||||
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
|
||||
const code = dsaEmail!.metadata.code;
|
||||
|
||||
const verifyResponse = await createBuilderWithoutAuth<{ticket: string}>(harness)
|
||||
.post('/reports/dsa/email/verify')
|
||||
.body({email, code})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/dsa')
|
||||
.body({
|
||||
ticket: verifyResponse.ticket,
|
||||
report_type: 'user',
|
||||
category: 'harassment',
|
||||
user_id: targetUser.userId.toString(),
|
||||
reporter_full_legal_name: 'John Doe',
|
||||
reporter_country_of_residence: 'US',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user