feat: media silence (#13842)

* feat: media silence

* fix: lint

* feat: deny creating custom emoji reaction and using custom emoji from media silenced hosts

* chore: メディアサイレンスの説明にカスタム絵文字の話を追加

* Update locales/ja-JP.yml

Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>

* chore: update index.d.ts

* docs(changelog): update changelog

---------

Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>
This commit is contained in:
anatawa12 2024-07-30 19:47:45 +09:00 committed by GitHub
parent 8f40f932e4
commit 5c42a0e439
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 124 additions and 11 deletions

View file

@ -9,6 +9,8 @@
- Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に - Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に
- 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます - 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます
- Feat: ユーザ作成時にSystemWebhookを送信可能に #14281 - Feat: ユーザ作成時にSystemWebhookを送信可能に #14281
- Feat: メディアサイレンスを実装 #13842
- メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。
- Enhance: 管理画面でアーカイブにしたお知らせを表示・編集できるように - Enhance: 管理画面でアーカイブにしたお知らせを表示・編集できるように
- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
- Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題

12
locales/index.d.ts vendored
View file

@ -864,6 +864,10 @@ export interface Locale extends ILocale {
* *
*/ */
"silenceThisInstance": string; "silenceThisInstance": string;
/**
*
*/
"mediaSilenceThisInstance": string;
/** /**
* *
*/ */
@ -948,6 +952,14 @@ export interface Locale extends ILocale {
* *
*/ */
"silencedInstancesDescription": string; "silencedInstancesDescription": string;
/**
*
*/
"mediaSilencedInstances": string;
/**
* 使
*/
"mediaSilencedInstancesDescription": string;
/** /**
* *
*/ */

View file

@ -212,6 +212,7 @@ perDay: "1日ごと"
stopActivityDelivery: "アクティビティの配送を停止" stopActivityDelivery: "アクティビティの配送を停止"
blockThisInstance: "このサーバーをブロック" blockThisInstance: "このサーバーをブロック"
silenceThisInstance: "サーバーをサイレンス" silenceThisInstance: "サーバーをサイレンス"
mediaSilenceThisInstance: "サーバーをメディアサイレンス"
operations: "操作" operations: "操作"
software: "ソフトウェア" software: "ソフトウェア"
version: "バージョン" version: "バージョン"
@ -233,6 +234,8 @@ blockedInstances: "ブロックしたサーバー"
blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定します。ブロックされたサーバーは、このインスタンスとやり取りできなくなります。" blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定します。ブロックされたサーバーは、このインスタンスとやり取りできなくなります。"
silencedInstances: "サイレンスしたサーバー" silencedInstances: "サイレンスしたサーバー"
silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定します。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになります。ブロックしたインスタンスには影響しません。" silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定します。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになります。ブロックしたインスタンスには影響しません。"
mediaSilencedInstances: "メディアサイレンスしたサーバー"
mediaSilencedInstancesDescription: "メディアサイレンスしたいサーバーのホストを改行で区切って設定します。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。ブロックしたインスタンスには影響しません。"
muteAndBlock: "ミュートとブロック" muteAndBlock: "ミュートとブロック"
mutedUsers: "ミュートしたユーザー" mutedUsers: "ミュートしたユーザー"
blockedUsers: "ブロックしたユーザー" blockedUsers: "ブロックしたユーザー"

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class MediaSilenceForHosts1716197366117 {
name = 'MediaSilenceForHosts1716197366117'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "mediaSilencedHosts" character varying(1024) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "mediaSilencedHosts"`);
}
}

View file

@ -43,6 +43,7 @@ import { RoleService } from '@/core/RoleService.js';
import { correctFilename } from '@/misc/correct-filename.js'; import { correctFilename } from '@/misc/correct-filename.js';
import { isMimeImage } from '@/misc/is-mime-image.js'; import { isMimeImage } from '@/misc/is-mime-image.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { UtilityService } from '@/core/UtilityService.js';
type AddFileArgs = { type AddFileArgs = {
/** User who wish to add file */ /** User who wish to add file */
@ -127,6 +128,7 @@ export class DriveService {
private driveChart: DriveChart, private driveChart: DriveChart,
private perUserDriveChart: PerUserDriveChart, private perUserDriveChart: PerUserDriveChart,
private instanceChart: InstanceChart, private instanceChart: InstanceChart,
private utilityService: UtilityService,
) { ) {
const logger = new Logger('drive', 'blue'); const logger = new Logger('drive', 'blue');
this.registerLogger = logger.createSubLogger('register', 'yellow'); this.registerLogger = logger.createSubLogger('register', 'yellow');
@ -587,6 +589,7 @@ export class DriveService {
sensitive ?? false sensitive ?? false
: false; : false;
if (user && this.utilityService.isMediaSilencedHost(instance.mediaSilencedHosts, user.host)) file.isSensitive = true;
if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; if (info.sensitive && profile!.autoSensitive) file.isSensitive = true;
if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true;
if (userRoleNSFW) file.isSensitive = true; if (userRoleNSFW) file.isSensitive = true;

View file

@ -364,6 +364,9 @@ export class NoteCreateService implements OnApplicationShutdown {
mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens);
} }
// if the host is media-silenced, custom emojis are not allowed
if (this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, user.host)) emojis = [];
tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32); tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32);
if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) {

View file

@ -105,6 +105,8 @@ export class ReactionService {
@bindThis @bindThis
public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) { public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) {
const meta = await this.metaService.fetch();
// Check blocking // Check blocking
if (note.userId !== user.id) { if (note.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
@ -148,6 +150,11 @@ export class ReactionService {
if ((note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && emoji.isSensitive) { if ((note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && emoji.isSensitive) {
reaction = FALLBACK; reaction = FALLBACK;
} }
// for media silenced host, custom emoji reactions are not allowed
if (reacterHost != null && this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, reacterHost)) {
reaction = FALLBACK;
}
} else { } else {
// リアクションとして使う権限がない // リアクションとして使う権限がない
reaction = FALLBACK; reaction = FALLBACK;
@ -220,8 +227,6 @@ export class ReactionService {
} }
} }
const meta = await this.metaService.fetch();
if (meta.enableChartsForRemoteUser || (user.host == null)) { if (meta.enableChartsForRemoteUser || (user.host == null)) {
this.perUserReactionsChart.update(user, note); this.perUserReactionsChart.update(user, note);
} }

View file

@ -42,6 +42,12 @@ export class UtilityService {
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
} }
@bindThis
public isMediaSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean {
if (!silencedHosts || host == null) return false;
return silencedHosts.some(x => host.toLowerCase() === x);
}
@bindThis @bindThis
public concatNoteContentsForKeyWordCheck(content: { public concatNoteContentsForKeyWordCheck(content: {
cw?: string | null; cw?: string | null;

View file

@ -50,6 +50,7 @@ export class InstanceEntityService {
maintainerName: instance.maintainerName, maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail, maintainerEmail: instance.maintainerEmail,
isSilenced: this.utilityService.isSilencedHost(meta.silencedHosts, instance.host), isSilenced: this.utilityService.isSilencedHost(meta.silencedHosts, instance.host),
isMediaSilenced: this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, instance.host),
iconUrl: instance.iconUrl, iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl, faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor, themeColor: instance.themeColor,

View file

@ -86,6 +86,11 @@ export class MiMeta {
}) })
public silencedHosts: string[]; public silencedHosts: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
public mediaSilencedHosts: string[];
@Column('varchar', { @Column('varchar', {
length: 1024, length: 1024,
nullable: true, nullable: true,

View file

@ -88,6 +88,10 @@ export const packedFederationInstanceSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
isMediaSilenced: {
type: 'boolean',
optional: false, nullable: false,
},
iconUrl: { iconUrl: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,

View file

@ -128,6 +128,16 @@ export const meta = {
nullable: false, nullable: false,
}, },
}, },
mediaSilencedHosts: {
type: 'array',
optional: false,
nullable: false,
items: {
type: 'string',
optional: false,
nullable: false,
},
},
pinnedUsers: { pinnedUsers: {
type: 'array', type: 'array',
optional: false, nullable: false, optional: false, nullable: false,
@ -552,6 +562,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
hiddenTags: instance.hiddenTags, hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts, blockedHosts: instance.blockedHosts,
silencedHosts: instance.silencedHosts, silencedHosts: instance.silencedHosts,
mediaSilencedHosts: instance.mediaSilencedHosts,
sensitiveWords: instance.sensitiveWords, sensitiveWords: instance.sensitiveWords,
prohibitedWords: instance.prohibitedWords, prohibitedWords: instance.prohibitedWords,
preservedUsernames: instance.preservedUsernames, preservedUsernames: instance.preservedUsernames,

View file

@ -150,6 +150,13 @@ export const paramDef = {
type: 'string', type: 'string',
}, },
}, },
mediaSilencedHosts: {
type: 'array',
nullable: true,
items: {
type: 'string',
},
},
summalyProxy: { summalyProxy: {
type: 'string', nullable: true, type: 'string', nullable: true,
description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.', description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.',
@ -203,6 +210,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return h !== '' && h !== lv && !set.blockedHosts?.includes(h); return h !== '' && h !== lv && !set.blockedHosts?.includes(h);
}); });
} }
if (Array.isArray(ps.mediaSilencedHosts)) {
let lastValue = '';
set.mediaSilencedHosts = ps.mediaSilencedHosts.sort().filter((h) => {
const lv = lastValue;
lastValue = h;
return h !== '' && h !== lv && !set.blockedHosts?.includes(h);
});
}
if (ps.themeColor !== undefined) { if (ps.themeColor !== undefined) {
set.themeColor = ps.themeColor; set.themeColor = ps.themeColor;
} }

View file

@ -8,14 +8,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init"> <FormSuspense :p="init">
<MkTextarea v-if="tab === 'block'" v-model="blockedHosts"> <template v-if="tab === 'block'">
<span>{{ i18n.ts.blockedInstances }}</span> <MkTextarea v-model="blockedHosts">
<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> <span>{{ i18n.ts.blockedInstances }}</span>
</MkTextarea> <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
<MkTextarea v-else-if="tab === 'silence'" v-model="silencedHosts" class="_formBlock"> </MkTextarea>
<span>{{ i18n.ts.silencedInstances }}</span> </template>
<template #caption>{{ i18n.ts.silencedInstancesDescription }}</template> <template v-else-if="tab === 'silence'">
</MkTextarea> <MkTextarea v-model="silencedHosts" class="_formBlock">
<span>{{ i18n.ts.silencedInstances }}</span>
<template #caption>{{ i18n.ts.silencedInstancesDescription }}</template>
</MkTextarea>
<MkTextarea v-model="mediaSilencedHosts" class="_formBlock">
<span>{{ i18n.ts.mediaSilencedInstances }}</span>
<template #caption>{{ i18n.ts.mediaSilencedInstancesDescription }}</template>
</MkTextarea>
</template>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> <MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</FormSuspense> </FormSuspense>
</MkSpacer> </MkSpacer>
@ -36,18 +44,21 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
const blockedHosts = ref<string>(''); const blockedHosts = ref<string>('');
const silencedHosts = ref<string>(''); const silencedHosts = ref<string>('');
const mediaSilencedHosts = ref<string>('');
const tab = ref('block'); const tab = ref('block');
async function init() { async function init() {
const meta = await misskeyApi('admin/meta'); const meta = await misskeyApi('admin/meta');
blockedHosts.value = meta.blockedHosts.join('\n'); blockedHosts.value = meta.blockedHosts.join('\n');
silencedHosts.value = meta.silencedHosts.join('\n'); silencedHosts.value = meta.silencedHosts.join('\n');
mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n');
} }
function save() { function save() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
blockedHosts: blockedHosts.value.split('\n') || [], blockedHosts: blockedHosts.value.split('\n') || [],
silencedHosts: silencedHosts.value.split('\n') || [], silencedHosts: silencedHosts.value.split('\n') || [],
mediaSilencedHosts: mediaSilencedHosts.value.split('\n') || [],
}).then(() => { }).then(() => {
fetchInstance(true); fetchInstance(true);

View file

@ -47,6 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-if="suspensionState !== 'none'" :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton> <MkButton v-if="suspensionState !== 'none'" :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> <MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> <MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
<MkTextarea v-model="moderationNote" manualSave> <MkTextarea v-model="moderationNote" manualSave>
<template #label>{{ i18n.ts.moderationNote }}</template> <template #label>{{ i18n.ts.moderationNote }}</template>
@ -167,6 +168,7 @@ const instance = ref<Misskey.entities.FederationInstance | null>(null);
const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none'); const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none');
const isBlocked = ref(false); const isBlocked = ref(false);
const isSilenced = ref(false); const isSilenced = ref(false);
const isMediaSilenced = ref(false);
const faviconUrl = ref<string | null>(null); const faviconUrl = ref<string | null>(null);
const moderationNote = ref(''); const moderationNote = ref('');
@ -195,8 +197,9 @@ async function fetch(): Promise<void> {
suspensionState.value = instance.value?.suspensionState ?? 'none'; suspensionState.value = instance.value?.suspensionState ?? 'none';
isBlocked.value = instance.value?.isBlocked ?? false; isBlocked.value = instance.value?.isBlocked ?? false;
isSilenced.value = instance.value?.isSilenced ?? false; isSilenced.value = instance.value?.isSilenced ?? false;
isMediaSilenced.value = instance.value?.isMediaSilenced ?? false;
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview'); faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
moderationNote.value = instance.value?.moderationNote; moderationNote.value = instance.value?.moderationNote ?? '';
} }
async function toggleBlock(): Promise<void> { async function toggleBlock(): Promise<void> {
@ -218,6 +221,16 @@ async function toggleSilenced(): Promise<void> {
}); });
} }
async function toggleMediaSilenced(): Promise<void> {
if (!meta.value) throw new Error('No meta?');
if (!instance.value) throw new Error('No instance?');
const { host } = instance.value;
const mediaSilencedHosts = meta.value.mediaSilencedHosts ?? [];
await misskeyApi('admin/update-meta', {
mediaSilencedHosts: isMediaSilenced.value ? mediaSilencedHosts.concat([host]) : mediaSilencedHosts.filter(x => x !== host),
});
}
async function stopDelivery(): Promise<void> { async function stopDelivery(): Promise<void> {
if (!instance.value) throw new Error('No instance?'); if (!instance.value) throw new Error('No instance?');
suspensionState.value = 'manuallySuspended'; suspensionState.value = 'manuallySuspended';

View file

@ -4599,6 +4599,7 @@ export type components = {
maintainerName: string | null; maintainerName: string | null;
maintainerEmail: string | null; maintainerEmail: string | null;
isSilenced: boolean; isSilenced: boolean;
isMediaSilenced: boolean;
/** Format: url */ /** Format: url */
iconUrl: string | null; iconUrl: string | null;
/** Format: url */ /** Format: url */
@ -5044,6 +5045,7 @@ export type operations = {
enableServiceWorker: boolean; enableServiceWorker: boolean;
translatorAvailable: boolean; translatorAvailable: boolean;
silencedHosts?: string[]; silencedHosts?: string[];
mediaSilencedHosts: string[];
pinnedUsers: string[]; pinnedUsers: string[];
hiddenTags: string[]; hiddenTags: string[];
blockedHosts: string[]; blockedHosts: string[];
@ -9371,6 +9373,7 @@ export type operations = {
perUserListTimelineCacheMax?: number; perUserListTimelineCacheMax?: number;
notesPerOneAd?: number; notesPerOneAd?: number;
silencedHosts?: string[] | null; silencedHosts?: string[] | null;
mediaSilencedHosts?: string[] | null;
/** @description [Deprecated] Use "urlPreviewSummaryProxyUrl" instead. */ /** @description [Deprecated] Use "urlPreviewSummaryProxyUrl" instead. */
summalyProxy?: string | null; summalyProxy?: string | null;
urlPreviewEnabled?: boolean; urlPreviewEnabled?: boolean;