magnetar/src/model/processing/user.rs

727 lines
23 KiB
Rust

use crate::model::data::user::{UserBaseSource, UserProfileExtSource, UserRelationExtSource};
use crate::model::processing::drive::DriveModel;
use crate::model::processing::emoji::EmojiModel;
use crate::model::processing::note::NoteModel;
use crate::model::processing::{get_mm_token_emoji, PackError, PackResult};
use crate::model::{PackType, PackingContext};
use crate::web::pagination::Pagination;
use crate::web::{AccessForbidden, ApiError};
use either::Either;
use futures_util::future::OptionFuture;
use futures_util::{StreamExt, TryStreamExt};
use magnetar_calckey_model::ck;
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::mmm::Token;
use magnetar_sdk::types::drive::PackDriveFileBase;
use magnetar_sdk::types::emoji::EmojiContext;
use magnetar_sdk::types::instance::InstanceTicker;
use magnetar_sdk::types::user::{
MovedTo, PackSecurityKeyBase, PackUserBase, PackUserMaybeAll, PackUserSelfMaybeAll,
ProfileField, SecurityKeyBase, UserAuthOverviewExt, UserBase, UserDetailExt, UserProfileExt,
UserProfilePinsEx, UserRelationExt, UserRelationship, UserSecretsExt, UserSelfExt,
};
use magnetar_sdk::types::{Id, MmXml};
use magnetar_sdk::{mmm, Optional, Packed, Required};
use serde::{Deserialize, Serialize};
use tokio::{join, try_join};
use tracing::warn;
use url::Url;
pub trait UserShapedData<'a>: Send + Sync {
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 banner(&self) -> Option<&'a ck::drive_file::Model>;
}
pub struct UserBorrowedData<'a> {
pub user: &'a ck::user::Model,
pub profile: Option<&'a ck::user_profile::Model>,
pub avatar: Option<&'a ck::drive_file::Model>,
pub banner: Option<&'a ck::drive_file::Model>,
}
impl<'a> UserShapedData<'a> for &'a UserData {
fn user(&self) -> &'a ck::user::Model {
&self.user
}
fn profile(&self) -> Option<&'a ck::user_profile::Model> {
self.profile.as_ref()
}
fn avatar(&self) -> Option<&'a ck::drive_file::Model> {
self.avatar.as_ref()
}
fn banner(&self) -> Option<&'a ck::drive_file::Model> {
self.banner.as_ref()
}
}
impl<'a> UserShapedData<'a> for UserBorrowedData<'a> {
fn user(&self) -> &'a ck::user::Model {
self.user
}
fn profile(&self) -> Option<&'a ck::user_profile::Model> {
self.profile
}
fn avatar(&self) -> Option<&'a ck::drive_file::Model> {
self.avatar
}
fn banner(&self) -> Option<&'a ck::drive_file::Model> {
self.banner
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileFieldRaw<'a> {
name: &'a str,
value: &'a str,
}
pub struct UserModel;
impl UserModel {
pub fn tokenize_username(&self, user: &ck::user::Model) -> Token {
mmm::Context::default().parse_ui(user.name.as_deref().unwrap_or(&user.username))
}
pub fn tokenize_description(&self, user: &ck::user_profile::Model) -> Option<Token> {
user.description
.as_deref()
.map(|d| mmm::Context::default().parse_inline(d))
}
pub fn get_effective_avatar_url(
&self,
ctx: &PackingContext,
user: &ck::user::Model,
avatar: Option<&PackDriveFileBase>,
) -> PackResult<Url> {
Ok(avatar
.and_then(
|PackDriveFileBase {
file: Required(base),
..
}| base.thumbnail_url.as_deref(),
)
.map(Url::parse)
.and_then(|r| {
if let Err(e) = r {
warn!("Failed to parse avatar URL: {e}");
}
r.ok().map(Ok)
})
.unwrap_or_else(|| {
Url::parse(&format!(
"{}://{}/identicon/{}",
ctx.service.config.networking.protocol,
ctx.service.config.networking.host,
user.id
))
})?)
}
pub async fn base_from_existing<'a>(
&self,
ctx: &PackingContext,
user_data: &dyn UserShapedData<'a>,
) -> PackResult<PackUserBase> {
let user = user_data.user();
let drive_file_pack = DriveModel;
let avatar = match (user_data.avatar(), &user.avatar_id) {
(Some(avatar_file), _) => Some(drive_file_pack.pack_existing(ctx, avatar_file)),
(None, Some(av_id)) => drive_file_pack.get_cached_base(ctx, av_id).await?,
_ => None,
};
let avatar_url = &self.get_effective_avatar_url(ctx, user, avatar.as_ref())?;
let username_mm = self.tokenize_username(user);
let emoji_model = EmojiModel;
let shortcodes = emoji_model.deduplicate_emoji(ctx, get_mm_token_emoji(&username_mm));
let emojis = emoji_model
.fetch_many_emojis(ctx, &shortcodes, user.host.as_deref())
.await?;
let instance = ctx
.service
.remote_instance_cache
.get(
user.host
.as_deref()
.unwrap_or(&ctx.service.config.networking.host),
)
.await?
.map(|i| InstanceTicker::extract(ctx, i.as_ref()));
let emoji_context = EmojiContext(emojis);
let base = UserBase::extract(
ctx,
UserBaseSource {
user,
username_mm: mmm::to_xml_string(&username_mm).map(MmXml).as_ref().ok(),
avatar_url,
avatar: avatar.as_ref(),
emoji_context: &emoji_context,
instance: instance.as_ref(),
},
);
Ok(PackUserBase::pack_from((
Required(Id::from(&user.id)),
Required(base),
)))
}
async fn get_profile(
&self,
ctx: &PackingContext,
user: &ck::user::Model,
) -> PackResult<ck::user_profile::Model> {
ctx.service
.db
.get_user_profile_by_id(&user.id)
.await?
.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>(
&self,
ctx: &PackingContext,
user_data: &dyn UserShapedData<'a>,
profile: &ck::user_profile::Model,
relation: Option<&UserRelationExt>,
emoji_out: &mut EmojiContext,
) -> PackResult<UserProfileExt> {
let user = user_data.user();
let drive_file_pack = DriveModel;
let banner = match (user_data.banner(), &user.banner_id) {
(Some(banner_file), _) => Some(drive_file_pack.pack_existing(ctx, banner_file)),
(None, Some(av_id)) => drive_file_pack.get_cached_base(ctx, av_id).await?,
_ => None,
};
let moved = match &user.moved_to_uri {
Some(uri) => {
let moved = ctx.service.db.get_user_by_uri(uri).await?;
moved.and_then(|m| {
Some(MovedTo {
moved_to_uri: m.uri?,
username: m.username,
host: m
.host
.unwrap_or_else(|| ctx.service.config.networking.host.to_string()),
})
})
}
None => None,
};
let description_mm = self.tokenize_description(profile);
let fields = Vec::<ProfileFieldRaw>::deserialize(&profile.fields)?;
let parser = mmm::Context::new(2);
let profile_fields = fields
.into_iter()
.map(|f| {
let tok = parser.parse_profile_fields(f.value);
ProfileField {
name: f.name.to_string(),
value: f.value.to_string(),
value_mm: mmm::to_xml_string(&tok).map(MmXml).ok(),
verified_at: None,
}
})
.collect::<Vec<_>>();
if let Some(desc_mm) = &description_mm {
let emoji_model = EmojiModel;
let shortcodes = emoji_model.deduplicate_emoji(ctx, get_mm_token_emoji(desc_mm));
let emojis = emoji_model
.fetch_many_emojis(ctx, &shortcodes, user.host.as_deref())
.await?;
emoji_out.extend_from(&emojis);
}
Ok(UserProfileExt::extract(
ctx,
UserProfileExtSource {
user,
profile,
profile_fields: &profile_fields,
banner: banner.as_ref(),
description_mm: description_mm
.and_then(|desc_mm| mmm::to_xml_string(&desc_mm).map(MmXml).ok())
.as_ref(),
relation,
moved_to: moved.as_ref(),
},
))
}
pub async fn secrets_from_base(
&self,
ctx: &PackingContext,
user: &ck::user::Model,
profile: &ck::user_profile::Model,
) -> PackResult<UserSecretsExt> {
let secrets = ctx
.service
.db
.get_user_security_keys_by_id(&user.id)
.await?
.into_iter()
.map(|k| {
PackSecurityKeyBase::pack_from((
Required(Id::from(&k.id)),
Required(SecurityKeyBase {
name: k.name,
last_used_at: Some(k.last_used.into()),
}),
))
})
.collect::<Vec<_>>();
Ok(UserSecretsExt::extract(ctx, (profile, &secrets)))
}
pub async fn self_full_from_base<'a>(
&self,
ctx: &PackingContext,
user_data: &impl UserShapedData<'a>,
req: &UserSelfReq,
) -> PackResult<PackUserSelfMaybeAll> {
let user = user_data.user();
let should_fetch_profile = user_data.profile().is_none()
&& (req.profile.unwrap_or_default()
|| req.secrets.unwrap_or_default()
|| req.self_detail.unwrap_or_default());
let profile_raw_promise =
OptionFuture::from(should_fetch_profile.then(|| self.get_profile(ctx, user)));
let (base_res, profile_res) =
join!(self.base_from_existing(ctx, user_data), profile_raw_promise);
let mut base = base_res?;
let profile_raw = profile_res.transpose()?;
let profile_ref = user_data.profile().or(profile_raw.as_ref());
let detail = req
.detail
.unwrap_or_default()
.then(|| UserDetailExt::extract(ctx, user));
let profile = OptionFuture::from(req.profile.unwrap_or_default().then(|| {
self.profile_from_base(
ctx,
user_data,
profile_ref.unwrap(),
None,
&mut base.user.0.emojis,
)
}));
let note_model = NoteModel {
with_context: true,
attachments: true,
};
let pins = OptionFuture::from(
req.pins
.unwrap_or_default()
.then(|| note_model.fetch_pins(ctx, user)),
);
let secrets = OptionFuture::from(
req.secrets
.unwrap_or_default()
.then(|| self.secrets_from_base(ctx, user, profile_ref.unwrap())),
);
let (profile_res, pins_res, secrets_res) = join!(profile, pins, secrets);
let profile_resolved = profile_res.transpose()?;
let pins_resolved = pins_res
.transpose()?
.map(|notes| UserProfilePinsEx::extract(ctx, &notes));
let secrets_resolved = secrets_res.transpose()?;
let self_info = req
.self_detail
.unwrap_or_default()
.then(|| UserSelfExt::extract(ctx, (&user, profile_ref.unwrap())));
Ok(PackUserSelfMaybeAll {
id: base.id,
user: base.user,
profile: Optional(profile_resolved),
pins: Optional(pins_resolved),
detail: Optional(detail),
secrets: Optional(secrets_resolved),
self_detail: Optional(self_info),
})
}
pub async fn relations_from_base(
&self,
ctx: &PackingContext,
user: &ck::user::Model,
) -> PackResult<UserRelationExt> {
let Some(me_user) = ctx.self_user.as_deref() else {
return Ok(UserRelationExt {
follows_you: false,
you_follow: false,
they_request_follow: false,
you_request_follow: false,
blocks_you: false,
you_block: false,
mute: false,
mute_renotes: false,
});
};
let me = Either::Right(me_user);
let them = Either::Right(user);
let (
follow_in,
follow_out,
follow_request_in,
follow_request_out,
block_in,
block_out,
mute,
renote_mute,
) = try_join!(
ctx.is_relationship_between(them, me, UserRelationship::Follow),
ctx.is_relationship_between(me, them, UserRelationship::Follow),
ctx.is_relationship_between(them, me, UserRelationship::FollowRequest),
ctx.is_relationship_between(me, them, UserRelationship::FollowRequest),
ctx.is_relationship_between(them, me, UserRelationship::Block),
ctx.is_relationship_between(me, them, UserRelationship::Block),
ctx.is_relationship_between(me, them, UserRelationship::Mute),
ctx.is_relationship_between(me, them, UserRelationship::RenoteMute)
)?;
Ok(UserRelationExt::extract(
ctx,
UserRelationExtSource {
follow_in,
follow_out,
follow_request_in,
follow_request_out,
block_in,
block_out,
mute,
renote_mute,
},
))
}
pub fn auth_overview_from_profile(
&self,
ctx: &PackingContext,
profile: &ck::user_profile::Model,
) -> PackResult<UserAuthOverviewExt> {
Ok(UserAuthOverviewExt::extract(ctx, profile))
}
pub async fn foreign_full_from_base(
&self,
ctx: &PackingContext,
user_data: &dyn UserShapedData<'_>,
req: &UserByIdReq,
) -> PackResult<PackUserMaybeAll> {
let user = user_data.user();
let should_fetch_profile = req.profile.unwrap_or_default() || req.auth.unwrap_or_default();
let profile_raw_promise =
OptionFuture::from(should_fetch_profile.then(|| self.get_profile(ctx, user)));
let (base, profile) = join!(self.base_from_existing(ctx, user_data), profile_raw_promise);
let mut base = base?;
let profile_raw = profile.transpose()?;
let detail = req
.detail
.unwrap_or_default()
.then(|| UserDetailExt::extract(ctx, user));
let profile = OptionFuture::from(req.profile.unwrap_or_default().then(|| {
self.profile_from_base(
ctx,
user_data,
profile_raw.as_ref().unwrap(),
None,
&mut base.user.0.emojis,
)
}));
let note_model = NoteModel {
with_context: true,
attachments: true,
};
let pins = OptionFuture::from(
req.pins
.unwrap_or_default()
.then(|| note_model.fetch_pins(ctx, user)),
);
let relations = OptionFuture::from(
req.relation
.unwrap_or_default()
.then(|| self.relations_from_base(ctx, user)),
);
let auth = req
.auth
.unwrap_or_default()
.then(|| self.auth_overview_from_profile(ctx, profile_raw.as_ref().unwrap()))
.transpose()?;
let (profile_res, pins_res, relations_res) = join!(profile, pins, relations);
let profile_resolved = profile_res.transpose()?;
let pins_resolved = pins_res
.transpose()?
.map(|notes| UserProfilePinsEx::extract(ctx, &notes));
let relations_resolved = relations_res.transpose()?;
Ok(PackUserMaybeAll {
id: base.id,
user: base.user,
profile: Optional(profile_resolved),
pins: Optional(pins_resolved),
detail: Optional(detail),
relation: Optional(relations_resolved),
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) {
return Ok(());
}
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: true,
with_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: true,
with_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: true,
with_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?)
}
}