forked from FoundKeyGang/FoundKey
fix: regular expressions in word mutes (#8254)
* fix: handle regex exceptions for word mutes * add i18n strings Co-authored-by: rinsuki <428rinsuki+git@gmail.com> * stricter input validation in backend * add migration for hard mutes * fix * use correct regex library in migration * use query builder to avoid SQL injection Co-authored-by: Robin B <robflop98@outlook.com> Co-authored-by: rinsuki <428rinsuki+git@gmail.com>
This commit is contained in:
parent
7ba5512a65
commit
afb6304979
6 changed files with 173 additions and 31 deletions
|
@ -595,6 +595,8 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する"
|
||||||
smtpSecureInfo: "STARTTLS使用時はオフにします。"
|
smtpSecureInfo: "STARTTLS使用時はオフにします。"
|
||||||
testEmail: "配信テスト"
|
testEmail: "配信テスト"
|
||||||
wordMute: "ワードミュート"
|
wordMute: "ワードミュート"
|
||||||
|
regexpError: "正規表現エラー"
|
||||||
|
regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:"
|
||||||
instanceMute: "インスタンスミュート"
|
instanceMute: "インスタンスミュート"
|
||||||
userSaysSomething: "{name}が何かを言いました"
|
userSaysSomething: "{name}が何かを言いました"
|
||||||
makeActive: "アクティブにする"
|
makeActive: "アクティブにする"
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
const RE2 = require('re2');
|
||||||
|
const { MigrationInterface, QueryRunner } = require("typeorm");
|
||||||
|
|
||||||
|
module.exports = class convertHardMutes1644010796173 {
|
||||||
|
name = 'convertHardMutes1644010796173'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
let entries = await queryRunner.query(`SELECT "userId", "mutedWords" FROM "user_profile"`);
|
||||||
|
for(let i = 0; i < entries.length; i++) {
|
||||||
|
let words = entries[i].mutedWords
|
||||||
|
.map(line => {
|
||||||
|
const regexp = line.join(" ").match(/^\/(.+)\/(.*)$/);
|
||||||
|
if (regexp) {
|
||||||
|
// convert regexp's
|
||||||
|
try {
|
||||||
|
new RE2(regexp[1], regexp[2]);
|
||||||
|
return `/${regexp[1]}/${regexp[2]}`;
|
||||||
|
} catch (err) {
|
||||||
|
// invalid regex, ignore it
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// remove empty segments
|
||||||
|
return line.filter(x => x !== '');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// remove empty lines
|
||||||
|
.filter(x => !(Array.isArray(x) && x.length === 0));
|
||||||
|
|
||||||
|
await queryRunner.connection.createQueryBuilder()
|
||||||
|
.update('user_profile')
|
||||||
|
.set({
|
||||||
|
mutedWords: words
|
||||||
|
})
|
||||||
|
.where('userId = :id', { id: entries[i].userId })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
let entries = await queryRunner.query(`SELECT "userId", "mutedWords" FROM "user_profile"`);
|
||||||
|
for(let i = 0; i < entries.length; i++) {
|
||||||
|
let words = entries[i].mutedWords
|
||||||
|
.map(line => {
|
||||||
|
if (Array.isArray(line)) {
|
||||||
|
return line;
|
||||||
|
} else {
|
||||||
|
// do not split regex at spaces again
|
||||||
|
return [line];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// remove empty lines
|
||||||
|
.filter(x => !(Array.isArray(x) && x.length === 0));
|
||||||
|
|
||||||
|
await queryRunner.connection.createQueryBuilder()
|
||||||
|
.update('user_profile')
|
||||||
|
.set({
|
||||||
|
mutedWords: words
|
||||||
|
})
|
||||||
|
.where('userId = :id', { id: entries[i].userId })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,26 +11,31 @@ type UserLike = {
|
||||||
id: User['id'];
|
id: User['id'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: string[][]): Promise<boolean> {
|
export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: Array<string | string[]>): Promise<boolean> {
|
||||||
// 自分自身
|
// 自分自身
|
||||||
if (me && (note.userId === me.id)) return false;
|
if (me && (note.userId === me.id)) return false;
|
||||||
|
|
||||||
const words = mutedWords
|
if (mutedWords.length > 0) {
|
||||||
// Clean up
|
|
||||||
.map(xs => xs.filter(x => x !== ''))
|
|
||||||
.filter(xs => xs.length > 0);
|
|
||||||
|
|
||||||
if (words.length > 0) {
|
|
||||||
if (note.text == null) return false;
|
if (note.text == null) return false;
|
||||||
|
|
||||||
const matched = words.some(and =>
|
const matched = mutedWords.some(filter => {
|
||||||
and.every(keyword => {
|
if (Array.isArray(filter)) {
|
||||||
const regexp = keyword.match(/^\/(.+)\/(.*)$/);
|
return filter.every(keyword => note.text!.includes(keyword));
|
||||||
if (regexp) {
|
} else {
|
||||||
|
// represents RegExp
|
||||||
|
const regexp = filter.match(/^\/(.+)\/(.*)$/);
|
||||||
|
|
||||||
|
// This should never happen due to input sanitisation.
|
||||||
|
if (!regexp) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
return new RE2(regexp[1], regexp[2]).test(note.text!);
|
return new RE2(regexp[1], regexp[2]).test(note.text!);
|
||||||
|
} catch (err) {
|
||||||
|
// This should never happen due to input sanitisation.
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return note.text!.includes(keyword);
|
}
|
||||||
}));
|
});
|
||||||
|
|
||||||
if (matched) return true;
|
if (matched) return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
const RE2 = require('re2');
|
||||||
import $ from 'cafy';
|
import $ from 'cafy';
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { ID } from '@/misc/cafy-id';
|
import { ID } from '@/misc/cafy-id';
|
||||||
|
@ -117,7 +118,7 @@ export const meta = {
|
||||||
},
|
},
|
||||||
|
|
||||||
mutedWords: {
|
mutedWords: {
|
||||||
validator: $.optional.arr($.arr($.str)),
|
validator: $.optional.arr($.either($.arr($.str.min(1)).min(1), $.str)),
|
||||||
},
|
},
|
||||||
|
|
||||||
mutedInstances: {
|
mutedInstances: {
|
||||||
|
@ -163,6 +164,12 @@ export const meta = {
|
||||||
code: 'NO_SUCH_PAGE',
|
code: 'NO_SUCH_PAGE',
|
||||||
id: '8e01b590-7eb9-431b-a239-860e086c408e',
|
id: '8e01b590-7eb9-431b-a239-860e086c408e',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
invalidRegexp: {
|
||||||
|
message: 'Invalid Regular Expression.',
|
||||||
|
code: 'INVALID_REGEXP',
|
||||||
|
id: '0d786918-10df-41cd-8f33-8dec7d9a89a5',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
res: {
|
res: {
|
||||||
|
@ -191,6 +198,18 @@ export default define(meta, async (ps, _user, token) => {
|
||||||
if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId;
|
if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId;
|
||||||
if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId;
|
if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId;
|
||||||
if (ps.mutedWords !== undefined) {
|
if (ps.mutedWords !== undefined) {
|
||||||
|
// validate regular expression syntax
|
||||||
|
ps.mutedWords.filter(x => !Array.isArray(x)).forEach(x => {
|
||||||
|
const regexp = x.match(/^\/(.+)\/(.*)$/);
|
||||||
|
if (!regexp) throw new ApiError(meta.errors.invalidRegexp);
|
||||||
|
|
||||||
|
try {
|
||||||
|
new RE2(regexp[1], regexp[2]);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ApiError(meta.errors.invalidRegexp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
profileUpdates.mutedWords = ps.mutedWords;
|
profileUpdates.mutedWords = ps.mutedWords;
|
||||||
profileUpdates.enableWordMute = ps.mutedWords.length > 0;
|
profileUpdates.enableWordMute = ps.mutedWords.length > 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,18 +81,65 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
|
|
||||||
async created() {
|
async created() {
|
||||||
this.softMutedWords = this.$store.state.mutedWords.map(x => x.join(' ')).join('\n');
|
const render = (mutedWords) => mutedWords.map(x => {
|
||||||
this.hardMutedWords = this.$i.mutedWords.map(x => x.join(' ')).join('\n');
|
if (Array.isArray(x)) {
|
||||||
|
return x.join(' ');
|
||||||
|
} else {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
this.softMutedWords = render(this.$store.state.mutedWords);
|
||||||
|
this.hardMutedWords = render(this.$i.mutedWords);
|
||||||
|
|
||||||
this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count;
|
this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count;
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
async save() {
|
async save() {
|
||||||
this.$store.set('mutedWords', this.softMutedWords.trim().split('\n').map(x => x.trim().split(' ')));
|
const parseMutes = (mutes, tab) => {
|
||||||
|
// split into lines, remove empty lines and unnecessary whitespace
|
||||||
|
let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line != '');
|
||||||
|
|
||||||
|
// check each line if it is a RegExp or not
|
||||||
|
for(let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const regexp = line.match(/^\/(.+)\/(.*)$/);
|
||||||
|
if (regexp) {
|
||||||
|
// check that the RegExp is valid
|
||||||
|
try {
|
||||||
|
new RegExp(regexp[1], regexp[2]);
|
||||||
|
// note that regex lines will not be split by spaces!
|
||||||
|
} catch (err) {
|
||||||
|
// invalid syntax: do not save, do not reset changed flag
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: this.$ts.regexpError,
|
||||||
|
text: this.$t('regexpErrorDescription', { tab, line: i + 1 }) + "\n" + err.toString()
|
||||||
|
});
|
||||||
|
// re-throw error so these invalid settings are not saved
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines[i] = line.split(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let softMutes, hardMutes;
|
||||||
|
try {
|
||||||
|
softMutes = parseMutes(this.softMutedWords, this.$ts._wordMute.soft);
|
||||||
|
hardMutes = parseMutes(this.hardMutedWords, this.$ts._wordMute.hard);
|
||||||
|
} catch (err) {
|
||||||
|
// already displayed error message in parseMutes
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.set('mutedWords', softMutes);
|
||||||
await os.api('i/update', {
|
await os.api('i/update', {
|
||||||
mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')),
|
mutedWords: hardMutes,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.changed = false;
|
this.changed = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,28 @@
|
||||||
export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): boolean {
|
export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: Array<string | string[]>): boolean {
|
||||||
// 自分自身
|
// 自分自身
|
||||||
if (me && (note.userId === me.id)) return false;
|
if (me && (note.userId === me.id)) return false;
|
||||||
|
|
||||||
const words = mutedWords
|
if (mutedWords.length > 0) {
|
||||||
// Clean up
|
|
||||||
.map(xs => xs.filter(x => x !== ''))
|
|
||||||
.filter(xs => xs.length > 0);
|
|
||||||
|
|
||||||
if (words.length > 0) {
|
|
||||||
if (note.text == null) return false;
|
if (note.text == null) return false;
|
||||||
|
|
||||||
const matched = words.some(and =>
|
const matched = mutedWords.some(filter => {
|
||||||
and.every(keyword => {
|
if (Array.isArray(filter)) {
|
||||||
const regexp = keyword.match(/^\/(.+)\/(.*)$/);
|
return filter.every(keyword => note.text!.includes(keyword));
|
||||||
if (regexp) {
|
} else {
|
||||||
|
// represents RegExp
|
||||||
|
const regexp = filter.match(/^\/(.+)\/(.*)$/);
|
||||||
|
|
||||||
|
// This should never happen due to input sanitisation.
|
||||||
|
if (!regexp) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
return new RegExp(regexp[1], regexp[2]).test(note.text!);
|
return new RegExp(regexp[1], regexp[2]).test(note.text!);
|
||||||
|
} catch (err) {
|
||||||
|
// This should never happen due to input sanitisation.
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return note.text!.includes(keyword);
|
}
|
||||||
}));
|
});
|
||||||
|
|
||||||
if (matched) return true;
|
if (matched) return true;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue