#![recursion_limit = "256"] // oh nooo leptos... mod auth; mod app; mod components; mod page; mod config; mod groups; mod actors; mod activities; mod objects; mod timeline; use apb::{Activity, Object, Actor, Base}; pub use app::App; pub use config::Config; pub use auth::Auth; pub mod prelude; pub const URL_BASE: &str = "https://dev.upub.social"; pub const URL_PREFIX: &str = "/web"; pub const URL_SENSITIVE: &str = "https://cdn.alemi.dev/social/nsfw.png"; pub const FALLBACK_IMAGE_URL: &str = "https://cdn.alemi.dev/social/gradient.png"; pub const NAME: &str = "μ"; pub const DEFAULT_COLOR: &str = "#BF616A"; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); use std::{ops::Deref, sync::Arc}; use uriproxy::UriClass; pub type Doc = Arc<serde_json::Value>; pub mod cache { use super::DashmapCache; lazy_static::lazy_static! { pub static ref OBJECTS: DashmapCache<super::Doc> = DashmapCache::default(); pub static ref WEBFINGER: DashmapCache<String> = DashmapCache::default(); pub static ref TIMELINES: DashmapCache<(Option<String>, Vec<String>)> = DashmapCache::default(); } } #[derive(Debug)] pub enum LookupStatus<T> { Resolving, // TODO use this to avoid fetching twice! Found(T), NotFound, } impl<T> LookupStatus<T> { fn inner(&self) -> Option<&T> { if let Self::Found(x) = self { return Some(x); } None } } pub trait Cache { type Item; fn lookup(&self, key: &str) -> Option<impl Deref<Target = LookupStatus<Self::Item>>>; fn store(&self, key: &str, value: Self::Item) -> Option<Self::Item>; fn invalidate(&self, key: &str); fn clear(&self); fn get(&self, key: &str) -> Option<Self::Item> where Self::Item : Clone { Some(self.lookup(key)?.deref().inner()?.clone()) } fn get_or(&self, key: &str, or: Self::Item) -> Self::Item where Self::Item : Clone { self.get(key).unwrap_or(or) } fn get_or_default(&self, key: &str) -> Self::Item where Self::Item : Clone + Default { self.get(key).unwrap_or_default() } } #[derive(Default, Clone)] pub struct DashmapCache<T>(Arc<dashmap::DashMap<String, LookupStatus<T>>>); impl<T> Cache for DashmapCache<T> { type Item = T; fn lookup(&self, key: &str) -> Option<impl Deref<Target = LookupStatus<Self::Item>>> { self.0.get(key) } fn store(&self, key: &str, value: Self::Item) -> Option<Self::Item> { self.0.insert(key.to_string(), LookupStatus::Found(value)) .and_then(|x| if let LookupStatus::Found(x) = x { Some(x) } else { None } ) } fn invalidate(&self, key: &str) { self.0.remove(key); } fn clear(&self) { self.0.clear(); } } impl DashmapCache<Doc> { pub async fn fetch(&self, key: &str, kind: UriClass, auth: Auth) -> Option<Doc> { let full_key = Uri::full(kind, key); tracing::debug!("resolving {key} -> {full_key}"); match self.get(&full_key) { Some(x) => Some(x), None => { match Http::fetch::<serde_json::Value>(&Uri::api(kind, key, true), auth).await { Ok(obj) => { let obj = Arc::new(obj); self.include(obj.clone()); Some(obj) }, Err(e) => { tracing::error!("failed loading object from backend: {e}"); None }, } }, } } pub fn include(&self, obj: Doc) { if let Ok(id) = obj.id() { tracing::debug!("storing object {id}: {obj}"); cache::OBJECTS.store(&id, obj.clone()); if obj.actor_type().is_ok() { if let Ok(url) = obj.url().id() { cache::WEBFINGER.store(&url, id); } } } if let Ok(sub_obj) = obj.object().into_inner() { if let Ok(sub_id) = sub_obj.id() { tracing::debug!("storing sub object {sub_id}: {sub_obj}"); cache::OBJECTS.store(&sub_id, Arc::new(sub_obj)); } } } pub async fn preload(&self, key: String, kind: UriClass, auth: Auth) -> Option<Doc> { let doc = self.fetch(&key, kind, auth).await?; let mut sub_tasks = Vec::new(); match kind { UriClass::Activity => { if let Ok(actor) = doc.actor().id() { sub_tasks.push(self.preload(actor, UriClass::Actor, auth)); } let clazz = match doc.activity_type().unwrap_or(apb::ActivityType::Activity) { // TODO activities like Announce or Update may be multiple things, we can't know before apb::ActivityType::Accept(_) => UriClass::Activity, apb::ActivityType::Reject(_) => UriClass::Activity, apb::ActivityType::Undo => UriClass::Activity, apb::ActivityType::Follow => UriClass::Actor, _ => UriClass::Object, }; if let Ok(object) = doc.object().id() { sub_tasks.push(self.preload(object, clazz, auth)); } }, UriClass::Object => { if let Ok(actor) = doc.attributed_to().id() { sub_tasks.push(self.preload(actor, UriClass::Actor, auth)); } if let Ok(quote) = doc.quote_url().id() { sub_tasks.push(self.preload(quote, UriClass::Object, auth)); } }, _ => {}, } futures::future::join_all(sub_tasks).await; Some(doc) } } impl DashmapCache<String> { pub async fn blocking_resolve(&self, user: &str, domain: &str) -> Option<String> { if let Some(x) = self.resource(user, domain) { return Some(x); } self.fetch(user, domain).await; self.resource(user, domain) } pub fn resolve(&self, user: &str, domain: &str) -> Option<String> { if let Some(x) = self.resource(user, domain) { return Some(x); } let (_self, user, domain) = (self.clone(), user.to_string(), domain.to_string()); leptos::task::spawn_local(async move { _self.fetch(&user, &domain).await }); None } fn resource(&self, user: &str, domain: &str) -> Option<String> { let query = format!("{user}@{domain}"); self.get(&query) } async fn fetch(&self, user: &str, domain: &str) { let query = format!("{user}@{domain}"); self.0.insert(query.to_string(), LookupStatus::Resolving); match reqwest::get(format!("{URL_BASE}/.well-known/webfinger?resource=acct:{query}")).await { Ok(res) => match res.error_for_status() { Ok(res) => match res.json::<jrd::JsonResourceDescriptor>().await { Ok(doc) => { if let Some(uid) = doc.links.into_iter().find(|x| x.rel == "self").and_then(|x| x.href) { self.0.insert(query, LookupStatus::Found(uid)); } else { self.0.insert(query, LookupStatus::NotFound); } }, Err(e) => { tracing::error!("invalid webfinger response: {e:?}"); self.0.remove(&query); }, }, Err(e) => { tracing::error!("could not resolve webfinbger: {e:?}"); self.0.insert(query, LookupStatus::NotFound); }, }, Err(e) => { tracing::error!("failed accessing webfinger server: {e:?}"); self.0.remove(&query); }, } } } use leptos_router::params::Params; // TODO can i remove this? #[derive(Clone, leptos::Params, PartialEq)] pub struct IdParam { id: Option<String>, } pub struct Http; impl Http { pub async fn request<T: serde::ser::Serialize>( method: reqwest::Method, url: &str, data: Option<&T>, auth: Auth, ) -> reqwest::Result<reqwest::Response> { use leptos::prelude::GetUntracked; let mut req = reqwest::Client::new() .request(method, url); if let Some(auth) = auth.token.get_untracked().filter(|x| !x.is_empty()) { req = req.header("Authorization", format!("Bearer {}", auth)); } if let Some(data) = data { req = req.json(data); } req.send().await } pub async fn fetch<T: serde::de::DeserializeOwned>(url: &str, token: Auth) -> reqwest::Result<T> { Self::request::<()>(reqwest::Method::GET, url, None, token) .await? .error_for_status()? .json::<T>() .await } pub async fn post<T: serde::ser::Serialize>(url: &str, data: &T, token: Auth) -> reqwest::Result<()> { Self::request(reqwest::Method::POST, url, Some(data), token) .await? .error_for_status()?; Ok(()) } } pub struct Uri; impl Uri { pub fn full(kind: UriClass, id: &str) -> String { uriproxy::uri(URL_BASE, kind, id) } pub fn pretty(url: &str, len: usize) -> String { let bare = url.replace("https://", ""); if bare.len() < len { bare } else { format!("{}..", bare.get(..len).unwrap_or_default()) } //.replace('/', "\u{200B}/\u{200B}") } pub fn short(url: &str) -> String { if url.starts_with(URL_BASE) || url.starts_with('/') { uriproxy::decompose(url) } else if url.starts_with("https://") || url.starts_with("http://") { uriproxy::compact(url) } else { url.to_string() } } /// convert url id to valid frontend view id: /// /// accepts: /// pub fn web(kind: UriClass, url: &str) -> String { let kind = kind.as_ref(); format!("/web/{kind}/{}", Self::short(url)) } /// convert url id to valid backend api id /// /// accepts: /// pub fn api(kind: UriClass, url: &str, fetch: bool) -> String { let kind = kind.as_ref(); format!("{URL_BASE}/{kind}/{}{}", Self::short(url), if fetch { "?fetch=true" } else { "" }) } pub fn domain(full: &str) -> String { full .replacen("https://", "", 1) .replacen("http://", "", 1) .split('/') .next() .unwrap_or_default() .to_string() } }