feat: add announcement channels, publishing and following (#3090)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-01 16:57:21 +02:00
1 parent 376afd2ad6
commit 7e4d5137f8
381 files changed
+45650 -14076

No files matched your search

+93 -17
View File
@@ -3,6 +3,7 @@
import {createHash, randomBytes, randomInt} from 'node:crypto';
import type {ChannelID, GuildID, InviteCode, MessageID, ReportID, UserID} from '@app/api/BrandedTypes';
import {
createAttachmentID,
createChannelID,
createGuildID,
createInviteCode,
@@ -48,7 +49,7 @@ import {ReportStatus, ReportType} from '@app/api/report/IReportRepository';
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {InviteTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {InviteTypes, MessageFlags, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -155,7 +156,7 @@ export class ReportService {
channelId,
messageId,
});
const reportedUserId = message.authorId;
const reportedUserId = await this.resolveReportedAuthorId(message);
if (reportedUserId == null) {
throw new UnknownMessageError();
}
@@ -164,7 +165,7 @@ export class ReportService {
}
const [reportedUser, messageContext] = await Promise.all([
this.userRepository.findUnique(reportedUserId),
this.gatherMessageContext(channelId, messageId, authChannel),
this.gatherMessageContext(channelId, messageId, authChannel, reportedUserId),
]);
if (!reportedUser) {
throw new UnknownUserError();
@@ -464,20 +465,21 @@ export class ReportService {
if (!channel) throw new UnknownChannelError();
const message = await this.channelRepository.getMessage(channelId, messageId);
if (!message) throw new UnknownMessageError();
if (message.authorId == null) {
const reportedAuthorId = await this.resolveReportedAuthorId(message);
if (reportedAuthorId == null) {
throw new UnknownUserError();
}
if (report.reported_user_tag) {
const tagged = await this.findUserByTag(report.reported_user_tag);
if (tagged.id !== message.authorId) {
if (tagged.id !== reportedAuthorId) {
throw new InvalidDsaReportTargetError();
}
}
const reportedUser = await this.userRepository.findUnique(message.authorId);
const reportedUser = await this.userRepository.findUnique(reportedAuthorId);
if (!reportedUser) {
throw new UnknownUserError();
}
const messageContext = await this.gatherMessageContext(channelId, messageId);
const messageContext = await this.gatherMessageContext(channelId, messageId, undefined, reportedAuthorId);
const guild = channel.guildId ? await this.guildRepository.findUnique(channel.guildId) : null;
const contentWarningSnapshot = await this.buildContentWarningSnapshot(guild, channel);
return {
@@ -491,7 +493,7 @@ export class ReportService {
report_type: ReportType.MESSAGE,
category: report.category,
additional_info: report.additional_info ?? null,
reported_user_id: message.authorId,
reported_user_id: reportedAuthorId,
reported_user_avatar_hash: reportedUser.avatarHash || null,
reported_guild_id: channel.guildId || null,
reported_guild_name: guild?.name ?? null,
@@ -637,6 +639,24 @@ export class ReportService {
};
}
private async resolveReportedAuthorId(message: Message): Promise<UserID | null> {
if ((message.flags & MessageFlags.IS_CROSSPOST) === 0) {
return message.authorId;
}
if ((message.flags & MessageFlags.SOURCE_MESSAGE_DELETED) !== 0) {
return null;
}
const reference = message.reference;
if (!reference?.messageId) {
return null;
}
const source = await this.channelRepository.getMessage(reference.channelId, reference.messageId);
if (!source || (source.flags & MessageFlags.IS_CROSSPOST) !== 0) {
return null;
}
return source.authorId;
}
private async canAccessMessage(authChannel: AuthenticatedChannel, messageId: MessageID): Promise<boolean> {
if (!authChannel.guild) {
return true;
@@ -879,6 +899,7 @@ export class ReportService {
channelId: ChannelID,
targetMessageId: MessageID,
authChannel?: AuthenticatedChannel,
targetAuthorId?: UserID,
): Promise<Array<IARMessageContextRow>> {
const messagesBefore = await this.channelRepository.listMessages(
channelId,
@@ -900,10 +921,13 @@ export class ReportService {
[...messagesBefore, targetMessage, ...messagesAfter],
authChannel,
);
const contextAuthorId = (msg: Message): UserID | null =>
msg.authorId ?? (msg.id === targetMessageId ? (targetAuthorId ?? null) : null);
const userIds = new Set<UserID>();
for (const msg of allMessages) {
if (msg.authorId) {
userIds.add(msg.authorId);
const authorId = contextAuthorId(msg);
if (authorId) {
userIds.add(authorId);
}
}
const users = new Map<UserID, User>();
@@ -915,15 +939,23 @@ export class ReportService {
}
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)
: [];
const authorId = contextAuthorId(message);
const author = authorId != null ? users.get(authorId) : null;
if (!author || authorId == null) continue;
const clonedAttachments = [
...(message.attachments
? await this.cloneAttachmentsForReport(
message.attachments,
MessageHelpers.attachmentStorageChannelId(message),
channelId,
)
: []),
...(await this.cloneOwnedEmbedAttachmentsForReport(message, channelId)),
];
context.push({
message_id: message.id,
channel_id: channelId,
author_id: message.authorId!,
author_id: authorId,
author_username: author.username,
author_discriminator: author.discriminator,
author_avatar_hash: author.avatarHash || null,
@@ -966,6 +998,7 @@ export class ReportService {
private async cloneAttachmentsForReport(
attachments: Array<Attachment>,
sourceChannelId: ChannelID,
reportedChannelId: ChannelID,
): Promise<Array<MessageAttachment>> {
const clonedAttachments: Array<MessageAttachment> = [];
for (const attachment of attachments) {
@@ -975,7 +1008,7 @@ export class ReportService {
sourceBucket: Config.s3.buckets.cdn,
sourceKey,
destinationBucket: Config.s3.buckets.reports,
destinationKey: sourceKey,
destinationKey: MessageHelpers.makeAttachmentCdnKey(reportedChannelId, attachment.id, attachment.filename),
newContentType: attachment.contentType,
});
const clonedAttachment: MessageAttachment = {
@@ -1005,6 +1038,49 @@ export class ReportService {
return clonedAttachments;
}
private async cloneOwnedEmbedAttachmentsForReport(
message: Message,
reportedChannelId: ChannelID,
): Promise<Array<MessageAttachment>> {
const clonedAttachments: Array<MessageAttachment> = [];
for (const {key, media} of MessageHelpers.collectOwnedEmbedAttachments(message)) {
const [, , attachmentId, ...filenameParts] = key.split('/');
const filename = filenameParts.join('/');
try {
const metadata = await this.storageService.getObjectMetadata(Config.s3.buckets.cdn, key);
if (!metadata) continue;
const id = createAttachmentID(BigInt(attachmentId!));
const contentType = media.content_type ?? metadata.contentType;
await this.storageService.copyObject({
sourceBucket: Config.s3.buckets.cdn,
sourceKey: key,
destinationBucket: Config.s3.buckets.reports,
destinationKey: MessageHelpers.makeAttachmentCdnKey(reportedChannelId, id, filename),
newContentType: contentType,
});
clonedAttachments.push({
attachment_id: id,
filename,
size: BigInt(metadata.contentLength),
title: null,
description: media.description,
width: media.width,
height: media.height,
content_type: contentType,
content_hash: media.content_hash,
placeholder: media.placeholder,
flags: media.flags & ~MessageHelpers.EMBED_MEDIA_OWNED_ATTACHMENT_FLAG,
duration: media.duration,
nsfw: null,
waveform: null,
});
} catch (error) {
Logger.error({error, key, reportedChannelId}, 'Failed to clone embed attachment for report');
}
}
return clonedAttachments;
}
private async checkReportBan(userId: UserID | null): Promise<void> {
if (!userId) {
return;
@@ -0,0 +1,231 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
clearTestEmails,
createUniqueEmail,
findLastTestEmail,
listTestEmails,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createChannelID, createGuildID, createMessageID, createReportID} from '@app/api/BrandedTypes';
import {type AnnouncementWorld, announcementWorld} from '@app/api/channel/tests/AnnouncementTestUtils';
import {sendChannelMessage} from '@app/api/channel/tests/ChannelTestUtils';
import {readMessageRow, writeMessageRow} from '@app/api/channel/tests/CrosspostTestUtils';
import {ReportRepository} from '@app/api/report/ReportRepository';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {createWebhook, executeWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {MessageFlags, MessageReferenceTypes} from '@fluxer/constants/src/ChannelConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface ReportResponse {
report_id: string;
status: string;
}
interface CopyFixture {
world: AnnouncementWorld;
sourceId: string;
copyId: string;
}
async function writeCopy(params: {
harness: ApiTestHarness;
owner: TestAccount;
targetChannelId: string;
sourceGuildId: string;
sourceChannelId: string;
sourceMessageId: string;
content: string;
extraFlags?: number;
}): Promise<string> {
const webhook = await createWebhook(params.harness, params.targetChannelId, params.owner.token, 'Follower');
const {json} = await executeWebhook(
params.harness,
webhook.id,
webhook.token,
{content: params.content, wait: true},
200,
);
const copyId = json!.id;
const row = await readMessageRow(params.targetChannelId, copyId);
await writeMessageRow(row!, {
flags: MessageFlags.IS_CROSSPOST | (params.extraFlags ?? 0),
message_reference: {
channel_id: createChannelID(BigInt(params.sourceChannelId)),
guild_id: createGuildID(BigInt(params.sourceGuildId)),
message_id: createMessageID(BigInt(params.sourceMessageId)),
type: MessageReferenceTypes.DEFAULT,
},
});
return copyId;
}
async function createCopyOfMemberMessage(harness: ApiTestHarness, extraFlags?: number): Promise<CopyFixture> {
const world = await announcementWorld(harness);
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'published update');
const copyId = await writeCopy({
harness,
owner: world.b.owner,
targetChannelId: world.b.t1.id,
sourceGuildId: world.a.guild.id,
sourceChannelId: world.a.ann.id,
sourceMessageId: source.id,
content: 'published update',
extraFlags,
});
return {world, sourceId: source.id, copyId};
}
function reportMessage(harness: ApiTestHarness, token: string, channelId: string, messageId: string) {
return createBuilder<ReportResponse>(harness, token)
.post('/reports/message')
.body({channel_id: channelId, message_id: messageId, category: 'harassment'});
}
async function readReport(reportId: string) {
const report = await new ReportRepository().getReport(createReportID(BigInt(reportId)));
expect(report).not.toBeNull();
return report!;
}
async function createDsaTicket(harness: ApiTestHarness): Promise<string> {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-crosspost');
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
const dsaEmail = findLastTestEmail(await listTestEmails(harness), 'dsa_report_verification');
const {ticket} = await createBuilder<{ticket: string}>(harness, '')
.post('/reports/dsa/email/verify')
.body({email, code: dsaEmail!.metadata.code})
.expect(HTTP_STATUS.OK)
.execute();
return ticket;
}
function dsaMessageReport(harness: ApiTestHarness, ticket: string, channelId: string, messageId: string) {
return createBuilderWithoutAuth<ReportResponse>(harness)
.post('/reports/dsa')
.body({
ticket,
report_type: 'message',
category: 'harassment',
message_link: `https://fluxer.test/channels/0/${channelId}/${messageId}`,
reporter_full_legal_name: 'Jane Doe',
reporter_country_of_residence: 'DE',
});
}
describe('Reporting published copies', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('a member of the target reports a copy and the source author is reported', async () => {
const {world, copyId} = await createCopyOfMemberMessage(harness);
const result = await reportMessage(harness, world.b.webhookManager.token, world.b.t1.id, copyId)
.expect(HTTP_STATUS.OK)
.execute();
const report = await readReport(result.report_id);
expect(report.reportedUserId?.toString()).toBe(world.a.member.userId);
expect(report.reportedMessageId?.toString()).toBe(copyId);
expect(report.reportedChannelId?.toString()).toBe(world.b.t1.id);
expect(report.reportedGuildId?.toString()).toBe(world.b.guild.id);
const reportedContext = report.messageContext?.find((entry) => entry.messageId.toString() === copyId);
expect(reportedContext?.authorId.toString()).toBe(world.a.member.userId);
expect(reportedContext?.content).toBe('published update');
});
test('the source author cannot report a copy of their own message', async () => {
const world = await announcementWorld(harness);
const source = await sendChannelMessage(harness, world.b.owner.token, world.a.ann.id, 'my own update');
const copyId = await writeCopy({
harness,
owner: world.b.owner,
targetChannelId: world.b.t1.id,
sourceGuildId: world.a.guild.id,
sourceChannelId: world.a.ann.id,
sourceMessageId: source.id,
content: 'my own update',
});
await reportMessage(harness, world.b.owner.token, world.b.t1.id, copyId)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CANNOT_REPORT_OWN_MESSAGE)
.execute();
});
test('the message-link report on a copy reports the source author', async () => {
const {world, copyId} = await createCopyOfMemberMessage(harness);
const ticket = await createDsaTicket(harness);
const result = await dsaMessageReport(harness, ticket, world.b.t1.id, copyId).expect(HTTP_STATUS.OK).execute();
const report = await readReport(result.report_id);
expect(report.reportedUserId?.toString()).toBe(world.a.member.userId);
expect(report.reportedMessageId?.toString()).toBe(copyId);
expect(report.reportedChannelId?.toString()).toBe(world.b.t1.id);
});
test('a copy of a webhook-authored source keeps the existing errors', async () => {
const world = await announcementWorld(harness);
const sourceWebhook = await createWebhook(harness, world.a.ann.id, world.a.owner.token, 'Source hook');
const {json: source} = await executeWebhook(
harness,
sourceWebhook.id,
sourceWebhook.token,
{content: 'from a webhook', wait: true},
200,
);
const copyId = await writeCopy({
harness,
owner: world.b.owner,
targetChannelId: world.b.t1.id,
sourceGuildId: world.a.guild.id,
sourceChannelId: world.a.ann.id,
sourceMessageId: source!.id,
content: 'from a webhook',
});
await reportMessage(harness, world.b.webhookManager.token, world.b.t1.id, copyId)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_MESSAGE)
.execute();
const ticket = await createDsaTicket(harness);
await dsaMessageReport(harness, ticket, world.b.t1.id, copyId)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_USER)
.execute();
});
test('a source-deleted copy keeps the existing errors', async () => {
const {world, copyId} = await createCopyOfMemberMessage(harness, MessageFlags.SOURCE_MESSAGE_DELETED);
await reportMessage(harness, world.b.webhookManager.token, world.b.t1.id, copyId)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_MESSAGE)
.execute();
const ticket = await createDsaTicket(harness);
await dsaMessageReport(harness, ticket, world.b.t1.id, copyId)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_USER)
.execute();
});
test('a copy whose source is gone keeps the existing error', async () => {
const {world, sourceId, copyId} = await createCopyOfMemberMessage(harness);
await createBuilder(harness, world.a.member.token)
.delete(`/channels/${world.a.ann.id}/messages/${sourceId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await reportMessage(harness, world.b.webhookManager.token, world.b.t1.id, copyId)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_MESSAGE)
.execute();
});
test('reports on ordinary webhook messages are unchanged', async () => {
const world = await announcementWorld(harness);
const webhook = await createWebhook(harness, world.b.t1.id, world.b.owner.token, 'Plain hook');
const {json} = await executeWebhook(harness, webhook.id, webhook.token, {content: 'plain', wait: true}, 200);
await reportMessage(harness, world.b.webhookManager.token, world.b.t1.id, json!.id)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_MESSAGE)
.execute();
});
});