From 83c15b1026558d6e6b7ba243eb03a1b917f5bf27 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Fri, 12 Apr 2024 15:43:17 +0900
Subject: [PATCH] refactor (backend): port checkWordMute to backend-rs

Co-authored-by: sup39 <dev@sup39.dev>
---
 packages/backend-rs/index.d.ts                |   9 ++
 packages/backend-rs/index.js                  |   3 +-
 packages/backend-rs/src/database/error.rs     |   9 --
 packages/backend-rs/src/database/mod.rs       |   9 +-
 .../backend-rs/src/misc/check_word_mute.rs    | 107 ++++++++++++++++++
 packages/backend-rs/src/misc/mod.rs           |   1 +
 packages/backend-rs/src/model/error.rs        |   6 +-
 .../backend/src/misc/check-hit-antenna.ts     |   5 +-
 packages/backend/src/misc/check-word-mute.ts  |  76 -------------
 .../api/stream/channels/global-timeline.ts    |   4 +-
 .../api/stream/channels/home-timeline.ts      |   4 +-
 .../api/stream/channels/hybrid-timeline.ts    |   4 +-
 .../api/stream/channels/local-timeline.ts     |   4 +-
 .../stream/channels/recommended-timeline.ts   |   4 +-
 packages/backend/src/services/note/create.ts  |   4 +-
 15 files changed, 138 insertions(+), 111 deletions(-)
 delete mode 100644 packages/backend-rs/src/database/error.rs
 create mode 100644 packages/backend-rs/src/misc/check_word_mute.rs
 delete mode 100644 packages/backend/src/misc/check-word-mute.ts

diff --git a/packages/backend-rs/index.d.ts b/packages/backend-rs/index.d.ts
index 3168aa7105..6bc51dc7c4 100644
--- a/packages/backend-rs/index.d.ts
+++ b/packages/backend-rs/index.d.ts
@@ -118,6 +118,15 @@ export interface Acct {
 }
 export function stringToAcct(acct: string): Acct
 export function acctToString(acct: Acct): string
+export interface NoteLike {
+  fileIds: Array<string>
+  userId: string | null
+  text: string | null
+  cw: string | null
+  renoteId: string | null
+  replyId: string | null
+}
+export function checkWordMute(note: NoteLike, mutedWordLists: Array<Array<string>>, mutedPatterns: Array<string>): Promise<boolean>
 export function nyaify(text: string, lang?: string | undefined | null): string
 export interface AbuseUserReport {
   id: string
diff --git a/packages/backend-rs/index.js b/packages/backend-rs/index.js
index 800baabeb2..868e632d85 100644
--- a/packages/backend-rs/index.js
+++ b/packages/backend-rs/index.js
@@ -310,11 +310,12 @@ if (!nativeBinding) {
   throw new Error(`Failed to load native binding`)
 }
 
-const { readServerConfig, stringToAcct, acctToString, nyaify, AntennaSrcEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initIdGenerator, getTimestamp, genId, secureRndstr, IdConvertType, convertId } = nativeBinding
+const { readServerConfig, stringToAcct, acctToString, checkWordMute, nyaify, AntennaSrcEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initIdGenerator, getTimestamp, genId, secureRndstr, IdConvertType, convertId } = nativeBinding
 
 module.exports.readServerConfig = readServerConfig
 module.exports.stringToAcct = stringToAcct
 module.exports.acctToString = acctToString
+module.exports.checkWordMute = checkWordMute
 module.exports.nyaify = nyaify
 module.exports.AntennaSrcEnum = AntennaSrcEnum
 module.exports.MutedNoteReasonEnum = MutedNoteReasonEnum
diff --git a/packages/backend-rs/src/database/error.rs b/packages/backend-rs/src/database/error.rs
deleted file mode 100644
index babdd68318..0000000000
--- a/packages/backend-rs/src/database/error.rs
+++ /dev/null
@@ -1,9 +0,0 @@
-use sea_orm::error::DbErr;
-
-#[derive(thiserror::Error, Debug, PartialEq, Eq)]
-pub enum Error {
-    #[error("The database connections have not been initialized yet")]
-    Uninitialized,
-    #[error("ORM error: {0}")]
-    OrmError(#[from] DbErr),
-}
diff --git a/packages/backend-rs/src/database/mod.rs b/packages/backend-rs/src/database/mod.rs
index eb4935f6e9..f598e35cc7 100644
--- a/packages/backend-rs/src/database/mod.rs
+++ b/packages/backend-rs/src/database/mod.rs
@@ -1,12 +1,9 @@
-pub mod error;
-
 use crate::config::server::SERVER_CONFIG;
-use error::Error;
-use sea_orm::{Database, DbConn};
+use sea_orm::{Database, DbConn, DbErr};
 
 static DB_CONN: once_cell::sync::OnceCell<DbConn> = once_cell::sync::OnceCell::new();
 
-async fn init_database() -> Result<&'static DbConn, Error> {
+async fn init_database() -> Result<&'static DbConn, DbErr> {
     let database_uri = format!(
         "postgres://{}:{}@{}:{}/{}",
         SERVER_CONFIG.db.user,
@@ -19,7 +16,7 @@ async fn init_database() -> Result<&'static DbConn, Error> {
     Ok(DB_CONN.get_or_init(move || conn))
 }
 
-pub async fn db_conn() -> Result<&'static DbConn, Error> {
+pub async fn db_conn() -> Result<&'static DbConn, DbErr> {
     match DB_CONN.get() {
         Some(conn) => Ok(conn),
         None => init_database().await,
diff --git a/packages/backend-rs/src/misc/check_word_mute.rs b/packages/backend-rs/src/misc/check_word_mute.rs
new file mode 100644
index 0000000000..3a761a61b0
--- /dev/null
+++ b/packages/backend-rs/src/misc/check_word_mute.rs
@@ -0,0 +1,107 @@
+use crate::database::db_conn;
+use crate::model::entity::{drive_file, note};
+use once_cell::sync::Lazy;
+use regex::Regex;
+use sea_orm::{prelude::*, QuerySelect};
+
+#[crate::export(object)]
+pub struct NoteLike {
+    pub file_ids: Vec<String>,
+    pub user_id: Option<String>,
+    pub text: Option<String>,
+    pub cw: Option<String>,
+    pub renote_id: Option<String>,
+    pub reply_id: Option<String>,
+}
+
+async fn all_texts(note: NoteLike) -> Result<Vec<String>, DbErr> {
+    let db = db_conn().await?;
+
+    let mut texts: Vec<String> = vec![];
+
+    if let Some(text) = note.text {
+        texts.push(text);
+    }
+    if let Some(cw) = note.cw {
+        texts.push(cw);
+    }
+
+    texts.extend(
+        drive_file::Entity::find()
+            .select_only()
+            .column(drive_file::Column::Comment)
+            .filter(drive_file::Column::Id.is_in(note.file_ids))
+            .into_tuple::<String>()
+            .all(db)
+            .await?,
+    );
+
+    if let Some(renote_id) = note.renote_id {
+        if let Some((text, cw)) = note::Entity::find_by_id(renote_id)
+            .select_only()
+            .columns([note::Column::Text, note::Column::Cw])
+            .into_tuple::<(String, String)>()
+            .one(db)
+            .await?
+        {
+            texts.push(text);
+            texts.push(cw);
+        }
+    }
+
+    if let Some(reply_id) = note.reply_id {
+        if let Some((text, cw)) = note::Entity::find_by_id(reply_id)
+            .select_only()
+            .columns([note::Column::Text, note::Column::Cw])
+            .into_tuple::<(String, String)>()
+            .one(db)
+            .await?
+        {
+            texts.push(text);
+            texts.push(cw);
+        }
+    }
+
+    Ok(texts)
+}
+
+fn convert_regex(js_regex: &str) -> String {
+    static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/(.+)/(.*)$").unwrap());
+    RE.replace(js_regex, "(?$2)$1").to_string()
+}
+
+fn check_word_mute_impl(
+    texts: &[String],
+    muted_word_lists: &[Vec<String>],
+    muted_patterns: &[String],
+) -> bool {
+    muted_word_lists.iter().any(|muted_word_list| {
+        texts.iter().any(|text| {
+            let text_lower = text.to_lowercase();
+            muted_word_list
+                .iter()
+                .all(|muted_word| text_lower.contains(&muted_word.to_lowercase()))
+        })
+    }) || muted_patterns.iter().any(|muted_pattern| {
+        Regex::new(convert_regex(muted_pattern).as_str())
+            .map(|re| texts.iter().any(|text| re.is_match(text)))
+            .unwrap_or(false)
+    })
+}
+
+#[crate::export]
+pub async fn check_word_mute(
+    note: NoteLike,
+    muted_word_lists: Vec<Vec<String>>,
+    muted_patterns: Vec<String>,
+) -> Result<bool, DbErr> {
+    if muted_word_lists.is_empty() && muted_patterns.is_empty() {
+        Ok(false)
+    } else {
+        Ok(check_word_mute_impl(
+            &all_texts(note).await?,
+            &muted_word_lists,
+            &muted_patterns,
+        ))
+    }
+}
diff --git a/packages/backend-rs/src/misc/mod.rs b/packages/backend-rs/src/misc/mod.rs
index 02cdbfb404..ba404ca0d0 100644
--- a/packages/backend-rs/src/misc/mod.rs
+++ b/packages/backend-rs/src/misc/mod.rs
@@ -1,2 +1,3 @@
 pub mod acct;
+pub mod check_word_mute;
 pub mod nyaify;
diff --git a/packages/backend-rs/src/model/error.rs b/packages/backend-rs/src/model/error.rs
index 25a74ce10f..b3d9f6eda5 100644
--- a/packages/backend-rs/src/model/error.rs
+++ b/packages/backend-rs/src/model/error.rs
@@ -2,10 +2,8 @@
 pub enum Error {
     #[error("Failed to parse string: {0}")]
     ParseError(#[from] parse_display::ParseError),
-    #[error("Failed to get database connection: {0}")]
-    DbConnError(#[from] crate::database::error::Error),
-    #[error("Database operation error: {0}")]
-    DbOperationError(#[from] sea_orm::DbErr),
+    #[error("Database error: {0}")]
+    DbError(#[from] sea_orm::DbErr),
     #[error("Requested entity not found")]
     NotFound,
 }
diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts
index a2090934b6..5b795a2c21 100644
--- a/packages/backend/src/misc/check-hit-antenna.ts
+++ b/packages/backend/src/misc/check-hit-antenna.ts
@@ -4,8 +4,7 @@ import type { User } from "@/models/entities/user.js";
 import type { UserProfile } from "@/models/entities/user-profile.js";
 import { Blockings, Followings, UserProfiles } from "@/models/index.js";
 import { getFullApAccount } from "@/misc/convert-host.js";
-import { stringToAcct } from "backend-rs";
-import { getWordHardMute } from "@/misc/check-word-mute.js";
+import { checkWordMute, stringToAcct } from "backend-rs";
 import type { Packed } from "@/misc/schema.js";
 import { Cache } from "@/misc/cache.js";
 
@@ -124,7 +123,7 @@ export async function checkHitAntenna(
 		mutes.mutedWords != null &&
 		mutes.mutedPatterns != null &&
 		antenna.userId !== note.userId &&
-		(await getWordHardMute(note, mutes.mutedWords, mutes.mutedPatterns))
+		(await checkWordMute(note, mutes.mutedWords, mutes.mutedPatterns))
 	)
 		return false;
 
diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts
deleted file mode 100644
index f07f2a0fe5..0000000000
--- a/packages/backend/src/misc/check-word-mute.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import RE2 from "re2";
-import type { Note } from "@/models/entities/note.js";
-
-type NoteLike = {
-	userId: Note["userId"];
-	text: Note["text"];
-	files?: Note["files"];
-	cw?: Note["cw"];
-	reply?: NoteLike | null;
-	renote?: NoteLike | null;
-};
-
-function checkWordMute(
-	note: NoteLike | null | undefined,
-	mutedWords: string[][],
-	mutedPatterns: string[],
-): boolean {
-	if (note == null) return false;
-
-	let text = `${note.cw ?? ""} ${note.text ?? ""}`;
-	if (note.files != null)
-		text += ` ${note.files.map((f) => f.comment ?? "").join(" ")}`;
-	text = text.trim();
-
-	if (text === "") return false;
-
-	for (const mutedWord of mutedWords) {
-		// Clean up
-		const keywords = mutedWord.filter((keyword) => keyword !== "");
-
-		if (
-			keywords.length > 0 &&
-			keywords.every((keyword) =>
-				text.toLowerCase().includes(keyword.toLowerCase()),
-			)
-		)
-			return true;
-	}
-
-	for (const mutedPattern of mutedPatterns) {
-		// represents RegExp
-		const regexp = mutedPattern.match(/^\/(.+)\/(.*)$/);
-
-		// This should never happen due to input sanitisation.
-		if (!regexp) {
-			console.warn(`Found invalid regex in word mutes: ${mutedPattern}`);
-			continue;
-		}
-
-		try {
-			if (new RE2(regexp[1], regexp[2]).test(text)) return true;
-		} catch (err) {
-			// This should never happen due to input sanitisation.
-		}
-	}
-
-	return false;
-}
-
-export async function getWordHardMute(
-	note: NoteLike | null,
-	mutedWords: string[][],
-	mutedPatterns: string[],
-): Promise<boolean> {
-	if (note == null || mutedWords == null || mutedPatterns == null) return false;
-
-	if (mutedWords.length > 0) {
-		return (
-			checkWordMute(note, mutedWords, mutedPatterns) ||
-			checkWordMute(note.reply, mutedWords, mutedPatterns) ||
-			checkWordMute(note.renote, mutedWords, mutedPatterns)
-		);
-	}
-
-	return false;
-}
diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts
index 79d2fe90ec..97295af57a 100644
--- a/packages/backend/src/server/api/stream/channels/global-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts
@@ -1,6 +1,6 @@
 import Channel from "../channel.js";
 import { fetchMeta } from "@/misc/fetch-meta.js";
-import { getWordHardMute } from "@/misc/check-word-mute.js";
+import { checkWordMute } from "backend-rs";
 import { isInstanceMuted } from "@/misc/is-instance-muted.js";
 import { isUserRelated } from "@/misc/is-user-related.js";
 import type { Packed } from "@/misc/schema.js";
@@ -72,7 +72,7 @@ export default class extends Channel {
 		if (
 			this.userProfile &&
 			this.user?.id !== note.userId &&
-			(await getWordHardMute(
+			(await checkWordMute(
 				note,
 				this.userProfile.mutedWords,
 				this.userProfile.mutedPatterns,
diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts
index 8f23946259..8103fe9b69 100644
--- a/packages/backend/src/server/api/stream/channels/home-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts
@@ -1,5 +1,5 @@
 import Channel from "../channel.js";
-import { getWordHardMute } from "@/misc/check-word-mute.js";
+import { checkWordMute } from "backend-rs";
 import { isUserRelated } from "@/misc/is-user-related.js";
 import { isInstanceMuted } from "@/misc/is-instance-muted.js";
 import type { Packed } from "@/misc/schema.js";
@@ -69,7 +69,7 @@ export default class extends Channel {
 		if (
 			this.userProfile &&
 			this.user?.id !== note.userId &&
-			(await getWordHardMute(
+			(await checkWordMute(
 				note,
 				this.userProfile.mutedWords,
 				this.userProfile.mutedPatterns,
diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
index 7f5c662b8c..9052e7c2a5 100644
--- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
@@ -1,6 +1,6 @@
 import Channel from "../channel.js";
 import { fetchMeta } from "@/misc/fetch-meta.js";
-import { getWordHardMute } from "@/misc/check-word-mute.js";
+import { checkWordMute } from "backend-rs";
 import { isUserRelated } from "@/misc/is-user-related.js";
 import { isInstanceMuted } from "@/misc/is-instance-muted.js";
 import type { Packed } from "@/misc/schema.js";
@@ -86,7 +86,7 @@ export default class extends Channel {
 		if (
 			this.userProfile &&
 			this.user?.id !== note.userId &&
-			(await getWordHardMute(
+			(await checkWordMute(
 				note,
 				this.userProfile.mutedWords,
 				this.userProfile.mutedPatterns,
diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts
index 1df87dbfc8..bd31c94f9d 100644
--- a/packages/backend/src/server/api/stream/channels/local-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts
@@ -1,6 +1,6 @@
 import Channel from "../channel.js";
 import { fetchMeta } from "@/misc/fetch-meta.js";
-import { getWordHardMute } from "@/misc/check-word-mute.js";
+import { checkWordMute } from "backend-rs";
 import { isUserRelated } from "@/misc/is-user-related.js";
 import type { Packed } from "@/misc/schema.js";
 
@@ -64,7 +64,7 @@ export default class extends Channel {
 		if (
 			this.userProfile &&
 			this.user?.id !== note.userId &&
-			(await getWordHardMute(
+			(await checkWordMute(
 				note,
 				this.userProfile.mutedWords,
 				this.userProfile.mutedPatterns,
diff --git a/packages/backend/src/server/api/stream/channels/recommended-timeline.ts b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts
index a9da732f89..26c3cbfc68 100644
--- a/packages/backend/src/server/api/stream/channels/recommended-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts
@@ -1,6 +1,6 @@
 import Channel from "../channel.js";
 import { fetchMeta } from "@/misc/fetch-meta.js";
-import { getWordHardMute } from "@/misc/check-word-mute.js";
+import { checkWordMute } from "backend-rs";
 import { isUserRelated } from "@/misc/is-user-related.js";
 import { isInstanceMuted } from "@/misc/is-instance-muted.js";
 import type { Packed } from "@/misc/schema.js";
@@ -84,7 +84,7 @@ export default class extends Channel {
 		if (
 			this.userProfile &&
 			this.user?.id !== note.userId &&
-			(await getWordHardMute(
+			(await checkWordMute(
 				note,
 				this.userProfile.mutedWords,
 				this.userProfile.mutedPatterns,
diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts
index 54e53c6c96..0a4ddc517f 100644
--- a/packages/backend/src/services/note/create.ts
+++ b/packages/backend/src/services/note/create.ts
@@ -44,7 +44,7 @@ import { Poll } from "@/models/entities/poll.js";
 import { createNotification } from "@/services/create-notification.js";
 import { isDuplicateKeyValueError } from "@/misc/is-duplicate-key-value-error.js";
 import { checkHitAntenna } from "@/misc/check-hit-antenna.js";
-import { getWordHardMute } from "@/misc/check-word-mute.js";
+import { checkWordMute } from "backend-rs";
 import { addNoteToAntenna } from "@/services/add-note-to-antenna.js";
 import { countSameRenotes } from "@/misc/count-same-renotes.js";
 import { deliverToRelays, getCachedRelays } from "../relay.js";
@@ -380,7 +380,7 @@ export default async (
 			.then((us) => {
 				for (const u of us) {
 					if (u.userId === user.id) return;
-					getWordHardMute(note, u.mutedWords, u.mutedPatterns).then(
+					checkWordMute(note, u.mutedWords, u.mutedPatterns).then(
 						(shouldMute: boolean) => {
 							if (shouldMute) {
 								MutedNotes.insert({