diff --git a/fluxer_api/src/api/report/ReportService.ts b/fluxer_api/src/api/report/ReportService.ts index ad153e035..56cb723fe 100644 --- a/fluxer_api/src/api/report/ReportService.ts +++ b/fluxer_api/src/api/report/ReportService.ts @@ -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 { - 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 { + private async readDsaTicket(ticket: string): Promise { 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), }; } diff --git a/fluxer_api/src/api/report/tests/ContentReporting.test.ts b/fluxer_api/src/api/report/tests/ContentReporting.test.ts index 5d6424ee3..c64ac0cad 100644 --- a/fluxer_api/src/api/report/tests/ContentReporting.test.ts +++ b/fluxer_api/src/api/report/tests/ContentReporting.test.ts @@ -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(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(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(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) diff --git a/fluxer_api/src/api/search/SearchService.ts b/fluxer_api/src/api/search/SearchService.ts index f5c66a83a..f8a53e700 100644 --- a/fluxer_api/src/api/search/SearchService.ts +++ b/fluxer_api/src/api/search/SearchService.ts @@ -61,7 +61,7 @@ export class SearchService { const {channel_id, channel_ids, context_channel_id, context_guild_id, ...searchParams} = data; const contextChannelId = context_channel_id ? createChannelID(context_channel_id) : null; const contextGuildId = context_guild_id ? createGuildID(context_guild_id) : null; - const channelIds = channel_ids?.map((id) => createChannelID(id)) ?? []; + const channelIds = (channel_ids ?? channel_id)?.map((id) => createChannelID(id)) ?? []; const scope = searchParams.scope ?? 'current'; let result: MessageSearchResponse; switch (scope) { diff --git a/fluxer_api/src/api/search/tests/MessageSearchFilters.test.ts b/fluxer_api/src/api/search/tests/MessageSearchFilters.test.ts index 1c7292546..3c93b9250 100644 --- a/fluxer_api/src/api/search/tests/MessageSearchFilters.test.ts +++ b/fluxer_api/src/api/search/tests/MessageSearchFilters.test.ts @@ -12,7 +12,12 @@ import { createGuild, getChannel, } from '../../guild/tests/GuildTestUtils'; -import {markChannelAsIndexed, pinMessage, sendMessage} from '../../message/tests/MessageTestUtils'; +import { + markChannelAsIndexed, + markGuildChannelsAsIndexed, + pinMessage, + sendMessage, +} from '../../message/tests/MessageTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {HTTP_STATUS} from '../../test/TestConstants'; import {createBuilder} from '../../test/TestRequestBuilder'; @@ -292,6 +297,55 @@ describe('Message Search Filters', () => { expect(result.messages.every((m) => m.channel_id !== channel3.id)).toBe(true); } }); + test('channel_id narrows scope like channel_ids', async () => { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Channel Id Alias Guild'); + const channel1 = await getChannel(harness, account.token, guild.system_channel_id!); + const channel2 = await createChannel(harness, account.token, guild.id, 'alias-second-channel'); + const timestamp = Date.now(); + const baseContent = `channel-id-alias-${timestamp}`; + await sendMessage(harness, account.token, channel1.id, `${baseContent} channel1 msg`); + await sendMessage(harness, account.token, channel2.id, `${baseContent} channel2 msg`); + await markGuildChannelsAsIndexed(harness, account.token, guild.id); + const result = await createBuilder(harness, account.token) + .post('/search/messages') + .body({ + content: baseContent, + scope: 'all_guilds', + channel_id: [channel1.id], + }) + .expect(HTTP_STATUS.OK) + .execute(); + if (!isSearchResult(result)) { + expect.fail('Expected search result but got indexing response'); + } + expect(result.messages.length).toBeGreaterThan(0); + for (const msg of result.messages) { + expect(msg.channel_id).toBe(channel1.id); + } + expect(result.messages.every((m) => m.channel_id !== channel2.id)).toBe(true); + }); + test('channel_id outside the resolved scope returns 403', async () => { + const account = await createTestAccount(harness); + const outsider = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Channel Id Scope Guild'); + const outsideGuild = await createGuild(harness, outsider.token, 'Channel Id Outside Guild'); + const timestamp = Date.now(); + const baseContent = `channel-id-scope-${timestamp}`; + await sendMessage(harness, account.token, guild.system_channel_id!, `${baseContent} own msg`); + await sendMessage(harness, outsider.token, outsideGuild.system_channel_id!, `${baseContent} outside msg`); + await markGuildChannelsAsIndexed(harness, account.token, guild.id); + await markGuildChannelsAsIndexed(harness, outsider.token, outsideGuild.id); + await createBuilder(harness, account.token) + .post('/search/messages') + .body({ + content: baseContent, + scope: 'all_guilds', + channel_id: [outsideGuild.system_channel_id!], + }) + .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS') + .execute(); + }); }); describe('Content Type Filtering (has/exclude_has)', () => { test('has: link finds messages with links', async () => {