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 { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/_.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 { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { RelayService } from '@/core/RelayService.js'; import { RelayService } from '@/core/RelayService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.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 @bindThis
public async publishToFollowers(userId: MiUser['id']) { public async publishToFollowers(userId: MiUser['id']) {
const user = await this.usersRepository.findOneBy({ id: userId }); 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)) { 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.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(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) => { filter = (note) => {
if (!ps.ignoreAuthorFromUserSuspension) { if (!ps.ignoreAuthorFromUserSuspension) {
if (note.user!.isSuspended) return false; 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.renoteUserId && note.renote?.user?.isSuspended) return false;
if (note.userId !== note.replyUserId && note.reply?.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 { 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']; }; userChangeDeletedState: { id: MiUser['id']; isDeleted: MiUser['isDeleted']; };
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; }; userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
remoteUserUpdated: { id: MiUser['id']; }; remoteUserUpdated: { id: MiUser['id']; };

View File

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

View File

@ -174,6 +174,9 @@ export class QueueService {
@bindThis @bindThis
public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>) { public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>) {
if (content == null) return null; 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 contentBody = JSON.stringify(content);
const digest = ApRequestCreator.createDigest(contentBody); const digest = ApRequestCreator.createDigest(contentBody);

View File

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

View File

@ -229,9 +229,7 @@ export class UserFollowingService implements OnModuleInit {
followee: { followee: {
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']
}, },
follower: { follower: MiUser,
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']
},
silent = false, silent = false,
withReplies?: boolean, withReplies?: boolean,
): Promise<void> { ): Promise<void> {
@ -244,6 +242,7 @@ export class UserFollowingService implements OnModuleInit {
followerId: follower.id, followerId: follower.id,
followeeId: followee.id, followeeId: followee.id,
withReplies: withReplies, withReplies: withReplies,
isFollowerSuspended: follower.isSuspended,
// 非正規化 // 非正規化
followerHost: follower.host, followerHost: follower.host,
@ -734,6 +733,7 @@ export class UserFollowingService implements OnModuleInit {
return this.followingsRepository.createQueryBuilder('following') return this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId') .select('following.followeeId')
.where('following.followerId = :followerId', { followerId: userId }) .where('following.followerId = :followerId', { followerId: userId })
.andWhere('following.isFollowerSuspended = false')
.getMany(); .getMany();
} }
@ -743,6 +743,7 @@ export class UserFollowingService implements OnModuleInit {
where: { where: {
followerId, followerId,
followeeId, 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; return userQuery;
} }
@ -243,7 +243,8 @@ export class UserSearchService {
.where('user.updatedAt IS NULL') .where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
})) }))
.andWhere('user.isSuspended = FALSE'); .andWhere('user.isSuspended = FALSE')
.andWhere('user.isRemoteSuspended = FALSE');
if (mutingQuery) { if (mutingQuery) {
nameQuery.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`); nameQuery.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`);
@ -286,6 +287,7 @@ export class UserSearchService {
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
})) }))
.andWhere('user.isSuspended = FALSE') .andWhere('user.isSuspended = FALSE')
.andWhere('user.isRemoteSuspended = FALSE')
.setParameters(profQuery.getParameters()); .setParameters(profQuery.getParameters());
users = users.concat(await userQuery users = users.concat(await userQuery

View File

@ -4,17 +4,14 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js'; import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiRemoteUser, MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
@Injectable() @Injectable()
export class UserSuspendService { export class UserSuspendService {
@ -29,9 +26,8 @@ export class UserSuspendService {
private followRequestsRepository: FollowRequestsRepository, private followRequestsRepository: FollowRequestsRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private queueService: QueueService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private apRendererService: ApRendererService, private accountUpdateService: AccountUpdateService,
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
) { ) {
} }
@ -49,8 +45,20 @@ export class UserSuspendService {
}); });
(async () => { (async () => {
await this.postSuspend(user).catch(e => {}); await this.postSuspend(user, false).catch((e: any) => { });
await this.unFollowAll(user).catch(e => {}); 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 () => { (async () => {
await this.postUnsuspend(user).catch(e => {}); await this.postUnsuspend(user, false).catch((e: any) => { });
await this.restoreFollowings(user).catch((e: any) => { });
})(); })();
} }
@bindThis @bindThis
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> { public async unsuspendFromRemote(user: { id: MiRemoteUser['id']; host: MiRemoteUser['host'] }): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); 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({ this.followRequestsRepository.delete({
followeeId: user.id, followeeId: user.id,
@ -83,80 +107,55 @@ export class UserSuspendService {
}); });
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信 this.accountUpdateService.publishToFollowersAndSharedInboxAndRelays(user.id);
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);
}
} }
} }
@bindThis @bindThis
private async postUnsuspend(user: MiUser): Promise<void> { private async postUnsuspend(user: { id: MiUser['id']; host: MiUser['host'] }, isFromRemote: boolean): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); this.globalEventService.publishInternalEvent(
'userChangeSuspendedState',
isFromRemote ? { id: user.id, isRemoteSuspended: false } : { id: user.id, isSuspended: false }
);
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにUndo Delete配信 this.accountUpdateService.publishToFollowersAndSharedInboxAndRelays(user.id);
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);
}
} }
} }
@bindThis @bindThis
private async unFollowAll(follower: MiUser) { private async suspendFollowings(follower: { id: MiUser['id'] }) {
const followings = await this.followingsRepository.find({ await this.followingsRepository.update(
where: { {
followerId: follower.id, 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;
} }
this.queueService.createUnfollowJob(jobs); if (this.userEntityService.isSuspendedEither(follower)) {
// フォロー関係を復元しない
return;
}
// フォロー関係を復元isFollowerSuspended: falseに変更
await this.followingsRepository.update(
{
followerId: follower.id,
},
{
isFollowerSuspended: false,
}
);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,6 +38,7 @@ import { RoleService } from '@/core/RoleService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.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 { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js'; import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
@ -74,6 +75,7 @@ export class ApPersonService implements OnModuleInit {
private instanceChart: InstanceChart; private instanceChart: InstanceChart;
private apLoggerService: ApLoggerService; private apLoggerService: ApLoggerService;
private accountMoveService: AccountMoveService; private accountMoveService: AccountMoveService;
private userSuspendService: UserSuspendService;
private logger: Logger; private logger: Logger;
constructor( constructor(
@ -126,6 +128,7 @@ export class ApPersonService implements OnModuleInit {
this.instanceChart = this.moduleRef.get('InstanceChart'); this.instanceChart = this.moduleRef.get('InstanceChart');
this.apLoggerService = this.moduleRef.get('ApLoggerService'); this.apLoggerService = this.moduleRef.get('ApLoggerService');
this.accountMoveService = this.moduleRef.get('AccountMoveService'); this.accountMoveService = this.moduleRef.get('AccountMoveService');
this.userSuspendService = this.moduleRef.get('UserSuspendService');
this.logger = this.apLoggerService.logger; this.logger = this.apLoggerService.logger;
} }
@ -393,6 +396,7 @@ export class ApPersonService implements OnModuleInit {
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null, makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null, makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
emojis, emojis,
isRemoteSuspended: person.suspended === true,
})) as MiRemoteUser; })) as MiRemoteUser;
let _description: string | null = null; let _description: string | null = null;
@ -570,6 +574,7 @@ export class ApPersonService implements OnModuleInit {
movedToUri: person.movedTo ?? null, movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null, alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: person.discoverable, isExplorable: person.discoverable,
isRemoteSuspended: person.suspended === true,
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image).catch(() => ({}))), ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image).catch(() => ({}))),
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>; } as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
@ -598,6 +603,19 @@ export class ApPersonService implements OnModuleInit {
return 'skip'; 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) { if (person.publicKey) {
await this.userPublickeysRepository.update({ userId: exist.id }, { await this.userPublickeysRepository.update({ userId: exist.id }, {
keyId: person.publicKey.id, keyId: person.publicKey.id,

View File

@ -195,6 +195,7 @@ export interface IActor extends IObject {
}; };
'vcard:bday'?: string; 'vcard:bday'?: string;
'vcard:Address'?: string; 'vcard:Address'?: string;
suspended?: boolean;
} }
export const isCollection = (object: IObject): object is ICollection => 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 == null) return false;
if (notifier.host && userMutedInstances.has(notifier.host)) 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; return true;
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -124,6 +124,10 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
isRemoteSuspended: {
type: 'boolean',
optional: false, nullable: false,
},
isHibernated: { isHibernated: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
@ -246,6 +250,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
isModerator: isModerator, isModerator: isModerator,
isSilenced: isSilenced, isSilenced: isSilenced,
isSuspended: user.isSuspended, isSuspended: user.isSuspended,
isRemoteSuspended: user.isRemoteSuspended,
isHibernated: user.isHibernated, isHibernated: user.isHibernated,
lastActiveDate: user.lastActiveDate ? user.lastActiveDate.toISOString() : null, lastActiveDate: user.lastActiveDate ? user.lastActiveDate.toISOString() : null,
moderationNote: profile.moderationNote ?? '', 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'); const query = this.usersRepository.createQueryBuilder('user');
switch (ps.state) { 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 '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 'suspended': query.where('user.isSuspended = TRUE'); break;
case 'admin': { case 'admin': {

View File

@ -50,7 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) 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 const followings = await query
.limit(ps.limit) .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) => { super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) 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 const followings = await query
.limit(ps.limit) .limit(ps.limit)

View File

@ -94,11 +94,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.followingsRepository.count({ this.followingsRepository.count({
where: { where: {
followeeHost: Not(IsNull()), followeeHost: Not(IsNull()),
isFollowerSuspended: false,
}, },
}), }),
this.followingsRepository.count({ this.followingsRepository.count({
where: { where: {
followerHost: Not(IsNull()), 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'); if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
const query = this.usersRepository.createQueryBuilder('user') const query = this.usersRepository.createQueryBuilder('user')
.where(':tag <@ user.tags', { tag: [normalizeForSearch(ps.tag)] }) .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)); 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: { where: {
followeeId: user.id, followeeId: user.id,
followerId: me.id, followerId: me.id,
isFollowerSuspended: false,
}, },
}); });
if (!isFollowing) { 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) 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.followeeId = :userId', { userId: user.id })
.andWhere('following.isFollowerSuspended = FALSE')
.innerJoinAndSelect('following.follower', 'follower'); .innerJoinAndSelect('following.follower', 'follower');
const followings = await query const followings = await query
.limit(ps.limit) .limit(ps.limit)
.getMany(); .getMany();
console.log(followings);
return await this.followingEntityService.packMany(followings, me, { populateFollower: true }); 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: { where: {
followeeId: user.id, followeeId: user.id,
followerId: me.id, followerId: me.id,
isFollowerSuspended: false,
}, },
}); });
if (!isFollowing) { 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) 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.followerId = :userId', { userId: user.id })
.andWhere('following.isFollowerSuspended = false')
.innerJoinAndSelect('following.followee', 'followee'); .innerJoinAndSelect('following.followee', 'followee');
if (ps.birthday) { 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') const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId') .select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id }); .where('following.followerId = :followerId', { followerId: me.id })
.andWhere('following.isFollowerSuspended = false');
query query
.andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`); .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), id: In(ps.userIds),
isSuspended: false, isSuspended: false,
isRemoteSuspended: false,
}); });
// リクエストされた通りに並べ替え // リクエストされた通りに並べ替え

View File

@ -435,6 +435,7 @@ export class ClientServerService {
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
host: host ?? IsNull(), host: host ?? IsNull(),
isSuspended: false, isSuspended: false,
isRemoteSuspended: false,
requireSigninToViewContents: false, requireSigninToViewContents: false,
}); });
@ -494,6 +495,7 @@ export class ClientServerService {
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
host: host ?? IsNull(), host: host ?? IsNull(),
isSuspended: false, isSuspended: false,
isRemoteSuspended: false,
}); });
vary(reply.raw, 'Accept'); vary(reply.raw, 'Accept');
@ -543,6 +545,7 @@ export class ClientServerService {
id: request.params.user, id: request.params.user,
host: IsNull(), host: IsNull(),
isSuspended: false, isSuspended: false,
isRemoteSuspended: false,
}); });
if (user == null) { 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 { NotificationService } from '@/core/NotificationService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { ChatService } from '@/core/ChatService.js'; import { ChatService } from '@/core/ChatService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
@ -170,6 +171,7 @@ describe('UserEntityService', () => {
InstanceChart, InstanceChart,
ApLoggerService, ApLoggerService,
AccountMoveService, AccountMoveService,
UserSuspendService,
ReactionService, ReactionService,
ReactionsBufferingService, ReactionsBufferingService,
NotificationService, 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="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
<span class="state"> <span class="state">
<span v-if="suspended" class="suspended">Suspended</span> <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="silenced" class="silenced">Silenced</span>
<span v-if="moderator" class="moderator">Moderator</span> <span v-if="moderator" class="moderator">Moderator</span>
</span> </span>
@ -261,6 +262,7 @@ const ap = ref<any>(null);
const moderator = ref(info.value.isModerator); const moderator = ref(info.value.isModerator);
const silenced = ref(info.value.isSilenced); const silenced = ref(info.value.isSilenced);
const suspended = ref(info.value.isSuspended); const suspended = ref(info.value.isSuspended);
const remoteSuspended = ref(info.value.isRemoteSuspended);
const isSystem = ref(user.value.host == null && user.value.username.includes('.')); const isSystem = ref(user.value.host == null && user.value.username.includes('.'));
const moderationNote = ref(info.value.moderationNote); const moderationNote = ref(info.value.moderationNote);
const filesPaginator = markRaw(new Paginator('admin/drive/files', { const filesPaginator = markRaw(new Paginator('admin/drive/files', {
@ -317,6 +319,7 @@ async function refreshUser() {
moderator.value = info.value.isModerator; moderator.value = info.value.isModerator;
silenced.value = info.value.isSilenced; silenced.value = info.value.isSilenced;
suspended.value = info.value.isSuspended; suspended.value = info.value.isSuspended;
remoteSuspended.value = info.value.isRemoteSuspended;
isSystem.value = user.value.host == null && user.value.username.includes('.'); isSystem.value = user.value.host == null && user.value.username.includes('.');
moderationNote.value = info.value.moderationNote; moderationNote.value = info.value.moderationNote;
} }

View File

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