fix: 特定文字列を含むノートを投稿できないようにする管理画面用設定項目を追加 (#13210)

* fix: 特定文字列を含むノートを投稿できないようにする管理画面用設定項目を追加

* Serviceでチェックするように変更
This commit is contained in:
おさむのひと 2024-02-09 10:07:18 +09:00 committed by GitHub
parent c0cb76f0ec
commit 614c9a0fc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 191 additions and 29 deletions

View File

@ -24,6 +24,8 @@
- Fix: リモートユーザーのリアクション一覧がすべて見えてしまうのを修正 - Fix: リモートユーザーのリアクション一覧がすべて見えてしまうのを修正
* すべてのリモートユーザーのリアクション一覧を見えないようにします * すべてのリモートユーザーのリアクション一覧を見えないようにします
- Enhance: モデレーターはすべてのユーザーのリアクション一覧を見られるように - Enhance: モデレーターはすべてのユーザーのリアクション一覧を見られるように
- Fix: 特定のキーワードを含むノートが投稿された際、エラーに出来るような設定項目を追加 #13207
* デフォルトは空欄なので適用前と同等の動作になります
### Client ### Client
- Feat: 新しいゲームを追加 - Feat: 新しいゲームを追加

12
locales/index.d.ts vendored
View File

@ -4180,6 +4180,18 @@ export interface Locale extends ILocale {
* AND指定になり * AND指定になり
*/ */
"sensitiveWordsDescription2": string; "sensitiveWordsDescription2": string;
/**
*
*/
"prohibitedWords": string;
/**
* 稿
*/
"prohibitedWordsDescription": string;
/**
* AND指定になり
*/
"prohibitedWordsDescription2": string;
/** /**
* *
*/ */

View File

@ -1041,6 +1041,9 @@ resetPasswordConfirm: "パスワードリセットしますか?"
sensitiveWords: "センシティブワード" sensitiveWords: "センシティブワード"
sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。"
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
prohibitedWords: "禁止ワード"
prohibitedWordsDescription: "設定したワードが含まれるノートを投稿しようとした際、エラーとなるようにします。改行で区切って複数設定できます。"
prohibitedWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
hiddenTags: "非表示ハッシュタグ" hiddenTags: "非表示ハッシュタグ"
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。" hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
notesSearchNotAvailable: "ノート検索は利用できません。" notesSearchNotAvailable: "ノート検索は利用できません。"

View File

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

View File

@ -163,7 +163,7 @@ export class HashtagService {
const instance = await this.metaService.fetch(); const instance = await this.metaService.fetch();
const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t));
if (hiddenTags.includes(hashtag)) return; if (hiddenTags.includes(hashtag)) return;
if (this.utilityService.isSensitiveWordIncluded(hashtag, instance.sensitiveWords)) return; if (this.utilityService.isKeyWordIncluded(hashtag, instance.sensitiveWords)) return;
// YYYYMMDDHHmm (10分間隔) // YYYYMMDDHHmm (10分間隔)
const now = new Date(); const now = new Date();

View File

@ -151,6 +151,8 @@ type Option = {
export class NoteCreateService implements OnApplicationShutdown { export class NoteCreateService implements OnApplicationShutdown {
#shutdownController = new AbortController(); #shutdownController = new AbortController();
public static ContainsProhibitedWordsError = class extends Error {};
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@ -254,13 +256,19 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.visibility === 'public' && data.channel == null) { if (data.visibility === 'public' && data.channel == null) {
const sensitiveWords = meta.sensitiveWords; const sensitiveWords = meta.sensitiveWords;
if (this.utilityService.isSensitiveWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) {
data.visibility = 'home'; data.visibility = 'home';
} else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) {
data.visibility = 'home'; data.visibility = 'home';
} }
} }
if (!user.host) {
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) {
throw new NoteCreateService.ContainsProhibitedWordsError();
}
}
const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host); const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host);
if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { if (data.visibility === 'public' && inSilencedInstance && user.host !== null) {

View File

@ -43,13 +43,13 @@ export class UtilityService {
} }
@bindThis @bindThis
public isSensitiveWordIncluded(text: string, sensitiveWords: string[]): boolean { public isKeyWordIncluded(text: string, keyWords: string[]): boolean {
if (sensitiveWords.length === 0) return false; if (keyWords.length === 0) return false;
if (text === '') return false; if (text === '') return false;
const regexpregexp = /^\/(.+)\/(.*)$/; const regexpregexp = /^\/(.+)\/(.*)$/;
const matched = sensitiveWords.some(filter => { const matched = keyWords.some(filter => {
// represents RegExp // represents RegExp
const regexp = filter.match(regexpregexp); const regexp = filter.match(regexpregexp);
// This should never happen due to input sanitisation. // This should never happen due to input sanitisation.

View File

@ -76,6 +76,11 @@ export class MiMeta {
}) })
public sensitiveWords: string[]; public sensitiveWords: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
public prohibitedWords: string[];
@Column('varchar', { @Column('varchar', {
length: 1024, array: true, default: '{}', length: 1024, array: true, default: '{}',
}) })

View File

@ -156,6 +156,13 @@ export const meta = {
type: 'string', type: 'string',
}, },
}, },
prohibitedWords: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
},
},
bannedEmailDomains: { bannedEmailDomains: {
type: 'array', type: 'array',
optional: true, nullable: false, optional: true, nullable: false,
@ -515,6 +522,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
blockedHosts: instance.blockedHosts, blockedHosts: instance.blockedHosts,
silencedHosts: instance.silencedHosts, silencedHosts: instance.silencedHosts,
sensitiveWords: instance.sensitiveWords, sensitiveWords: instance.sensitiveWords,
prohibitedWords: instance.prohibitedWords,
preservedUsernames: instance.preservedUsernames, preservedUsernames: instance.preservedUsernames,
hcaptchaSecretKey: instance.hcaptchaSecretKey, hcaptchaSecretKey: instance.hcaptchaSecretKey,
mcaptchaSecretKey: instance.mcaptchaSecretKey, mcaptchaSecretKey: instance.mcaptchaSecretKey,

View File

@ -41,6 +41,11 @@ export const paramDef = {
type: 'string', type: 'string',
}, },
}, },
prohibitedWords: {
type: 'array', nullable: true, items: {
type: 'string',
},
},
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
mascotImageUrl: { type: 'string', nullable: true }, mascotImageUrl: { type: 'string', nullable: true },
bannerUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true },
@ -177,6 +182,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (Array.isArray(ps.sensitiveWords)) { if (Array.isArray(ps.sensitiveWords)) {
set.sensitiveWords = ps.sensitiveWords.filter(Boolean); set.sensitiveWords = ps.sensitiveWords.filter(Boolean);
} }
if (Array.isArray(ps.prohibitedWords)) {
set.prohibitedWords = ps.prohibitedWords.filter(Boolean);
}
if (Array.isArray(ps.silencedHosts)) { if (Array.isArray(ps.silencedHosts)) {
let lastValue = ''; let lastValue = '';
set.silencedHosts = ps.silencedHosts.sort().filter((h) => { set.silencedHosts = ps.silencedHosts.sort().filter((h) => {

View File

@ -17,6 +17,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { NoteCreateService } from '@/core/NoteCreateService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { isPureRenote } from '@/misc/is-pure-renote.js'; import { isPureRenote } from '@/misc/is-pure-renote.js';
import { MetaService } from '@/core/MetaService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -111,6 +113,12 @@ export const meta = {
code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL',
id: '33510210-8452-094c-6227-4a6c05d99f00', id: '33510210-8452-094c-6227-4a6c05d99f00',
}, },
containsProhibitedWords: {
message: 'Cannot post because it contains prohibited words.',
code: 'CONTAINS_PROHIBITED_WORDS',
id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
},
}, },
} as const; } as const;
@ -340,6 +348,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
// 投稿を作成 // 投稿を作成
try {
const note = await this.noteCreateService.create(me, { const note = await this.noteCreateService.create(me, {
createdAt: new Date(), createdAt: new Date(),
files: files, files: files,
@ -365,6 +374,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return { return {
createdNote: await this.noteEntityService.pack(note, me), createdNote: await this.noteEntityService.pack(note, me),
}; };
} catch (e) {
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
if (e instanceof NoteCreateService.ContainsProhibitedWordsError) {
throw new ApiError(meta.errors.containsProhibitedWords);
}
throw e;
}
}); });
} }
} }

View File

@ -16,12 +16,14 @@ describe('Note', () => {
let alice: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse;
let tom: misskey.entities.SignupResponse;
beforeAll(async () => { beforeAll(async () => {
const connection = await initTestDb(true); const connection = await initTestDb(true);
Notes = connection.getRepository(MiNote); Notes = connection.getRepository(MiNote);
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' }); bob = await signup({ username: 'bob' });
tom = await signup({ username: 'tom', host: 'example.com' });
}, 1000 * 60 * 2); }, 1000 * 60 * 2);
test('投稿できる', async () => { test('投稿できる', async () => {
@ -607,6 +609,77 @@ describe('Note', () => {
assert.strictEqual(note2.status, 200); assert.strictEqual(note2.status, 200);
assert.strictEqual(note2.body.createdNote.visibility, 'home'); assert.strictEqual(note2.body.createdNote.visibility, 'home');
}); });
test('禁止ワードを含む投稿はエラーになる (単語指定)', async () => {
const prohibited = await api('admin/update-meta', {
prohibitedWords: [
'test',
],
}, alice);
assert.strictEqual(prohibited.status, 204);
await new Promise(x => setTimeout(x, 2));
const note1 = await api('/notes/create', {
text: 'hogetesthuge',
}, alice);
assert.strictEqual(note1.status, 400);
assert.strictEqual(note1.body.error.code, 'CONTAINS_PROHIBITED_WORDS');
});
test('禁止ワードを含む投稿はエラーになる (正規表現)', async () => {
const prohibited = await api('admin/update-meta', {
prohibitedWords: [
'/Test/i',
],
}, alice);
assert.strictEqual(prohibited.status, 204);
const note2 = await api('/notes/create', {
text: 'hogetesthuge',
}, alice);
assert.strictEqual(note2.status, 400);
assert.strictEqual(note2.body.error.code, 'CONTAINS_PROHIBITED_WORDS');
});
test('禁止ワードを含む投稿はエラーになる (スペースアンド)', async () => {
const prohibited = await api('admin/update-meta', {
prohibitedWords: [
'Test hoge',
],
}, alice);
assert.strictEqual(prohibited.status, 204);
const note2 = await api('/notes/create', {
text: 'hogeTesthuge',
}, alice);
assert.strictEqual(note2.status, 400);
assert.strictEqual(note2.body.error.code, 'CONTAINS_PROHIBITED_WORDS');
});
test('禁止ワードを含んでいてもリモートノートはエラーにならない', async () => {
const prohibited = await api('admin/update-meta', {
prohibitedWords: [
'test',
],
}, alice);
assert.strictEqual(prohibited.status, 204);
await new Promise(x => setTimeout(x, 2));
const note1 = await api('/notes/create', {
text: 'hogetesthuge',
}, tom);
assert.strictEqual(note1.status, 200);
});
}); });
describe('notes/delete', () => { describe('notes/delete', () => {

View File

@ -40,6 +40,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template> <template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea> </MkTextarea>
<MkTextarea v-model="prohibitedWords">
<template #label>{{ i18n.ts.prohibitedWords }}</template>
<template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkTextarea v-model="hiddenTags"> <MkTextarea v-model="hiddenTags">
<template #label>{{ i18n.ts.hiddenTags }}</template> <template #label>{{ i18n.ts.hiddenTags }}</template>
<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template> <template #caption>{{ i18n.ts.hiddenTagsDescription }}</template>
@ -76,6 +81,7 @@ import FormLink from '@/components/form/link.vue';
const enableRegistration = ref<boolean>(false); const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false); const emailRequiredForSignup = ref<boolean>(false);
const sensitiveWords = ref<string>(''); const sensitiveWords = ref<string>('');
const prohibitedWords = ref<string>('');
const hiddenTags = ref<string>(''); const hiddenTags = ref<string>('');
const preservedUsernames = ref<string>(''); const preservedUsernames = ref<string>('');
const tosUrl = ref<string | null>(null); const tosUrl = ref<string | null>(null);
@ -86,6 +92,7 @@ async function init() {
enableRegistration.value = !meta.disableRegistration; enableRegistration.value = !meta.disableRegistration;
emailRequiredForSignup.value = meta.emailRequiredForSignup; emailRequiredForSignup.value = meta.emailRequiredForSignup;
sensitiveWords.value = meta.sensitiveWords.join('\n'); sensitiveWords.value = meta.sensitiveWords.join('\n');
prohibitedWords.value = meta.prohibitedWords.join('\n');
hiddenTags.value = meta.hiddenTags.join('\n'); hiddenTags.value = meta.hiddenTags.join('\n');
preservedUsernames.value = meta.preservedUsernames.join('\n'); preservedUsernames.value = meta.preservedUsernames.join('\n');
tosUrl.value = meta.tosUrl; tosUrl.value = meta.tosUrl;
@ -99,6 +106,7 @@ function save() {
tosUrl: tosUrl.value, tosUrl: tosUrl.value,
privacyPolicyUrl: privacyPolicyUrl.value, privacyPolicyUrl: privacyPolicyUrl.value,
sensitiveWords: sensitiveWords.value.split('\n'), sensitiveWords: sensitiveWords.value.split('\n'),
prohibitedWords: prohibitedWords.value.split('\n'),
hiddenTags: hiddenTags.value.split('\n'), hiddenTags: hiddenTags.value.split('\n'),
preservedUsernames: preservedUsernames.value.split('\n'), preservedUsernames: preservedUsernames.value.split('\n'),
}).then(() => { }).then(() => {

View File

@ -4659,6 +4659,7 @@ export type operations = {
hiddenTags: string[]; hiddenTags: string[];
blockedHosts: string[]; blockedHosts: string[];
sensitiveWords: string[]; sensitiveWords: string[];
prohibitedWords: string[];
bannedEmailDomains?: string[]; bannedEmailDomains?: string[];
preservedUsernames: string[]; preservedUsernames: string[];
hcaptchaSecretKey: string | null; hcaptchaSecretKey: string | null;
@ -8413,6 +8414,7 @@ export type operations = {
hiddenTags?: string[] | null; hiddenTags?: string[] | null;
blockedHosts?: string[] | null; blockedHosts?: string[] | null;
sensitiveWords?: string[] | null; sensitiveWords?: string[] | null;
prohibitedWords?: string[] | null;
themeColor?: string | null; themeColor?: string | null;
mascotImageUrl?: string | null; mascotImageUrl?: string | null;
bannerUrl?: string | null; bannerUrl?: string | null;