mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(app,api): let group DM owners allow mature content (#3350)
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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}}.",
|
||||
|
||||
Reference in new issue
Block a user