feat(app,api): let group DM owners allow mature content (#3350)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 14:12:13 +02:00
1 parent 4749eb7f86
commit 9a20821332
126 files changed
+2127 -1277

No files matched your search

@@ -165,6 +165,7 @@ function serializeGroupDMChannel(channel: Channel): ChannelResponse {
icon: channel.iconHash ?? null,
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
nicks: nicknameMap.size > 0 ? nicks : undefined,
nsfw: channel.isNsfw,
};
}
@@ -179,6 +179,7 @@ export class ChannelDataService {
icon: groupDmData.icon !== undefined ? groupDmData.icon : undefined,
ownerId: groupDmData.owner_id ? createUserID(groupDmData.owner_id) : undefined,
nicks: groupDmData.nicks,
nsfw: groupDmData.nsfw ?? undefined,
requestCache,
});
}
@@ -12,7 +12,9 @@ import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {GroupDmMatureContentIneligibleError} from '@fluxer/errors/src/domains/channel/GroupDmMatureContentIneligibleError';
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
@@ -37,6 +39,7 @@ export class GroupDmUpdateService {
icon,
ownerId,
nicks,
nsfw,
requestCache,
}: {
userId: UserID;
@@ -45,6 +48,7 @@ export class GroupDmUpdateService {
icon?: string | null;
ownerId?: UserID;
nicks?: Record<string, string | null> | null;
nsfw?: boolean;
requestCache: RequestCache;
}): Promise<Channel> {
const channel = await this.channelRepository.channelData.findUnique(channelId);
@@ -113,6 +117,20 @@ export class GroupDmUpdateService {
updates.nicks = updatedNicknames.size > 0 ? (updatedNicknames as Map<string, string>) : null;
}
}
if (nsfw !== undefined && nsfw !== channel.isNsfw) {
if (channel.ownerId !== userId) {
throw new MissingPermissionsError();
}
if (nsfw) {
const recipients = await Promise.all(
Array.from(channel.recipientIds).map((id) => this.userRepository.findUnique(id)),
);
if (recipients.some((recipient) => !recipient || !canUserAccessNsfwContent(recipient))) {
throw new GroupDmMatureContentIneligibleError();
}
}
updates.nsfw = nsfw;
}
let iconHash: string | null = null;
if (icon !== undefined) {
iconHash = await this.avatarService.uploadAvatar({
@@ -24,6 +24,7 @@ import type {User} from '@app/api/models/User';
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
import {assertAccountNotLimited} from '@app/api/user/AccountLimit';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import {UserPermissionUtils} from '@app/api/utils/UserPermissionUtils';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
@@ -36,6 +37,7 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
import {CannotRemoveOtherRecipientsError} from '@fluxer/errors/src/domains/core/CannotRemoveOtherRecipientsError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
import {NotFriendsWithUserError} from '@fluxer/errors/src/domains/user/NotFriendsWithUserError';
export class GroupDmOperationsService {
@@ -84,6 +86,9 @@ export class GroupDmOperationsService {
userId,
targetId: recipientId,
});
if (channel.isNsfw && !(await this.canJoinMatureGroup(recipientId))) {
throw new MissingAccessError();
}
const {channel: updatedChannel} = await this.addRecipientViaInviteWithResult({
channelId,
recipientId,
@@ -122,6 +127,11 @@ export class GroupDmOperationsService {
return updatedChannel;
}
private async canJoinMatureGroup(userId: UserID): Promise<boolean> {
const user = await this.userRepository.findUnique(userId);
return user != null && canUserAccessNsfwContent(user);
}
private async assertInviterNotLimited(userId: UserID): Promise<void> {
const user = await this.userRepository.findUnique(userId);
if (user) assertAccountNotLimited(user);
@@ -169,6 +179,9 @@ export class GroupDmOperationsService {
if (channel.recipientIds.has(recipientId)) {
return {channel, recipientAdded: false};
}
if (channel.isNsfw && !(await this.canJoinMatureGroup(recipientId))) {
throw new NsfwContentRequiresAgeVerificationError();
}
const inviterUser = inviterId ? await this.userRepository.findUnique(inviterId) : null;
const fallbackLimit = MAX_GROUP_DM_RECIPIENTS;
const recipientLimit = this.resolveLimitForUser(inviterUser ?? null, 'max_group_dm_recipients', fallbackLimit);
@@ -9,7 +9,7 @@ import * as EmojiUtils from '@app/api/utils/EmojiUtils';
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {GuildExplicitContentFilterTypes, GuildFeatures, GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {SensitiveMediaFilterLevel} from '@fluxer/constants/src/UserConstants';
import {RelationshipTypes, SensitiveMediaFilterLevel} from '@fluxer/constants/src/UserConstants';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -40,6 +40,30 @@ export class MessageContentService {
});
}
async resolveDmNsfwContext(channel: Channel, senderId: UserID): Promise<DmNsfwContext | undefined> {
if (channel.type !== ChannelTypes.DM) {
return undefined;
}
const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== senderId);
if (recipientIds.length !== 1) {
return undefined;
}
const recipientId = recipientIds[0];
const [senderSettings, recipientSettings, friendship] = await Promise.all([
this.userRepository.findSettings(senderId),
this.userRepository.findSettings(recipientId),
this.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.FRIEND),
]);
const areFriends = friendship != null;
const senderFilterLevel = areFriends
? (senderSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
: (senderSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
const recipientFilterLevel = areFriends
? (recipientSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
: (recipientSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
return {senderFilterLevel, recipientFilterLevel};
}
isNSFWContentAllowed(params: {
channel?: Channel;
guild?: GuildResponse | null;
@@ -61,6 +85,9 @@ export class MessageContentService {
if (channel?.type === ChannelTypes.DM_PERSONAL_NOTES) {
return true;
}
if (channel?.type === ChannelTypes.GROUP_DM && channel.isNsfw) {
return true;
}
if (!guild) {
if (dmNsfwContext) {
const eitherBlocks =
@@ -175,6 +175,7 @@ export class MessageEditService {
});
}
const isBugHunterBot = !!user?.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
const dmNsfwContext = guild ? undefined : await this.deps.persistenceService.resolveDmNsfwContext(channel, userId);
const updateResult = await this.deps.messageWriteLock.withFreshMessage(channelId, messageId, async (fresh) => {
if (!fresh) throw new UnknownMessageError();
return this.deps.crosspostPropagation.withPublishedEditBudget({fresh, actor: 'author'}, () =>
@@ -190,6 +191,7 @@ export class MessageEditService {
isBot: user?.isBot,
isBugHunterBot,
locale: user?.locale,
dmNsfwContext,
}),
);
});
@@ -158,6 +158,10 @@ export class MessagePersistenceService {
return resolveNsfwScopeChannel(channel, (channelId) => this.channelRepository.channelData.findUnique(channelId));
}
resolveDmNsfwContext(channel: Channel, senderId: UserID): Promise<DmNsfwContext | undefined> {
return this.contentService.resolveDmNsfwContext(channel, senderId);
}
getEmbedAttachmentResolver(): MessageEmbedAttachmentResolver {
return this.embedAttachmentResolver;
}
@@ -402,6 +406,7 @@ export class MessagePersistenceService {
isBot?: boolean;
isBugHunterBot?: boolean;
locale?: string | null;
dmNsfwContext?: DmNsfwContext;
}): Promise<UpdateMessageResult> {
const {message, messageId, data, channel, guild, member} = params;
if (message.messageSnapshots && message.messageSnapshots.length > 0) {
@@ -412,6 +417,7 @@ export class MessagePersistenceService {
guild,
member,
isBot: params.isBot,
dmNsfwContext: params.dmNsfwContext,
});
const updatedRowData = {...message.toRow()};
let hasChanges = false;
@@ -19,7 +19,6 @@ import type {AuthenticatedChannel} from '@app/api/channel/services/Authenticated
import type {CrosspostPropagation} from '@app/api/channel/services/message/CrosspostPropagation';
import {emitMessageCreated} from '@app/api/channel/services/message/MessageActivity';
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
import type {DmNsfwContext} from '@app/api/channel/services/message/MessageContentService';
import type {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService';
import type {MessageEmbedAttachmentResolver} from '@app/api/channel/services/message/MessageEmbedAttachmentResolver';
import {
@@ -69,12 +68,7 @@ import {
} from '@fluxer/constants/src/ChannelConstants';
import {GuildNSFWLevel, GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {threadWriteBlock} from '@fluxer/constants/src/ThreadPermissionUtils';
import {
DELETED_USER_ID,
RelationshipTypes,
SensitiveMediaFilterLevel,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {DELETED_USER_ID, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
@@ -193,33 +187,6 @@ export class MessageSendService {
return includeDefault ? {includeDefault} : null;
}
private async buildDmNsfwContext(channel: Channel, senderId: UserID): Promise<DmNsfwContext | undefined> {
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return undefined;
}
if (channel.type !== ChannelTypes.DM) {
return undefined;
}
const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== senderId);
if (recipientIds.length !== 1) {
return undefined;
}
const recipientId = recipientIds[0];
const [senderSettings, recipientSettings, friendship] = await Promise.all([
this.deps.userRepository.findSettings(senderId),
this.deps.userRepository.findSettings(recipientId),
this.deps.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.FRIEND),
]);
const areFriends = friendship != null;
const senderFilterLevel = areFriends
? (senderSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
: (senderSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
const recipientFilterLevel = areFriends
? (recipientSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
: (recipientSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
return {senderFilterLevel, recipientFilterLevel};
}
private attachmentsToProcess(attachments?: Array<AttachmentRequestData>): Array<AttachmentToProcess> | undefined {
if (!attachments) return undefined;
const processed = attachments.filter(
@@ -1033,7 +1000,7 @@ export class MessageSendService {
channelId,
data,
});
const dmNsfwContext = guild ? undefined : await this.buildDmNsfwContext(channel, user.id);
const dmNsfwContext = guild ? undefined : await this.deps.persistenceService.resolveDmNsfwContext(channel, user.id);
const messageId = forumStarter ? channelIdToMessageId(channel.id) : await this.generateMessageId(channel);
let mentionData: SendMentionData | undefined;
const shouldExtractMentions =
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {MessageContentService} from '@app/api/channel/services/message/MessageContentService';
import {createChannelInvite, createFriendship, createGroupDmChannel} from '@app/api/channel/tests/ChannelTestUtils';
import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
describe('group dm mature content', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness.shutdown();
});
async function setup() {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const other = await createTestAccount(harness);
await createFriendship(harness, owner, member);
await createFriendship(harness, owner, other);
await ensureSessionStarted(harness, owner.token);
const group = await createGroupDmChannel(harness, owner.token, [member.userId, other.userId]);
return {owner, member, other, groupId: group.id};
}
async function joinAsMinor(groupId: string, owner: TestAccount, status: number, code?: string) {
const minor = await createTestAccount(harness, {dateOfBirth: '2012-01-01'});
const invite = await createChannelInvite(harness, owner.token, groupId);
await createBuilder(harness, minor.token).post(`/invites/${invite.code}`).body({}).expect(status, code).execute();
}
function setNsfw(account: TestAccount, channelId: string, nsfw: boolean, status = 200, code?: string) {
return createBuilder<ChannelResponse>(harness, account.token)
.patch(`/channels/${channelId}`)
.body({type: ChannelTypes.GROUP_DM, nsfw})
.expect(status, code)
.execute();
}
it('lets the owner mark the group and allows mature media once marked', async () => {
const s = await setup();
const scope = vi.spyOn(MessageContentService.prototype, 'isNSFWContentAllowed');
await sendMessage(harness, s.owner.token, s.groupId, 'before');
expect(scope.mock.results.at(-1)?.value).toBe(false);
const updated = await setNsfw(s.owner, s.groupId, true);
expect(updated.nsfw).toBe(true);
await sendMessage(harness, s.owner.token, s.groupId, 'after');
expect(scope.mock.results.at(-1)?.value).toBe(true);
const cleared = await setNsfw(s.owner, s.groupId, false);
expect(cleared.nsfw).toBe(false);
});
it('rejects the toggle from a member who is not the owner', async () => {
const s = await setup();
await setNsfw(s.member, s.groupId, true, 403);
});
it('refuses to mark a group that includes a minor', async () => {
const s = await setup();
await joinAsMinor(s.groupId, s.owner, 200);
await setNsfw(s.owner, s.groupId, true, 400, APIErrorCodes.GROUP_DM_MATURE_CONTENT_INELIGIBLE);
});
it('refuses to let a minor join a marked group', async () => {
const s = await setup();
await setNsfw(s.owner, s.groupId, true);
await joinAsMinor(s.groupId, s.owner, 403, APIErrorCodes.NSFW_CONTENT_AGE_RESTRICTED);
});
});
+6
View File
@@ -32324,6 +32324,10 @@
"nicks": {
"description": "Custom nicknames for users in this group DM",
"anyOf": [{"$ref": "#/components/schemas/ChannelNicknameOverrides"}, {"type": "null"}]
},
"nsfw": {
"description": "Whether the group DM is marked for mature content (owner only)",
"type": ["boolean", "null"]
}
}
},
@@ -39725,6 +39729,7 @@
"MAX_GROUP_DM_RECIPIENTS",
"MAX_GROUP_DMS",
"GROUP_DM_RECIPIENTS_NOT_ADDABLE",
"GROUP_DM_MATURE_CONTENT_INELIGIBLE",
"MAX_GUILD_CHANNELS",
"MAX_GUILD_MEMBERS",
"MAX_GUILD_ROLES",
@@ -40021,6 +40026,7 @@
"You've reached the maximum of {count, plural, one {# group DM recipient} other {# group DM recipients}}.",
"You've reached the maximum of {count, plural, one {# group DM} other {# group DMs}}.",
"One or more selected users can't be added to this group DM.",
"Not everyone in this group is eligible for mature content.",
"You've reached the maximum of {count, plural, one {# community channel} other {# community channels}}.",
"You've reached the maximum of {count, plural, one {# community member} other {# community members}}.",
"You've reached the maximum of {count, plural, one {# community role} other {# community roles}}.",