diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b85892a01c..48381f1e13 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -330,6 +330,7 @@ stopActivityDelivery: "アクティビティの配送を停止" blockThisInstance: "このサーバーをブロック" silenceThisInstance: "サーバーをサイレンス" mediaSilenceThisInstance: "サーバーをメディアサイレンス" +quarantineThisInstance: "サーバーに公開投稿のみ配送" operations: "操作" software: "ソフトウェア" version: "バージョン" @@ -3088,6 +3089,8 @@ _moderationLogTypes: deleteGalleryPost: "ギャラリーの投稿を削除" updateOfficialTags: "公式タグ一覧を更新" promoteQueue: "ジョブキューを再試行" + quarantineRemoteInstance: "公開投稿のみ配送に制限" + unquarantineRemoteInstance: "公開投稿のみ配送を解除" _fileViewer: title: "ファイルの詳細" diff --git a/packages/backend/migration/1734500881453-AddQuarantineLimited.js b/packages/backend/migration/1734500881453-AddQuarantineLimited.js new file mode 100644 index 0000000000..82b9b41f7b --- /dev/null +++ b/packages/backend/migration/1734500881453-AddQuarantineLimited.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project, yojo-art team + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddQuarantineLimited1734500881453 { + name = 'AddQuarantineLimited1734500881453' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "instance" ADD "quarantineLimited" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "quarantineLimited"`); + } +} diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index ca118c130e..cb2944fd4d 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -288,6 +288,7 @@ export interface InternalEventTypes { unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; + clearQuarantinedHostsCache: string; } type EventTypesToEventPayload = EventUnionFromDictionary>>; diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index a04d4a0595..eb7824613d 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; -import type { IActivity } from '@/core/activitypub/type.js'; +import { isAnnounce, isPost, type IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js'; @@ -107,6 +107,23 @@ export class QueueService { }); } + private isPublicContent(content: IActivity) { + let toPublicOnly = false; + if (isAnnounce(content)) { + toPublicOnly = true; + } + if (typeof content.object !== 'string') { + if (isPost(content.object)) { + toPublicOnly = true; + } + } + if (toPublicOnly) { + return String(content.to) === 'https://www.w3.org/ns/activitystreams#Public' || String(content.cc) === 'https://www.w3.org/ns/activitystreams#Public'; + } else { + return true; + } + } + @bindThis public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) { if (content == null) return null; @@ -123,6 +140,7 @@ export class QueueService { digest, to, isSharedInbox, + isPublicContent: this.isPublicContent(content), }; return this.deliverQueue.add(to, data, { @@ -165,6 +183,7 @@ export class QueueService { digest, to: d[0], isSharedInbox: d[1], + isPublicContent: this.isPublicContent(content), }, opts, }))); diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 04b3265289..05017b139a 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -60,6 +60,7 @@ export class InstanceEntityService { latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, moderationNote: iAmModerator ? instance.moderationNote : null, reversiVersion: instance.reversiVersion, + isQuarantineLimited: instance.quarantineLimited, }; } diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts index 0287b2f010..939530e967 100644 --- a/packages/backend/src/models/Instance.ts +++ b/packages/backend/src/models/Instance.ts @@ -163,4 +163,12 @@ export class MiInstance { length: 64, nullable: true, }) public reversiVersion: string | null; + /** + * このインスタンスへの配送制限 + */ + @Index() + @Column('boolean', { + default: false, + }) + public quarantineLimited: boolean; } diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index 8f4ead0b14..d1018f4efa 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -124,5 +124,9 @@ export const packedFederationInstanceSchema = { type: 'string', optional: true, nullable: true, }, + isQuarantineLimited: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 5a16496011..23b17355f8 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -6,6 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Bull from 'bullmq'; import { Not } from 'typeorm'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { InstancesRepository, MiMeta } from '@/models/_.js'; import type Logger from '@/logger.js'; @@ -20,6 +21,7 @@ import FederationChart from '@/core/chart/charts/federation.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { GlobalEvents } from '@/core/GlobalEventService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type { DeliverJobData } from '../types.js'; @@ -27,6 +29,7 @@ import type { DeliverJobData } from '../types.js'; export class DeliverProcessorService { private logger: Logger; private suspendedHostsCache: MemorySingleCache; + private quarantinedHostsCache: MemorySingleCache; private latest: string | null; constructor( @@ -35,6 +38,8 @@ export class DeliverProcessorService { @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, private utilityService: UtilityService, private federatedInstanceService: FederatedInstanceService, @@ -47,6 +52,25 @@ export class DeliverProcessorService { ) { this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); this.suspendedHostsCache = new MemorySingleCache(1000 * 60 * 60); // 1h + this.quarantinedHostsCache = new MemorySingleCache(1000 * 60 * 60); // 1h + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'clearQuarantinedHostsCache': { + this.quarantinedHostsCache.delete(); + break; + } + default: + break; + } + } } @bindThis @@ -70,6 +94,21 @@ export class DeliverProcessorService { if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) { return 'skip (suspended)'; } + // isQuarantinedなら中断 + let quarantinedHosts = this.quarantinedHostsCache.get(); + if (quarantinedHosts == null) { + quarantinedHosts = await this.instancesRepository.find({ + where: { + quarantineLimited: true, + }, + }); + this.quarantinedHostsCache.set(quarantinedHosts); + } + if (quarantinedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) { + if (!job.data.isPublicContent) { + return 'skip (quarantined)'; + } + } try { await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest); diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 6b6335a7ad..f2f5d6ac30 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -27,6 +27,8 @@ export type DeliverJobData = { to: string; /** whether it is sharedInbox */ isSharedInbox: boolean; + /** Activity is Public */ + isPublicContent: boolean; }; export type InboxJobData = { diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts index fed7bfbbde..e08c0ebc72 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -10,6 +10,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; export const meta = { tags: ['admin'], @@ -25,6 +26,7 @@ export const paramDef = { host: { type: 'string' }, isSuspended: { type: 'boolean' }, moderationNote: { type: 'string' }, + isQuarantineLimit: { type: 'boolean' }, }, required: ['host'], } as const; @@ -34,6 +36,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, + private globalEventService: GlobalEventService, private utilityService: UtilityService, private federatedInstanceService: FederatedInstanceService, @@ -52,9 +55,22 @@ export default class extends Endpoint { // eslint- if (ps.isSuspended != null && isSuspendedBefore !== ps.isSuspended) { suspensionState = ps.isSuspended ? 'manuallySuspended' : 'none'; } + const isQuarantineLimitBefore = instance.quarantineLimited; + let quarantineLimited: undefined | boolean; + + if (ps.isQuarantineLimit != null && isQuarantineLimitBefore !== ps.isQuarantineLimit) { + quarantineLimited = ps.isQuarantineLimit; + } + const moderationNoteBefore = instance.moderationNote; + if ((!ps.moderationNote || moderationNoteBefore === ps.moderationNote) && (!quarantineLimited || isQuarantineLimitBefore === quarantineLimited) && (!suspensionState || isSuspendedBefore === ps.isSuspended)) { + //何も変更が無い時はupdateを呼ばない + //呼ぶとエラーが発生する + return; + } await this.federatedInstanceService.update(instance.id, { suspensionState, + quarantineLimited, moderationNote: ps.moderationNote, }); @@ -71,6 +87,20 @@ export default class extends Endpoint { // eslint- }); } } + if (ps.isQuarantineLimit != null && isQuarantineLimitBefore !== ps.isQuarantineLimit) { + if (ps.isQuarantineLimit) { + this.moderationLogService.log(me, 'quarantineRemoteInstance', { + id: instance.id, + host: instance.host, + }); + } else { + this.moderationLogService.log(me, 'unquarantineRemoteInstance', { + id: instance.id, + host: instance.host, + }); + } + this.globalEventService.publishInternalEvent('clearQuarantinedHostsCache', ''); + } if (ps.moderationNote != null && instance.moderationNote !== ps.moderationNote) { this.moderationLogService.log(me, 'updateRemoteInstanceNote', { diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index baa737bb98..cbed34b3d4 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -127,6 +127,8 @@ export const moderationLogTypes = [ 'deleteGalleryPost', 'updateOfficialTags', 'unsetUserMutualLink', + 'quarantineRemoteInstance', + 'unquarantineRemoteInstance', ] as const; export type ModerationLogPayloads = { @@ -392,7 +394,15 @@ export type ModerationLogPayloads = { userId: string; userUsername: string; userMutualLinkSections: { name: string | null; mutualLinks: { fileId: string; description: string | null; imgSrc: string; }[]; }[] | [] - } + }; + quarantineRemoteInstance: { + id: string; + host: string; + }; + unquarantineRemoteInstance: { + id: string; + host: string; + }; }; export type Serialized = { diff --git a/packages/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index 20981043ba..03ee3024a1 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -2706,10 +2706,16 @@ type ModerationLog = { } | { type: 'deleteGalleryPost'; info: ModerationLogPayloads['deleteGalleryPost']; +} | { + type: 'quarantineRemoteInstance'; + info: ModerationLogPayloads['quarantineRemoteInstance']; +} | { + type: 'unquarantineRemoteInstance'; + info: ModerationLogPayloads['unquarantineRemoteInstance']; }); // @public (undocumented) -export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "forwardAbuseReport", "updateAbuseReportNote", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost"]; +export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "forwardAbuseReport", "updateAbuseReportNote", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost", "quarantineRemoteInstance", "unquarantineRemoteInstance"]; // @public (undocumented) type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index d708581b6b..1f53d50bc5 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -5073,6 +5073,7 @@ export type components = { latestRequestReceivedAt: string | null; moderationNote?: string | null; reversiVersion?: string | null; + isQuarantineLimited: boolean; }; GalleryPost: { /** @@ -8733,6 +8734,7 @@ export type operations = { host: string; isSuspended?: boolean; moderationNote?: string; + isQuarantineLimit?: boolean; }; }; }; diff --git a/packages/cherrypick-js/src/consts.ts b/packages/cherrypick-js/src/consts.ts index 02c26d242b..2207ffe8c2 100644 --- a/packages/cherrypick-js/src/consts.ts +++ b/packages/cherrypick-js/src/consts.ts @@ -167,6 +167,8 @@ export const moderationLogTypes = [ 'deletePage', 'deleteFlash', 'deleteGalleryPost', + 'quarantineRemoteInstance', + 'unquarantineRemoteInstance', ] as const; // See: packages/backend/src/core/ReversiService.ts@L410 @@ -439,4 +441,12 @@ export type ModerationLogPayloads = { postUserUsername: string; post: GalleryPost; }; + quarantineRemoteInstance: { + id: string; + host: string; + }; + unquarantineRemoteInstance: { + id: string; + host: string; + }; }; diff --git a/packages/cherrypick-js/src/entities.ts b/packages/cherrypick-js/src/entities.ts index 26c888665c..b11ee8f75a 100644 --- a/packages/cherrypick-js/src/entities.ts +++ b/packages/cherrypick-js/src/entities.ts @@ -195,6 +195,12 @@ export type ModerationLog = { } | { type: 'deleteGalleryPost'; info: ModerationLogPayloads['deleteGalleryPost']; +} | { + type: 'quarantineRemoteInstance'; + info: ModerationLogPayloads['quarantineRemoteInstance']; +} | { + type: 'unquarantineRemoteInstance'; + info: ModerationLogPayloads['unquarantineRemoteInstance']; }); export type ServerStats = { diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 45dd6d6ad1..2bc2733ac8 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only 'markSensitiveDriveFile', 'resetPassword', 'suspendRemoteInstance', + 'quarantineRemoteInstance', ].includes(log.type), [$style.logRed]: [ 'suspend', @@ -80,6 +81,8 @@ SPDX-License-Identifier: AGPL-3.0-only : @{{ log.info.pageUserUsername }} : @{{ log.info.flashUserUsername }} : @{{ log.info.postUserUsername }} + : {{ log.info.host }} + : {{ log.info.host }}