Implemented pagination and user follower listing

This commit is contained in:
Natty 2024-01-09 22:29:06 +01:00
parent a658452138
commit 1bef42ead5
Signed by: natty
GPG Key ID: BF6CB659ADEE60EC
19 changed files with 1074 additions and 284 deletions

1
Cargo.lock generated
View File

@ -1519,6 +1519,7 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded",
"strum", "strum",
"thiserror", "thiserror",
"tokio", "tokio",

View File

@ -115,6 +115,7 @@ percent-encoding = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_urlencoded = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
unicode-segmentation = { workspace = true } unicode-segmentation = { workspace = true }

View File

@ -112,6 +112,8 @@ pub mod rel {
link_rel!(pub RelWebFingerProfilePage, "http://webfinger.net/rel/profile-page"); link_rel!(pub RelWebFingerProfilePage, "http://webfinger.net/rel/profile-page");
link_rel!(pub RelSelf, "self"); link_rel!(pub RelSelf, "self");
link_rel!(pub RelNext, "next");
link_rel!(pub RelPrev, "prev");
link_rel!(pub RelOStatusSubscribe, "http://ostatus.org/schema/1.0/subscribe"); link_rel!(pub RelOStatusSubscribe, "http://ostatus.org/schema/1.0/subscribe");
link_rel!(pub RelNodeInfo20, "http://nodeinfo.diaspora.software/ns/schema/2.0"); link_rel!(pub RelNodeInfo20, "http://nodeinfo.diaspora.software/ns/schema/2.0");
link_rel!(pub RelNodeInfo21, "http://nodeinfo.diaspora.software/ns/schema/2.1"); link_rel!(pub RelNodeInfo21, "http://nodeinfo.diaspora.software/ns/schema/2.1");

View File

@ -1,7 +1,6 @@
pub mod emoji; pub mod emoji;
pub mod model_ext; pub mod model_ext;
pub mod note_model; pub mod note_model;
pub mod paginated;
pub mod poll; pub mod poll;
pub mod user_model; pub mod user_model;
@ -27,7 +26,7 @@ use thiserror::Error;
use tokio::select; use tokio::select;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::log::LevelFilter; use tracing::log::LevelFilter;
use tracing::{error, info}; use tracing::{error, info, trace};
#[derive(Debug)] #[derive(Debug)]
pub struct ConnectorConfig { pub struct ConnectorConfig {
@ -445,7 +444,7 @@ impl CalckeySub {
} }
}; };
println!("Got message: {:#?}", parsed); trace!("Got message: {:#?}", parsed);
handler(parsed).await; handler(parsed).await;
} }

View File

@ -1,9 +1,12 @@
use chrono::{DateTime, Utc};
use ext_calckey_model_migration::{ use ext_calckey_model_migration::{
Alias, Expr, IntoIden, Quote, SelectExpr, SelectStatement, TableRef, Alias, Expr, IntoIden, Quote, SelectExpr, SelectStatement, TableRef,
}; };
use magnetar_sdk::types::SpanFilter;
use sea_orm::{ use sea_orm::{
ColumnTrait, Condition, DynIden, EntityTrait, Iden, Iterable, JoinType, RelationDef, ColumnTrait, Condition, ConnectionTrait, Cursor, CursorTrait, DbErr, DynIden, EntityTrait,
RelationTrait, FromQueryResult, Iden, IntoIdentity, Iterable, JoinType, RelationDef, RelationTrait, Select,
SelectModel, SelectorTrait,
}; };
use std::fmt::Write; use std::fmt::Write;
@ -126,12 +129,21 @@ impl SelectColumnsExt for SelectStatement {
} }
pub trait AliasColumnExt { pub trait AliasColumnExt {
fn col_tuple(&self, col: impl IntoIden) -> (MagIden, MagIden);
fn col(&self, col: impl IntoIden) -> Expr; fn col(&self, col: impl IntoIden) -> Expr;
} }
impl<T: Iden + ?Sized> AliasColumnExt for T { impl<T: Iden + ?Sized> AliasColumnExt for T {
fn col_tuple(&self, col: impl IntoIden) -> (MagIden, MagIden) {
(
MagIden::alias(&self.to_string()),
MagIden::DynIden(col.into_iden()),
)
}
fn col(&self, col: impl IntoIden) -> Expr { fn col(&self, col: impl IntoIden) -> Expr {
Expr::col((Alias::new(self.to_string()).into_iden(), col.into_iden())) Expr::col(self.col_tuple(col))
} }
} }
@ -181,6 +193,8 @@ impl<T: EntityTrait> EntityPrefixExt for T {
pub trait AliasSourceExt { pub trait AliasSourceExt {
fn with_from_alias(&self, alias: &MagIden) -> RelationDef; fn with_from_alias(&self, alias: &MagIden) -> RelationDef;
fn with_to_alias(&self, alias: &MagIden) -> RelationDef;
fn with_alias(&self, from: &MagIden, to: &MagIden) -> RelationDef; fn with_alias(&self, from: &MagIden, to: &MagIden) -> RelationDef;
} }
@ -191,6 +205,12 @@ impl<T: RelationTrait> AliasSourceExt for T {
def def
} }
fn with_to_alias(&self, alias: &MagIden) -> RelationDef {
let mut def = self.def();
def.to_tbl = def.to_tbl.alias(Alias::new(alias.clone().to_string()));
def
}
fn with_alias(&self, from: &MagIden, to: &MagIden) -> RelationDef { fn with_alias(&self, from: &MagIden, to: &MagIden) -> RelationDef {
let mut def = self.def(); let mut def = self.def();
def.from_tbl = def.from_tbl.alias(from.clone().into_iden()); def.from_tbl = def.from_tbl.alias(from.clone().into_iden());
@ -198,3 +218,109 @@ impl<T: RelationTrait> AliasSourceExt for T {
def def
} }
} }
pub trait CursorPaginationExt<E> {
type Selector: SelectorTrait + Send + Sync;
fn cursor_by_columns_and_span<C>(
self,
order_columns: C,
pagination: &SpanFilter,
limit: Option<u64>,
) -> Cursor<Self::Selector>
where
C: IntoIdentity;
async fn get_paginated_model<M, C, T>(
self,
db: &T,
columns: C,
curr: &SpanFilter,
prev: &mut Option<SpanFilter>,
next: &mut Option<SpanFilter>,
limit: u64,
) -> Result<Vec<M>, DbErr>
where
M: FromQueryResult + ModelPagination,
C: IntoIdentity,
T: ConnectionTrait;
}
impl<E, M> CursorPaginationExt<E> for Select<E>
where
E: EntityTrait<Model = M>,
M: FromQueryResult + Sized + Send + Sync,
{
type Selector = SelectModel<M>;
fn cursor_by_columns_and_span<C>(
self,
order_columns: C,
pagination: &SpanFilter,
limit: Option<u64>,
) -> Cursor<Self::Selector>
where
C: IntoIdentity,
{
let mut cursor = self.cursor_by(order_columns);
if let Some(start) = pagination.start() {
cursor.after(start);
}
if let Some(end) = pagination.end() {
cursor.before(end);
}
if let Some(lim) = limit {
if pagination.is_desc() {
cursor.last(lim);
} else {
cursor.first(lim);
}
}
cursor
}
async fn get_paginated_model<Q, C, T>(
self,
db: &T,
columns: C,
curr: &SpanFilter,
prev: &mut Option<SpanFilter>,
next: &mut Option<SpanFilter>,
limit: u64,
) -> Result<Vec<Q>, DbErr>
where
Q: FromQueryResult + ModelPagination,
C: IntoIdentity,
T: ConnectionTrait,
{
let mut result = self
.cursor_by_columns_and_span(columns, curr, Some(limit + 1))
.into_model::<Q>()
.all(db)
.await?;
if curr.is_desc() {
result.reverse();
}
if result.len() > limit as usize {
result.pop();
let last = result.last();
*next = last.and_then(|c| curr.next(c.time(), c.id()));
}
let first = result.first();
*prev = first.and_then(|c| curr.prev(c.time(), c.id()));
Ok(result)
}
}
pub trait ModelPagination {
fn id(&self) -> &str;
fn time(&self) -> DateTime<Utc>;
}

View File

@ -1,9 +1,8 @@
use crate::model_ext::{AliasSuffixExt, EntityPrefixExt, MagIden}; use crate::model_ext::{AliasSuffixExt, EntityPrefixExt, MagIden, ModelPagination};
use crate::note_model::{INTERACTION_REACTION, INTERACTION_RENOTE, RENOTE, REPLY, USER}; use crate::note_model::{INTERACTION_REACTION, INTERACTION_RENOTE, RENOTE, REPLY, USER};
use crate::user_model::UserData; use crate::user_model::UserData;
use chrono::{DateTime, Utc};
use ck::note; use ck::note;
use ext_calckey_model_migration::IntoIden;
use sea_orm::sea_query::Alias;
use sea_orm::Iden; use sea_orm::Iden;
use sea_orm::{DbErr, FromQueryResult, QueryResult}; use sea_orm::{DbErr, FromQueryResult, QueryResult};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -48,6 +47,16 @@ pub struct NoteData {
pub renote: Option<Box<NoteData>>, pub renote: Option<Box<NoteData>>,
} }
impl ModelPagination for NoteData {
fn id(&self) -> &str {
&self.note.id
}
fn time(&self) -> DateTime<Utc> {
self.note.created_at.into()
}
}
impl FromQueryResult for NoteData { impl FromQueryResult for NoteData {
fn from_query_result(res: &QueryResult, prefix: &str) -> Result<Self, DbErr> { fn from_query_result(res: &QueryResult, prefix: &str) -> Result<Self, DbErr> {
let prefix = if prefix.is_empty() { let prefix = if prefix.is_empty() {

View File

@ -3,8 +3,8 @@ pub mod data;
use ext_calckey_model_migration::SelectStatement; use ext_calckey_model_migration::SelectStatement;
use sea_orm::sea_query::{Asterisk, Expr, IntoIden, Query, SelectExpr, SimpleExpr}; use sea_orm::sea_query::{Asterisk, Expr, IntoIden, Query, SelectExpr, SimpleExpr};
use sea_orm::{ use sea_orm::{
Condition, CursorTrait, EntityName, EntityTrait, Iden, JoinType, QueryFilter, QueryOrder, Condition, EntityTrait, Iden, JoinType, QueryFilter, QueryOrder, QuerySelect, QueryTrait,
QuerySelect, QueryTrait, Select, Select,
}; };
use ck::{note, note_reaction, user, user_note_pining}; use ck::{note, note_reaction, user, user_note_pining};
@ -12,10 +12,9 @@ use data::{sub_interaction_reaction, sub_interaction_renote, NoteData};
use magnetar_sdk::types::SpanFilter; use magnetar_sdk::types::SpanFilter;
use crate::model_ext::{ use crate::model_ext::{
join_columns_default, AliasColumnExt, AliasSourceExt, AliasSuffixExt, EntityPrefixExt, MagIden, join_columns_default, AliasColumnExt, AliasSourceExt, AliasSuffixExt, CursorPaginationExt,
SelectColumnsExt, EntityPrefixExt, MagIden, SelectColumnsExt,
}; };
use crate::paginated::PaginatedModel;
use crate::user_model::{UserResolveOptions, UserResolver}; use crate::user_model::{UserResolveOptions, UserResolver};
use crate::{CalckeyDbError, CalckeyModel}; use crate::{CalckeyDbError, CalckeyModel};
@ -138,21 +137,17 @@ impl NoteResolver {
let visibility_filter = options let visibility_filter = options
.visibility_filter .visibility_filter
.with_note_and_user_tables(&note::Entity.base_prefix()); .with_note_and_user_tables(&note::Entity.base_prefix());
let time_filter = options
.time_range
.as_ref()
.and_then(note::Entity::range_into_expr);
let id_filter = options.ids.as_ref().map(ids_into_expr); let id_filter = options.ids.as_ref().map(ids_into_expr);
let notes = select let note = select
.filter(visibility_filter) .filter(visibility_filter)
.apply_if(id_filter, Select::<note::Entity>::filter) .apply_if(id_filter, Select::<note::Entity>::filter)
.apply_if(time_filter, Select::<note::Entity>::filter)
.into_model::<NoteData>() .into_model::<NoteData>()
.one(self.db.inner()) .one(self.db.inner())
.await?; .await?;
Ok(notes) Ok(note)
} }
pub async fn get_many( pub async fn get_many(
@ -163,26 +158,36 @@ impl NoteResolver {
let visibility_filter = options let visibility_filter = options
.visibility_filter .visibility_filter
.with_note_and_user_tables(&note::Entity.base_prefix()); .with_note_and_user_tables(&note::Entity.base_prefix());
let time_filter = options
.time_range
.as_ref()
.and_then(note::Entity::range_into_expr);
let id_filter = options.ids.as_ref().map(ids_into_expr); let id_filter = options.ids.as_ref().map(ids_into_expr);
let notes = select let notes_select = select
.filter(visibility_filter) .filter(visibility_filter)
.apply_if(id_filter, Select::<note::Entity>::filter) .apply_if(id_filter, Select::<note::Entity>::filter)
.apply_if(time_filter, Select::<note::Entity>::filter)
.apply_if(options.only_pins_from.as_deref(), |s, _| { .apply_if(options.only_pins_from.as_deref(), |s, _| {
s.order_by_desc(Expr::col(( s.order_by_desc(Expr::col((
note::Entity.base_prefix().into_iden().join_str(PIN), note::Entity.base_prefix().into_iden().join_str(PIN),
user_note_pining::Column::CreatedAt, user_note_pining::Column::CreatedAt,
))) )))
}) });
.apply_if(options.limit, Select::<note::Entity>::limit)
.into_model::<NoteData>() let notes = if let Some(pagination) = &options.time_range {
.all(self.db.inner()) notes_select
.await?; .get_paginated_model::<NoteData, _, _>(
self.db.inner(),
(note::Column::CreatedAt, note::Column::Id),
pagination,
&mut None,
&mut None,
options.limit.unwrap_or(20),
)
.await?
} else {
notes_select
.apply_if(options.limit, Select::<note::Entity>::limit)
.into_model::<NoteData>()
.all(self.db.inner())
.await?
};
Ok(notes) Ok(notes)
} }

View File

@ -1,95 +0,0 @@
use ext_calckey_model_migration::{Expr, SimpleExpr};
use magnetar_sdk::types::{EndFilter, RangeFilter, SpanFilter, StartFilter};
use sea_orm::Iden;
pub trait PaginatedModel: 'static {
fn time_column() -> impl Iden;
fn id_column() -> impl Iden;
fn range_into_expr(filter: &SpanFilter) -> Option<SimpleExpr> {
match filter {
SpanFilter::Range(RangeFilter {
time_start,
time_end,
id_start,
id_end,
}) => Some(
Expr::tuple([
Expr::col(Self::time_column()).into(),
Expr::col(Self::id_column()).into(),
])
.between(
Expr::tuple([
Expr::value(time_start.clone()),
Expr::value(id_start.clone()),
]),
Expr::tuple([Expr::value(time_end.clone()), Expr::value(id_end.clone())]),
),
),
SpanFilter::Start(StartFilter {
id_start,
time_start,
}) => Some(
Expr::tuple([
Expr::col(Self::time_column()).into(),
Expr::col(Self::id_column()).into(),
])
.gt(Expr::tuple([
Expr::value(time_start.clone()),
Expr::value(id_start.clone()),
])),
),
SpanFilter::End(EndFilter { id_end, time_end }) => Some(
Expr::tuple([
Expr::col(Self::time_column()).into(),
Expr::col(Self::id_column()).into(),
])
.lt(Expr::tuple([
Expr::value(time_end.clone()),
Expr::value(id_end.clone()),
])),
),
SpanFilter::None(_) => None,
}
}
}
impl PaginatedModel for ck::note::Entity {
fn time_column() -> impl Iden {
ck::note::Column::CreatedAt
}
fn id_column() -> impl Iden {
ck::note::Column::Id
}
}
impl PaginatedModel for ck::user::Entity {
fn time_column() -> impl Iden {
ck::user::Column::CreatedAt
}
fn id_column() -> impl Iden {
ck::user::Column::Id
}
}
impl PaginatedModel for ck::following::Entity {
fn time_column() -> impl Iden {
ck::following::Column::CreatedAt
}
fn id_column() -> impl Iden {
ck::following::Column::Id
}
}
impl PaginatedModel for ck::follow_request::Entity {
fn time_column() -> impl Iden {
ck::follow_request::Column::CreatedAt
}
fn id_column() -> impl Iden {
ck::follow_request::Column::Id
}
}

View File

@ -1,25 +1,25 @@
use crate::model_ext::{AliasSourceExt, AliasSuffixExt, EntityPrefixExt, MagIden}; use crate::model_ext::{
use crate::{model_ext::SelectColumnsExt, CalckeyModel}; AliasSourceExt, AliasSuffixExt, CursorPaginationExt, EntityPrefixExt, MagIden, ModelPagination,
use ck::{drive_file, user}; };
use crate::{model_ext::SelectColumnsExt, CalckeyDbError, CalckeyModel};
use chrono::{DateTime, Utc};
use ck::{drive_file, follow_request, following, user, user_profile};
use ext_calckey_model_migration::{IntoIden, SelectStatement}; use ext_calckey_model_migration::{IntoIden, SelectStatement};
use sea_orm::sea_query::Alias; use magnetar_sdk::types::SpanFilter;
use sea_orm::{DbErr, FromQueryResult, Iden, JoinType, QueryResult}; use sea_orm::{
ColumnTrait, DbErr, EntityTrait, FromQueryResult, Iden, JoinType, QueryFilter, QueryResult,
QuerySelect,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserData { pub struct UserData {
pub user: user::Model, pub user: user::Model,
pub profile: Option<user_profile::Model>,
pub avatar: Option<drive_file::Model>, pub avatar: Option<drive_file::Model>,
pub banner: Option<drive_file::Model>, pub banner: Option<drive_file::Model>,
} }
pub struct UserResolveOptions {
pub with_avatar_and_banner: bool,
}
const AVATAR: &str = "avatar.";
const BANNER: &str = "banner.";
impl FromQueryResult for UserData { impl FromQueryResult for UserData {
fn from_query_result(res: &QueryResult, prefix: &str) -> Result<Self, DbErr> { fn from_query_result(res: &QueryResult, prefix: &str) -> Result<Self, DbErr> {
let prefix = if prefix.is_empty() { let prefix = if prefix.is_empty() {
@ -30,6 +30,10 @@ impl FromQueryResult for UserData {
Ok(UserData { Ok(UserData {
user: user::Model::from_query_result(res, &prefix.to_string())?, user: user::Model::from_query_result(res, &prefix.to_string())?,
profile: user_profile::Model::from_query_result_optional(
res,
&prefix.join_str_as_str(PROFILE),
)?,
avatar: drive_file::Model::from_query_result_optional( avatar: drive_file::Model::from_query_result_optional(
res, res,
&prefix.join_str_as_str(AVATAR), &prefix.join_str_as_str(AVATAR),
@ -42,6 +46,75 @@ impl FromQueryResult for UserData {
} }
} }
impl ModelPagination for UserData {
fn id(&self) -> &str {
&self.user.id
}
fn time(&self) -> DateTime<Utc> {
self.user.created_at.into()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserFollowData {
pub follow: following::Model,
pub user: UserData,
}
impl FromQueryResult for UserFollowData {
fn from_query_result(res: &QueryResult, prefix: &str) -> Result<Self, DbErr> {
Ok(UserFollowData {
user: UserData::from_query_result(res, prefix)?,
follow: following::Model::from_query_result(res, prefix)?,
})
}
}
impl ModelPagination for UserFollowData {
fn id(&self) -> &str {
&self.follow.id
}
fn time(&self) -> DateTime<Utc> {
self.follow.created_at.into()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserFollowRequestData {
pub follow_request: follow_request::Model,
pub user: UserData,
}
impl FromQueryResult for UserFollowRequestData {
fn from_query_result(res: &QueryResult, prefix: &str) -> Result<Self, DbErr> {
Ok(UserFollowRequestData {
user: UserData::from_query_result(res, prefix)?,
follow_request: follow_request::Model::from_query_result(res, prefix)?,
})
}
}
impl ModelPagination for UserFollowRequestData {
fn id(&self) -> &str {
&self.follow_request.id
}
fn time(&self) -> DateTime<Utc> {
self.follow_request.created_at.into()
}
}
pub struct UserResolveOptions {
pub with_avatar_and_banner: bool,
pub with_profile: bool,
}
const PROFILE: &str = "profile.";
const AVATAR: &str = "avatar.";
const BANNER: &str = "banner.";
pub struct UserResolver { pub struct UserResolver {
db: CalckeyModel, db: CalckeyModel,
} }
@ -57,10 +130,23 @@ impl UserResolver {
user_tbl: &MagIden, user_tbl: &MagIden,
UserResolveOptions { UserResolveOptions {
with_avatar_and_banner, with_avatar_and_banner,
with_profile,
}: &UserResolveOptions, }: &UserResolveOptions,
) { ) {
q.add_aliased_columns::<user::Entity>(user_tbl); q.add_aliased_columns::<user::Entity>(user_tbl);
if *with_profile {
let profile_tbl = user_tbl.join_str(PROFILE);
q.add_aliased_columns::<user_profile::Entity>(&profile_tbl);
q.join_columns(
JoinType::LeftJoin,
user::Relation::UserProfile.with_from_alias(user_tbl),
&profile_tbl,
);
}
if *with_avatar_and_banner { if *with_avatar_and_banner {
let avatar_tbl = user_tbl.join_str(AVATAR); let avatar_tbl = user_tbl.join_str(AVATAR);
let banner_tbl = user_tbl.join_str(BANNER); let banner_tbl = user_tbl.join_str(BANNER);
@ -81,7 +167,120 @@ impl UserResolver {
} }
} }
pub async fn get_followers(&self, user_id: &str) -> Vec<UserData> { pub async fn get_follow_requests(
todo!() &self,
options: &UserResolveOptions,
followee: &str,
pagination: &SpanFilter,
prev: &mut Option<SpanFilter>,
next: &mut Option<SpanFilter>,
limit: u64,
) -> Result<Vec<UserData>, CalckeyDbError> {
let user_tbl = user::Entity.base_prefix();
let mut select = follow_request::Entity::find().join_as(
JoinType::InnerJoin,
follow_request::Relation::User1.with_to_alias(&user_tbl),
user_tbl.clone().into_iden(),
);
let query = QuerySelect::query(&mut select);
self.resolve(query, &user_tbl, options);
let followers = select
.filter(follow_request::Column::FolloweeId.eq(followee))
.get_paginated_model::<UserFollowRequestData, _, _>(
&self.db.0,
(
follow_request::Column::CreatedAt,
follow_request::Column::Id,
),
pagination,
prev,
next,
limit,
)
.await?
.into_iter()
.map(|u| u.user)
.collect();
Ok(followers)
}
pub async fn get_followees(
&self,
options: &UserResolveOptions,
follower: &str,
pagination: &SpanFilter,
prev: &mut Option<SpanFilter>,
next: &mut Option<SpanFilter>,
limit: u64,
) -> Result<Vec<UserData>, CalckeyDbError> {
let user_tbl = user::Entity.base_prefix();
let mut select = following::Entity::find().join_as(
JoinType::InnerJoin,
following::Relation::User2.with_to_alias(&user_tbl),
user_tbl.clone().into_iden(),
);
let query = QuerySelect::query(&mut select);
self.resolve(query, &user_tbl, options);
let followers = select
.filter(following::Column::FollowerId.eq(follower))
.get_paginated_model::<UserFollowData, _, _>(
&self.db.0,
(following::Column::CreatedAt, following::Column::Id),
pagination,
prev,
next,
limit,
)
.await?
.into_iter()
.map(|u| u.user)
.collect();
Ok(followers)
}
pub async fn get_followers(
&self,
options: &UserResolveOptions,
followee: &str,
pagination: &SpanFilter,
prev: &mut Option<SpanFilter>,
next: &mut Option<SpanFilter>,
limit: u64,
) -> Result<Vec<UserData>, CalckeyDbError> {
let user_tbl = user::Entity.base_prefix();
let mut select = following::Entity::find().join_as(
JoinType::InnerJoin,
following::Relation::User1.with_to_alias(&user_tbl),
user_tbl.clone().into_iden(),
);
let query = QuerySelect::query(&mut select);
self.resolve(query, &user_tbl, options);
let followers = select
.filter(following::Column::FolloweeId.eq(followee))
.get_paginated_model::<UserFollowData, _, _>(
&self.db.0,
(following::Column::CreatedAt, following::Column::Id),
pagination,
prev,
next,
limit,
)
.await?
.into_iter()
.map(|u| u.user)
.collect();
Ok(followers)
} }
} }

View File

@ -24,7 +24,7 @@ pub enum ErrorKind {
#[derive(Clone, Debug, Serialize, Deserialize, TS)] #[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)] #[ts(export)]
pub struct Empty; pub struct Empty {}
impl From<Empty> for () { impl From<Empty> for () {
fn from(_: Empty) -> Self {} fn from(_: Empty) -> Self {}

View File

@ -88,7 +88,7 @@ pub struct GetUserByAcct;
endpoint = "/users/:id/followers", endpoint = "/users/:id/followers",
method = Method::GET, method = Method::GET,
request = "Empty", request = "Empty",
response = "Vec<PackUserBase>" response = "Vec<PackUserMaybeAll>"
)] )]
pub struct GetFollowersById; pub struct GetFollowersById;
@ -97,7 +97,7 @@ pub struct GetFollowersById;
endpoint = "/users/:id/following", endpoint = "/users/:id/following",
method = Method::GET, method = Method::GET,
request = "Empty", request = "Empty",
response = "Vec<PackUserBase>" response = "Vec<PackUserMaybeAll>"
)] )]
pub struct GetFollowingById; pub struct GetFollowingById;
@ -106,7 +106,7 @@ pub struct GetFollowingById;
endpoint = "/users/@self/followers", endpoint = "/users/@self/followers",
method = Method::GET, method = Method::GET,
request = "Empty", request = "Empty",
response = "Vec<PackUserBase>" response = "Vec<PackUserMaybeAll>"
)] )]
pub struct GetFollowersSelf; pub struct GetFollowersSelf;
@ -115,7 +115,7 @@ pub struct GetFollowersSelf;
endpoint = "/users/@self/following", endpoint = "/users/@self/following",
method = Method::GET, method = Method::GET,
request = "Empty", request = "Empty",
response = "Vec<PackUserBase>" response = "Vec<PackUserMaybeAll>"
)] )]
pub struct GetFollowingSelf; pub struct GetFollowingSelf;
@ -124,6 +124,6 @@ pub struct GetFollowingSelf;
endpoint = "/users/@self/follow-requests", endpoint = "/users/@self/follow-requests",
method = Method::GET, method = Method::GET,
request = "Empty", request = "Empty",
response = "Vec<PackUserBase>" response = "Vec<PackUserMaybeAll>"
)] )]
pub struct GetFollowRequestsSelf; pub struct GetFollowRequestsSelf;

View File

@ -5,8 +5,11 @@ pub mod note;
pub mod timeline; pub mod timeline;
pub mod user; pub mod user;
use crate::endpoints::ErrorKind;
use crate::util_types::U64Range;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::de::Error;
use serde::{Deserialize, Deserializer, Serialize};
use ts_rs::TS; use ts_rs::TS;
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
@ -14,22 +17,22 @@ use ts_rs::TS;
pub struct RangeFilter { pub struct RangeFilter {
pub time_start: DateTime<Utc>, pub time_start: DateTime<Utc>,
pub time_end: DateTime<Utc>, pub time_end: DateTime<Utc>,
pub id_start: DateTime<Utc>, pub id_start: String,
pub id_end: DateTime<Utc>, pub id_end: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)] #[ts(export)]
pub struct StartFilter { pub struct StartFilter {
pub time_start: DateTime<Utc>, pub time_start: DateTime<Utc>,
pub id_start: DateTime<Utc>, pub id_start: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)] #[ts(export)]
pub struct EndFilter { pub struct EndFilter {
pub time_end: DateTime<Utc>, pub time_end: DateTime<Utc>,
pub id_end: DateTime<Utc>, pub id_end: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
@ -46,6 +49,119 @@ pub enum SpanFilter {
None(NoFilter), None(NoFilter),
} }
impl SpanFilter {
pub fn is_desc(&self) -> bool {
!matches!(self, Self::Start(_))
}
pub fn start(&self) -> Option<(DateTime<Utc>, String)> {
match self {
Self::Start(StartFilter {
time_start,
id_start,
})
| Self::Range(RangeFilter {
time_start,
id_start,
..
}) => Some((*time_start, id_start.clone())),
_ => None,
}
}
pub fn end(&self) -> Option<(DateTime<Utc>, String)> {
match self {
Self::End(EndFilter { time_end, id_end })
| Self::Range(RangeFilter {
time_end, id_end, ..
}) => Some((*time_end, id_end.clone())),
_ => None,
}
}
pub fn prev(&self, first_date: DateTime<Utc>, first_id: &str) -> Option<SpanFilter> {
match self {
Self::Start(_) => Some(SpanFilter::End(EndFilter {
time_end: first_date,
id_end: first_id.to_string(),
})),
Self::End(_) => Some(SpanFilter::Start(StartFilter {
time_start: first_date,
id_start: first_id.to_string(),
})),
// TODO: This is jank
// It forgets the bounds of the cursor
Self::Range(_) => Some(SpanFilter::Start(StartFilter {
time_start: first_date,
id_start: first_id.to_string(),
})),
Self::None(_) => None,
}
}
pub fn next(&self, last_date: DateTime<Utc>, last_id: &str) -> Option<SpanFilter> {
match self {
Self::Start(_) => Some(SpanFilter::Start(StartFilter {
time_start: last_date,
id_start: last_id.to_string(),
})),
Self::End(_) | Self::None(_) => Some(SpanFilter::End(EndFilter {
time_end: last_date,
id_end: last_id.to_string(),
})),
Self::Range(RangeFilter { time_start, .. }) if *time_start > last_date => None,
Self::Range(RangeFilter {
time_start,
id_start,
..
}) => Some(SpanFilter::Range(RangeFilter {
time_start: *time_start,
id_start: id_start.clone(),
time_end: last_date,
id_end: last_id.to_string(),
})),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct PaginationShape {
#[serde(default = "pagination_default")]
#[serde(deserialize_with = "deserialize_pagination")]
pub pagination: SpanFilter,
#[serde(default = "limit_defalt")]
#[serde(deserialize_with = "deserialize_limit")]
pub limit: U64Range<10, 100>,
}
fn pagination_default() -> SpanFilter {
SpanFilter::None(NoFilter {})
}
fn limit_defalt() -> U64Range<10, 100> {
U64Range::try_from(20).unwrap()
}
fn deserialize_limit<'de, D>(deserializer: D) -> Result<U64Range<10, 100>, D::Error>
where
D: Deserializer<'de>,
{
let str_val = String::deserialize(deserializer)?;
U64Range::try_from(str_val.parse::<u64>().map_err(Error::custom)?)
.map_err(|_| Error::custom("number out of range"))
}
fn deserialize_pagination<'de, D>(deserializer: D) -> Result<SpanFilter, D::Error>
where
D: Deserializer<'de>,
{
let str_val = String::deserialize(deserializer)?;
serde_urlencoded::from_str::<SpanFilter>(&str_val).map_err(Error::custom)
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)] #[ts(export)]
#[repr(transparent)] #[repr(transparent)]

View File

@ -3,7 +3,9 @@ mod user;
use crate::api_v1::note::handle_note; use crate::api_v1::note::handle_note;
use crate::api_v1::user::{ use crate::api_v1::user::{
handle_user_by_id_many, handle_user_info, handle_user_info_by_acct, handle_user_info_self, handle_follow_requests_self, handle_followers, handle_followers_self, handle_following,
handle_following_self, handle_user_by_id_many, handle_user_info, handle_user_info_by_acct,
handle_user_info_self,
}; };
use crate::service::MagnetarService; use crate::service::MagnetarService;
use crate::web::auth; use crate::web::auth;
@ -19,6 +21,14 @@ pub fn create_api_router(service: Arc<MagnetarService>) -> Router {
.route("/users/by-acct/:id", get(handle_user_info_by_acct)) .route("/users/by-acct/:id", get(handle_user_info_by_acct))
.route("/users/lookup-many", get(handle_user_by_id_many)) .route("/users/lookup-many", get(handle_user_by_id_many))
.route("/users/:id", get(handle_user_info)) .route("/users/:id", get(handle_user_info))
.route(
"/users/@self/follow-requests",
get(handle_follow_requests_self),
)
.route("/users/@self/following", get(handle_following_self))
.route("/users/:id/following", get(handle_following))
.route("/users/@self/followers", get(handle_followers_self))
.route("/users/:id/followers", get(handle_followers))
.route("/notes/:id", get(handle_note)) .route("/notes/:id", get(handle_note))
.layer(from_fn_with_state( .layer(from_fn_with_state(
AuthState::new(service.clone()), AuthState::new(service.clone()),

View File

@ -1,13 +1,11 @@
use crate::model::processing::user::{UserBorrowedData, UserModel}; use crate::model::processing::user::{UserBorrowedData, UserModel, UserShapedData};
use crate::model::processing::PackError;
use crate::model::PackingContext; use crate::model::PackingContext;
use crate::service::MagnetarService; use crate::service::MagnetarService;
use crate::web::auth::{AuthenticatedUser, MaybeUser}; use crate::web::auth::{AuthenticatedUser, MaybeUser};
use crate::web::pagination::Pagination;
use crate::web::{ApiError, ArgumentOutOfRange, ObjectNotFound}; use crate::web::{ApiError, ArgumentOutOfRange, ObjectNotFound};
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::Json; use axum::Json;
use futures::StreamExt;
use futures_util::TryStreamExt;
use itertools::Itertools; use itertools::Itertools;
use magnetar_common::util::lenient_parse_tag_decode; use magnetar_common::util::lenient_parse_tag_decode;
use magnetar_sdk::endpoints::user::{ use magnetar_sdk::endpoints::user::{
@ -29,6 +27,7 @@ pub async fn handle_user_info_self(
&ctx, &ctx,
&UserBorrowedData { &UserBorrowedData {
user: user.as_ref(), user: user.as_ref(),
profile: None,
avatar: None, avatar: None,
banner: None, banner: None,
}, },
@ -56,6 +55,7 @@ pub async fn handle_user_info(
&ctx, &ctx,
&UserBorrowedData { &UserBorrowedData {
user: &user_model, user: &user_model,
profile: None,
avatar: None, avatar: None,
banner: None, banner: None,
}, },
@ -88,6 +88,7 @@ pub async fn handle_user_info_by_acct(
&ctx, &ctx,
&UserBorrowedData { &UserBorrowedData {
user: &user_model, user: &user_model,
profile: None,
avatar: None, avatar: None,
banner: None, banner: None,
}, },
@ -118,20 +119,15 @@ pub async fn handle_user_by_id_many(
.iter() .iter()
.map(|user| UserBorrowedData { .map(|user| UserBorrowedData {
user, user,
profile: None,
avatar: None, avatar: None,
banner: None, banner: None,
}) })
.map(|u| Box::new(u) as Box<dyn UserShapedData>)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let futures = user_data let users_proc = user_model
.iter() .pack_many_base(&ctx, &user_data.iter().map(Box::as_ref).collect::<Vec<_>>())
.map(|user| user_model.base_from_existing(&ctx, user))
.collect::<Vec<_>>();
let users_proc = futures::stream::iter(futures)
.buffered(20)
.err_into::<PackError>()
.try_collect::<Vec<_>>()
.await? .await?
.into_iter() .into_iter()
.map(|u| (u.id.0.id.clone(), u)) .map(|u| (u.id.0.id.clone(), u))
@ -144,3 +140,76 @@ pub async fn handle_user_by_id_many(
Ok(Json(users_ordered)) Ok(Json(users_ordered))
} }
pub async fn handle_following_self(
Query(_): Query<Req<GetFollowingSelf>>,
State(service): State<Arc<MagnetarService>>,
AuthenticatedUser(user): AuthenticatedUser,
mut pagination: Pagination,
) -> Result<(Pagination, Json<Res<GetFollowingSelf>>), ApiError> {
let ctx = PackingContext::new(service, Some(user.clone())).await?;
let user_model = UserModel;
let users = user_model
.get_followees(&ctx, &user.id, &mut pagination)
.await?;
Ok((pagination, Json(users)))
}
pub async fn handle_followers_self(
Query(_): Query<Req<GetFollowersSelf>>,
State(service): State<Arc<MagnetarService>>,
AuthenticatedUser(user): AuthenticatedUser,
mut pagination: Pagination,
) -> Result<(Pagination, Json<Res<GetFollowersSelf>>), ApiError> {
let ctx = PackingContext::new(service, Some(user.clone())).await?;
let user_model = UserModel;
let users = user_model
.get_followers(&ctx, &user.id, &mut pagination)
.await?;
Ok((pagination, Json(users)))
}
pub async fn handle_following(
Query(_): Query<Req<GetFollowingById>>,
Path(id): Path<String>,
State(service): State<Arc<MagnetarService>>,
MaybeUser(user): MaybeUser,
mut pagination: Pagination,
) -> Result<(Pagination, Json<Res<GetFollowingById>>), ApiError> {
let ctx = PackingContext::new(service, user.clone()).await?;
let user_model = UserModel;
let users = user_model.get_followees(&ctx, &id, &mut pagination).await?;
Ok((pagination, Json(users)))
}
pub async fn handle_followers(
Query(_): Query<Req<GetFollowersById>>,
Path(id): Path<String>,
State(service): State<Arc<MagnetarService>>,
MaybeUser(user): MaybeUser,
mut pagination: Pagination,
) -> Result<(Pagination, Json<Res<GetFollowingById>>), ApiError> {
let ctx = PackingContext::new(service, user.clone()).await?;
let user_model = UserModel;
let users = user_model.get_followers(&ctx, &id, &mut pagination).await?;
Ok((pagination, Json(users)))
}
pub async fn handle_follow_requests_self(
Query(_): Query<Req<GetFollowRequestsSelf>>,
State(service): State<Arc<MagnetarService>>,
AuthenticatedUser(user): AuthenticatedUser,
mut pagination: Pagination,
) -> Result<(Pagination, Json<Res<GetFollowingById>>), ApiError> {
let ctx = PackingContext::new(service, Some(user.clone())).await?;
let user_model = UserModel;
let users = user_model
.get_follow_requests(&ctx, &user.id, &mut pagination)
.await?;
Ok((pagination, Json(users)))
}

View File

@ -18,7 +18,7 @@ use magnetar_calckey_model::note_model::data::{
}; };
use magnetar_calckey_model::note_model::{NoteResolveOptions, NoteVisibilityFilterFactory}; use magnetar_calckey_model::note_model::{NoteResolveOptions, NoteVisibilityFilterFactory};
use magnetar_calckey_model::poll::PollResolver; use magnetar_calckey_model::poll::PollResolver;
use magnetar_calckey_model::sea_orm::sea_query::{IntoIden, PgFunc, Query, SimpleExpr}; use magnetar_calckey_model::sea_orm::sea_query::{PgFunc, Query, SimpleExpr};
use magnetar_calckey_model::sea_orm::{ActiveEnum, ColumnTrait, Iden, IntoSimpleExpr}; use magnetar_calckey_model::sea_orm::{ActiveEnum, ColumnTrait, Iden, IntoSimpleExpr};
use magnetar_calckey_model::user_model::UserResolveOptions; use magnetar_calckey_model::user_model::UserResolveOptions;
use magnetar_calckey_model::{ck, CalckeyDbError}; use magnetar_calckey_model::{ck, CalckeyDbError};
@ -581,6 +581,7 @@ impl NoteModel {
limit: None, limit: None,
user_options: UserResolveOptions { user_options: UserResolveOptions {
with_avatar_and_banner: self.with_context, with_avatar_and_banner: self.with_context,
with_profile: false,
}, },
with_reply_target: self.with_context, with_reply_target: self.with_context,
with_renote_target: self.with_context, with_renote_target: self.with_context,
@ -619,6 +620,7 @@ impl NoteModel {
limit: None, limit: None,
user_options: UserResolveOptions { user_options: UserResolveOptions {
with_avatar_and_banner: self.with_context, with_avatar_and_banner: self.with_context,
with_profile: false,
}, },
with_reply_target: self.with_context, with_reply_target: self.with_context,
with_renote_target: self.with_context, with_renote_target: self.with_context,

View File

@ -4,10 +4,14 @@ use crate::model::processing::emoji::EmojiModel;
use crate::model::processing::note::NoteModel; use crate::model::processing::note::NoteModel;
use crate::model::processing::{get_mm_token_emoji, PackError, PackResult}; use crate::model::processing::{get_mm_token_emoji, PackError, PackResult};
use crate::model::{PackType, PackingContext}; use crate::model::{PackType, PackingContext};
use crate::web::pagination::Pagination;
use crate::web::{AccessForbidden, ApiError};
use either::Either; use either::Either;
use futures_util::future::OptionFuture; use futures_util::future::OptionFuture;
use futures_util::{StreamExt, TryStreamExt};
use magnetar_calckey_model::ck; use magnetar_calckey_model::ck;
use magnetar_calckey_model::user_model::UserData; use magnetar_calckey_model::ck::sea_orm_active_enums::UserProfileFfvisibilityEnum;
use magnetar_calckey_model::user_model::{UserData, UserResolveOptions};
use magnetar_sdk::endpoints::user::{UserByIdReq, UserSelfReq}; use magnetar_sdk::endpoints::user::{UserByIdReq, UserSelfReq};
use magnetar_sdk::mmm::Token; use magnetar_sdk::mmm::Token;
use magnetar_sdk::types::drive::PackDriveFileBase; use magnetar_sdk::types::drive::PackDriveFileBase;
@ -27,12 +31,14 @@ use url::Url;
pub trait UserShapedData<'a>: Send + Sync { pub trait UserShapedData<'a>: Send + Sync {
fn user(&self) -> &'a ck::user::Model; fn user(&self) -> &'a ck::user::Model;
fn profile(&self) -> Option<&'a ck::user_profile::Model>;
fn avatar(&self) -> Option<&'a ck::drive_file::Model>; fn avatar(&self) -> Option<&'a ck::drive_file::Model>;
fn banner(&self) -> Option<&'a ck::drive_file::Model>; fn banner(&self) -> Option<&'a ck::drive_file::Model>;
} }
pub struct UserBorrowedData<'a> { pub struct UserBorrowedData<'a> {
pub user: &'a ck::user::Model, pub user: &'a ck::user::Model,
pub profile: Option<&'a ck::user_profile::Model>,
pub avatar: Option<&'a ck::drive_file::Model>, pub avatar: Option<&'a ck::drive_file::Model>,
pub banner: Option<&'a ck::drive_file::Model>, pub banner: Option<&'a ck::drive_file::Model>,
} }
@ -42,6 +48,10 @@ impl<'a> UserShapedData<'a> for &'a UserData {
&self.user &self.user
} }
fn profile(&self) -> Option<&'a ck::user_profile::Model> {
self.profile.as_ref()
}
fn avatar(&self) -> Option<&'a ck::drive_file::Model> { fn avatar(&self) -> Option<&'a ck::drive_file::Model> {
self.avatar.as_ref() self.avatar.as_ref()
} }
@ -56,6 +66,10 @@ impl<'a> UserShapedData<'a> for UserBorrowedData<'a> {
self.user self.user
} }
fn profile(&self) -> Option<&'a ck::user_profile::Model> {
self.profile
}
fn avatar(&self) -> Option<&'a ck::drive_file::Model> { fn avatar(&self) -> Option<&'a ck::drive_file::Model> {
self.avatar self.avatar
} }
@ -179,6 +193,18 @@ impl UserModel {
.ok_or_else(|| PackError::DataError("Missing user profile".to_string())) .ok_or_else(|| PackError::DataError("Missing user profile".to_string()))
} }
pub async fn get_profile_by_id(
&self,
ctx: &PackingContext,
id: &str,
) -> PackResult<ck::user_profile::Model> {
ctx.service
.db
.get_user_profile_by_id(id)
.await?
.ok_or_else(|| PackError::DataError("Missing user profile".to_string()))
}
pub async fn profile_from_base<'a>( pub async fn profile_from_base<'a>(
&self, &self,
ctx: &PackingContext, ctx: &PackingContext,
@ -295,14 +321,15 @@ impl UserModel {
) -> PackResult<PackUserSelfMaybeAll> { ) -> PackResult<PackUserSelfMaybeAll> {
let user = user_data.user(); let user = user_data.user();
let should_fetch_profile = let should_fetch_profile = user_data.profile().is_none()
req.profile.unwrap_or_default() || req.secrets.unwrap_or_default(); && (req.profile.unwrap_or_default() || req.secrets.unwrap_or_default());
let profile_raw_promise = let profile_raw_promise =
OptionFuture::from(should_fetch_profile.then(|| self.get_profile(ctx, user))); OptionFuture::from(should_fetch_profile.then(|| self.get_profile(ctx, user)));
let (base_res, profile_res) = let (base_res, profile_res) =
join!(self.base_from_existing(ctx, user_data), profile_raw_promise); join!(self.base_from_existing(ctx, user_data), profile_raw_promise);
let mut base = base_res?; let mut base = base_res?;
let profile_raw = profile_res.transpose()?; let profile_raw = profile_res.transpose()?;
let profile_ref = user_data.profile().or(profile_raw.as_ref());
let detail = req let detail = req
.detail .detail
@ -313,7 +340,7 @@ impl UserModel {
self.profile_from_base( self.profile_from_base(
ctx, ctx,
user_data, user_data,
profile_raw.as_ref().unwrap(), profile_ref.unwrap(),
None, None,
&mut base.user.0.emojis, &mut base.user.0.emojis,
) )
@ -332,7 +359,7 @@ impl UserModel {
let secrets = OptionFuture::from( let secrets = OptionFuture::from(
req.secrets req.secrets
.unwrap_or_default() .unwrap_or_default()
.then(|| self.secrets_from_base(ctx, user, profile_raw.as_ref().unwrap())), .then(|| self.secrets_from_base(ctx, user, profile_ref.unwrap())),
); );
let (profile_res, pins_res, secrets_res) = join!(profile, pins, secrets); let (profile_res, pins_res, secrets_res) = join!(profile, pins, secrets);
@ -486,4 +513,205 @@ impl UserModel {
auth: Optional(auth), auth: Optional(auth),
}) })
} }
pub async fn pack_many_base(
&self,
ctx: &PackingContext,
users: &[&dyn UserShapedData<'_>],
) -> PackResult<Vec<PackUserBase>> {
let futures = users
.iter()
.map(|&user| self.base_from_existing(ctx, user))
.collect::<Vec<_>>();
let users_proc = futures::stream::iter(futures)
.buffered(20)
.err_into::<PackError>()
.try_collect::<Vec<_>>()
.await?;
Ok(users_proc)
}
pub async fn pack_many_maybe_full(
&self,
ctx: &PackingContext,
users: &[&dyn UserShapedData<'_>],
req: &UserByIdReq,
) -> PackResult<Vec<PackUserMaybeAll>> {
let futures = users
.iter()
.map(|&user| self.foreign_full_from_base(ctx, user, req))
.collect::<Vec<_>>();
let users_proc = futures::stream::iter(futures)
.buffered(20)
.err_into::<PackError>()
.try_collect::<Vec<_>>()
.await?;
Ok(users_proc)
}
pub async fn follower_visibility_check(
&self,
ctx: &PackingContext,
id: &str,
) -> Result<(), ApiError> {
if !ctx.is_id_self(id) {
let profile = self.get_profile_by_id(ctx, id).await?;
match (&ctx.self_user, &profile.ff_visibility) {
(_, UserProfileFfvisibilityEnum::Public) => {}
(Some(self_user), UserProfileFfvisibilityEnum::Followers)
if ctx
.is_relationship_between(
Either::Right(self_user),
Either::Left(id),
UserRelationship::Follow,
)
.await? => {}
_ => {
Err(AccessForbidden(
"Follower information not visible".to_string(),
))?;
}
}
}
Ok(())
}
pub async fn get_followers(
&self,
ctx: &PackingContext,
id: &str,
pagination: &mut Pagination,
) -> Result<Vec<PackUserMaybeAll>, ApiError> {
self.follower_visibility_check(ctx, id).await?;
let users = ctx
.service
.db
.get_user_resolver()
.get_followers(
&UserResolveOptions {
with_avatar_and_banner: true,
with_profile: true,
},
id,
&pagination.current,
&mut pagination.prev,
&mut pagination.next,
pagination.limit.into(),
)
.await?;
let users_ref = users
.iter()
.map(|u| Box::new(u) as Box<dyn UserShapedData<'_>>)
.collect::<Vec<_>>();
Ok(self
.pack_many_maybe_full(
ctx,
&users_ref.iter().map(Box::as_ref).collect::<Vec<_>>(),
&UserByIdReq {
profile: Some(true),
auth: None,
detail: None,
pins: None,
relation: None,
},
)
.await?)
}
pub async fn get_followees(
&self,
ctx: &PackingContext,
id: &str,
pagination: &mut Pagination,
) -> Result<Vec<PackUserMaybeAll>, ApiError> {
self.follower_visibility_check(ctx, id).await?;
let users = ctx
.service
.db
.get_user_resolver()
.get_followees(
&UserResolveOptions {
with_avatar_and_banner: true,
with_profile: true,
},
id,
&pagination.current,
&mut pagination.prev,
&mut pagination.next,
pagination.limit.into(),
)
.await?;
let users_ref = users
.iter()
.map(|u| Box::new(u) as Box<dyn UserShapedData<'_>>)
.collect::<Vec<_>>();
Ok(self
.pack_many_maybe_full(
ctx,
&users_ref.iter().map(Box::as_ref).collect::<Vec<_>>(),
&UserByIdReq {
profile: Some(true),
auth: None,
detail: None,
pins: None,
relation: None,
},
)
.await?)
}
pub async fn get_follow_requests(
&self,
ctx: &PackingContext,
id: &str,
pagination: &mut Pagination,
) -> Result<Vec<PackUserMaybeAll>, ApiError> {
let users = ctx
.service
.db
.get_user_resolver()
.get_follow_requests(
&UserResolveOptions {
with_avatar_and_banner: true,
with_profile: true,
},
id,
&pagination.current,
&mut pagination.prev,
&mut pagination.next,
pagination.limit.into(),
)
.await?;
let users_ref = users
.iter()
.map(|u| Box::new(u) as Box<dyn UserShapedData<'_>>)
.collect::<Vec<_>>();
Ok(self
.pack_many_maybe_full(
ctx,
&users_ref.iter().map(Box::as_ref).collect::<Vec<_>>(),
&UserByIdReq {
profile: Some(true),
auth: None,
detail: None,
pins: None,
relation: None,
},
)
.await?)
}
} }

View File

@ -1,118 +1,48 @@
use cached::{Cached, TimedCache}; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use std::future::Future; use serde_json::Value;
use std::hash::Hash; pub fn serialize_as_urlenc(value: &Value) -> String {
use std::sync::Arc; match value {
use tokio::sync::Mutex; Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => utf8_percent_encode(&n.to_string(), NON_ALPHANUMERIC).to_string(),
Value::String(s) => utf8_percent_encode(s, NON_ALPHANUMERIC).to_string(),
Value::Array(arr) => {
let pairs = arr
.iter()
.map(|k| {
utf8_percent_encode(
&if matches!(k, Value::Array(_) | Value::Object(_)) {
utf8_percent_encode(&serialize_as_urlenc(k), NON_ALPHANUMERIC)
.to_string()
} else {
serialize_as_urlenc(k)
},
NON_ALPHANUMERIC,
)
.to_string()
})
.collect::<Vec<_>>();
#[derive(Debug, Clone)] pairs.join("&")
pub struct SingleTimedAsyncCache<V: Clone + Send + 'static> {
inner: Arc<Mutex<TimedCache<(), V>>>,
}
impl<V: Clone + Send + 'static> SingleTimedAsyncCache<V> {
pub fn with_lifespan(lifespan: u64) -> Self {
Self {
inner: Arc::new(Mutex::new(TimedCache::with_lifespan(lifespan))),
} }
} Value::Object(entries) => {
let pairs = entries
.iter()
.map(|(k, v)| {
format!(
"{}={}",
utf8_percent_encode(&k, NON_ALPHANUMERIC).to_string(),
if matches!(v, Value::Array(_) | Value::Object(_)) {
utf8_percent_encode(&serialize_as_urlenc(v), NON_ALPHANUMERIC)
.to_string()
} else {
serialize_as_urlenc(v)
}
)
})
.collect::<Vec<_>>();
pub async fn put(&self, val: V) -> Option<V> { pairs.join("&")
let mut cache = self.inner.lock().await;
cache.cache_set((), val)
}
pub async fn get_opt(&self) -> Option<V> {
let mut cache = self.inner.lock().await;
cache.cache_get(&()).cloned()
}
pub async fn get<E, FT, F>(&self, f: F) -> Result<V, E>
where
FT: Future<Output = Result<V, E>>,
F: FnOnce() -> FT,
{
let mut cache = self.inner.lock().await;
if let Some(val) = cache.cache_get(&()) {
return Ok(val.clone());
} }
let val = f().await?;
cache.cache_set((), val.clone());
Ok(val)
}
pub async fn get_sync<E, F>(&self, f: F) -> Result<V, E>
where
F: FnOnce() -> Result<V, E>,
{
let mut cache = self.inner.lock().await;
if let Some(val) = cache.cache_get(&()) {
return Ok(val.clone());
}
let val = f()?;
cache.cache_set((), val.clone());
Ok(val)
}
}
#[derive(Debug, Clone)]
pub struct TimedAsyncCache<K: Clone + Send, V: Clone + Send + 'static> {
inner: Arc<Mutex<TimedCache<K, V>>>,
}
impl<K: Clone + Send + Eq + Hash, V: Clone + Send + 'static> TimedAsyncCache<K, V> {
pub fn with_lifespan(lifespan: u64) -> Self {
Self {
inner: Arc::new(Mutex::new(TimedCache::with_lifespan(lifespan))),
}
}
pub async fn put(&self, key: K, val: V) -> Option<V> {
let mut cache = self.inner.lock().await;
cache.cache_set(key, val)
}
pub async fn remove(&self, key: &K) -> Option<V> {
let mut cache = self.inner.lock().await;
cache.cache_remove(key)
}
pub async fn get_opt(&self, key: &K) -> Option<V> {
let mut cache = self.inner.lock().await;
cache.cache_get(key).cloned()
}
pub async fn get<E, FT, F>(&self, key: K, f: F) -> Result<V, E>
where
FT: Future<Output = Result<V, E>>,
F: FnOnce() -> FT,
{
let mut cache = self.inner.lock().await;
if let Some(val) = cache.cache_get(&key) {
return Ok(val.clone());
}
let val = f().await?;
cache.cache_set(key, val.clone());
Ok(val)
}
pub async fn get_sync<E, F>(&self, key: K, f: F) -> Result<V, E>
where
F: FnOnce() -> Result<V, E>,
{
let mut cache = self.inner.lock().await;
if let Some(val) = cache.cache_get(&key) {
return Ok(val.clone());
}
let val = f()?;
cache.cache_set(key, val.clone());
Ok(val)
} }
} }

View File

@ -8,6 +8,7 @@ use serde::Serialize;
use serde_json::json; use serde_json::json;
pub mod auth; pub mod auth;
pub mod pagination;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[repr(transparent)] #[repr(transparent)]
@ -42,6 +43,15 @@ pub struct ApiError {
pub message: String, pub message: String,
} }
#[derive(Debug)]
pub struct AccessForbidden(pub String);
impl From<&AccessForbidden> for &str {
fn from(_: &AccessForbidden) -> &'static str {
"AccessForbidden"
}
}
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
( (
@ -56,6 +66,20 @@ impl IntoResponse for ApiError {
} }
} }
impl From<AccessForbidden> for ApiError {
fn from(err: AccessForbidden) -> Self {
Self {
status: StatusCode::FORBIDDEN,
code: err.error_code(),
message: if cfg!(debug_assertions) {
format!("Forbidden: {}", err.0)
} else {
"Forbidden".to_string()
},
}
}
}
impl From<FediverseTagParseError> for ApiError { impl From<FediverseTagParseError> for ApiError {
fn from(err: FediverseTagParseError) -> Self { fn from(err: FediverseTagParseError) -> Self {
Self { Self {

164
src/web/pagination.rs Normal file
View File

@ -0,0 +1,164 @@
use crate::service::MagnetarService;
use crate::util::serialize_as_urlenc;
use crate::web::{ApiError, IntoErrorCode};
use axum::extract::rejection::QueryRejection;
use axum::extract::{FromRequestParts, OriginalUri, Query};
use axum::http::header::InvalidHeaderValue;
use axum::http::request::Parts;
use axum::http::{HeaderValue, StatusCode, Uri};
use axum::response::{IntoResponse, IntoResponseParts, Response, ResponseParts};
use axum::RequestPartsExt;
use either::Either;
use itertools::Itertools;
use magnetar_calckey_model::sea_orm::prelude::async_trait::async_trait;
use magnetar_core::web_model::rel::{RelNext, RelPrev};
use magnetar_sdk::types::{NoFilter, PaginationShape, SpanFilter};
use magnetar_sdk::util_types::U64Range;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use strum::IntoStaticStr;
use thiserror::Error;
use tracing::error;
#[derive(Debug)]
pub struct Pagination {
base_uri: Uri,
pub current: SpanFilter,
pub prev: Option<SpanFilter>,
pub next: Option<SpanFilter>,
pub limit: U64Range<10, 100>,
query_rest: HashMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PaginationQuery {
#[serde(flatten)]
pagination: PaginationShape,
#[serde(flatten)]
query_rest: HashMap<String, String>,
}
#[derive(Debug, Error, IntoStaticStr)]
pub enum PaginationBuilderError {
#[error("Query rejection: {0}")]
QueryRejection(#[from] QueryRejection),
#[error("HTTP error: {0}")]
HttpError(#[from] axum::http::Error),
#[error("Value of out of range error")]
OutOfRange,
#[error("Invalid header value")]
InvalidHeaderValue(#[from] InvalidHeaderValue),
#[error("Query string serialization error: {0}")]
SerializationErrorQuery(#[from] serde_urlencoded::ser::Error),
#[error("Query string serialization error: {0}")]
SerializationErrorJson(#[from] serde_json::Error),
}
impl From<PaginationBuilderError> for ApiError {
fn from(err: PaginationBuilderError) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: err.error_code(),
message: if cfg!(debug_assertions) {
format!("Pagination builder error: {}", err)
} else {
"Pagination builder error".to_string()
},
}
}
}
impl IntoResponse for PaginationBuilderError {
fn into_response(self) -> Response {
ApiError::from(self).into_response()
}
}
#[async_trait]
impl FromRequestParts<Arc<MagnetarService>> for Pagination {
type Rejection = PaginationBuilderError;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<MagnetarService>,
) -> Result<Self, Self::Rejection> {
let OriginalUri(original_uri) = parts.extract::<OriginalUri>().await.unwrap();
let base_uri = Uri::builder()
.scheme(state.config.networking.protocol.as_ref())
.authority(state.config.networking.host.as_str())
.path_and_query(original_uri.path())
.build()?;
let Query(PaginationQuery {
pagination,
query_rest,
}) = parts.extract::<Query<PaginationQuery>>().await?;
Ok(Pagination {
base_uri,
prev: None,
next: None,
current: pagination.pagination,
limit: pagination.limit,
query_rest,
})
}
}
impl IntoResponseParts for Pagination {
type Error = PaginationBuilderError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
let wrap = |uri: Uri, query: String, rel: Either<RelPrev, RelNext>| {
format!(
"<{}?{}>; rel=\"{}\"",
uri.to_string(),
query,
rel.as_ref()
.map_either(RelPrev::as_ref, RelNext::as_ref)
.into_inner()
)
};
let url_prev = if let Some(prev) = self.prev {
let query_prev = serialize_as_urlenc(&serde_json::to_value(PaginationQuery {
pagination: PaginationShape {
pagination: prev,
limit: self.limit,
},
query_rest: self.query_rest.clone(),
})?);
Some(wrap(
self.base_uri.clone(),
query_prev,
Either::Left(RelPrev),
))
} else {
None
};
let url_next = if let Some(next) = self.next {
let query_next = serialize_as_urlenc(&serde_json::to_value(PaginationQuery {
pagination: PaginationShape {
pagination: next,
limit: self.limit,
},
query_rest: self.query_rest,
})?);
Some(wrap(self.base_uri, query_next, Either::Right(RelNext)))
} else {
None
};
let parts = [url_prev, url_next].iter().flatten().join(", ");
if !parts.is_empty() {
res.headers_mut()
.insert(axum::http::header::LINK, HeaderValue::from_str(&parts)?);
}
Ok(res)
}
}