diff --git a/Cargo.lock b/Cargo.lock index c8158f2..bcbaf35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1452,7 +1452,9 @@ dependencies = [ "cached", "cfg-if", "chrono", + "compact_str", "dotenvy", + "either", "headers", "hyper", "itertools 0.11.0", @@ -1465,6 +1467,7 @@ dependencies = [ "magnetar_webfinger", "miette", "percent-encoding", + "regex", "serde", "serde_json", "strum", @@ -1513,6 +1516,8 @@ dependencies = [ "futures-core", "futures-util", "magnetar_common", + "magnetar_sdk", + "once_cell", "redis", "sea-orm", "serde", @@ -2754,9 +2759,9 @@ checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" [[package]] name = "smawk" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" @@ -3073,9 +3078,9 @@ checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "supports-color" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4950e7174bffabe99455511c39707310e7e9b440364a2fcb1cc21521be57b354" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" dependencies = [ "is-terminal", "is_ci", @@ -3621,13 +3626,9 @@ checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" [[package]] name = "unicode-linebreak" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137" -dependencies = [ - "hashbrown 0.12.3", - "regex", -] +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-normalization" diff --git a/Cargo.toml b/Cargo.toml index 172b55b..b21e186 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,9 +99,12 @@ cfg-if = { workspace = true } itertools = { workspace = true } +compact_str = { workspace = true } +either = { workspace = true } strum = { workspace = true, features = ["derive"] } thiserror = { workspace = true } miette = { workspace = true, features = ["fancy"] } +regex = { workspace = true } percent-encoding = { workspace = true } diff --git a/ext_calckey_model/Cargo.toml b/ext_calckey_model/Cargo.toml index 12e6a72..62acb6a 100644 --- a/ext_calckey_model/Cargo.toml +++ b/ext_calckey_model/Cargo.toml @@ -11,6 +11,7 @@ ck = { path = "./entity_ck" } ext_calckey_model_migration = { path = "./migration" } magnetar_common = { path = "../magnetar_common" } +magnetar_sdk = { path = "../magnetar_sdk" } dotenvy = { workspace = true} futures-core = { workspace = true } @@ -24,4 +25,5 @@ serde_json = { workspace = true } strum = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } -thiserror = { workspace = true } \ No newline at end of file +thiserror = { workspace = true } +once_cell = "1.18.0" diff --git a/ext_calckey_model/src/lib.rs b/ext_calckey_model/src/lib.rs index 6f8fdc3..7273d74 100644 --- a/ext_calckey_model/src/lib.rs +++ b/ext_calckey_model/src/lib.rs @@ -1,15 +1,19 @@ +pub mod note_model; + pub use ck; use ck::*; pub use sea_orm; +use crate::note_model::NoteResolver; use chrono::Utc; use ext_calckey_model_migration::{Migrator, MigratorTrait}; use futures_util::StreamExt; use redis::IntoConnectionInfo; +use sea_orm::sea_query::IntoIden; use sea_orm::ActiveValue::Set; use sea_orm::{ - ColumnTrait, ConnectOptions, DatabaseConnection, DbErr, EntityTrait, QueryFilter, QueryOrder, - TransactionTrait, + ColumnTrait, ConnectOptions, DatabaseConnection, DbErr, EntityTrait, JoinType, QueryFilter, + QueryOrder, RelationDef, RelationTrait, TransactionTrait, }; use serde::{Deserialize, Serialize}; use std::future::Future; @@ -34,6 +38,18 @@ pub enum CalckeyDbError { DbError(#[from] DbErr), } +trait AliasSourceExt { + fn with_alias(&self, alias: I) -> RelationDef; +} + +impl AliasSourceExt for T { + fn with_alias(&self, alias: I) -> RelationDef { + let mut def = self.def(); + def.from_tbl = def.from_tbl.alias(alias); + def + } +} + impl CalckeyModel { pub async fn new(config: ConnectorConfig) -> Result { let opt = ConnectOptions::new(config.url) @@ -116,6 +132,66 @@ impl CalckeyModel { .await?) } + pub async fn get_follower_status( + &self, + from: &str, + to: &str, + ) -> Result, CalckeyDbError> { + Ok(following::Entity::find() + .filter( + following::Column::FollowerId + .eq(from) + .and(following::Column::FolloweeId.eq(to)), + ) + .one(&self.0) + .await?) + } + + pub async fn get_block_status( + &self, + from: &str, + to: &str, + ) -> Result, CalckeyDbError> { + Ok(blocking::Entity::find() + .filter( + blocking::Column::BlockerId + .eq(from) + .and(blocking::Column::BlockeeId.eq(to)), + ) + .one(&self.0) + .await?) + } + + pub async fn get_mute_status( + &self, + from: &str, + to: &str, + ) -> Result, CalckeyDbError> { + Ok(muting::Entity::find() + .filter( + muting::Column::MuterId + .eq(from) + .and(muting::Column::MuteeId.eq(to)), + ) + .one(&self.0) + .await?) + } + + pub async fn get_renote_mute_status( + &self, + from: &str, + to: &str, + ) -> Result, CalckeyDbError> { + Ok(renote_muting::Entity::find() + .filter( + renote_muting::Column::MuterId + .eq(from) + .and(renote_muting::Column::MuteeId.eq(to)), + ) + .one(&self.0) + .await?) + } + pub async fn get_local_emoji(&self) -> Result, CalckeyDbError> { Ok(emoji::Entity::find() .filter(emoji::Column::Host.is_null()) @@ -211,6 +287,10 @@ impl CalckeyModel { Ok(meta) } + + pub fn get_note_resolver(&self) -> NoteResolver { + NoteResolver::new(self.clone()) + } } #[derive(Debug)] diff --git a/ext_calckey_model/src/note_model.rs b/ext_calckey_model/src/note_model.rs new file mode 100644 index 0000000..4c9f750 --- /dev/null +++ b/ext_calckey_model/src/note_model.rs @@ -0,0 +1,281 @@ +use sea_orm::sea_query::{Alias, Expr, IntoIden, SelectExpr, SimpleExpr}; +use sea_orm::{ + ColumnTrait, DbErr, EntityTrait, FromQueryResult, Iden, Iterable, JoinType, QueryFilter, + QueryResult, QuerySelect, QueryTrait, RelationTrait, Select, +}; +use serde::{Deserialize, Serialize}; + +use ck::{drive_file, note, user}; +use magnetar_sdk::types::RangeFilter; +use once_cell::unsync::Lazy; + +use crate::{AliasSourceExt, CalckeyDbError, CalckeyModel}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoteData { + pub note: note::Model, + pub user: user::Model, + pub avatar: Option, + pub banner: Option, + pub reply: Option>, + pub renote: Option>, +} + +const USER: &str = "user."; +const USER_AVATAR: &str = "user.avatar."; +const USER_BANNER: &str = "user.banner."; +const REPLY: &str = "reply."; +const REPLY_USER: &str = "reply.user."; +const REPLY_USER_AVATAR: &str = "reply.user.avatar."; +const REPLY_USER_BANNER: &str = "reply.user.banner."; +const RENOTE: &str = "renote."; +const RENOTE_USER: &str = "renote.user."; +const RENOTE_USER_AVATAR: &str = "renote.user.avatar."; +const RENOTE_USER_BANNER: &str = "renote.user.banner."; + +impl FromQueryResult for NoteData { + fn from_query_result(res: &QueryResult, _pre: &str) -> Result { + let reply = note::Model::from_query_result_optional(res, REPLY)? + .map::, _>(|r| { + Ok(Box::new(NoteData { + note: r, + user: user::Model::from_query_result(res, REPLY_USER)?, + avatar: drive_file::Model::from_query_result_optional(res, REPLY_USER_AVATAR)?, + banner: drive_file::Model::from_query_result_optional(res, REPLY_USER_BANNER)?, + reply: None, + renote: None, + })) + }) + .transpose()?; + + let renote = note::Model::from_query_result_optional(res, RENOTE)? + .map::, _>(|r| { + Ok(Box::new(NoteData { + note: r, + user: user::Model::from_query_result(res, RENOTE_USER)?, + avatar: drive_file::Model::from_query_result_optional(res, RENOTE_USER_AVATAR)?, + banner: drive_file::Model::from_query_result_optional(res, RENOTE_USER_BANNER)?, + reply: None, + renote: None, + })) + }) + .transpose()?; + + Ok(NoteData { + note: note::Model::from_query_result(res, "")?, + user: user::Model::from_query_result(res, USER)?, + avatar: drive_file::Model::from_query_result_optional(res, USER_AVATAR)?, + banner: drive_file::Model::from_query_result_optional(res, USER_BANNER)?, + reply, + renote, + }) + } +} + +pub struct NoteResolver { + db: CalckeyModel, +} + +pub trait NoteVisibilityFilterFactory: Send + Sync { + fn with_note_and_user_tables(&self, note: Option) -> SimpleExpr; +} + +pub struct NoteResolveOptions { + pub ids: Option>, + pub visibility_filter: Box, + pub time_range: Option, + pub with_user: bool, + pub with_reply_target: bool, + pub with_renote_target: bool, +} + +trait SelectColumnsExt { + fn add_aliased_columns(self, alias: Option<&str>, entity: T) -> Self; +} + +impl SelectColumnsExt for Select { + fn add_aliased_columns( + mut self: Select, + alias: Option<&str>, + entity: T, + ) -> Select { + for col in T::Column::iter() { + let column: &T::Column = &col; + + let iden = alias.unwrap_or_else(|| entity.table_name()); + let alias = format!("{}{}", iden, col.to_string()); + + let column_ref = Expr::col((Alias::new(iden), column.as_column_ref().1)); + + QuerySelect::query(&mut self).expr(SelectExpr { + expr: col.select_as(column_ref), + alias: Some(Alias::new(&alias).into_iden()), + window: None, + }); + } + + self + } +} + +const ALIAS_USER: Lazy = Lazy::new(|| Alias::new(USER)); +const ALIAS_USER_AVATAR: Lazy = Lazy::new(|| Alias::new(USER_AVATAR)); +const ALIAS_USER_BANNER: Lazy = Lazy::new(|| Alias::new(USER_BANNER)); +const ALIAS_REPLY: Lazy = Lazy::new(|| Alias::new(REPLY)); +const ALIAS_REPLY_USER: Lazy = Lazy::new(|| Alias::new(REPLY_USER)); +const ALIAS_REPLY_USER_AVATAR: Lazy = Lazy::new(|| Alias::new(REPLY_USER_AVATAR)); +const ALIAS_REPLY_USER_BANNER: Lazy = Lazy::new(|| Alias::new(REPLY_USER_BANNER)); +const ALIAS_RENOTE: Lazy = Lazy::new(|| Alias::new(RENOTE)); +const ALIAS_RENOTE_USER: Lazy = Lazy::new(|| Alias::new(RENOTE_USER)); +const ALIAS_RENOTE_USER_AVATAR: Lazy = Lazy::new(|| Alias::new(RENOTE_USER_AVATAR)); +const ALIAS_RENOTE_USER_BANNER: Lazy = Lazy::new(|| Alias::new(RENOTE_USER_BANNER)); + +impl NoteResolver { + pub fn new(db: CalckeyModel) -> Self { + NoteResolver { db } + } + + pub async fn get_one( + &self, + options: &NoteResolveOptions, + ) -> Result, CalckeyDbError> { + let select = self.resolve(options); + let visibility_filter = options.visibility_filter.with_note_and_user_tables(None); + let time_filter = options.time_range.as_ref().map(|f| match f { + RangeFilter::TimeStart(start) => note::Column::CreatedAt.gte(start.clone()), + RangeFilter::TimeRange(range) => { + note::Column::CreatedAt.between(range.start().clone(), range.end().clone()) + } + RangeFilter::TimeEnd(end) => note::Column::CreatedAt.lt(end.clone()), + }); + + let id_filter = options.ids.as_ref().map(|ids| { + if ids.len() == 1 { + note::Column::Id.eq(&ids[0]) + } else { + note::Column::Id.is_in(ids) + } + }); + + let notes = select + .filter(visibility_filter) + .apply_if(id_filter, Select::::filter) + .apply_if(time_filter, Select::::filter) + .into_model::() + .one(self.db.inner()) + .await?; + Ok(notes) + } + + pub fn resolve(&self, options: &NoteResolveOptions) -> Select { + let mut select = note::Entity::find().add_aliased_columns(Some(USER), user::Entity); + + if options.with_user { + select = select + .add_aliased_columns(Some(USER_AVATAR), drive_file::Entity) + .add_aliased_columns(Some(USER_BANNER), drive_file::Entity); + } + + if options.with_reply_target { + select = select + .add_aliased_columns(Some(REPLY), note::Entity) + .add_aliased_columns(Some(REPLY_USER), user::Entity); + + if options.with_user { + select = select + .add_aliased_columns(Some(REPLY_USER_AVATAR), drive_file::Entity) + .add_aliased_columns(Some(REPLY_USER_BANNER), drive_file::Entity); + } + } + + if options.with_renote_target { + select = select + .add_aliased_columns(Some(RENOTE), note::Entity) + .add_aliased_columns(Some(RENOTE_USER), user::Entity); + + if options.with_user { + select = select + .add_aliased_columns(Some(RENOTE_USER_AVATAR), drive_file::Entity) + .add_aliased_columns(Some(RENOTE_USER_BANNER), drive_file::Entity) + } + } + + if options.with_reply_target { + select = select + .join_as( + JoinType::LeftJoin, + note::Relation::SelfRef2.def(), + ALIAS_REPLY.clone(), + ) + .join_as( + JoinType::LeftJoin, + note::Relation::User.with_alias(ALIAS_REPLY.clone()), + ALIAS_REPLY_USER.clone(), + ); + } + + if options.with_renote_target { + select = select + .join_as( + JoinType::LeftJoin, + note::Relation::SelfRef1.def(), + ALIAS_RENOTE.clone(), + ) + .join_as( + JoinType::InnerJoin, + note::Relation::User.with_alias(ALIAS_RENOTE.clone()), + ALIAS_RENOTE_USER.clone(), + ); + } + + select = select.join_as( + JoinType::InnerJoin, + note::Relation::User.def(), + ALIAS_USER.clone(), + ); + + if options.with_user { + select = select + .join_as( + JoinType::LeftJoin, + user::Relation::DriveFile2.with_alias(ALIAS_USER.clone()), + ALIAS_USER_AVATAR.clone(), + ) + .join_as( + JoinType::LeftJoin, + user::Relation::DriveFile1.with_alias(ALIAS_USER.clone()), + ALIAS_USER_BANNER.clone(), + ); + + if options.with_reply_target { + select = select + .join_as( + JoinType::LeftJoin, + user::Relation::DriveFile2.with_alias(ALIAS_REPLY_USER.clone()), + ALIAS_REPLY_USER_AVATAR.clone(), + ) + .join_as( + JoinType::LeftJoin, + user::Relation::DriveFile1.with_alias(ALIAS_REPLY_USER.clone()), + ALIAS_REPLY_USER_BANNER.clone(), + ); + } + + if options.with_renote_target { + select = select + .join_as( + JoinType::LeftJoin, + user::Relation::DriveFile2.with_alias(ALIAS_RENOTE_USER.clone()), + ALIAS_RENOTE_USER_AVATAR.clone(), + ) + .join_as( + JoinType::LeftJoin, + user::Relation::DriveFile1.with_alias(ALIAS_RENOTE_USER.clone()), + ALIAS_RENOTE_USER_BANNER.clone(), + ); + } + } + + select + } +} diff --git a/magnetar_mmm_parser/src/lib.rs b/magnetar_mmm_parser/src/lib.rs index 613f269..0631cc7 100644 --- a/magnetar_mmm_parser/src/lib.rs +++ b/magnetar_mmm_parser/src/lib.rs @@ -255,6 +255,25 @@ impl Token { } } + pub fn walk_speech_transform(&mut self, func: &impl Fn(&mut CompactString)) { + match self { + Token::Sequence(items) => { + items + .iter_mut() + .for_each(|tok| tok.walk_speech_transform(func)); + } + Token::Small(inner) + | Token::BoldItalic(inner) + | Token::Bold(inner) + | Token::Italic(inner) + | Token::Center(inner) + | Token::Function { inner, .. } + | Token::Strikethrough(inner) => inner.walk_speech_transform(func), + Token::PlainText(text) => func(text), + _ => {} + } + } + fn write(&self, writer: &mut quick_xml::Writer) -> quick_xml::Result<()> { match self { Token::PlainText(plain) => { diff --git a/magnetar_sdk/src/endpoints/mod.rs b/magnetar_sdk/src/endpoints/mod.rs index f77198d..31e59e8 100644 --- a/magnetar_sdk/src/endpoints/mod.rs +++ b/magnetar_sdk/src/endpoints/mod.rs @@ -1,3 +1,4 @@ +pub mod note; pub mod timeline; pub mod user; diff --git a/magnetar_sdk/src/endpoints/note.rs b/magnetar_sdk/src/endpoints/note.rs new file mode 100644 index 0000000..9766294 --- /dev/null +++ b/magnetar_sdk/src/endpoints/note.rs @@ -0,0 +1,25 @@ +use crate::endpoints::Endpoint; +use crate::types::note::PackNoteMaybeFull; +use http::Method; +use magnetar_sdk_macros::Endpoint; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +// Get note by id +#[derive(Serialize, Deserialize, TS)] +#[ts(export)] +pub struct NoteByIdReq { + #[serde(default)] + pub context: bool, + #[serde(default)] + pub attachments: bool, +} + +#[derive(Endpoint)] +#[endpoint( + endpoint = "/notes/:id", + method = Method::GET, + request = NoteByIdReq, + response = PackNoteMaybeFull +)] +pub struct GetNoteById; diff --git a/magnetar_sdk/src/endpoints/timeline.rs b/magnetar_sdk/src/endpoints/timeline.rs index 8970416..addeb36 100644 --- a/magnetar_sdk/src/endpoints/timeline.rs +++ b/magnetar_sdk/src/endpoints/timeline.rs @@ -1,5 +1,5 @@ use crate::endpoints::Endpoint; -use crate::types::note::{NoteListFilter, PackNoteFull}; +use crate::types::note::{NoteListFilter, PackNoteMaybeFull}; use crate::util_types::U64Range; use http::Method; use magnetar_sdk_macros::Endpoint; @@ -23,13 +23,13 @@ fn default_timeline_limit() -> U64Range); +pub struct GetTimelineRes(pub Vec); #[derive(Endpoint)] #[endpoint( endpoint = "/timeline", method = Method::GET, request = GetTimelineReq, - response = Vec::, + response = Vec::, )] pub struct GetTimeline; diff --git a/magnetar_sdk/src/endpoints/user.rs b/magnetar_sdk/src/endpoints/user.rs index df488fc..d4efd81 100644 --- a/magnetar_sdk/src/endpoints/user.rs +++ b/magnetar_sdk/src/endpoints/user.rs @@ -22,7 +22,7 @@ pub struct UserSelfReq { #[derive(Endpoint)] #[endpoint( - endpoint = "/users/@self/overview/info", + endpoint = "/users/@self", method = Method::GET, request = UserSelfReq, response = PackUserSelfMaybeAll @@ -47,7 +47,7 @@ pub struct UserByIdReq { #[derive(Endpoint)] #[endpoint( - endpoint = "/users/:user_id/info", + endpoint = "/users/:user_id", method = Method::GET, request = UserByIdReq, response = PackUserMaybeAll diff --git a/magnetar_sdk/src/types/note.rs b/magnetar_sdk/src/types/note.rs index 2942600..338dc8f 100644 --- a/magnetar_sdk/src/types/note.rs +++ b/magnetar_sdk/src/types/note.rs @@ -56,7 +56,7 @@ pub struct NoteBase { pub uri: Option, pub url: Option, pub text: String, - pub text_mm: MmXml, + pub text_mm: Option, pub visibility: NoteVisibility, pub user: Box, pub parent_note_id: Option, @@ -92,8 +92,8 @@ pub struct NoteDetailExt { } pack!( - PackNoteFull, - Required as id & Required as note & Required as attachment & Required as detail + PackNoteMaybeFull, + Required as id & Required as note & Option as attachment & Option as detail ); #[derive(Clone, Debug, Deserialize, Serialize, TS)] diff --git a/magnetar_sdk/src/types/user.rs b/magnetar_sdk/src/types/user.rs index a515716..bcfdaa2 100644 --- a/magnetar_sdk/src/types/user.rs +++ b/magnetar_sdk/src/types/user.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; -use crate::types::note::PackNoteFull; +use crate::types::note::PackNoteMaybeFull; use magnetar_sdk_macros::pack; #[derive(Clone, Debug, Default, Deserialize, Serialize, TS)] @@ -88,7 +88,7 @@ pub struct UserProfileExt { #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[ts(export)] pub struct UserProfilePinsEx { - pub pinned_notes: Vec, + pub pinned_notes: Vec, // pub pinned_page: Option, } diff --git a/src/api_v1/mod.rs b/src/api_v1/mod.rs index 3d798b3..79a49db 100644 --- a/src/api_v1/mod.rs +++ b/src/api_v1/mod.rs @@ -1,5 +1,7 @@ +mod note; mod user; +use crate::api_v1::note::handle_note; use crate::api_v1::user::{handle_user_info, handle_user_info_self}; use crate::service::MagnetarService; use crate::web::auth; @@ -13,6 +15,7 @@ pub fn create_api_router(service: Arc) -> Router { Router::new() .route("/users/@self", get(handle_user_info_self)) .route("/users/:id", get(handle_user_info)) + .route("/notes/:id", get(handle_note)) .layer(from_fn_with_state( AuthState::new(service.clone()), auth::auth, diff --git a/src/api_v1/note.rs b/src/api_v1/note.rs new file mode 100644 index 0000000..a64484f --- /dev/null +++ b/src/api_v1/note.rs @@ -0,0 +1,31 @@ +use axum::extract::{Path, Query, State}; +use axum::{debug_handler, Json}; +use std::sync::Arc; + +use crate::model::processing::note::NoteModel; +use crate::model::PackingContext; +use magnetar_sdk::endpoints::note::{GetNoteById, NoteByIdReq}; +use magnetar_sdk::endpoints::{Req, Res}; + +use crate::service::MagnetarService; +use crate::web::auth::MaybeUser; +use crate::web::{ApiError, ObjectNotFound}; + +#[debug_handler] +pub async fn handle_note( + Path(id): Path, + Query(NoteByIdReq { + context, + attachments, + }): Query>, + State(service): State>, + MaybeUser(self_user): MaybeUser, +) -> Result>, ApiError> { + let ctx = PackingContext::new(service, self_user.clone()).await?; + let note = NoteModel + .fetch_single(&ctx, self_user.as_deref(), &id, context, attachments) + .await? + .ok_or_else(|| ObjectNotFound(id))?; + + Ok(Json(note.into())) +} diff --git a/src/api_v1/user.rs b/src/api_v1/user.rs index 74cdb18..10db333 100644 --- a/src/api_v1/user.rs +++ b/src/api_v1/user.rs @@ -5,7 +5,6 @@ use crate::web::auth::{AuthenticatedUser, MaybeUser}; use crate::web::{ApiError, ObjectNotFound}; use axum::extract::{Path, Query, State}; use axum::Json; -use magnetar_calckey_model::ck; use magnetar_sdk::endpoints::user::{GetUserById, GetUserSelf, UserByIdReq, UserSelfReq}; use magnetar_sdk::endpoints::{Req, Res}; use std::sync::Arc; diff --git a/src/model/data/note.rs b/src/model/data/note.rs index 147bb78..5068eb0 100644 --- a/src/model/data/note.rs +++ b/src/model/data/note.rs @@ -29,7 +29,7 @@ impl PackType<&ck::note_reaction::Model> for ReactionBase { pub struct NoteBaseSource<'a> { pub note: &'a ck::note::Model, pub cw_mm: Option<&'a MmXml>, - pub text_mm: &'a MmXml, + pub text_mm: Option<&'a MmXml>, pub reactions: &'a Vec, pub user: &'a UserBase, pub emoji_context: &'a EmojiContext, @@ -55,7 +55,7 @@ impl PackType> for NoteBase { uri: note.uri.clone(), url: note.url.clone(), text: note.text.clone().unwrap_or_default(), - text_mm: text_mm.clone(), + text_mm: text_mm.map(ToOwned::to_owned).clone(), visibility: match note.visibility { NVE::Followers => NoteVisibility::Followers, NVE::Hidden => NoteVisibility::Direct, diff --git a/src/model/data/user.rs b/src/model/data/user.rs index 4ec64bc..608d07a 100644 --- a/src/model/data/user.rs +++ b/src/model/data/user.rs @@ -1,7 +1,7 @@ use magnetar_calckey_model::ck; use magnetar_calckey_model::ck::sea_orm_active_enums::UserProfileFfvisibilityEnum; use magnetar_sdk::types::emoji::{EmojiContext, PackEmojiBase}; -use magnetar_sdk::types::note::PackNoteFull; +use magnetar_sdk::types::note::PackNoteMaybeFull; use magnetar_sdk::types::user::{ AvatarDecoration, PackSecurityKeyBase, ProfileField, SecurityKeyBase, SpeechTransform, UserBase, UserDetailExt, UserProfileExt, UserProfilePinsEx, UserRelationExt, UserSecretsExt, @@ -187,8 +187,8 @@ impl PackType> for UserRelationExt { } } -impl PackType<&[PackNoteFull]> for UserProfilePinsEx { - fn extract(_context: &PackingContext, pinned_notes: &[PackNoteFull]) -> Self { +impl PackType<&[PackNoteMaybeFull]> for UserProfilePinsEx { + fn extract(_context: &PackingContext, pinned_notes: &[PackNoteMaybeFull]) -> Self { UserProfilePinsEx { pinned_notes: pinned_notes.to_owned(), } diff --git a/src/model/mod.rs b/src/model/mod.rs index a1c5e2d..c83ae28 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,7 +1,11 @@ +use crate::model::data::id::BaseId; use crate::model::processing::PackResult; use crate::service::MagnetarService; -use magnetar_calckey_model::ck; +use either::Either; +use magnetar_calckey_model::{ck, CalckeyDbError}; +use std::collections::HashMap; use std::sync::Arc; +use tokio::sync::Mutex; pub mod data; pub mod processing; @@ -17,12 +21,28 @@ impl Default for ProcessingLimits { } } +#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)] +enum UserRelationship { + Follow, + Mute, + Block, + RenoteMute, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +struct UserRelationshipLink { + from: String, + to: String, + rel_type: UserRelationship, +} + #[derive(Clone, Debug)] pub struct PackingContext { instance_meta: Arc, self_user: Option>, service: Arc, limits: ProcessingLimits, + relationships: Arc>>, } pub trait PackType: 'static { @@ -39,6 +59,7 @@ impl PackingContext { self_user, service, limits: Default::default(), + relationships: Arc::new(Mutex::new(HashMap::new())), }) } @@ -46,8 +67,81 @@ impl PackingContext { self.self_user.as_deref() } - fn is_self(&self, user: &ck::user::Model) -> bool { + fn is_id_self(&self, user_id: &str) -> bool { self.self_user() - .is_some_and(|self_user| self_user.id == user.id) + .is_some_and(|self_user| self_user.id == user_id) + } + + fn is_self(&self, user: &ck::user::Model) -> bool { + self.is_id_self(&user.id) + } + + async fn has_relationship_with( + &self, + user: &ck::user::Model, + rel_type: UserRelationship, + ) -> Result { + let Some(self_user) = self.self_user.as_deref() else { + return Ok(false); + }; + + self.is_relationship_between( + Either::Right(self_user.into()), + Either::Right(user.into()), + rel_type, + ) + .await + } + + async fn is_relationship_between( + &self, + from: Either<&str, &ck::user::Model>, + to: Either<&str, &ck::user::Model>, + rel_type: UserRelationship, + ) -> Result { + let link = UserRelationshipLink { + from: from.left_or_else(ck::user::Model::get_id).to_string(), + to: to.left_or_else(ck::user::Model::get_id).to_string(), + rel_type, + }; + + let read = self.relationships.lock().await; + if let Some(relationship) = read.get(&link) { + return Ok(*relationship); + } + drop(read); + + let relationship = match rel_type { + UserRelationship::Block => self + .service + .db + .get_block_status(&link.from, &link.to) + .await? + .is_some(), + UserRelationship::Follow => self + .service + .db + .get_follower_status(&link.from, &link.to) + .await? + .is_some(), + UserRelationship::Mute => self + .service + .db + .get_mute_status(&link.from, &link.to) + .await? + .is_some(), + UserRelationship::RenoteMute => self + .service + .db + .get_renote_mute_status(&link.from, &link.to) + .await? + .is_some(), + }; + + let mut write = self.relationships.lock().await; + write.insert(link, relationship); + drop(write); + + return Ok(relationship); } } diff --git a/src/model/processing/mod.rs b/src/model/processing/mod.rs index 8d7af81..59153ed 100644 --- a/src/model/processing/mod.rs +++ b/src/model/processing/mod.rs @@ -8,6 +8,7 @@ use thiserror::Error; pub mod drive; pub mod emoji; +pub mod note; pub mod user; #[derive(Debug, Error, strum::IntoStaticStr)] diff --git a/src/model/processing/note.rs b/src/model/processing/note.rs new file mode 100644 index 0000000..c831d4e --- /dev/null +++ b/src/model/processing/note.rs @@ -0,0 +1,259 @@ +use crate::model::data::id::BaseId; +use crate::model::data::note::NoteBaseSource; +use crate::model::processing::emoji::EmojiModel; +use crate::model::processing::user::UserModel; +use crate::model::processing::{get_mm_token_emoji, PackResult}; +use crate::model::{PackType, PackingContext, UserRelationship}; +use compact_str::CompactString; +use either::Either; +use magnetar_calckey_model::ck::sea_orm_active_enums::NoteVisibilityEnum; +use magnetar_calckey_model::note_model::{NoteResolveOptions, NoteVisibilityFilterFactory}; +use magnetar_calckey_model::sea_orm::prelude::Expr; +use magnetar_calckey_model::sea_orm::sea_query::{Alias, IntoIden, PgFunc, Query, SimpleExpr}; +use magnetar_calckey_model::sea_orm::{ActiveEnum, ColumnTrait, IntoSimpleExpr}; +use magnetar_calckey_model::{ck, CalckeyDbError}; +use magnetar_sdk::mmm::Token; +use magnetar_sdk::types::emoji::EmojiContext; +use magnetar_sdk::types::note::{NoteBase, PackNoteMaybeFull}; +use magnetar_sdk::types::{Id, MmXml}; +use magnetar_sdk::{mmm, Packed, Required}; + +#[derive(Debug, Clone)] +pub struct NoteVisibilityFilterSimple(Option); + +impl NoteVisibilityFilterFactory for NoteVisibilityFilterSimple { + fn with_note_and_user_tables(&self, note_tbl: Option) -> SimpleExpr { + let note_tbl_name = + note_tbl.map_or_else(|| ck::note::Entity.into_iden(), |a| a.into_iden()); + + let note_visibility = Expr::col((note_tbl_name.clone(), ck::note::Column::Visibility)); + let note_mentions = Expr::col((note_tbl_name.clone(), ck::note::Column::Mentions)); + let note_reply_user_id = Expr::col((note_tbl_name.clone(), ck::note::Column::ReplyUserId)); + let note_visible_user_ids = + Expr::col((note_tbl_name.clone(), ck::note::Column::VisibleUserIds)); + let note_user_id = Expr::col((note_tbl_name, ck::note::Column::UserId)); + + let is_public = note_visibility + .clone() + .eq(NoteVisibilityEnum::Public.as_enum()) + .or(note_visibility + .clone() + .eq(NoteVisibilityEnum::Home.as_enum())); + + let Some(user_id_str) = &self.0 else { + return is_public; + }; + + let self_user_id = SimpleExpr::Constant(user_id_str.into()); + + let is_self = note_user_id.clone().eq(self_user_id.clone()); + + let is_visible_specified = { + let either_specified_or_followers = note_visibility + .clone() + .eq(NoteVisibilityEnum::Specified.as_enum()) + .or(note_visibility + .clone() + .eq(NoteVisibilityEnum::Followers.as_enum())) + .into_simple_expr(); + + let mentioned_or_specified = self_user_id + .clone() + .eq(PgFunc::any(note_mentions.into_simple_expr())) + .or(self_user_id.eq(PgFunc::any(note_visible_user_ids))); + + either_specified_or_followers.and(mentioned_or_specified) + }; + + let is_visible_followers = { + note_visibility + .eq(NoteVisibilityEnum::Followers.as_enum()) + .and( + note_user_id.in_subquery( + Query::select() + .column(ck::following::Column::FolloweeId) + .from(ck::following::Entity) + .cond_where(ck::following::Column::FollowerId.eq(user_id_str)) + .to_owned(), + ), + ) + .or(note_reply_user_id.eq(user_id_str)) + }; + + is_self + .or(is_public) + .or(is_visible_followers) + .or(is_visible_specified) + } +} + +pub struct NoteVisibilityFilterModel; + +impl NoteVisibilityFilterModel { + pub async fn is_note_visible( + &self, + ctx: &PackingContext, + user: Option<&ck::user::Model>, + note: &ck::note::Model, + ) -> Result { + if user.is_some_and(|user| user.id == note.user_id) { + return Ok(true); + } + + if matches!( + note.visibility, + NoteVisibilityEnum::Public | NoteVisibilityEnum::Home + ) { + return Ok(true); + } + + if matches!( + note.visibility, + NoteVisibilityEnum::Followers | NoteVisibilityEnum::Specified + ) { + let Some(user) = user else { + return Ok(false); + }; + + if note.mentions.contains(&user.id) || note.visible_user_ids.contains(&user.id) { + return Ok(true); + } + + if matches!(note.visibility, NoteVisibilityEnum::Specified) { + return Ok(false); + } + + let following = ctx + .is_relationship_between( + Either::Right(user), + Either::Left(¬e.user_id), + UserRelationship::Follow, + ) + .await?; + + // The second condition generally will not happen in the API, + // however it allows some AP processing, with activities + // between two foreign objects + return Ok(following || user.host.is_some() && note.user_host.is_some()); + } + + return Ok(false); + } + + pub fn new_note_visibility_filter(&self, user: Option<&str>) -> NoteVisibilityFilterSimple { + NoteVisibilityFilterSimple(user.map(str::to_string)) + } +} + +struct SpeechTransformNyan; + +impl SpeechTransformNyan { + fn new() -> Self { + SpeechTransformNyan + } + + fn transform(&self, text: &mut CompactString) { + // TODO + } +} + +pub struct NoteModel; + +impl NoteModel { + pub fn tokenize_note_text(&self, note: &ck::note::Model) -> Option { + note.text + .as_deref() + .map(|text| mmm::Context::default().parse_full(text)) + } + + pub fn tokenize_note_cw(&self, note: &ck::note::Model) -> Option { + note.cw + .as_deref() + .map(|text| mmm::Context::default().parse_ui(text)) + } + + pub async fn fetch_single( + &self, + ctx: &PackingContext, + as_user: Option<&ck::user::Model>, + id: &str, + show_context: bool, + attachments: bool, + ) -> PackResult> { + let note_resolver = ctx.service.db.get_note_resolver(); + let Some(note) = note_resolver + .get_one(&NoteResolveOptions { + ids: Some(vec![id.to_owned()]), + visibility_filter: Box::new( + NoteVisibilityFilterModel + .new_note_visibility_filter(as_user.map(ck::user::Model::get_id)), + ), + time_range: None, + with_user: show_context, + with_reply_target: show_context, + with_renote_target: show_context, + }) + .await? + else { + return Ok(None); + }; + + let Required(ref user) = UserModel.base_from_existing(ctx, ¬e.user).await?.user; + + let cw_tok = self.tokenize_note_cw(¬e.note); + let mut text_tok = self.tokenize_note_text(¬e.note); + + let mut emoji_extracted = Vec::new(); + + if let Some(cw_tok) = &cw_tok { + emoji_extracted.extend_from_slice(&get_mm_token_emoji(cw_tok)); + } + + if let Some(text_tok) = &mut text_tok { + emoji_extracted.extend_from_slice(&get_mm_token_emoji(text_tok)); + + if note.user.is_cat && note.user.speak_as_cat { + let transformer = SpeechTransformNyan::new(); + text_tok.walk_speech_transform(&|text| transformer.transform(text)); + } + } + + let emoji_model = EmojiModel; + let shortcodes = emoji_model.deduplicate_emoji(ctx, emoji_extracted); + let emojis = emoji_model + .fetch_many_emojis(ctx, &shortcodes, note.user.host.as_deref()) + .await?; + let emoji_context = &EmojiContext(emojis); + + // TODO: Polls, reactions, attachments, ... + + let note_base = NoteBase::extract( + ctx, + NoteBaseSource { + note: ¬e.note, + cw_mm: cw_tok + .as_ref() + .map(mmm::to_xml_string) + .and_then(Result::ok) + .map(MmXml) + .as_ref(), + text_mm: text_tok + .as_ref() + .map(mmm::to_xml_string) + .and_then(Result::ok) + .map(MmXml) + .as_ref(), + reactions: &vec![], + user, + emoji_context, + }, + ); + + Ok(Some(PackNoteMaybeFull::pack_from(( + Required(Id::from(note.note.id.clone())), + Required(note_base), + None, + None, + )))) + } +} diff --git a/src/model/processing/user.rs b/src/model/processing/user.rs index 88ff5b5..5243857 100644 --- a/src/model/processing/user.rs +++ b/src/model/processing/user.rs @@ -3,13 +3,11 @@ use crate::model::processing::emoji::EmojiModel; use crate::model::processing::{get_mm_token_emoji, PackResult}; use crate::model::{PackType, PackingContext}; use magnetar_calckey_model::ck; -use magnetar_calckey_model::sea_orm::EntityTrait; use magnetar_sdk::mmm::Token; use magnetar_sdk::types::emoji::EmojiContext; use magnetar_sdk::types::user::{PackUserBase, UserBase}; use magnetar_sdk::types::{Id, MmXml}; use magnetar_sdk::{mmm, Packed, Required}; -use std::sync::Arc; pub struct UserModel; diff --git a/src/service/user_cache.rs b/src/service/local_user_cache.rs similarity index 96% rename from src/service/user_cache.rs rename to src/service/local_user_cache.rs index 10bc576..571323e 100644 --- a/src/service/user_cache.rs +++ b/src/service/local_user_cache.rs @@ -27,20 +27,20 @@ impl From for ApiError { UserCacheError::RedisError(err) => err.into(), }; - api_error.message = format!("User cache error: {}", api_error.message); + api_error.message = format!("Local user cache error: {}", api_error.message); api_error } } -struct UserCache { +struct LocalUserCache { lifetime: TimedCache, id_to_user: HashMap>, token_to_user: HashMap>, uri_to_user: HashMap>, } -impl UserCache { +impl LocalUserCache { fn purge(&mut self, user: impl AsRef) { let user = user.as_ref(); @@ -120,20 +120,20 @@ impl UserCache { } } -pub struct UserCacheService { +pub struct LocalUserCacheService { db: CalckeyModel, #[allow(dead_code)] token_watch: CalckeySub, - cache: Arc>, + cache: Arc>, } -impl UserCacheService { +impl LocalUserCacheService { pub(super) async fn new( config: &MagnetarConfig, db: CalckeyModel, redis: CalckeyCache, ) -> Result { - let cache = Arc::new(Mutex::new(UserCache { + let cache = Arc::new(Mutex::new(LocalUserCache { lifetime: TimedCache::with_lifespan(60 * 5), id_to_user: HashMap::new(), token_to_user: HashMap::new(), diff --git a/src/service/mod.rs b/src/service/mod.rs index e1f6670..74a091e 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -7,13 +7,13 @@ use thiserror::Error; pub mod emoji_cache; pub mod generic_id_cache; pub mod instance_meta_cache; -pub mod user_cache; +pub mod local_user_cache; pub struct MagnetarService { pub db: CalckeyModel, pub cache: CalckeyCache, pub config: &'static MagnetarConfig, - pub auth_cache: user_cache::UserCacheService, + pub local_user_cache: local_user_cache::LocalUserCacheService, pub instance_meta_cache: instance_meta_cache::InstanceMetaCacheService, pub emoji_cache: emoji_cache::EmojiCacheService, pub drive_file_cache: generic_id_cache::GenericIdCacheService, @@ -32,7 +32,7 @@ impl Debug for MagnetarService { #[derive(Debug, Error)] pub enum ServiceInitError { #[error("Authentication cache initialization error: {0}")] - AuthCacheError(#[from] user_cache::UserCacheError), + AuthCacheError(#[from] local_user_cache::UserCacheError), } impl MagnetarService { @@ -41,8 +41,8 @@ impl MagnetarService { db: CalckeyModel, cache: CalckeyCache, ) -> Result { - let auth_cache = - user_cache::UserCacheService::new(config, db.clone(), cache.clone()).await?; + let local_user_cache = + local_user_cache::LocalUserCacheService::new(config, db.clone(), cache.clone()).await?; let instance_meta_cache = instance_meta_cache::InstanceMetaCacheService::new(db.clone()); let emoji_cache = emoji_cache::EmojiCacheService::new(db.clone()); let drive_file_cache = @@ -52,7 +52,7 @@ impl MagnetarService { db, cache, config, - auth_cache, + local_user_cache, instance_meta_cache, emoji_cache, drive_file_cache, diff --git a/src/web/auth.rs b/src/web/auth.rs index 7483cb5..f740b3a 100644 --- a/src/web/auth.rs +++ b/src/web/auth.rs @@ -1,4 +1,4 @@ -use crate::service::user_cache::UserCacheError; +use crate::service::local_user_cache::UserCacheError; use crate::service::MagnetarService; use crate::web::{ApiError, IntoErrorCode}; use axum::async_trait; @@ -175,7 +175,7 @@ impl AuthState { let token = token.token(); if is_user_token(token) { - let user_cache = &self.service.auth_cache; + let user_cache = &self.service.local_user_cache; let user = user_cache.get_by_token(token).await?; if let Some(user) = user { @@ -194,7 +194,7 @@ impl AuthState { let user = self .service - .auth_cache + .local_user_cache .get_by_id(&access_token.user_id) .await?;