This commit is contained in:
tamaina 2025-09-25 20:33:24 +09:00 committed by GitHub
commit 82e961a04c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1512 additions and 210 deletions

View File

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RemoteSuspend1751848750315 {
name = 'RemoteSuspend1751848750315'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "isRemoteSuspended" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "user"."isSuspended" IS 'Whether the User is suspended by the local moderators.'`);
await queryRunner.query(`COMMENT ON COLUMN "user"."isRemoteSuspended" IS 'Whether the User is suspended by the remote moderators.'`);
}
async down(queryRunner) {
await queryRunner.query(`COMMENT ON COLUMN "user"."isRemoteSuspended" IS 'Whether the User is suspended by the remote moderators.'`);
await queryRunner.query(`COMMENT ON COLUMN "user"."isSuspended" IS 'Whether the User is suspended.'`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isRemoteSuspended"`);
}
}

View File

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class FollowingIsFollowerSuspended1752410859370 {
name = 'FollowingIsFollowerSuspended1752410859370'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_ce62b50d882d4e9dee10ad0d2f"`);
await queryRunner.query(`ALTER TABLE "following" ADD "isFollowerSuspended" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_1896254b78a41a50e0396fdabd" ON "following" ("followeeId", "followerHost", "isFollowerSuspended", "isFollowerHibernated") `);
await queryRunner.query(`CREATE INDEX "IDX_d2b8dbf0b772042f4fe241a29d" ON "following" ("followerId", "followeeId", "isFollowerSuspended") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_d2b8dbf0b772042f4fe241a29d"`);
await queryRunner.query(`DROP INDEX "public"."IDX_1896254b78a41a50e0396fdabd"`);
await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "isFollowerSuspended"`);
await queryRunner.query(`CREATE INDEX "IDX_ce62b50d882d4e9dee10ad0d2f" ON "following" ("followeeId", "followerHost", "isFollowerHibernated") `);
}
}

View File

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class FollowingIsFollowerSuspendedCopySuspendedState1752410900000 {
name = 'FollowingIsFollowerSuspendedCopySuspendedState1752410900000'
async up(queryRunner) {
// Update existing records based on user suspension status
await queryRunner.query(`
UPDATE "following"
SET "isFollowerSuspended" = "user"."isSuspended"
FROM "user"
WHERE "following"."followerId" = "user"."id"
`);
}
async down(queryRunner) {
}
}

View File

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { RelayService } from '@/core/RelayService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
@ -26,16 +26,49 @@ export class AccountUpdateService {
) {
}
private async createUpdatePersonActivity(user: MiLocalUser) {
return this.apRendererService.addContext(
this.apRendererService.renderUpdate(
await this.apRendererService.renderPerson(user), user
)
);
}
@bindThis
public async publishToFollowers(userId: MiUser['id']) {
const user = await this.usersRepository.findOneBy({ id: userId });
if (user == null) throw new Error('user not found');
if (user == null || user.isDeleted) {
// ユーザーが存在しない、または削除されている場合は何もしない
return;
}
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
// ローカルユーザーならUpdateを配信
if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));
const content = await this.createUpdatePersonActivity(user);
this.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(user, content);
}
}
@bindThis
async publishToFollowersAndSharedInboxAndRelays(userId: MiUser['id']) {
const user = await this.usersRepository.findOneBy({ id: userId });
if (user == null || user.isDeleted) {
// ユーザーが存在しない、または削除されている場合は何もしない
return;
}
// ローカルユーザーならUpdateを配信
if (this.userEntityService.isLocalUser(user)) {
const content = await this.createUpdatePersonActivity(user);
const manager = this.apDeliverManagerService.createDeliverManager(user, content);
manager.addAllKnowingSharedInboxRecipe();
manager.addFollowersRecipe();
await Promise.allSettled([
manager.execute(),
this.relayService.deliverToRelays(user, content),
]);
}
}
}

View File

@ -149,6 +149,7 @@ export class FanoutTimelineEndpointService {
filter = (note) => {
if (!ps.ignoreAuthorFromUserSuspension) {
if (note.user!.isSuspended) return false;
if (note.user!.isRemoteSuspended) return false;
}
if (note.userId !== note.renoteUserId && note.renote?.user?.isSuspended) return false;
if (note.userId !== note.replyUserId && note.reply?.user?.isSuspended) return false;

View File

@ -225,7 +225,7 @@ type UndefinedAsNullAll<T> = {
};
export interface InternalEventTypes {
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; } | { id: MiUser['id']; isRemoteSuspended: MiUser['isRemoteSuspended']; };
userChangeDeletedState: { id: MiUser['id']; isDeleted: MiUser['isDeleted']; };
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
remoteUserUpdated: { id: MiUser['id']; };

View File

@ -549,6 +549,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// TODO: キャッシュ
this.followingsRepository.findBy({
followeeId: user.id,
isFollowerSuspended: false,
notify: 'normal',
}).then(async followings => {
if (note.visibility !== 'specified') {
@ -854,6 +855,7 @@ export class NoteCreateService implements OnApplicationShutdown {
where: {
followeeId: user.id,
followerHost: IsNull(),
isFollowerSuspended: false,
isFollowerHibernated: false,
},
select: ['followerId', 'withReplies'],

View File

@ -174,6 +174,9 @@ export class QueueService {
@bindThis
public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>) {
if (content == null) return null;
inboxes.delete(null as unknown as string); // remove null inboxes
if (inboxes.size === 0) return null;
const contentBody = JSON.stringify(content);
const digest = ApRequestCreator.createDigest(contentBody);

View File

@ -260,7 +260,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
// サスペンド済みユーザである
case 'isSuspended': {
return user.isSuspended;
return this.userEntityService.isSuspendedEither(user);
}
// 鍵アカウントユーザである
case 'isLocked': {

View File

@ -229,9 +229,7 @@ export class UserFollowingService implements OnModuleInit {
followee: {
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']
},
follower: {
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']
},
follower: MiUser,
silent = false,
withReplies?: boolean,
): Promise<void> {
@ -244,6 +242,7 @@ export class UserFollowingService implements OnModuleInit {
followerId: follower.id,
followeeId: followee.id,
withReplies: withReplies,
isFollowerSuspended: follower.isSuspended,
// 非正規化
followerHost: follower.host,
@ -734,6 +733,7 @@ export class UserFollowingService implements OnModuleInit {
return this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: userId })
.andWhere('following.isFollowerSuspended = false')
.getMany();
}
@ -743,6 +743,7 @@ export class UserFollowingService implements OnModuleInit {
where: {
followerId,
followeeId,
isFollowerSuspended: false,
},
});
}

View File

@ -207,7 +207,7 @@ export class UserSearchService {
}
}
userQuery.andWhere('user.isSuspended = FALSE');
userQuery.andWhere('user.isSuspended = FALSE').andWhere('user.isRemoteSuspended = FALSE');
return userQuery;
}
@ -243,7 +243,8 @@ export class UserSearchService {
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}))
.andWhere('user.isSuspended = FALSE');
.andWhere('user.isSuspended = FALSE')
.andWhere('user.isRemoteSuspended = FALSE');
if (mutingQuery) {
nameQuery.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`);
@ -286,6 +287,7 @@ export class UserSearchService {
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}))
.andWhere('user.isSuspended = FALSE')
.andWhere('user.isRemoteSuspended = FALSE')
.setParameters(profQuery.getParameters());
users = users.concat(await userQuery

View File

@ -4,17 +4,14 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
import type { MiRemoteUser, MiUser } from '@/models/User.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
@Injectable()
export class UserSuspendService {
@ -29,9 +26,8 @@ export class UserSuspendService {
private followRequestsRepository: FollowRequestsRepository,
private userEntityService: UserEntityService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
private apRendererService: ApRendererService,
private accountUpdateService: AccountUpdateService,
private moderationLogService: ModerationLogService,
) {
}
@ -49,8 +45,20 @@ export class UserSuspendService {
});
(async () => {
await this.postSuspend(user).catch(e => {});
await this.unFollowAll(user).catch(e => {});
await this.postSuspend(user, false).catch((e: any) => { });
await this.suspendFollowings(user).catch((e: any) => { });
})();
}
@bindThis
public async suspendFromRemote(user: { id: MiRemoteUser['id']; host: MiRemoteUser['host'] }): Promise<void> {
await this.usersRepository.update(user.id, {
isRemoteSuspended: true,
});
(async () => {
await this.postSuspend(user, true).catch((e: any) => { });
await this.suspendFollowings(user).catch((e: any) => { });
})();
}
@ -67,13 +75,29 @@ export class UserSuspendService {
});
(async () => {
await this.postUnsuspend(user).catch(e => {});
await this.postUnsuspend(user, false).catch((e: any) => { });
await this.restoreFollowings(user).catch((e: any) => { });
})();
}
@bindThis
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
public async unsuspendFromRemote(user: { id: MiRemoteUser['id']; host: MiRemoteUser['host'] }): Promise<void> {
await this.usersRepository.update(user.id, {
isRemoteSuspended: false,
});
(async () => {
await this.postUnsuspend(user, true).catch((e: any) => { });
await this.restoreFollowings(user).catch((e: any) => { });
})();
}
@bindThis
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }, isFromRemote: boolean): Promise<void> {
this.globalEventService.publishInternalEvent(
'userChangeSuspendedState',
isFromRemote ? { id: user.id, isRemoteSuspended: true } : { id: user.id, isSuspended: true }
);
this.followRequestsRepository.delete({
followeeId: user.id,
@ -83,80 +107,55 @@ export class UserSuspendService {
});
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) },
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
this.queueService.deliver(user, content, inbox, true);
}
this.accountUpdateService.publishToFollowersAndSharedInboxAndRelays(user.id);
}
}
@bindThis
private async postUnsuspend(user: MiUser): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
private async postUnsuspend(user: { id: MiUser['id']; host: MiUser['host'] }, isFromRemote: boolean): Promise<void> {
this.globalEventService.publishInternalEvent(
'userChangeSuspendedState',
isFromRemote ? { id: user.id, isRemoteSuspended: false } : { id: user.id, isSuspended: false }
);
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにUndo Delete配信
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) },
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
this.queueService.deliver(user as any, content, inbox, true);
}
this.accountUpdateService.publishToFollowersAndSharedInboxAndRelays(user.id);
}
}
@bindThis
private async unFollowAll(follower: MiUser) {
const followings = await this.followingsRepository.find({
where: {
private async suspendFollowings(follower: { id: MiUser['id'] }) {
await this.followingsRepository.update(
{
followerId: follower.id,
followeeId: Not(IsNull()),
},
});
{
isFollowerSuspended: true,
}
);
}
const jobs: RelationshipJobData[] = [];
for (const following of followings) {
if (following.followeeId && following.followerId) {
jobs.push({
from: { id: following.followerId },
to: { id: following.followeeId },
silent: true,
});
@bindThis
private async restoreFollowings(_follower: { id: MiUser['id'] }) {
// 最新の情報を取得
const follower = await this.usersRepository.findOneBy({ id: _follower.id });
if (follower == null) {
// ユーザーが削除されている場合は何もしないでおく
return;
}
if (this.userEntityService.isSuspendedEither(follower)) {
// フォロー関係を復元しない
return;
}
this.queueService.createUnfollowJob(jobs);
// フォロー関係を復元isFollowerSuspended: falseに変更
await this.followingsRepository.update(
{
followerId: follower.id,
},
{
isFollowerSuspended: false,
}
);
}
}

View File

@ -44,6 +44,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
avatarDecorations: [],
tags: [],
isSuspended: false,
isRemoteSuspended: false,
isLocked: false,
isBot: false,
isCat: true,

View File

@ -9,10 +9,12 @@ import { DI } from '@/di-symbols.js';
import type { FollowingsRepository } from '@/models/_.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import type { IActivity } from '@/core/activitypub/type.js';
import { ThinUser } from '@/queue/types.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import type Logger from '@/logger.js';
import { ApLoggerService } from './ApLoggerService.js';
interface IRecipe {
type: string;
@ -27,12 +29,19 @@ interface IDirectRecipe extends IRecipe {
to: MiRemoteUser;
}
interface IAllKnowingSharedInboxRecipe extends IRecipe {
type: 'AllKnowingSharedInbox';
}
const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe =>
recipe.type === 'Followers';
const isDirect = (recipe: IRecipe): recipe is IDirectRecipe =>
recipe.type === 'Direct';
const isAllKnowingSharedInbox = (recipe: IRecipe): recipe is IAllKnowingSharedInboxRecipe =>
recipe.type === 'AllKnowingSharedInbox';
class DeliverManager {
private actor: ThinUser;
private activity: IActivity | null;
@ -40,16 +49,15 @@ class DeliverManager {
/**
* Constructor
* @param userEntityService
* @param followingsRepository
* @param queueService
* @param actor Actor
* @param activity Activity to deliver
*/
constructor(
private userEntityService: UserEntityService,
private followingsRepository: FollowingsRepository,
private queueService: QueueService,
private logger: Logger,
actor: { id: MiUser['id']; host: null; },
activity: IActivity | null,
@ -91,6 +99,18 @@ class DeliverManager {
this.addRecipe(recipe);
}
/**
* Add recipe for all-knowing shared inbox deliver
*/
@bindThis
public addAllKnowingSharedInboxRecipe(): void {
const deliver: IAllKnowingSharedInboxRecipe = {
type: 'AllKnowingSharedInbox',
};
this.addRecipe(deliver);
}
/**
* Add recipe
* @param recipe Recipe
@ -104,11 +124,30 @@ class DeliverManager {
* Execute delivers
*/
@bindThis
public async execute(): Promise<void> {
public async execute(opts: { ignoreSuspend?: boolean } = {}): Promise<void> {
//#region collect inboxes by recipes
// The value flags whether it is shared or not.
// key: inbox URL, value: whether it is sharedInbox
const inboxes = new Map<string, boolean>();
if (this.recipes.some(r => isAllKnowingSharedInbox(r))) {
// all-knowing shared inbox
const followings = await this.followingsRepository.createQueryBuilder('f')
.select([
'f.followerSharedInbox',
'f.followeeSharedInbox',
])
.where('f.followerSharedInbox IS NOT NULL')
.orWhere('f.followeeSharedInbox IS NOT NULL')
.distinct()
.getRawMany<{ f_followerSharedInbox: string | null; f_followeeSharedInbox: string | null; }>();
for (const following of followings) {
if (following.f_followeeSharedInbox) inboxes.set(following.f_followeeSharedInbox, true);
if (following.f_followerSharedInbox) inboxes.set(following.f_followerSharedInbox, true);
}
}
// build inbox list
// Process follower recipes first to avoid duplication when processing direct recipes later.
if (this.recipes.some(r => isFollowers(r))) {
@ -119,6 +158,7 @@ class DeliverManager {
where: {
followeeId: this.actor.id,
followerHost: Not(IsNull()),
isFollowerSuspended: opts.ignoreSuspend ? undefined : false,
},
select: {
followerSharedInbox: true,
@ -142,21 +182,26 @@ class DeliverManager {
inboxes.set(recipe.to.inbox, false);
}
//#endregion
// deliver
await this.queueService.deliverMany(this.actor, this.activity, inboxes);
this.logger.info(`Deliver queues dispatched: inboxes=${inboxes.size} actorId=${this.actor.id} activityId=${this.activity?.id}`);
}
}
@Injectable()
export class ApDeliverManagerService {
private logger: Logger;
constructor(
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
private queueService: QueueService,
private apLoggerService: ApLoggerService,
) {
this.logger = this.apLoggerService.logger.createSubLogger('deliver-manager');
}
/**
@ -167,9 +212,9 @@ export class ApDeliverManagerService {
@bindThis
public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise<void> {
const manager = new DeliverManager(
this.userEntityService,
this.followingsRepository,
this.queueService,
this.logger,
actor,
activity,
);
@ -186,9 +231,9 @@ export class ApDeliverManagerService {
@bindThis
public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> {
const manager = new DeliverManager(
this.userEntityService,
this.followingsRepository,
this.queueService,
this.logger,
actor,
activity,
);
@ -205,9 +250,9 @@ export class ApDeliverManagerService {
@bindThis
public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise<void> {
const manager = new DeliverManager(
this.userEntityService,
this.followingsRepository,
this.queueService,
this.logger,
actor,
activity,
);
@ -218,10 +263,9 @@ export class ApDeliverManagerService {
@bindThis
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
return new DeliverManager(
this.userEntityService,
this.followingsRepository,
this.queueService,
this.logger,
actor,
activity,
);

View File

@ -141,7 +141,7 @@ export class ApInboxService {
@bindThis
public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
if (actor.isSuspended) return;
// ここでは凍結されているかどうかはチェックせず、各処理で判断する
if (isCreate(activity)) {
return await this.create(actor, activity, resolver);

View File

@ -577,6 +577,7 @@ export class ApRendererService {
publicKey: this.renderKey(user, keypair, '#main-key'),
isCat: user.isCat,
attachment: attachment.length ? attachment : undefined,
suspended: user.isSuspended,
};
if (user.movedToUri) {

View File

@ -543,6 +543,7 @@ const extension_context_definition = {
Emoji: 'toot:Emoji',
featured: 'toot:featured',
discoverable: 'toot:discoverable',
suspended: 'toot:suspended',
// schema
schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue',

View File

@ -38,6 +38,7 @@ import { RoleService } from '@/core/RoleService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common';
@ -74,6 +75,7 @@ export class ApPersonService implements OnModuleInit {
private instanceChart: InstanceChart;
private apLoggerService: ApLoggerService;
private accountMoveService: AccountMoveService;
private userSuspendService: UserSuspendService;
private logger: Logger;
constructor(
@ -126,6 +128,7 @@ export class ApPersonService implements OnModuleInit {
this.instanceChart = this.moduleRef.get('InstanceChart');
this.apLoggerService = this.moduleRef.get('ApLoggerService');
this.accountMoveService = this.moduleRef.get('AccountMoveService');
this.userSuspendService = this.moduleRef.get('UserSuspendService');
this.logger = this.apLoggerService.logger;
}
@ -393,6 +396,7 @@ export class ApPersonService implements OnModuleInit {
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
emojis,
isRemoteSuspended: person.suspended === true,
})) as MiRemoteUser;
let _description: string | null = null;
@ -570,6 +574,7 @@ export class ApPersonService implements OnModuleInit {
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: person.discoverable,
isRemoteSuspended: person.suspended === true,
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image).catch(() => ({}))),
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
@ -598,6 +603,19 @@ export class ApPersonService implements OnModuleInit {
return 'skip';
}
//#region suspend
if (exist.isRemoteSuspended === false && person.suspended === true) {
// リモートサーバーでアカウントが凍結された
this.logger.info(`Remote User Suspended: acct=${exist.username}@${exist.host} id=${exist.id} uri=${exist.uri}`);
this.userSuspendService.suspendFromRemote({ id: exist.id, host: exist.host });
}
if (exist.isRemoteSuspended === true && person.suspended === false) {
// リモートサーバーでアカウントが解凍された
this.logger.info(`Remote User Unsuspended: acct=${exist.username}@${exist.host} id=${exist.id} uri=${exist.uri}`);
this.userSuspendService.unsuspendFromRemote({ id: exist.id, host: exist.host });
}
//#endregion
if (person.publicKey) {
await this.userPublickeysRepository.update({ userId: exist.id }, {
keyId: person.publicKey.id,

View File

@ -195,6 +195,7 @@ export interface IActor extends IObject {
};
'vcard:bday'?: string;
'vcard:Address'?: string;
suspended?: boolean;
}
export const isCollection = (object: IObject): object is ICollection =>

View File

@ -290,7 +290,7 @@ export class NotificationEntityService implements OnModuleInit {
if (notifier == null) return false;
if (notifier.host && userMutedInstances.has(notifier.host)) return false;
if (notifier.isSuspended) return false;
if (this.userEntityService.isSuspendedEither(notifier)) return false;
return true;
}

View File

@ -69,6 +69,10 @@ function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean {
return !isLocalUser(user);
}
function isSuspendedEither(user: MiUser): boolean {
return user.isSuspended || user.isRemoteSuspended;
}
export type UserRelation = {
id: MiUser['id']
following: MiFollowing | null,
@ -163,6 +167,7 @@ export class UserEntityService implements OnModuleInit {
public isLocalUser = isLocalUser;
public isRemoteUser = isRemoteUser;
public isSuspendedEither = isSuspendedEither;
@bindThis
public async getRelation(me: MiUser['id'], target: MiUser['id']): Promise<UserRelation> {
@ -538,7 +543,7 @@ export class UserEntityService implements OnModuleInit {
bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
isLocked: user.isLocked,
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
isSuspended: user.isSuspended,
isSuspended: this.isSuspendedEither(user),
description: profile!.description,
location: profile!.location,
birthday: profile!.birthday,

View File

@ -9,7 +9,8 @@ import { MiUser } from './User.js';
@Entity('following')
@Index(['followerId', 'followeeId'], { unique: true })
@Index(['followeeId', 'followerHost', 'isFollowerHibernated'])
@Index(['followerId', 'followeeId', 'isFollowerSuspended'])
@Index(['followeeId', 'followerHost', 'isFollowerSuspended', 'isFollowerHibernated'])
export class MiFollowing {
@PrimaryColumn(id())
public id: string;
@ -45,6 +46,11 @@ export class MiFollowing {
})
public isFollowerHibernated: boolean;
@Column('boolean', {
default: false,
})
public isFollowerSuspended: boolean;
// タイムラインにその人のリプライまで含めるかどうか
@Column('boolean', {
default: false,

View File

@ -166,10 +166,16 @@ export class MiUser {
@Column('boolean', {
default: false,
comment: 'Whether the User is suspended.',
comment: 'Whether the User is suspended by the local moderators.',
})
public isSuspended: boolean;
@Column('boolean', {
default: false,
comment: 'Whether the User is suspended by the remote moderators.',
})
public isRemoteSuspended: boolean;
@Column('boolean', {
default: false,
comment: 'Whether the User is locked.',

View File

@ -215,6 +215,7 @@ export class ServerService implements OnApplicationShutdown {
usernameLower: username.toLowerCase(),
host: (host == null) || (host === this.config.host) ? IsNull() : host,
isSuspended: false,
isRemoteSuspended: false,
},
});

View File

@ -124,6 +124,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
isRemoteSuspended: {
type: 'boolean',
optional: false, nullable: false,
},
isHibernated: {
type: 'boolean',
optional: false, nullable: false,
@ -246,6 +250,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
isModerator: isModerator,
isSilenced: isSilenced,
isSuspended: user.isSuspended,
isRemoteSuspended: user.isRemoteSuspended,
isHibernated: user.isHibernated,
lastActiveDate: user.lastActiveDate ? user.lastActiveDate.toISOString() : null,
moderationNote: profile.moderationNote ?? '',

View File

@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.usersRepository.createQueryBuilder('user');
switch (ps.state) {
case 'available': query.where('user.isSuspended = FALSE'); break;
case 'available': query.where('user.isSuspended = FALSE').andWhere('user.isRemoteSuspended = FALSE'); break;
case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break;
case 'suspended': query.where('user.isSuspended = TRUE'); break;
case 'admin': {

View File

@ -50,7 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('following.followeeHost = :host', { host: ps.host });
.andWhere('following.followeeHost = :host', { host: ps.host })
.andWhere('following.isFollowerSuspended = false');
const followings = await query
.limit(ps.limit)

View File

@ -50,7 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('following.followerHost = :host', { host: ps.host });
.andWhere('following.followerHost = :host', { host: ps.host })
.andWhere('following.isFollowerSuspended = false');
const followings = await query
.limit(ps.limit)

View File

@ -94,11 +94,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.followingsRepository.count({
where: {
followeeHost: Not(IsNull()),
isFollowerSuspended: false,
},
}),
this.followingsRepository.count({
where: {
followerHost: Not(IsNull()),
isFollowerSuspended: false,
},
}),
]);

View File

@ -51,7 +51,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
const query = this.usersRepository.createQueryBuilder('user')
.where(':tag <@ user.tags', { tag: [normalizeForSearch(ps.tag)] })
.andWhere('user.isSuspended = FALSE');
.andWhere('user.isSuspended = FALSE')
.andWhere('user.isRemoteSuspended = FALSE');
const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5));

View File

@ -125,6 +125,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
where: {
followeeId: user.id,
followerId: me.id,
isFollowerSuspended: false,
},
});
if (!isFollowing) {
@ -136,12 +137,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('following.followeeId = :userId', { userId: user.id })
.andWhere('following.isFollowerSuspended = FALSE')
.innerJoinAndSelect('following.follower', 'follower');
const followings = await query
.limit(ps.limit)
.getMany();
console.log(followings);
return await this.followingEntityService.packMany(followings, me, { populateFollower: true });
});
}

View File

@ -133,6 +133,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
where: {
followeeId: user.id,
followerId: me.id,
isFollowerSuspended: false,
},
});
if (!isFollowing) {
@ -144,6 +145,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('following.followerId = :userId', { userId: user.id })
.andWhere('following.isFollowerSuspended = false')
.innerJoinAndSelect('following.followee', 'followee');
if (ps.birthday) {

View File

@ -68,7 +68,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });
.where('following.followerId = :followerId', { followerId: me.id })
.andWhere('following.isFollowerSuspended = false');
query
.andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`);

View File

@ -138,6 +138,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} : {
id: In(ps.userIds),
isSuspended: false,
isRemoteSuspended: false,
});
// リクエストされた通りに並べ替え

View File

@ -435,6 +435,7 @@ export class ClientServerService {
usernameLower: username.toLowerCase(),
host: host ?? IsNull(),
isSuspended: false,
isRemoteSuspended: false,
requireSigninToViewContents: false,
});
@ -494,6 +495,7 @@ export class ClientServerService {
usernameLower: username.toLowerCase(),
host: host ?? IsNull(),
isSuspended: false,
isRemoteSuspended: false,
});
vary(reply.raw, 'Accept');
@ -543,6 +545,7 @@ export class ClientServerService {
id: request.params.user,
host: IsNull(),
isSuspended: false,
isRemoteSuspended: false,
});
if (user == null) {

View File

@ -0,0 +1,95 @@
import assert, { rejects, strictEqual } from 'node:assert';
import * as Misskey from 'misskey-js';
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
const [aAdmin, bAdmin] = await Promise.all([
fetchAdmin('a.test'),
fetchAdmin('b.test'),
]);
describe('User Suspension', () => {
describe('Suspension', () => {
describe('Check suspend/unsuspend consistency', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => {
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 1); // followed by Bob
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
await sleep();
const aliceInBRaw = await bAdmin.client.request('admin/show-user', { userId: aliceInB.id });
strictEqual(aliceInBRaw.isRemoteSuspended, true);
const renewedAliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
strictEqual(renewedAliceInB.isSuspended, true);
await rejects(
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'ALREADY_FOLLOWING');
return true;
},
);
});
test('Alice gets unsuspended, Bob succeeds in following Alice', async () => {
await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id });
await sleep();
const aliceInBRenewed = await bAdmin.client.request('admin/show-user', { userId: aliceInB.id });
strictEqual(aliceInBRenewed.isRemoteSuspended, false);
await rejects(
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'ALREADY_FOLLOWING');
return true;
},
);
});
test('Alice can follow Bob', async () => {
await alice.client.request('following/create', { userId: bobInA.id });
await sleep();
const bobFollowers = await bob.client.request('users/followers', { userId: bob.id });
strictEqual(bobFollowers.length, 1); // followed by Alice
assert(bobFollowers[0].follower != null);
const renewedAliceInB = bobFollowers[0].follower;
assert(aliceInB.username === renewedAliceInB.username);
assert(aliceInB.host === renewedAliceInB.host);
assert(aliceInB.id === renewedAliceInB.id);
});
test('Alice follows Bob, and Alice gets suspended, the following relation hidden', async () => {
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
await sleep(1000);
const renewedAliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
strictEqual(renewedAliceInB.isSuspended, true);
const aliceInBRaw = await bAdmin.client.request('admin/show-user', { userId: aliceInB.id });
strictEqual(aliceInBRaw.isRemoteSuspended, true);
const bobFollowers = await bob.client.request('users/followers', { userId: bob.id });
strictEqual(bobFollowers.length, 0); // Relation is hidden
});
});
});
});

View File

@ -452,110 +452,4 @@ describe('User', () => {
});
});
});
describe('Suspension', () => {
describe('Check suspend/unsuspend consistency', () => {
let alice: LoginUser, bob: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
beforeAll(async () => {
[alice, bob] = await Promise.all([
createAccount('a.test'),
createAccount('b.test'),
]);
[bobInA, aliceInB] = await Promise.all([
resolveRemoteUser('b.test', bob.id, alice),
resolveRemoteUser('a.test', alice.id, bob),
]);
});
test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => {
await bob.client.request('following/create', { userId: aliceInB.id });
await sleep();
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 1); // followed by Bob
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
await sleep();
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation
await rejects(
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_USER');
return true;
},
);
});
test('Alice gets unsuspended, Bob succeeds in following Alice', async () => {
await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id });
await sleep();
const followers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(followers.length, 1); // FIXME: followers are not deleted??
/**
* FIXME: still rejected!
* seems to can't process Undo Delete activity because it is not implemented
* related @see https://github.com/misskey-dev/misskey/issues/13273
*/
await rejects(
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_USER');
return true;
},
);
// FIXME: resolving also fails
await rejects(
async () => await resolveRemoteUser('a.test', alice.id, bob),
(err: any) => {
strictEqual(err.code, 'INTERNAL_ERROR');
return true;
},
);
});
/**
* instead of simple unsuspension, let's tell existence by following from Alice
*/
test('Alice can follow Bob', async () => {
await alice.client.request('following/create', { userId: bobInA.id });
await sleep();
const bobFollowers = await bob.client.request('users/followers', { userId: bob.id });
strictEqual(bobFollowers.length, 1); // followed by Alice
assert(bobFollowers[0].follower != null);
const renewedaliceInB = bobFollowers[0].follower;
assert(aliceInB.username === renewedaliceInB.username);
assert(aliceInB.host === renewedaliceInB.host);
assert(aliceInB.id !== renewedaliceInB.id); // TODO: Same username and host, but their ids are different! Is it OK?
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // following are deleted
// Bob tries to follow Alice
await bob.client.request('following/create', { userId: renewedaliceInB.id });
await sleep();
const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id });
strictEqual(aliceFollowers.length, 1);
// FIXME: but resolving still fails ...
await rejects(
async () => await resolveRemoteUser('a.test', alice.id, bob),
(err: any) => {
strictEqual(err.code, 'INTERNAL_ERROR');
return true;
},
);
});
});
});
});

View File

@ -0,0 +1,620 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { IActivity } from '@/core/activitypub/type.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
import { QueueService } from '@/core/QueueService.js';
import { FollowingsRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
describe('ApDeliverManagerService', () => {
let service: ApDeliverManagerService;
let followingsRepository: jest.Mocked<FollowingsRepository>;
let queueService: jest.Mocked<QueueService>;
let apLoggerService: jest.Mocked<ApLoggerService>;
const mockLocalUser: MiLocalUser = {
id: 'local-user-id',
host: null,
} as MiLocalUser;
const mockRemoteUser1: MiRemoteUser & { inbox: string; sharedInbox: string; } = {
id: 'remote-user-1',
host: 'remote.example.com',
inbox: 'https://remote.example.com/inbox',
sharedInbox: 'https://remote.example.com/shared-inbox',
} as MiRemoteUser & { inbox: string; sharedInbox: string; };
const mockRemoteUser2: MiRemoteUser & { inbox: string; } = {
id: 'remote-user-2',
host: 'another.example.com',
inbox: 'https://another.example.com/inbox',
sharedInbox: null,
} as MiRemoteUser & { inbox: string; };
const mockActivity: IActivity = {
type: 'Create',
id: 'activity-id',
actor: 'https://local.example.com/users/local-user-id',
object: {
type: 'Note',
id: 'note-id',
content: 'Hello, world!',
},
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApDeliverManagerService,
{
provide: DI.followingsRepository,
useValue: {
find: jest.fn(),
createQueryBuilder: jest.fn(),
},
},
{
provide: QueueService,
useValue: {
deliverMany: jest.fn(),
},
},
{
provide: ApLoggerService,
useValue: {
logger: {
createSubLogger: jest.fn().mockReturnValue({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
},
},
},
],
}).compile();
service = module.get<ApDeliverManagerService>(ApDeliverManagerService);
followingsRepository = module.get(DI.followingsRepository);
queueService = module.get(QueueService);
apLoggerService = module.get(ApLoggerService);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('deliverToFollowers', () => {
it('should deliver activity to all followers', async () => {
const mockFollowings = [
{
followerSharedInbox: 'https://remote1.example.com/shared-inbox',
followerInbox: 'https://remote1.example.com/inbox',
},
{
followerSharedInbox: 'https://remote2.example.com/shared-inbox',
followerInbox: 'https://remote2.example.com/inbox',
},
{
followerSharedInbox: null,
followerInbox: 'https://remote3.example.com/inbox',
},
];
followingsRepository.find.mockResolvedValue(mockFollowings as any);
await service.deliverToFollowers(mockLocalUser, mockActivity);
expect(followingsRepository.find).toHaveBeenCalledWith({
where: {
followeeId: mockLocalUser.id,
followerHost: expect.anything(), // Not(IsNull())
isFollowerSuspended: false,
},
select: {
followerSharedInbox: true,
followerInbox: true,
},
});
expect(queueService.deliverMany).toHaveBeenCalledWith(
{ id: mockLocalUser.id },
mockActivity,
expect.any(Map),
);
// 呼び出されたinboxesを確認
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
expect(inboxes.size).toBe(3);
expect(inboxes.has('https://remote1.example.com/shared-inbox')).toBe(true);
expect(inboxes.has('https://remote2.example.com/shared-inbox')).toBe(true);
expect(inboxes.has('https://remote3.example.com/inbox')).toBe(true);
});
it('should exclude suspended followers by default', async () => {
followingsRepository.find.mockResolvedValue([]);
await service.deliverToFollowers(mockLocalUser, mockActivity);
expect(followingsRepository.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isFollowerSuspended: false,
}),
}),
);
});
});
describe('deliverToUser', () => {
it('should deliver activity to specific remote user', async () => {
await service.deliverToUser(mockLocalUser, mockActivity, mockRemoteUser1);
expect(queueService.deliverMany).toHaveBeenCalledWith(
{ id: mockLocalUser.id },
mockActivity,
expect.any(Map),
);
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
expect(inboxes.size).toBe(1);
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true);
});
it('should handle user without shared inbox', async () => {
await service.deliverToUser(mockLocalUser, mockActivity, mockRemoteUser2);
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
expect(inboxes.size).toBe(1);
expect(inboxes.has(mockRemoteUser2.inbox)).toBe(true);
});
it('should skip user with null inbox', async () => {
const userWithoutInbox = {
...mockRemoteUser1,
inbox: null,
} as MiRemoteUser;
await service.deliverToUser(mockLocalUser, mockActivity, userWithoutInbox);
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
expect(inboxes.size).toBe(0);
});
});
describe('deliverToUsers', () => {
it('should deliver activity to multiple remote users', async () => {
await service.deliverToUsers(mockLocalUser, mockActivity, [mockRemoteUser1, mockRemoteUser2]);
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
expect(inboxes.size).toBe(2);
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true);
expect(inboxes.has(mockRemoteUser2.inbox)).toBe(true);
});
});
describe('createDeliverManager', () => {
it('should create a DeliverManager instance', () => {
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
expect(manager).toBeDefined();
expect(typeof manager.addFollowersRecipe).toBe('function');
expect(typeof manager.addDirectRecipe).toBe('function');
expect(typeof manager.addAllKnowingSharedInboxRecipe).toBe('function');
expect(typeof manager.execute).toBe('function');
});
it('should allow manual recipe management', async () => {
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
followingsRepository.find.mockResolvedValue([
{
followerSharedInbox: null,
followerInbox: 'https://follower.example.com/inbox',
},
] as any);
// フォロワー配信のレシピを追加
manager.addFollowersRecipe();
// ダイレクト配信のレシピを追加
manager.addDirectRecipe(mockRemoteUser1);
await manager.execute();
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
expect(inboxes.size).toBe(2);
expect(inboxes.has('https://follower.example.com/inbox')).toBe(true);
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true);
});
it('should support ignoreSuspend option', async () => {
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
followingsRepository.find.mockResolvedValue([]);
manager.addFollowersRecipe();
await manager.execute({ ignoreSuspend: true });
expect(followingsRepository.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isFollowerSuspended: undefined, // ignoreSuspend: true なので undefined
}),
}),
);
});
it('followers and directs mixture: 先にfollowersでsharedInboxが追加されていた場合、directsでユーザーがそのsharedInboxを持っていたらinboxを追加しない', async () => {
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
followingsRepository.find.mockResolvedValue([
{
followerSharedInbox: mockRemoteUser1.sharedInbox,
followerInbox: mockRemoteUser2.inbox,
},
] as any);
manager.addFollowersRecipe();
manager.addDirectRecipe(mockRemoteUser1);
await manager.execute();
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
expect(inboxes.size).toBe(1);
expect(inboxes.has(mockRemoteUser1.sharedInbox)).toBe(true);
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(false);
});
});
describe('error handling', () => {
it('should throw error for non-local actor', () => {
const remoteActor = { id: 'remote-id', host: 'remote.example.com' } as any;
expect(() => {
service.createDeliverManager(remoteActor, mockActivity);
}).toThrow('actor.host must be null');
});
it('should throw error when follower has null inbox', async () => {
const mockFollowings = [
{
followerSharedInbox: null,
followerInbox: null, // null inbox
},
];
followingsRepository.find.mockResolvedValue(mockFollowings as any);
await expect(service.deliverToFollowers(mockLocalUser, mockActivity)).rejects.toThrow('inbox is null');
});
});
describe('AllKnowingSharedInbox recipe', () => {
it('should collect all shared inboxes when using AllKnowingSharedInbox', async () => {
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
distinct: jest.fn().mockReturnThis(),
getRawMany: jest.fn<any>().mockResolvedValue([
{ f_followerSharedInbox: 'https://shared1.example.com/inbox' },
{ f_followeeSharedInbox: 'https://shared2.example.com/inbox' },
]),
};
followingsRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
manager.addAllKnowingSharedInboxRecipe();
await manager.execute();
expect(followingsRepository.createQueryBuilder).toHaveBeenCalledWith('f');
expect(mockQueryBuilder.select).toHaveBeenCalledWith([
'f.followerSharedInbox',
'f.followeeSharedInbox',
]);
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
expect(inboxes.size).toBe(2);
expect(inboxes.has('https://shared1.example.com/inbox')).toBe(true);
expect(inboxes.has('https://shared2.example.com/inbox')).toBe(true);
});
});
});
describe('ApDeliverManagerService (SQL)', () => {
// followerにデータを挿入して、SQLの動作を確認します
let app: TestingModule;
let service: ApDeliverManagerService;
let followingsRepository: FollowingsRepository;
let usersRepository: UsersRepository;
let queueService: jest.Mocked<QueueService>;
async function createUser(data: Partial<{ id: string; username: string; host: string | null; inbox: string | null; sharedInbox: string | null; isSuspended: boolean }> = {}): Promise<any> {
const user = {
id: secureRndstr(16),
username: secureRndstr(16),
usernameLower: (data.username ?? secureRndstr(16)).toLowerCase(),
host: data.host ?? null,
inbox: data.inbox ?? null,
sharedInbox: data.sharedInbox ?? null,
isSuspended: data.isSuspended ?? false,
...data,
};
await usersRepository.insert(user);
return user;
}
async function createFollowing(follower: any, followee: any, data: Partial<{
followerInbox: string | null;
followerSharedInbox: string | null;
followeeInbox: string | null;
followeeSharedInbox: string | null;
isFollowerSuspended: boolean;
}> = {}): Promise<any> {
const following = {
id: secureRndstr(16),
followerId: follower.id,
followeeId: followee.id,
followerHost: follower.host,
followeeHost: followee.host,
followerInbox: data.followerInbox ?? follower.inbox,
followerSharedInbox: data.followerSharedInbox ?? follower.sharedInbox,
followeeInbox: data.followeeInbox ?? null,
followeeSharedInbox: data.followeeSharedInbox ?? null,
isFollowerSuspended: data.isFollowerSuspended ?? false,
isFollowerHibernated: false,
withReplies: false,
notify: null,
};
await followingsRepository.insert(following);
return following;
}
beforeEach(async () => {
const { Test } = await import('@nestjs/testing');
const { GlobalModule } = await import('@/GlobalModule.js');
const { DI } = await import('@/di-symbols.js');
app = await Test.createTestingModule({
imports: [GlobalModule],
providers: [
ApDeliverManagerService,
{
provide: QueueService,
useFactory: () => ({
deliverMany: jest.fn(),
}),
},
{
provide: ApLoggerService,
useValue: {
logger: {
createSubLogger: jest.fn().mockReturnValue({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
},
},
},
],
}).compile();
app.enableShutdownHooks();
service = app.get<ApDeliverManagerService>(ApDeliverManagerService);
followingsRepository = app.get<FollowingsRepository>(DI.followingsRepository);
usersRepository = app.get<UsersRepository>(DI.usersRepository);
queueService = app.get<QueueService>(QueueService) as jest.Mocked<QueueService>;
// Reset mocks
jest.clearAllMocks();
});
afterEach(async () => {
await app.close();
});
describe('deliverToFollowers with real data', () => {
it('should deliver to followers excluding suspended ones', async () => {
// Create local user (followee)
const localUser = await createUser({
host: null,
username: 'localuser',
});
// Create remote followers
const activeFollower = await createUser({
host: 'active.example.com',
username: 'activefollower',
inbox: 'https://active.example.com/inbox',
sharedInbox: 'https://active.example.com/shared-inbox',
isSuspended: false,
});
const suspendedFollower = await createUser({
host: 'suspended.example.com',
username: 'suspendedfollower',
inbox: 'https://suspended.example.com/inbox',
sharedInbox: 'https://suspended.example.com/shared-inbox',
isSuspended: true,
});
const followerWithoutSharedInbox = await createUser({
host: 'noshared.example.com',
username: 'noshared',
inbox: 'https://noshared.example.com/inbox',
sharedInbox: null,
isSuspended: false,
});
// Create following relationships
await createFollowing(activeFollower, localUser, {
followerInbox: activeFollower.inbox,
followerSharedInbox: activeFollower.sharedInbox,
isFollowerSuspended: false,
});
await createFollowing(suspendedFollower, localUser, {
followerInbox: suspendedFollower.inbox,
followerSharedInbox: suspendedFollower.sharedInbox,
isFollowerSuspended: true, // 凍結されたフォロワー
});
await createFollowing(followerWithoutSharedInbox, localUser, {
followerInbox: followerWithoutSharedInbox.inbox,
followerSharedInbox: null,
isFollowerSuspended: false,
});
const mockActivity = {
type: 'Create',
id: 'test-activity',
actor: `https://local.example.com/users/${localUser.id}`,
object: { type: 'Note', content: 'Hello' },
} as any;
// Execute delivery
await service.deliverToFollowers(localUser, mockActivity);
// Verify delivery was queued
expect(queueService.deliverMany).toHaveBeenCalledTimes(1);
const [actor, activity, inboxes] = queueService.deliverMany.mock.calls[0];
expect(actor.id).toBe(localUser.id);
expect(activity).toBe(mockActivity);
// Check inboxes - should include active followers but exclude suspended ones
expect(inboxes.size).toBe(2);
expect(inboxes.has('https://active.example.com/shared-inbox')).toBe(true);
expect(inboxes.has('https://noshared.example.com/inbox')).toBe(true);
expect(inboxes.has('https://suspended.example.com/shared-inbox')).toBe(false);
});
it('should include suspended followers when ignoreSuspend is true', async () => {
const localUser = await createUser({ host: null });
const suspendedFollower = await createUser({
host: 'suspended.example.com',
inbox: 'https://suspended.example.com/inbox',
isSuspended: true,
});
await createFollowing(suspendedFollower, localUser, {
isFollowerSuspended: true,
});
const manager = service.createDeliverManager(localUser, { type: 'Test' } as any);
manager.addFollowersRecipe();
// Execute with ignoreSuspend: true
await manager.execute({ ignoreSuspend: true });
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
expect(inboxes.size).toBe(1);
expect(inboxes.has('https://suspended.example.com/inbox')).toBe(true);
});
it('should handle mixed follower types correctly', async () => {
const localUser = await createUser({ host: null });
// フォロワー1: shared inbox あり
const follower1 = await createUser({
host: 'server1.example.com',
inbox: 'https://server1.example.com/users/user1/inbox',
sharedInbox: 'https://server1.example.com/inbox',
});
// フォロワー2: 同じサーバーの別ユーザーshared inbox は同じ)
const follower2 = await createUser({
host: 'server1.example.com',
inbox: 'https://server1.example.com/users/user2/inbox',
sharedInbox: 'https://server1.example.com/inbox',
});
// フォロワー3: 別サーバー、shared inbox なし
const follower3 = await createUser({
host: 'server2.example.com',
inbox: 'https://server2.example.com/users/user3/inbox',
sharedInbox: null,
});
await createFollowing(follower1, localUser);
await createFollowing(follower2, localUser);
await createFollowing(follower3, localUser);
await service.deliverToFollowers(localUser, { type: 'Test' } as any);
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
// shared inbox は重複排除されるので、2つのinboxのみ
expect(inboxes.size).toBe(2);
expect(inboxes.has('https://server1.example.com/inbox')).toBe(true); // shared inbox
expect(inboxes.has('https://server2.example.com/users/user3/inbox')).toBe(true); // individual inbox
// individual inbox は shared inbox があるので使用されない
expect(inboxes.has('https://server1.example.com/users/user1/inbox')).toBe(false);
expect(inboxes.has('https://server1.example.com/users/user2/inbox')).toBe(false);
});
});
describe('AllKnowingSharedInbox with real data', () => {
it('should collect all unique shared inboxes from database', async () => {
// Create users with various inbox configurations
const user1 = await createUser({ host: null });
const user2 = await createUser({ host: null });
const remoteUser1 = await createUser({
host: 'server1.example.com',
sharedInbox: 'https://server1.example.com/shared',
});
const remoteUser2 = await createUser({
host: 'server2.example.com',
sharedInbox: 'https://server2.example.com/shared',
});
const remoteUser3 = await createUser({
host: 'server1.example.com', // 同じサーバー
sharedInbox: 'https://server1.example.com/shared', // 同じ shared inbox
});
// Create following relationships
await createFollowing(remoteUser1, user1, {
followerSharedInbox: 'https://server1.example.com/shared',
});
await createFollowing(user1, remoteUser2, {
followerSharedInbox: null,
followeeSharedInbox: 'https://server2.example.com/shared',
});
await createFollowing(remoteUser3, user2, {
followerSharedInbox: 'https://server1.example.com/shared', // 重複
});
const manager = service.createDeliverManager(user1, { type: 'Test' } as any);
manager.addAllKnowingSharedInboxRecipe();
await manager.execute();
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
// 重複は除去されて2つのユニークな shared inbox
expect(inboxes.size).toBe(2);
expect(inboxes.has('https://server1.example.com/shared')).toBe(true);
expect(inboxes.has('https://server2.example.com/shared')).toBe(true);
});
});
});

View File

@ -0,0 +1,479 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { Test } from '@nestjs/testing';
import { setTimeout } from 'node:timers/promises';
import type { TestingModule } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
import {
MiFollowing,
MiUser,
FollowingsRepository,
FollowRequestsRepository,
UsersRepository,
} from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { randomString } from '../utils.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { RelayService } from '@/core/RelayService.js';
import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
import { MiRemoteUser } from '@/models/User.js';
function genHost() {
return randomString() + '.example.com';
}
describe('UserSuspendService', () => {
let app: TestingModule;
let userSuspendService: UserSuspendService;
let usersRepository: UsersRepository;
let followingsRepository: FollowingsRepository;
let followRequestsRepository: FollowRequestsRepository;
let userEntityService: jest.Mocked<UserEntityService>;
let queueService: jest.Mocked<QueueService>;
let globalEventService: jest.Mocked<GlobalEventService>;
let apRendererService: jest.Mocked<ApRendererService>;
let moderationLogService: jest.Mocked<ModerationLogService>;
async function createUser(data: Partial<MiUser> = {}): Promise<MiUser> {
const user = {
id: secureRndstr(16),
username: secureRndstr(16),
usernameLower: secureRndstr(16).toLowerCase(),
host: null,
isSuspended: false,
isRemoteSuspended: false,
...data,
} as MiUser;
await usersRepository.insert(user);
return user;
}
async function createFollowing(follower: MiUser, followee: MiUser, data: Partial<MiFollowing> = {}): Promise<MiFollowing> {
const following = {
id: secureRndstr(16),
followerId: follower.id,
followeeId: followee.id,
isFollowerSuspended: false,
isFollowerHibernated: false,
withReplies: false,
notify: null,
followerHost: follower.host,
followerInbox: null,
followerSharedInbox: null,
followeeHost: followee.host,
followeeInbox: null,
followeeSharedInbox: null,
...data,
} as MiFollowing;
await followingsRepository.insert(following);
return following;
}
beforeAll(async () => {
app = await Test.createTestingModule({
imports: [GlobalModule],
providers: [
UserSuspendService,
AccountUpdateService,
ApDeliverManagerService,
{
provide: UserEntityService,
useFactory: () => ({
isLocalUser: jest.fn(),
genLocalUserUri: jest.fn(),
isSuspendedEither: jest.fn(),
}),
},
{
provide: QueueService,
useFactory: () => ({
deliverMany: jest.fn(),
deliver: jest.fn(),
}),
},
{
provide: GlobalEventService,
useFactory: () => ({
publishInternalEvent: jest.fn(),
}),
},
{
provide: ModerationLogService,
useFactory: () => ({
log: jest.fn(),
}),
},
{
provide: RelayService,
useFactory: () => ({
deliverToRelays: jest.fn(),
}),
},
{
provide: ApRendererService,
useFactory: () => ({
renderDelete: jest.fn(),
renderUndo: jest.fn(),
renderPerson: jest.fn(),
renderUpdate: jest.fn(),
addContext: jest.fn(),
}),
},
{
provide: ApLoggerService,
useFactory: () => ({
logger: {
createSubLogger: jest.fn().mockReturnValue({
info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(),
}),
},
}),
},
],
}).compile();
app.enableShutdownHooks();
userSuspendService = app.get<UserSuspendService>(UserSuspendService);
usersRepository = app.get<UsersRepository>(DI.usersRepository);
followingsRepository = app.get<FollowingsRepository>(DI.followingsRepository);
followRequestsRepository = app.get<FollowRequestsRepository>(DI.followRequestsRepository);
userEntityService = app.get<UserEntityService>(UserEntityService) as jest.Mocked<UserEntityService>;
queueService = app.get<QueueService>(QueueService) as jest.Mocked<QueueService>;
globalEventService = app.get<GlobalEventService>(GlobalEventService) as jest.Mocked<GlobalEventService>;
apRendererService = app.get<ApRendererService>(ApRendererService) as jest.Mocked<ApRendererService>;
moderationLogService = app.get<ModerationLogService>(ModerationLogService) as jest.Mocked<ModerationLogService>;
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('suspend', () => {
test('should suspend user and update database', async () => {
const user = await createUser();
const moderator = await createUser();
await userSuspendService.suspend(user, moderator);
// ユーザーが凍結されているかチェック
const suspendedUser = await usersRepository.findOneBy({ id: user.id });
expect(suspendedUser?.isSuspended).toBe(true);
// モデレーションログが記録されているかチェック
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
});
test('should mark follower relationships as suspended', async () => {
const user = await createUser();
const followee1 = await createUser();
const followee2 = await createUser();
const moderator = await createUser();
// ユーザーがフォローしている関係を作成
await createFollowing(user, followee1);
await createFollowing(user, followee2);
await userSuspendService.suspend(user, moderator);
await setTimeout(250);
// フォロー関係が論理削除されているかチェック
const followings = await followingsRepository.find({
where: { followerId: user.id },
});
expect(followings).toHaveLength(2);
followings.forEach(following => {
expect(following.isFollowerSuspended).toBe(true);
});
});
test('should publish internal event for suspension', async () => {
const user = await createUser();
const moderator = await createUser();
await userSuspendService.suspend(user, moderator);
await setTimeout(250);
// 内部イベントが発行されているかチェック(非同期処理のため少し待つ)
await setTimeout(100);
expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith(
'userChangeSuspendedState',
{ id: user.id, isSuspended: true },
);
});
});
describe('unsuspend', () => {
test('should unsuspend user and update database', async () => {
const user = await createUser({ isSuspended: true });
const moderator = await createUser();
await userSuspendService.unsuspend(user, moderator);
await setTimeout(250);
// ユーザーの凍結が解除されているかチェック
const unsuspendedUser = await usersRepository.findOneBy({ id: user.id });
expect(unsuspendedUser?.isSuspended).toBe(false);
// モデレーションログが記録されているかチェック
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
});
test('should restore follower relationships', async () => {
userEntityService.isSuspendedEither.mockReturnValue(false);
const user = await createUser({ isSuspended: true });
const followee1 = await createUser();
const followee2 = await createUser();
const moderator = await createUser();
// 凍結状態のフォロー関係を作成
await createFollowing(user, followee1, { isFollowerSuspended: true });
await createFollowing(user, followee2, { isFollowerSuspended: true });
await userSuspendService.unsuspend(user, moderator);
await setTimeout(250);
// フォロー関係が復元されているかチェック
const followings = await followingsRepository.find({
where: { followerId: user.id },
});
expect(followings).toHaveLength(2);
followings.forEach(following => {
expect(following.isFollowerSuspended).toBe(false);
});
});
test('should publish internal event for unsuspension', async () => {
const user = await createUser({ isSuspended: true });
const moderator = await createUser();
await userSuspendService.unsuspend(user, moderator);
await setTimeout(250);
// 内部イベントが発行されているかチェック(非同期処理のため少し待つ)
await setTimeout(100);
expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith(
'userChangeSuspendedState',
{ id: user.id, isSuspended: false },
);
});
});
describe('integration test: suspend and unsuspend cycle', () => {
test('should preserve follow relationships through suspend/unsuspend cycle', async () => {
userEntityService.isSuspendedEither.mockReturnValue(false);
const user = await createUser();
const followee1 = await createUser();
const followee2 = await createUser();
const moderator = await createUser();
// 初期のフォロー関係を作成
await createFollowing(user, followee1);
await createFollowing(user, followee2);
// 初期状態の確認
let followings = await followingsRepository.find({
where: { followerId: user.id },
});
expect(followings).toHaveLength(2);
followings.forEach(following => {
expect(following.isFollowerSuspended).toBe(false);
});
// 凍結
await userSuspendService.suspend(user, moderator);
await setTimeout(250);
// 凍結後の状態確認
followings = await followingsRepository.find({
where: { followerId: user.id },
});
expect(followings).toHaveLength(2);
followings.forEach(following => {
expect(following.isFollowerSuspended).toBe(true);
});
// 凍結解除
const suspendedUser = await usersRepository.findOneByOrFail({ id: user.id });
await userSuspendService.unsuspend(suspendedUser, moderator);
await setTimeout(250);
// 凍結解除後の状態確認
followings = await followingsRepository.find({
where: { followerId: user.id },
});
expect(followings).toHaveLength(2);
followings.forEach(following => {
expect(following.isFollowerSuspended).toBe(false);
});
});
});
describe('ActivityPub delivery', () => {
test('should deliver Update Person activity on suspend of local user', async () => {
const localUser = await createUser({ host: null });
const moderator = await createUser();
userEntityService.isLocalUser.mockReturnValue(true);
userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`);
apRendererService.renderUpdate.mockReturnValue({ type: 'Update' } as any);
apRendererService.renderPerson.mockReturnValue({ type: 'Person' } as any);
apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Update' } as any);
await userSuspendService.suspend(localUser, moderator);
await setTimeout(250);
// ActivityPub配信が呼ばれているかチェック
expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser);
expect(apRendererService.renderUpdate).toHaveBeenCalled();
expect(apRendererService.renderPerson).toHaveBeenCalled();
expect(apRendererService.addContext).toHaveBeenCalled();
expect(queueService.deliverMany).toHaveBeenCalled();
});
test('should deliver Update Person activity on unsuspend of local user', async () => {
const localUser = await createUser({ host: null, isSuspended: true });
const moderator = await createUser();
userEntityService.isLocalUser.mockReturnValue(true);
userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`);
apRendererService.renderUpdate.mockReturnValue({ type: 'Update' } as any);
apRendererService.renderPerson.mockReturnValue({ type: 'Person' } as any);
apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Update' } as any);
await userSuspendService.suspend(localUser, moderator);
await setTimeout(250);
// ActivityPub配信が呼ばれているかチェック
expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser);
expect(apRendererService.renderUpdate).toHaveBeenCalled();
expect(apRendererService.renderPerson).toHaveBeenCalled();
expect(apRendererService.addContext).toHaveBeenCalled();
expect(queueService.deliverMany).toHaveBeenCalled();
});
test('should not deliver any activity on suspend of remote user', async () => {
const remoteUser = await createUser({ host: 'remote.example.com' });
const moderator = await createUser();
userEntityService.isLocalUser.mockReturnValue(false);
await userSuspendService.suspend(remoteUser, moderator);
await setTimeout(250);
// ActivityPub配信が呼ばれていないことをチェック
expect(userEntityService.isLocalUser).toHaveBeenCalledWith(remoteUser);
expect(apRendererService.renderDelete).not.toHaveBeenCalled();
expect(queueService.deliver).not.toHaveBeenCalled();
});
});
describe('suspension for remote user', () => {
test('should suspend remote user without AP delivery', async () => {
const remoteUser = await createUser({ host: genHost() });
const moderator = await createUser();
await userSuspendService.suspend(remoteUser, moderator);
await setTimeout(250);
// ユーザーが凍結されているかチェック
const suspendedUser = await usersRepository.findOneBy({ id: remoteUser.id });
expect(suspendedUser?.isSuspended).toBe(true);
// モデレーションログが記録されているかチェック
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', {
userId: remoteUser.id,
userUsername: remoteUser.username,
userHost: remoteUser.host,
});
// ActivityPub配信が呼ばれていないことを確認
expect(queueService.deliver).not.toHaveBeenCalled();
});
test('should unsuspend remote user without AP delivery', async () => {
const remoteUser = await createUser({ host: genHost(), isSuspended: true });
const moderator = await createUser();
await userSuspendService.unsuspend(remoteUser, moderator);
await setTimeout(250);
// ユーザーの凍結が解除されているかチェック
const unsuspendedUser = await usersRepository.findOneBy({ id: remoteUser.id });
expect(unsuspendedUser?.isSuspended).toBe(false);
// モデレーションログが記録されているかチェック
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', {
userId: remoteUser.id,
userUsername: remoteUser.username,
userHost: remoteUser.host,
});
// ActivityPub配信が呼ばれていないことを確認
expect(queueService.deliver).not.toHaveBeenCalled();
});
});
describe('suspension from remote', () => {
test('should suspend remote user and post suspend event', async () => {
const remoteUser = await createUser({ host: genHost() }) as MiRemoteUser;
await userSuspendService.suspendFromRemote(remoteUser);
// ユーザーがリモート凍結されているかチェック
const suspendedUser = await usersRepository.findOneBy({ id: remoteUser.id });
expect(suspendedUser?.isRemoteSuspended).toBe(true);
// イベントが発行されているかチェック
expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith(
'userChangeSuspendedState',
{ id: remoteUser.id, isRemoteSuspended: true },
);
});
test('should unsuspend remote user and post unsuspend event', async () => {
const remoteUser = await createUser({ host: genHost(), isRemoteSuspended: true }) as MiRemoteUser;
await userSuspendService.unsuspendFromRemote(remoteUser);
// ユーザーのリモート凍結が解除されているかチェック
const unsuspendedUser = await usersRepository.findOneBy({ id: remoteUser.id });
expect(unsuspendedUser?.isRemoteSuspended).toBe(false);
// イベントが発行されているかチェック
expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith(
'userChangeSuspendedState',
{ id: remoteUser.id, isRemoteSuspended: false },
);
});
});
});

View File

@ -51,6 +51,7 @@ import { ReactionService } from '@/core/ReactionService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { ChatService } from '@/core/ChatService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
process.env.NODE_ENV = 'test';
@ -170,6 +171,7 @@ describe('UserEntityService', () => {
InstanceChart,
ApLoggerService,
AccountMoveService,
UserSuspendService,
ReactionService,
ReactionsBufferingService,
NotificationService,

View File

@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
<span class="state">
<span v-if="suspended" class="suspended">Suspended</span>
<span v-if="remoteSuspended" class="suspended">Suspended in Remote</span>
<span v-if="silenced" class="silenced">Silenced</span>
<span v-if="moderator" class="moderator">Moderator</span>
</span>
@ -261,6 +262,7 @@ const ap = ref<any>(null);
const moderator = ref(info.value.isModerator);
const silenced = ref(info.value.isSilenced);
const suspended = ref(info.value.isSuspended);
const remoteSuspended = ref(info.value.isRemoteSuspended);
const isSystem = ref(user.value.host == null && user.value.username.includes('.'));
const moderationNote = ref(info.value.moderationNote);
const filesPaginator = markRaw(new Paginator('admin/drive/files', {
@ -317,6 +319,7 @@ async function refreshUser() {
moderator.value = info.value.isModerator;
silenced.value = info.value.isSilenced;
suspended.value = info.value.isSuspended;
remoteSuspended.value = info.value.isRemoteSuspended;
isSystem.value = user.value.host == null && user.value.username.includes('.');
moderationNote.value = info.value.moderationNote;
}

View File

@ -11720,6 +11720,7 @@ export interface operations {
isModerator: boolean;
isSilenced: boolean;
isSuspended: boolean;
isRemoteSuspended: boolean;
isHibernated: boolean;
lastActiveDate: string | null;
moderationNote: string;