fix: 特定文字列を含むノートを投稿できないようにする管理画面用設定項目を追加 (#13210)
* fix: 特定文字列を含むノートを投稿できないようにする管理画面用設定項目を追加 * Serviceでチェックするように変更
This commit is contained in:
		
							parent
							
								
									c0cb76f0ec
								
							
						
					
					
						commit
						614c9a0fc6
					
				|  | @ -24,6 +24,8 @@ | |||
| - Fix: リモートユーザーのリアクション一覧がすべて見えてしまうのを修正 | ||||
|   * すべてのリモートユーザーのリアクション一覧を見えないようにします | ||||
| - Enhance: モデレーターはすべてのユーザーのリアクション一覧を見られるように | ||||
| - Fix: 特定のキーワードを含むノートが投稿された際、エラーに出来るような設定項目を追加 #13207 | ||||
|   * デフォルトは空欄なので適用前と同等の動作になります | ||||
| 
 | ||||
| ### Client | ||||
| - Feat: 新しいゲームを追加 | ||||
|  |  | |||
|  | @ -4180,6 +4180,18 @@ export interface Locale extends ILocale { | |||
|      * スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。 | ||||
|      */ | ||||
|     "sensitiveWordsDescription2": string; | ||||
|     /** | ||||
|      * 禁止ワード | ||||
|      */ | ||||
|     "prohibitedWords": string; | ||||
|     /** | ||||
|      * 設定したワードが含まれるノートを投稿しようとした際、エラーとなるようにします。改行で区切って複数設定できます。 | ||||
|      */ | ||||
|     "prohibitedWordsDescription": string; | ||||
|     /** | ||||
|      * スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。 | ||||
|      */ | ||||
|     "prohibitedWordsDescription2": string; | ||||
|     /** | ||||
|      * 非表示ハッシュタグ | ||||
|      */ | ||||
|  |  | |||
|  | @ -1041,6 +1041,9 @@ resetPasswordConfirm: "パスワードリセットしますか?" | |||
| sensitiveWords: "センシティブワード" | ||||
| sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" | ||||
| sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" | ||||
| prohibitedWords: "禁止ワード" | ||||
| prohibitedWordsDescription: "設定したワードが含まれるノートを投稿しようとした際、エラーとなるようにします。改行で区切って複数設定できます。" | ||||
| prohibitedWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" | ||||
| hiddenTags: "非表示ハッシュタグ" | ||||
| hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。" | ||||
| notesSearchNotAvailable: "ノート検索は利用できません。" | ||||
|  |  | |||
|  | @ -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"`); | ||||
|     } | ||||
| } | ||||
|  | @ -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(); | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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.
 | ||||
|  |  | |||
|  | @ -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: '{}', | ||||
| 	}) | ||||
|  |  | |||
|  | @ -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<typeof meta, typeof paramDef> { // eslint- | |||
| 				blockedHosts: instance.blockedHosts, | ||||
| 				silencedHosts: instance.silencedHosts, | ||||
| 				sensitiveWords: instance.sensitiveWords, | ||||
| 				prohibitedWords: instance.prohibitedWords, | ||||
| 				preservedUsernames: instance.preservedUsernames, | ||||
| 				hcaptchaSecretKey: instance.hcaptchaSecretKey, | ||||
| 				mcaptchaSecretKey: instance.mcaptchaSecretKey, | ||||
|  |  | |||
|  | @ -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<typeof meta, typeof paramDef> { // 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) => { | ||||
|  |  | |||
|  | @ -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<typeof meta, typeof paramDef> { // 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; | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -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', () => { | ||||
|  |  | |||
|  | @ -40,6 +40,11 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 						<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template> | ||||
| 					</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"> | ||||
| 						<template #label>{{ i18n.ts.hiddenTags }}</template> | ||||
| 						<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template> | ||||
|  | @ -76,6 +81,7 @@ import FormLink from '@/components/form/link.vue'; | |||
| const enableRegistration = ref<boolean>(false); | ||||
| const emailRequiredForSignup = ref<boolean>(false); | ||||
| const sensitiveWords = ref<string>(''); | ||||
| const prohibitedWords = ref<string>(''); | ||||
| const hiddenTags = ref<string>(''); | ||||
| const preservedUsernames = ref<string>(''); | ||||
| const tosUrl = ref<string | null>(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(() => { | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue