mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat: add announcement channels, publishing and following (#3090)
This commit is contained in:
1 parent
376afd2ad6
commit
7e4d5137f8
381 files changed
+45650
-14076
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user