fix(api): correct report targets and ticket handling (#2504)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:30:41 +02:00
1 parent 1f627c9cc5
commit 632f4c7b6c
4 files changed
+219 -11

No files matched your search

+15 -8
View File
@@ -24,6 +24,7 @@ import {ReportBannedError} from '@fluxer/errors/src/domains/moderation/ReportBan
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 {SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IEmailService} from '@pkgs/email/src/IEmailService';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
@@ -170,7 +171,6 @@ export class ReportService {
reported_guild_invite_code: null,
...contentWarningSnapshot,
};
await this.consumeMessageReportRateLimits({reporter, channel, message});
let duplicateReservationCreated = false;
if (reporter.id) {
duplicateReservationCreated = await this.reportRepository.reserveMessageReportByReporter({
@@ -185,6 +185,7 @@ export class ReportService {
}
}
try {
await this.consumeMessageReportRateLimits({reporter, channel, message});
const report = await this.reportRepository.createReport(reportData);
if (this.reportSearchService && 'indexReport' in this.reportSearchService) {
await this.reportSearchService.indexReport(report).catch((error) => {
@@ -221,6 +222,9 @@ export class ReportService {
}
const reportId = createReportID(await this.snowflakeService.generate());
const guild = guildId ? await this.guildRepository.findUnique(guildId) : null;
if (guildId && !guild) {
throw new UnknownGuildError();
}
const contentWarningSnapshot = await this.buildContentWarningSnapshot(guild, null);
const reportData: IARSubmissionRow = {
report_id: reportId,
@@ -372,7 +376,7 @@ export class ReportService {
}
async createDsaReport(report: DsaReportRequest): Promise<IARSubmission> {
const ticket = await this.consumeDsaTicket(report.ticket);
const ticket = await this.readDsaTicket(report.ticket);
const reporterMeta: ReporterMetadata = {
id: null,
email: ticket.email_lower,
@@ -385,6 +389,7 @@ export class ReportService {
const reportId = createReportID(await this.snowflakeService.generate());
const reportRow = await this.buildDsaReportRow(reportId, report, reporterMeta);
await this.ensureReportRateLimit(this.createReportRateLimitIdentifier(reporterKey), REPORT_RATE_LIMIT_MAX, true);
await this.reportRepository.deleteDsaTicket(report.ticket);
const createdReport = await this.reportRepository.createReport(reportRow);
if (this.reportSearchService && 'indexReport' in this.reportSearchService) {
await this.reportSearchService.indexReport(createdReport).catch((error) => {
@@ -706,12 +711,11 @@ export class ReportService {
return user;
}
private async consumeDsaTicket(ticket: string): Promise<DSAReportTicketRow> {
private async readDsaTicket(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;
}
@@ -762,11 +766,14 @@ export class ReportService {
if (segments.length < 4 || segments[0] !== 'channels') {
throw new UnknownMessageError();
}
const channelIdSegment = segments[2];
const messageIdSegment = segments[3];
const channelId = SnowflakeType.safeParse(segments[2]);
const messageId = SnowflakeType.safeParse(segments[3]);
if (!channelId.success || !messageId.success) {
throw new UnknownMessageError();
}
return {
channelId: createChannelID(BigInt(channelIdSegment)),
messageId: createMessageID(BigInt(messageIdSegment)),
channelId: createChannelID(channelId.data),
messageId: createMessageID(messageId.data),
};
}
@@ -30,7 +30,7 @@ import {
import {ensureSessionStarted} from '../../message/tests/MessageTestUtils';
import {ReadStateRepository} from '../../read_state/ReadStateRepository';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {HTTP_STATUS, TEST_IDS} from '../../test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {ReportRepository} from '../ReportRepository';
@@ -129,6 +129,19 @@ describe('Content Reporting', () => {
.execute();
expect(result.report_id).toBeTruthy();
});
test('should reject a user report naming an unknown guild', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
await createBuilder(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'harassment',
guild_id: TEST_IDS.NONEXISTENT_GUILD,
})
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_GUILD)
.execute();
});
test('should reject report with invalid category', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
@@ -440,6 +453,45 @@ describe('Content Reporting', () => {
.execute();
expect(await countReports()).toBe(3);
});
test('a duplicate message report does not spend the reporter allowance', 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 [firstMessage, secondMessage] = await Promise.all([
sendChannelMessage(harness, targetUser.token, channel.id, 'Duplicate allowance target'),
sendChannelMessage(harness, targetUser.token, channel.id, 'Duplicate allowance second target'),
]);
await createBuilder<ReportResponse>(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: firstMessage.id,
category: 'harassment',
})
.expect(HTTP_STATUS.OK)
.execute();
for (let attempt = 0; attempt < 5; attempt += 1) {
await createBuilder(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: firstMessage.id,
category: 'spam',
})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
.execute();
}
await createBuilder<ReportResponse>(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: secondMessage.id,
category: 'harassment',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(await countReports()).toBe(2);
});
});
describe('Report Guild', () => {
async function setupMemberAndGuild(): Promise<{
@@ -817,6 +869,47 @@ describe('Content Reporting', () => {
expect(result.report_id).toBeTruthy();
expect(result.status).toBe('pending');
});
test('a DSA notice that fails target resolution keeps its 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();
await createBuilderWithoutAuth(harness)
.post('/reports/dsa')
.body({
ticket: verifyResponse.ticket,
report_type: 'user',
category: 'harassment',
user_id: TEST_IDS.NONEXISTENT_USER,
reporter_full_legal_name: 'John Doe',
reporter_country_of_residence: 'DE',
})
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_USER)
.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',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
test('should create DSA guild report with valid ticket', async () => {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-reporter');
@@ -848,6 +941,60 @@ describe('Content Reporting', () => {
expect(result.report_id).toBeTruthy();
expect(result.status).toBe('pending');
});
test('should reject DSA message report with a non-numeric message link segment', 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');
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();
await createBuilderWithoutAuth(harness)
.post('/reports/dsa')
.body({
ticket: verifyResponse.ticket,
report_type: 'message',
category: 'harassment',
message_link: 'https://fluxer.test/channels/1/abc/def',
reporter_full_legal_name: 'John Doe',
reporter_country_of_residence: 'DE',
})
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_MESSAGE)
.execute();
});
test('should reject DSA message report with an out-of-range message link segment', 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');
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();
await createBuilderWithoutAuth(harness)
.post('/reports/dsa')
.body({
ticket: verifyResponse.ticket,
report_type: 'message',
category: 'harassment',
message_link: 'https://fluxer.test/channels/1/99999999999999999999/1',
reporter_full_legal_name: 'John Doe',
reporter_country_of_residence: 'DE',
})
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_MESSAGE)
.execute();
});
test('should reject DSA report with invalid ticket', async () => {
const targetUser = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)