refactor (backend): port check-hit-antenna to backend-rs
This commit is contained in:
parent
a4779f233b
commit
5e53f9a8cf
12 changed files with 362 additions and 259 deletions
21
packages/backend-rs/index.d.ts
vendored
21
packages/backend-rs/index.d.ts
vendored
|
@ -213,7 +213,6 @@ export function stringToAcct(acct: string): Acct
|
|||
export function acctToString(acct: Acct): string
|
||||
export function initializeRustLogger(): void
|
||||
export function showServerInfo(): void
|
||||
export function addNoteToAntenna(antennaId: string, note: Note): void
|
||||
/**
|
||||
* Checks if a server is blocked.
|
||||
*
|
||||
|
@ -236,15 +235,7 @@ export function isSilencedServer(host: string): Promise<boolean>
|
|||
* `host` - punycoded instance host
|
||||
*/
|
||||
export function isAllowedServer(host: string): Promise<boolean>
|
||||
export interface NoteLikeForCheckWordMute {
|
||||
fileIds: Array<string>
|
||||
userId: string | null
|
||||
text: string | null
|
||||
cw: string | null
|
||||
renoteId: string | null
|
||||
replyId: string | null
|
||||
}
|
||||
export function checkWordMute(note: NoteLikeForCheckWordMute, mutedWords: Array<string>, mutedPatterns: Array<string>): Promise<boolean>
|
||||
export function checkWordMute(note: NoteLike, mutedWords: Array<string>, mutedPatterns: Array<string>): Promise<boolean>
|
||||
export function getFullApAccount(username: string, host?: string | undefined | null): string
|
||||
export function isSelfHost(host?: string | undefined | null): boolean
|
||||
export function isSameOrigin(uri: string): boolean
|
||||
|
@ -260,6 +251,15 @@ export interface ImageSize {
|
|||
height: number
|
||||
}
|
||||
export function getImageSizeFromUrl(url: string): Promise<ImageSize>
|
||||
/** TODO: handle name collisions better */
|
||||
export interface NoteLikeForAllTexts {
|
||||
fileIds: Array<string>
|
||||
userId: string
|
||||
text: string | null
|
||||
cw: string | null
|
||||
renoteId: string | null
|
||||
replyId: string | null
|
||||
}
|
||||
export interface NoteLikeForGetNoteSummary {
|
||||
fileIds: Array<string>
|
||||
text: string | null
|
||||
|
@ -1175,6 +1175,7 @@ export interface Webhook {
|
|||
latestSentAt: Date | null
|
||||
latestStatus: number | null
|
||||
}
|
||||
export function updateAntennaOnCreateNote(antenna: Antenna, note: Note, noteAuthor: Acct): Promise<void>
|
||||
export function fetchNodeinfo(host: string): Promise<Nodeinfo>
|
||||
export function nodeinfo_2_1(): Promise<any>
|
||||
export function nodeinfo_2_0(): Promise<any>
|
||||
|
|
|
@ -310,7 +310,7 @@ if (!nativeBinding) {
|
|||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, initializeRustLogger, showServerInfo, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, isSafeUrl, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, cpuInfo, cpuUsage, memoryUsage, storageUsage, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, watchNote, unwatchNote, PushNotificationKind, sendPushNotification, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, generateSecureRandomString, generateUserToken } = nativeBinding
|
||||
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, initializeRustLogger, showServerInfo, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, isSafeUrl, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, cpuInfo, cpuUsage, memoryUsage, storageUsage, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, updateAntennaOnCreateNote, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, watchNote, unwatchNote, PushNotificationKind, sendPushNotification, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, generateSecureRandomString, generateUserToken } = nativeBinding
|
||||
|
||||
module.exports.SECOND = SECOND
|
||||
module.exports.MINUTE = MINUTE
|
||||
|
@ -325,7 +325,6 @@ module.exports.stringToAcct = stringToAcct
|
|||
module.exports.acctToString = acctToString
|
||||
module.exports.initializeRustLogger = initializeRustLogger
|
||||
module.exports.showServerInfo = showServerInfo
|
||||
module.exports.addNoteToAntenna = addNoteToAntenna
|
||||
module.exports.isBlockedServer = isBlockedServer
|
||||
module.exports.isSilencedServer = isSilencedServer
|
||||
module.exports.isAllowedServer = isAllowedServer
|
||||
|
@ -370,6 +369,7 @@ module.exports.RelayStatusEnum = RelayStatusEnum
|
|||
module.exports.UserEmojimodpermEnum = UserEmojimodpermEnum
|
||||
module.exports.UserProfileFfvisibilityEnum = UserProfileFfvisibilityEnum
|
||||
module.exports.UserProfileMutingnotificationtypesEnum = UserProfileMutingnotificationtypesEnum
|
||||
module.exports.updateAntennaOnCreateNote = updateAntennaOnCreateNote
|
||||
module.exports.fetchNodeinfo = fetchNodeinfo
|
||||
module.exports.nodeinfo_2_1 = nodeinfo_2_1
|
||||
module.exports.nodeinfo_2_0 = nodeinfo_2_0
|
||||
|
|
|
@ -6,6 +6,12 @@ use serde::{Deserialize, Serialize};
|
|||
pub enum Category {
|
||||
#[strum(serialize = "fetchUrl")]
|
||||
FetchUrl,
|
||||
#[strum(serialize = "blocking")]
|
||||
Block,
|
||||
#[strum(serialize = "following")]
|
||||
Follow,
|
||||
#[strum(serialize = "wordMute")]
|
||||
WordMute,
|
||||
#[cfg(test)]
|
||||
#[strum(serialize = "usedOnlyForTesting")]
|
||||
Test,
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
use crate::database::{redis_conn, redis_key};
|
||||
use crate::model::entity::note;
|
||||
use crate::service::stream;
|
||||
use crate::util::id::{get_timestamp, InvalidIdErr};
|
||||
use redis::{streams::StreamMaxlen, Commands, RedisError};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Redis error: {0}")]
|
||||
RedisErr(#[from] RedisError),
|
||||
#[error("Invalid ID: {0}")]
|
||||
InvalidIdErr(#[from] InvalidIdErr),
|
||||
#[error("Stream error: {0}")]
|
||||
StreamErr(#[from] stream::Error),
|
||||
}
|
||||
|
||||
type Note = note::Model;
|
||||
|
||||
#[crate::export]
|
||||
pub fn add_note_to_antenna(antenna_id: String, note: &Note) -> Result<(), Error> {
|
||||
// for timeline API
|
||||
redis_conn()?.xadd_maxlen(
|
||||
redis_key(format!("antennaTimeline:{}", antenna_id)),
|
||||
StreamMaxlen::Approx(200),
|
||||
format!("{}-*", get_timestamp(¬e.id)?),
|
||||
&[("note", ¬e.id)],
|
||||
)?;
|
||||
|
||||
// for streaming API
|
||||
Ok(stream::antenna::publish(antenna_id, note)?)
|
||||
}
|
203
packages/backend-rs/src/misc/check_hit_antenna.rs
Normal file
203
packages/backend-rs/src/misc/check_hit_antenna.rs
Normal file
|
@ -0,0 +1,203 @@
|
|||
use crate::config::CONFIG;
|
||||
use crate::database::{cache, db_conn};
|
||||
use crate::federation::acct::Acct;
|
||||
use crate::misc::check_word_mute::check_word_mute_bare;
|
||||
use crate::misc::get_note_all_texts::{all_texts, NoteLike};
|
||||
use crate::model::entity::{
|
||||
antenna, blocking, following, note, sea_orm_active_enums::*, user_profile,
|
||||
};
|
||||
use sea_orm::{ColumnTrait, DbErr, EntityTrait, QueryFilter, QuerySelect};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum AntennaCheckError {
|
||||
#[error("Database error: {0}")]
|
||||
DbErr(#[from] DbErr),
|
||||
#[error("Cache error: {0}")]
|
||||
CacheErr(#[from] cache::Error),
|
||||
#[error("User profile not found: {0}")]
|
||||
UserProfileNotFoundErr(String),
|
||||
}
|
||||
|
||||
fn match_all(space_separated_words: &str, text: &str, case_sensitive: bool) -> bool {
|
||||
if case_sensitive {
|
||||
space_separated_words
|
||||
.split_whitespace()
|
||||
.all(|word| text.contains(word))
|
||||
} else {
|
||||
space_separated_words
|
||||
.to_lowercase()
|
||||
.split_whitespace()
|
||||
.all(|word| text.to_lowercase().contains(word))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_hit_antenna(
|
||||
antenna: &antenna::Model,
|
||||
note: note::Model,
|
||||
note_author: &Acct,
|
||||
) -> Result<bool, AntennaCheckError> {
|
||||
if note.visibility == NoteVisibilityEnum::Specified {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if antenna.with_file && note.file_ids.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if !antenna.with_replies && note.reply_id.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if antenna.src == AntennaSrcEnum::Users {
|
||||
let is_from_one_of_specified_authors = antenna
|
||||
.users
|
||||
.iter()
|
||||
.map(|s| s.parse::<Acct>().unwrap())
|
||||
.any(|acct| acct.username == note_author.username && acct.host == note_author.host);
|
||||
|
||||
if !is_from_one_of_specified_authors {
|
||||
return Ok(false);
|
||||
}
|
||||
} else if antenna.src == AntennaSrcEnum::Instances {
|
||||
let is_from_one_of_specified_servers = !antenna.instances.iter().any(|host| {
|
||||
host.to_ascii_lowercase()
|
||||
== note_author
|
||||
.host
|
||||
.clone()
|
||||
.unwrap_or(CONFIG.host.clone())
|
||||
.to_ascii_lowercase()
|
||||
});
|
||||
|
||||
if !is_from_one_of_specified_servers {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
// "Home", "Group", "List" sources are currently disabled
|
||||
|
||||
let note_texts = all_texts(NoteLike {
|
||||
file_ids: note.file_ids,
|
||||
user_id: note.user_id.clone(),
|
||||
text: note.text,
|
||||
cw: note.cw,
|
||||
renote_id: note.renote_id,
|
||||
reply_id: note.reply_id,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let has_keyword = antenna.keywords.iter().any(|words| {
|
||||
note_texts
|
||||
.iter()
|
||||
.any(|text| match_all(words, text, antenna.case_sensitive))
|
||||
});
|
||||
|
||||
if !has_keyword {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let has_excluded_word = antenna.exclude_keywords.iter().any(|words| {
|
||||
note_texts
|
||||
.iter()
|
||||
.any(|text| match_all(words, text, antenna.case_sensitive))
|
||||
});
|
||||
|
||||
if has_excluded_word {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let db = db_conn().await?;
|
||||
|
||||
let blocked_user_ids: Vec<String> = cache::get_one(cache::Category::Block, ¬e.user_id)?
|
||||
.unwrap_or({
|
||||
// cache miss
|
||||
let blocks = blocking::Entity::find()
|
||||
.select_only()
|
||||
.column(blocking::Column::BlockeeId)
|
||||
.filter(blocking::Column::BlockerId.eq(¬e.user_id))
|
||||
.into_tuple::<String>()
|
||||
.all(db)
|
||||
.await?;
|
||||
cache::set_one(cache::Category::Block, ¬e.user_id, &blocks, 10 * 60)?;
|
||||
blocks
|
||||
});
|
||||
|
||||
// if the antenna owner is blocked by the note author, return false
|
||||
if blocked_user_ids.contains(&antenna.user_id) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if [NoteVisibilityEnum::Home, NoteVisibilityEnum::Followers].contains(¬e.visibility) {
|
||||
let following_user_ids: Vec<String> =
|
||||
cache::get_one(cache::Category::Follow, &antenna.user_id)?.unwrap_or({
|
||||
// cache miss
|
||||
let following = following::Entity::find()
|
||||
.select_only()
|
||||
.column(following::Column::FolloweeId)
|
||||
.filter(following::Column::FollowerId.eq(&antenna.user_id))
|
||||
.into_tuple::<String>()
|
||||
.all(db)
|
||||
.await?;
|
||||
cache::set_one(
|
||||
cache::Category::Follow,
|
||||
&antenna.user_id,
|
||||
&following,
|
||||
10 * 60,
|
||||
)?;
|
||||
following
|
||||
});
|
||||
|
||||
// if the antenna owner is not following the note author, return false
|
||||
if !following_user_ids.contains(¬e.user_id) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
type WordMute = (
|
||||
Vec<String>, // muted words
|
||||
Vec<String>, // muted patterns
|
||||
);
|
||||
|
||||
let word_mute: WordMute = cache::get_one(cache::Category::WordMute, &antenna.user_id)?
|
||||
.unwrap_or({
|
||||
// cache miss
|
||||
let mute = user_profile::Entity::find()
|
||||
.select_only()
|
||||
.columns([
|
||||
user_profile::Column::MutedWords,
|
||||
user_profile::Column::MutedPatterns,
|
||||
])
|
||||
.into_tuple::<WordMute>()
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or({
|
||||
tracing::warn!("there is no user_profile for user {}", &antenna.user_id);
|
||||
AntennaCheckError::UserProfileNotFoundErr(antenna.user_id.clone())
|
||||
})?;
|
||||
cache::set_one(cache::Category::WordMute, &antenna.user_id, &mute, 10 * 60)?;
|
||||
mute
|
||||
});
|
||||
|
||||
if check_word_mute_bare(¬e_texts, &word_mute.0, &word_mute.1) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_test {
|
||||
use super::match_all;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_match_all() {
|
||||
assert_eq!(match_all("Apple", "apple and banana", false), true);
|
||||
assert_eq!(match_all("Apple", "apple and banana", true), false);
|
||||
assert_eq!(match_all("Apple Banana", "apple and banana", false), true);
|
||||
assert_eq!(match_all("Apple Banana", "apple and cinnamon", true), false);
|
||||
assert_eq!(
|
||||
match_all("Apple Banana", "apple and cinnamon", false),
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,91 +1,14 @@
|
|||
use crate::database::db_conn;
|
||||
use crate::model::entity::{drive_file, note};
|
||||
use crate::misc::get_note_all_texts::{all_texts, NoteLike};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use sea_orm::{prelude::*, QuerySelect};
|
||||
|
||||
// TODO: handle name collisions in a better way
|
||||
#[crate::export(object, js_name = "NoteLikeForCheckWordMute")]
|
||||
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::<Option<String>>()
|
||||
.all(db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
);
|
||||
|
||||
if let Some(renote_id) = ¬e.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::<(Option<String>, Option<String>)>()
|
||||
.one(db)
|
||||
.await?
|
||||
{
|
||||
if let Some(t) = text {
|
||||
texts.push(t);
|
||||
}
|
||||
if let Some(c) = cw {
|
||||
texts.push(c);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("nonexistent renote id: {:#?}", renote_id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(reply_id) = ¬e.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::<(Option<String>, Option<String>)>()
|
||||
.one(db)
|
||||
.await?
|
||||
{
|
||||
if let Some(t) = text {
|
||||
texts.push(t);
|
||||
}
|
||||
if let Some(c) = cw {
|
||||
texts.push(c);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("nonexistent reply id: {:#?}", reply_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(texts)
|
||||
}
|
||||
use sea_orm::DbErr;
|
||||
|
||||
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(
|
||||
pub fn check_word_mute_bare(
|
||||
texts: &[String],
|
||||
muted_words: &[String],
|
||||
muted_patterns: &[String],
|
||||
|
@ -112,7 +35,7 @@ pub async fn check_word_mute(
|
|||
if muted_words.is_empty() && muted_patterns.is_empty() {
|
||||
Ok(false)
|
||||
} else {
|
||||
Ok(check_word_mute_impl(
|
||||
Ok(check_word_mute_bare(
|
||||
&all_texts(note).await?,
|
||||
muted_words,
|
||||
muted_patterns,
|
||||
|
|
79
packages/backend-rs/src/misc/get_note_all_texts.rs
Normal file
79
packages/backend-rs/src/misc/get_note_all_texts.rs
Normal file
|
@ -0,0 +1,79 @@
|
|||
use crate::database::db_conn;
|
||||
use crate::model::entity::{drive_file, note};
|
||||
use sea_orm::{prelude::*, QuerySelect};
|
||||
|
||||
/// TODO: handle name collisions better
|
||||
#[crate::export(object, js_name = "NoteLikeForAllTexts")]
|
||||
pub struct NoteLike {
|
||||
pub file_ids: Vec<String>,
|
||||
pub user_id: String,
|
||||
pub text: Option<String>,
|
||||
pub cw: Option<String>,
|
||||
pub renote_id: Option<String>,
|
||||
pub reply_id: Option<String>,
|
||||
}
|
||||
|
||||
pub 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::<Option<String>>()
|
||||
.all(db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
);
|
||||
|
||||
if let Some(renote_id) = ¬e.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::<(Option<String>, Option<String>)>()
|
||||
.one(db)
|
||||
.await?
|
||||
{
|
||||
if let Some(t) = text {
|
||||
texts.push(t);
|
||||
}
|
||||
if let Some(c) = cw {
|
||||
texts.push(c);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("nonexistent renote id: {:#?}", renote_id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(reply_id) = ¬e.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::<(Option<String>, Option<String>)>()
|
||||
.one(db)
|
||||
.await?
|
||||
{
|
||||
if let Some(t) = text {
|
||||
texts.push(t);
|
||||
}
|
||||
if let Some(c) = cw {
|
||||
texts.push(c);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("nonexistent reply id: {:#?}", reply_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(texts)
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
pub mod add_note_to_antenna;
|
||||
pub mod check_hit_antenna;
|
||||
pub mod check_server_block;
|
||||
pub mod check_word_mute;
|
||||
pub mod convert_host;
|
||||
|
@ -6,6 +6,7 @@ pub mod emoji;
|
|||
pub mod escape_sql;
|
||||
pub mod format_milliseconds;
|
||||
pub mod get_image_size;
|
||||
pub mod get_note_all_texts;
|
||||
pub mod get_note_summary;
|
||||
pub mod is_safe_url;
|
||||
pub mod latest_version;
|
||||
|
|
48
packages/backend-rs/src/service/antenna.rs
Normal file
48
packages/backend-rs/src/service/antenna.rs
Normal file
|
@ -0,0 +1,48 @@
|
|||
use crate::database::{redis_conn, redis_key};
|
||||
use crate::federation::acct::Acct;
|
||||
use crate::misc::check_hit_antenna::{check_hit_antenna, AntennaCheckError};
|
||||
use crate::model::entity::{antenna, note};
|
||||
use crate::service::stream;
|
||||
use crate::util::id::{get_timestamp, InvalidIdErr};
|
||||
use redis::{streams::StreamMaxlen, Commands, RedisError};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Redis error: {0}")]
|
||||
RedisErr(#[from] RedisError),
|
||||
#[error("Invalid ID: {0}")]
|
||||
InvalidIdErr(#[from] InvalidIdErr),
|
||||
#[error("Stream error: {0}")]
|
||||
StreamErr(#[from] stream::Error),
|
||||
#[error("Failed to check if the note should be added to antenna: {0}")]
|
||||
AntennaCheckErr(#[from] AntennaCheckError),
|
||||
}
|
||||
|
||||
// https://github.com/napi-rs/napi-rs/issues/2060
|
||||
type Antenna = antenna::Model;
|
||||
type Note = note::Model;
|
||||
|
||||
#[crate::export]
|
||||
pub async fn update_antenna_on_create_note(
|
||||
antenna: &Antenna,
|
||||
note: Note,
|
||||
note_author: &Acct,
|
||||
) -> Result<(), Error> {
|
||||
if check_hit_antenna(antenna, note.clone(), note_author).await? {
|
||||
add_note_to_antenna(&antenna.id, ¬e)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_note_to_antenna(antenna_id: &str, note: &Note) -> Result<(), Error> {
|
||||
// for timeline API
|
||||
redis_conn()?.xadd_maxlen(
|
||||
redis_key(format!("antennaTimeline:{}", antenna_id)),
|
||||
StreamMaxlen::Approx(200),
|
||||
format!("{}-*", get_timestamp(¬e.id)?),
|
||||
&[("note", ¬e.id)],
|
||||
)?;
|
||||
|
||||
// for streaming API
|
||||
Ok(stream::antenna::publish(antenna_id.to_string(), note)?)
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
pub mod antenna;
|
||||
pub mod nodeinfo;
|
||||
pub mod note;
|
||||
pub mod push_notification;
|
||||
|
|
|
@ -1,126 +0,0 @@
|
|||
import type { Antenna } from "@/models/entities/antenna.js";
|
||||
import type { Note } from "@/models/entities/note.js";
|
||||
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 { checkWordMute, getFullApAccount, stringToAcct } from "backend-rs";
|
||||
import type { Packed } from "@/misc/schema.js";
|
||||
import { Cache } from "@/misc/cache.js";
|
||||
|
||||
const blockingCache = new Cache<User["id"][]>("blocking", 60 * 5);
|
||||
const hardMutesCache = new Cache<{
|
||||
userId: UserProfile["userId"];
|
||||
mutedWords: UserProfile["mutedWords"];
|
||||
mutedPatterns: UserProfile["mutedPatterns"];
|
||||
}>("hardMutes", 60 * 5);
|
||||
const followingCache = new Cache<User["id"][]>("following", 60 * 5);
|
||||
|
||||
export async function checkHitAntenna(
|
||||
antenna: Antenna,
|
||||
note: Note | Packed<"Note">,
|
||||
noteUser: { id: User["id"]; username: string; host: string | null },
|
||||
): Promise<boolean> {
|
||||
if (note.visibility === "specified") return false;
|
||||
if (antenna.withFile) {
|
||||
if (note.fileIds && note.fileIds.length === 0) return false;
|
||||
}
|
||||
if (!antenna.withReplies && note.replyId != null) return false;
|
||||
|
||||
if (antenna.src === "users") {
|
||||
const accts = antenna.users.map((x) => {
|
||||
const { username, host } = stringToAcct(x);
|
||||
return getFullApAccount(username, host).toLowerCase();
|
||||
});
|
||||
if (
|
||||
!accts.includes(
|
||||
getFullApAccount(noteUser.username, noteUser.host).toLowerCase(),
|
||||
)
|
||||
)
|
||||
return false;
|
||||
} else if (antenna.src === "instances") {
|
||||
const instances = antenna.instances
|
||||
.filter((x) => x !== "")
|
||||
.map((host) => {
|
||||
return host.toLowerCase();
|
||||
});
|
||||
if (!instances.includes(noteUser.host?.toLowerCase() ?? "")) return false;
|
||||
}
|
||||
|
||||
let text = `${note.text ?? ""} ${note.cw ?? ""}`;
|
||||
if (note.files != null)
|
||||
text += ` ${note.files.map((f) => f.comment ?? "").join(" ")}`;
|
||||
text = text.trim();
|
||||
|
||||
if (antenna.keywords.length > 0) {
|
||||
if (note.text == null) return false;
|
||||
|
||||
const matched = antenna.keywords.some((item) =>
|
||||
item
|
||||
.split(" ")
|
||||
.every((keyword) =>
|
||||
antenna.caseSensitive
|
||||
? text.includes(keyword)
|
||||
: text.toLowerCase().includes(keyword.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
if (!matched) return false;
|
||||
}
|
||||
|
||||
if (antenna.excludeKeywords.length > 0) {
|
||||
if (note.text == null) return false;
|
||||
|
||||
const matched = antenna.excludeKeywords.some((item) =>
|
||||
item
|
||||
.split(" ")
|
||||
.every((keyword) =>
|
||||
antenna.caseSensitive
|
||||
? note.text?.includes(keyword)
|
||||
: note.text?.toLowerCase().includes(keyword.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
if (matched) return false;
|
||||
}
|
||||
|
||||
// アンテナ作成者がノート作成者にブロックされていたらスキップ
|
||||
const blockings = await blockingCache.fetch(noteUser.id, () =>
|
||||
Blockings.findBy({ blockerId: noteUser.id }).then((res) =>
|
||||
res.map((x) => x.blockeeId),
|
||||
),
|
||||
);
|
||||
if (blockings.includes(antenna.userId)) return false;
|
||||
|
||||
if (note.visibility === "followers" || note.visibility === "home") {
|
||||
const following = await followingCache.fetch(antenna.userId, () =>
|
||||
Followings.find({
|
||||
where: { followerId: antenna.userId },
|
||||
select: ["followeeId"],
|
||||
}).then((relations) => relations.map((relation) => relation.followeeId)),
|
||||
);
|
||||
if (!following.includes(note.userId)) return false;
|
||||
}
|
||||
|
||||
const mutes = await hardMutesCache.fetch(antenna.userId, () =>
|
||||
UserProfiles.findOneByOrFail({
|
||||
userId: antenna.userId,
|
||||
}).then((profile) => {
|
||||
return {
|
||||
userId: antenna.userId,
|
||||
mutedWords: profile.mutedWords,
|
||||
mutedPatterns: profile.mutedPatterns,
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (
|
||||
mutes.mutedWords != null &&
|
||||
mutes.mutedPatterns != null &&
|
||||
antenna.userId !== note.userId &&
|
||||
(await checkWordMute(note, mutes.mutedWords, mutes.mutedPatterns))
|
||||
)
|
||||
return false;
|
||||
|
||||
// TODO: eval expression
|
||||
|
||||
return true;
|
||||
}
|
|
@ -42,9 +42,8 @@ import type { IPoll } from "@/models/entities/poll.js";
|
|||
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 {
|
||||
addNoteToAntenna,
|
||||
updateAntennaOnCreateNote,
|
||||
checkWordMute,
|
||||
genId,
|
||||
genIdAt,
|
||||
|
@ -401,12 +400,11 @@ export default async (
|
|||
|
||||
// Antenna
|
||||
for (const antenna of await getAntennas()) {
|
||||
checkHitAntenna(antenna, note, user).then((hit) => {
|
||||
if (hit) {
|
||||
// TODO: do this more sanely
|
||||
addNoteToAntenna(antenna.id, toRustObject(note));
|
||||
}
|
||||
});
|
||||
await updateAntennaOnCreateNote(
|
||||
toRustObject(antenna),
|
||||
toRustObject(note),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
// Channel
|
||||
|
|
Loading…
Reference in a new issue