diff --git a/CHANGELOG.md b/CHANGELOG.md index a32c557c9..1d788e152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ - Fix: リモートユーザーのリアクション一覧がすべて見えてしまうのを修正 * すべてのリモートユーザーのリアクション一覧を見えないようにします - Enhance: モデレーターはすべてのユーザーのリアクション一覧を見られるように +- Fix: 特定のキーワードを含むノートが投稿された際、エラーに出来るような設定項目を追加 #13207 + * デフォルトは空欄なので適用前と同等の動作になります ### Client - Feat: 新しいゲームを追加 diff --git a/locales/index.d.ts b/locales/index.d.ts index f8c497165..8f4c9d18e 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4180,6 +4180,18 @@ export interface Locale extends ILocale { * スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。 */ "sensitiveWordsDescription2": string; + /** + * 禁止ワード + */ + "prohibitedWords": string; + /** + * 設定したワードが含まれるノートを投稿しようとした際、エラーとなるようにします。改行で区切って複数設定できます。 + */ + "prohibitedWordsDescription": string; + /** + * スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。 + */ + "prohibitedWordsDescription2": string; /** * 非表示ハッシュタグ */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index cf45c13f7..534850242 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1041,6 +1041,9 @@ resetPasswordConfirm: "パスワードリセットしますか?" sensitiveWords: "センシティブワード" sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" +prohibitedWords: "禁止ワード" +prohibitedWordsDescription: "設定したワードが含まれるノートを投稿しようとした際、エラーとなるようにします。改行で区切って複数設定できます。" +prohibitedWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" hiddenTags: "非表示ハッシュタグ" hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。" notesSearchNotAvailable: "ノート検索は利用できません。" diff --git a/packages/backend/migration/1707429690000-prohibited-words.js b/packages/backend/migration/1707429690000-prohibited-words.js new file mode 100644 index 000000000..2dd62d8ff --- /dev/null +++ b/packages/backend/migration/1707429690000-prohibited-words.js @@ -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"`); + } +} diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts index 5a2417c9c..712530108 100644 --- a/packages/backend/src/core/HashtagService.ts +++ b/packages/backend/src/core/HashtagService.ts @@ -163,7 +163,7 @@ export class HashtagService { const instance = await this.metaService.fetch(); const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); if (hiddenTags.includes(hashtag)) return; - if (this.utilityService.isSensitiveWordIncluded(hashtag, instance.sensitiveWords)) return; + if (this.utilityService.isKeyWordIncluded(hashtag, instance.sensitiveWords)) return; // YYYYMMDDHHmm (10分間隔) const now = new Date(); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index f7e870831..153a6406a 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -151,6 +151,8 @@ type Option = { export class NoteCreateService implements OnApplicationShutdown { #shutdownController = new AbortController(); + public static ContainsProhibitedWordsError = class extends Error {}; + constructor( @Inject(DI.config) private config: Config, @@ -254,13 +256,19 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.visibility === 'public' && data.channel == null) { 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'; } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { 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); if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 5dec36c89..15b98abe6 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -43,13 +43,13 @@ export class UtilityService { } @bindThis - public isSensitiveWordIncluded(text: string, sensitiveWords: string[]): boolean { - if (sensitiveWords.length === 0) return false; + public isKeyWordIncluded(text: string, keyWords: string[]): boolean { + if (keyWords.length === 0) return false; if (text === '') return false; const regexpregexp = /^\/(.+)\/(.*)$/; - const matched = sensitiveWords.some(filter => { + const matched = keyWords.some(filter => { // represents RegExp const regexp = filter.match(regexpregexp); // This should never happen due to input sanitisation. diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 3265e85dd..bcde2db0b 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -76,6 +76,11 @@ export class MiMeta { }) public sensitiveWords: string[]; + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public prohibitedWords: string[]; + @Column('varchar', { length: 1024, array: true, default: '{}', }) diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 0627c5055..2af9e7cd9 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -156,6 +156,13 @@ export const meta = { type: 'string', }, }, + prohibitedWords: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + }, + }, bannedEmailDomains: { type: 'array', optional: true, nullable: false, @@ -515,6 +522,7 @@ export default class extends Endpoint { // eslint- blockedHosts: instance.blockedHosts, silencedHosts: instance.silencedHosts, sensitiveWords: instance.sensitiveWords, + prohibitedWords: instance.prohibitedWords, preservedUsernames: instance.preservedUsernames, hcaptchaSecretKey: instance.hcaptchaSecretKey, mcaptchaSecretKey: instance.mcaptchaSecretKey, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index d76d3dfee..ce8c8a505 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -41,6 +41,11 @@ export const paramDef = { type: 'string', }, }, + prohibitedWords: { + type: 'array', nullable: true, items: { + type: 'string', + }, + }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, mascotImageUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true }, @@ -177,6 +182,9 @@ export default class extends Endpoint { // eslint- if (Array.isArray(ps.sensitiveWords)) { set.sensitiveWords = ps.sensitiveWords.filter(Boolean); } + if (Array.isArray(ps.prohibitedWords)) { + set.prohibitedWords = ps.prohibitedWords.filter(Boolean); + } if (Array.isArray(ps.silencedHosts)) { let lastValue = ''; set.silencedHosts = ps.silencedHosts.sort().filter((h) => { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 787cda383..50969c71c 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -17,6 +17,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import { DI } from '@/di-symbols.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'; export const meta = { @@ -111,6 +113,12 @@ export const meta = { code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', 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; @@ -340,31 +348,40 @@ export default class extends Endpoint { // eslint- } // 投稿を作成 - const note = await this.noteCreateService.create(me, { - createdAt: new Date(), - files: files, - poll: ps.poll ? { - choices: ps.poll.choices, - multiple: ps.poll.multiple ?? false, - expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, - } : undefined, - text: ps.text ?? undefined, - reply, - renote, - cw: ps.cw, - localOnly: ps.localOnly, - reactionAcceptance: ps.reactionAcceptance, - visibility: ps.visibility, - visibleUsers, - channel, - apMentions: ps.noExtractMentions ? [] : undefined, - apHashtags: ps.noExtractHashtags ? [] : undefined, - apEmojis: ps.noExtractEmojis ? [] : undefined, - }); + try { + const note = await this.noteCreateService.create(me, { + createdAt: new Date(), + files: files, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } : undefined, + text: ps.text ?? undefined, + reply, + renote, + cw: ps.cw, + localOnly: ps.localOnly, + reactionAcceptance: ps.reactionAcceptance, + visibility: ps.visibility, + visibleUsers, + channel, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, + }); - return { - createdNote: await this.noteEntityService.pack(note, me), - }; + return { + createdNote: await this.noteEntityService.pack(note, me), + }; + } catch (e) { + // TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい + if (e instanceof NoteCreateService.ContainsProhibitedWordsError) { + throw new ApiError(meta.errors.containsProhibitedWords); + } + + throw e; + } }); } } diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 0280b051f..1bc8cb591 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -16,12 +16,14 @@ describe('Note', () => { let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; + let tom: misskey.entities.SignupResponse; beforeAll(async () => { const connection = await initTestDb(true); Notes = connection.getRepository(MiNote); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); + tom = await signup({ username: 'tom', host: 'example.com' }); }, 1000 * 60 * 2); test('投稿できる', async () => { @@ -607,6 +609,77 @@ describe('Note', () => { assert.strictEqual(note2.status, 200); 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', () => { diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 4915bee71..248b4c53c 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -40,6 +40,11 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + @@ -76,6 +81,7 @@ import FormLink from '@/components/form/link.vue'; const enableRegistration = ref(false); const emailRequiredForSignup = ref(false); const sensitiveWords = ref(''); +const prohibitedWords = ref(''); const hiddenTags = ref(''); const preservedUsernames = ref(''); const tosUrl = ref(null); @@ -86,6 +92,7 @@ async function init() { enableRegistration.value = !meta.disableRegistration; emailRequiredForSignup.value = meta.emailRequiredForSignup; sensitiveWords.value = meta.sensitiveWords.join('\n'); + prohibitedWords.value = meta.prohibitedWords.join('\n'); hiddenTags.value = meta.hiddenTags.join('\n'); preservedUsernames.value = meta.preservedUsernames.join('\n'); tosUrl.value = meta.tosUrl; @@ -99,6 +106,7 @@ function save() { tosUrl: tosUrl.value, privacyPolicyUrl: privacyPolicyUrl.value, sensitiveWords: sensitiveWords.value.split('\n'), + prohibitedWords: prohibitedWords.value.split('\n'), hiddenTags: hiddenTags.value.split('\n'), preservedUsernames: preservedUsernames.value.split('\n'), }).then(() => { diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index b7d65406c..94d6673ac 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4659,6 +4659,7 @@ export type operations = { hiddenTags: string[]; blockedHosts: string[]; sensitiveWords: string[]; + prohibitedWords: string[]; bannedEmailDomains?: string[]; preservedUsernames: string[]; hcaptchaSecretKey: string | null; @@ -8413,6 +8414,7 @@ export type operations = { hiddenTags?: string[] | null; blockedHosts?: string[] | null; sensitiveWords?: string[] | null; + prohibitedWords?: string[] | null; themeColor?: string | null; mascotImageUrl?: string | null; bannerUrl?: string | null;