Compare commits

..

4 Commits

Author SHA1 Message Date
Natty acdc3e8bc1
Merge branch 'development'
ci/woodpecker/push/ociImagePush Pipeline was successful Details
2023-10-29 02:11:10 +01:00
Natty 0755dac002
Note by ID testing endpoint 2023-10-29 02:10:48 +01:00
Natty 4bbc368f92
Note filtering by time frame 2023-10-29 01:40:03 +02:00
Natty 3cd43d840a
Basic note fetching 2023-10-29 01:27:32 +02:00
24 changed files with 847 additions and 50 deletions

21
Cargo.lock generated
View File

@ -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"

View File

@ -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 }

View File

@ -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 }
@ -25,3 +26,4 @@ strum = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
once_cell = "1.18.0"

View File

@ -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<I: IntoIden>(&self, alias: I) -> RelationDef;
}
impl<T: RelationTrait> AliasSourceExt for T {
fn with_alias<I: IntoIden>(&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<Self, CalckeyDbError> {
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<Option<following::Model>, 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<Option<blocking::Model>, 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<Option<muting::Model>, 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<Option<renote_muting::Model>, 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<Vec<emoji::Model>, 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)]

View File

@ -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<drive_file::Model>,
pub banner: Option<drive_file::Model>,
pub reply: Option<Box<NoteData>>,
pub renote: Option<Box<NoteData>>,
}
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<Self, DbErr> {
let reply = note::Model::from_query_result_optional(res, REPLY)?
.map::<Result<_, DbErr>, _>(|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::<Result<_, DbErr>, _>(|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<Alias>) -> SimpleExpr;
}
pub struct NoteResolveOptions {
pub ids: Option<Vec<String>>,
pub visibility_filter: Box<dyn NoteVisibilityFilterFactory>,
pub time_range: Option<RangeFilter>,
pub with_user: bool,
pub with_reply_target: bool,
pub with_renote_target: bool,
}
trait SelectColumnsExt {
fn add_aliased_columns<T: EntityTrait>(self, alias: Option<&str>, entity: T) -> Self;
}
impl SelectColumnsExt for Select<note::Entity> {
fn add_aliased_columns<T: EntityTrait>(
mut self: Select<note::Entity>,
alias: Option<&str>,
entity: T,
) -> Select<note::Entity> {
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<Alias> = Lazy::new(|| Alias::new(USER));
const ALIAS_USER_AVATAR: Lazy<Alias> = Lazy::new(|| Alias::new(USER_AVATAR));
const ALIAS_USER_BANNER: Lazy<Alias> = Lazy::new(|| Alias::new(USER_BANNER));
const ALIAS_REPLY: Lazy<Alias> = Lazy::new(|| Alias::new(REPLY));
const ALIAS_REPLY_USER: Lazy<Alias> = Lazy::new(|| Alias::new(REPLY_USER));
const ALIAS_REPLY_USER_AVATAR: Lazy<Alias> = Lazy::new(|| Alias::new(REPLY_USER_AVATAR));
const ALIAS_REPLY_USER_BANNER: Lazy<Alias> = Lazy::new(|| Alias::new(REPLY_USER_BANNER));
const ALIAS_RENOTE: Lazy<Alias> = Lazy::new(|| Alias::new(RENOTE));
const ALIAS_RENOTE_USER: Lazy<Alias> = Lazy::new(|| Alias::new(RENOTE_USER));
const ALIAS_RENOTE_USER_AVATAR: Lazy<Alias> = Lazy::new(|| Alias::new(RENOTE_USER_AVATAR));
const ALIAS_RENOTE_USER_BANNER: Lazy<Alias> = 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<Option<NoteData>, 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::<note::Entity>::filter)
.apply_if(time_filter, Select::<note::Entity>::filter)
.into_model::<NoteData>()
.one(self.db.inner())
.await?;
Ok(notes)
}
pub fn resolve(&self, options: &NoteResolveOptions) -> Select<note::Entity> {
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
}
}

View File

@ -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<T: Write>(&self, writer: &mut quick_xml::Writer<T>) -> quick_xml::Result<()> {
match self {
Token::PlainText(plain) => {

View File

@ -1,3 +1,4 @@
pub mod note;
pub mod timeline;
pub mod user;

View File

@ -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;

View File

@ -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<const MIN: u64, const MAX: u64>() -> U64Range<MIN, MAX
#[derive(Serialize, Deserialize, TS)]
#[ts(export)]
#[repr(transparent)]
pub struct GetTimelineRes(pub Vec<PackNoteFull>);
pub struct GetTimelineRes(pub Vec<PackNoteMaybeFull>);
#[derive(Endpoint)]
#[endpoint(
endpoint = "/timeline",
method = Method::GET,
request = GetTimelineReq,
response = Vec::<PackNoteFull>,
response = Vec::<PackNoteMaybeFull>,
)]
pub struct GetTimeline;

View File

@ -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

View File

@ -56,7 +56,7 @@ pub struct NoteBase {
pub uri: Option<String>,
pub url: Option<String>,
pub text: String,
pub text_mm: MmXml,
pub text_mm: Option<MmXml>,
pub visibility: NoteVisibility,
pub user: Box<PackUserBase>,
pub parent_note_id: Option<String>,
@ -92,8 +92,8 @@ pub struct NoteDetailExt {
}
pack!(
PackNoteFull,
Required<Id> as id & Required<NoteBase> as note & Required<NoteAttachmentExt> as attachment & Required<NoteDetailExt> as detail
PackNoteMaybeFull,
Required<Id> as id & Required<NoteBase> as note & Option<NoteAttachmentExt> as attachment & Option<NoteDetailExt> as detail
);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]

View File

@ -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<PackNoteFull>,
pub pinned_notes: Vec<PackNoteMaybeFull>,
// pub pinned_page: Option<Page>,
}

View File

@ -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<MagnetarService>) -> 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,

31
src/api_v1/note.rs Normal file
View File

@ -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<String>,
Query(NoteByIdReq {
context,
attachments,
}): Query<Req<GetNoteById>>,
State(service): State<Arc<MagnetarService>>,
MaybeUser(self_user): MaybeUser,
) -> Result<Json<Res<GetNoteById>>, 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()))
}

View File

@ -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;

View File

@ -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<PackReactionBase>,
pub user: &'a UserBase,
pub emoji_context: &'a EmojiContext,
@ -55,7 +55,7 @@ impl PackType<NoteBaseSource<'_>> 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,

View File

@ -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<UserRelationExtSource<'_>> 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(),
}

View File

@ -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<ck::meta::Model>,
self_user: Option<Arc<ck::user::Model>>,
service: Arc<MagnetarService>,
limits: ProcessingLimits,
relationships: Arc<Mutex<HashMap<UserRelationshipLink, bool>>>,
}
pub trait PackType<I>: '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<bool, CalckeyDbError> {
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<bool, CalckeyDbError> {
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);
}
}

View File

@ -8,6 +8,7 @@ use thiserror::Error;
pub mod drive;
pub mod emoji;
pub mod note;
pub mod user;
#[derive(Debug, Error, strum::IntoStaticStr)]

View File

@ -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<String>);
impl NoteVisibilityFilterFactory for NoteVisibilityFilterSimple {
fn with_note_and_user_tables(&self, note_tbl: Option<Alias>) -> 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<bool, CalckeyDbError> {
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(&note.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<Token> {
note.text
.as_deref()
.map(|text| mmm::Context::default().parse_full(text))
}
pub fn tokenize_note_cw(&self, note: &ck::note::Model) -> Option<Token> {
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<Option<PackNoteMaybeFull>> {
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, &note.user).await?.user;
let cw_tok = self.tokenize_note_cw(&note.note);
let mut text_tok = self.tokenize_note_text(&note.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: &note.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,
))))
}
}

View File

@ -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;

View File

@ -27,20 +27,20 @@ impl From<UserCacheError> 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<String, ()>,
id_to_user: HashMap<String, Arc<ck::user::Model>>,
token_to_user: HashMap<String, Arc<ck::user::Model>>,
uri_to_user: HashMap<String, Arc<ck::user::Model>>,
}
impl UserCache {
impl LocalUserCache {
fn purge(&mut self, user: impl AsRef<ck::user::Model>) {
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<Mutex<UserCache>>,
cache: Arc<Mutex<LocalUserCache>>,
}
impl UserCacheService {
impl LocalUserCacheService {
pub(super) async fn new(
config: &MagnetarConfig,
db: CalckeyModel,
redis: CalckeyCache,
) -> Result<Self, UserCacheError> {
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(),

View File

@ -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<ck::drive_file::Entity>,
@ -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<Self, ServiceInitError> {
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,

View File

@ -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?;