Merge pull request MisskeyIO#420 from merge-upstream
This commit is contained in:
commit
6dbec31895
@ -24,6 +24,8 @@
|
||||
- Fix: リモートユーザーのリアクション一覧がすべて見えてしまうのを修正
|
||||
* すべてのリモートユーザーのリアクション一覧を見えないようにします
|
||||
- Enhance: モデレーターはすべてのユーザーのリアクション一覧を見られるように
|
||||
- Fix: 特定のキーワードを含むノートが投稿された際、エラーに出来るような設定項目を追加 #13207
|
||||
* デフォルトは空欄なので適用前と同等の動作になります
|
||||
|
||||
### Client
|
||||
- Feat: 新しいゲームを追加
|
||||
|
@ -1048,6 +1048,9 @@ resetPasswordConfirm: "Really reset your password?"
|
||||
sensitiveWords: "Sensitive words"
|
||||
sensitiveWordsDescription: "The visibility of all notes containing any of the configured words will be set to \"Home\" automatically. You can list multiple by separating them via line breaks."
|
||||
sensitiveWordsDescription2: "Using spaces will create AND expressions and surrounding keywords with slashes will turn them into a regular expression."
|
||||
prohibitedWords: "Prohibited words"
|
||||
prohibitedWordsDescription: "All notes containing any of the configured words will be rejected. You can list multiple by separating them via line breaks."
|
||||
prohibitedWordsDescription2: "Using spaces will create AND expressions and surrounding keywords with slashes will turn them into a regular expression."
|
||||
hiddenTags: "Hidden hashtags"
|
||||
hiddenTagsDescription: "Select tags which will not shown on trend list.\nMultiple tags could be registered by lines."
|
||||
notesSearchNotAvailable: "Note search is unavailable."
|
||||
|
12
locales/index.d.ts
vendored
12
locales/index.d.ts
vendored
@ -4204,6 +4204,18 @@ export interface Locale extends ILocale {
|
||||
* スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。
|
||||
*/
|
||||
"sensitiveWordsDescription2": string;
|
||||
/**
|
||||
* 禁止ワード
|
||||
*/
|
||||
"prohibitedWords": string;
|
||||
/**
|
||||
* 設定したワードが含まれるノートを投稿しようとした際、エラーとなるようにします。改行で区切って複数設定できます。
|
||||
*/
|
||||
"prohibitedWordsDescription": string;
|
||||
/**
|
||||
* スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。
|
||||
*/
|
||||
"prohibitedWordsDescription2": string;
|
||||
/**
|
||||
* 非表示ハッシュタグ
|
||||
*/
|
||||
|
@ -1047,6 +1047,9 @@ resetPasswordConfirm: "パスワードリセットしますか?"
|
||||
sensitiveWords: "センシティブワード"
|
||||
sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。"
|
||||
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
|
||||
prohibitedWords: "禁止ワード"
|
||||
prohibitedWordsDescription: "設定したワードが含まれるノートを投稿しようとした際、エラーとなるようにします。改行で区切って複数設定できます。"
|
||||
prohibitedWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
|
||||
hiddenTags: "非表示ハッシュタグ"
|
||||
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
|
||||
notesSearchNotAvailable: "ノート検索は利用できません。"
|
||||
|
@ -1047,6 +1047,9 @@ resetPasswordConfirm: "비밀번호를 재설정하시겠습니까?"
|
||||
sensitiveWords: "민감한 단어"
|
||||
sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제합니다. 개행으로 구분하여 여러 개를 지정할 수 있습니다."
|
||||
sensitiveWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다."
|
||||
prohibitedWords: "금지된 단어"
|
||||
prohibitedWordsDescription: "설정한 단어가 포함된 노트는 게시할 수 없게 됩니다. 개행으로 구분하여 여러 개를 지정할 수 있습니다."
|
||||
prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다."
|
||||
hiddenTags: "숨긴 해시태그"
|
||||
hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다."
|
||||
notesSearchNotAvailable: "노트 검색을 이용하실 수 없습니다."
|
||||
|
16
packages/backend/migration/1707429690000-prohibited-words.js
Normal file
16
packages/backend/migration/1707429690000-prohibited-words.js
Normal 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"`);
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -61,6 +61,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
@ -260,13 +261,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 IdentifiableError('057d8d3e-b7ca-4f8b-b38c-dcdcbf34dc30');
|
||||
}
|
||||
}
|
||||
|
||||
const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host);
|
||||
|
||||
if (data.visibility === 'public' && inSilencedInstance && user.host !== null) {
|
||||
|
@ -48,31 +48,29 @@ export class UtilityService {
|
||||
return sensitiveMediaHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
private static readonly isFilterRegExpPattern = /^\/(.+)\/(.*)$/;
|
||||
|
||||
@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 = /^\/(.+)\/(.*)$/;
|
||||
return keyWords.some(filter => {
|
||||
const regexp = UtilityService.isFilterRegExpPattern.exec(filter);
|
||||
|
||||
const matched = sensitiveWords.some(filter => {
|
||||
// represents RegExp
|
||||
const regexp = filter.match(regexpregexp);
|
||||
// This should never happen due to input sanitisation.
|
||||
if (!regexp) {
|
||||
const words = filter.split(' ');
|
||||
return words.every(keyword => text.includes(keyword));
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: RE2インスタンスをキャッシュ
|
||||
return new RE2(regexp[1], regexp[2]).test(text);
|
||||
} catch (err) {
|
||||
// This should never happen due to input sanitisation.
|
||||
// This should never happen due to input sanitization.
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -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: '{}',
|
||||
})
|
||||
|
@ -166,6 +166,13 @@ export const meta = {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
prohibitedWords: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
bannedEmailDomains: {
|
||||
type: 'array',
|
||||
optional: true, nullable: false,
|
||||
@ -541,6 +548,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
silencedHosts: instance.silencedHosts,
|
||||
sensitiveMediaHosts: instance.sensitiveMediaHosts,
|
||||
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 },
|
||||
@ -193,6 +198,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
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,7 @@ 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 { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@ -112,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;
|
||||
|
||||
@ -341,31 +348,39 @@ 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 (err) {
|
||||
if (err instanceof IdentifiableError) {
|
||||
if (err.id === '057d8d3e-b7ca-4f8b-b38c-dcdcbf34dc30') throw new ApiError(meta.errors.containsProhibitedWords);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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="urlPreviewDenyList">
|
||||
<template #label>{{ i18n.ts.urlPreviewDenyList }}</template>
|
||||
<template #caption>{{ i18n.ts.urlPreviewDenyListDescription }}</template>
|
||||
@ -81,6 +86,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);
|
||||
@ -92,6 +98,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;
|
||||
@ -106,6 +113,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'),
|
||||
urlPreviewDenyList: urlPreviewDenyList.value?.split('\n'),
|
||||
|
@ -4819,6 +4819,7 @@ export type operations = {
|
||||
hiddenTags: string[];
|
||||
blockedHosts: string[];
|
||||
sensitiveWords: string[];
|
||||
prohibitedWords: string[];
|
||||
bannedEmailDomains?: string[];
|
||||
preservedUsernames: string[];
|
||||
hcaptchaSecretKey: string | null;
|
||||
@ -8850,6 +8851,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
Block a user