Merge 5e9f2e3ab3
into 218070eb13
This commit is contained in:
commit
82e961a04c
|
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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") `);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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']; };
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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': {
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 =>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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.',
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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 ?? '',
|
||||||
|
|
|
@ -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': {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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() })`);
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
// リクエストされた通りに並べ替え
|
// リクエストされた通りに並べ替え
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue