Skip to main content

upub_web/
lib.rs

1mod auth;
2mod app;
3mod components;
4mod page;
5mod config;
6mod groups;
7
8mod actors;
9mod activities;
10mod objects;
11mod timeline;
12mod lists;
13
14use apb::{Activity, Object, Actor, Base};
15pub use app::App;
16pub use config::Config;
17pub use auth::Auth;
18
19pub mod prelude;
20
21pub const URL_BASE: &str = match std::option_env!("UPUB_BASE_URL") { Some(x) => x, None => "" };
22pub const URL_PREFIX: &str = "/web";
23pub const URL_SENSITIVE: &str = "https://cdn.alemi.dev/social/nsfw.png";
24pub const FALLBACK_IMAGE_URL: &str = "https://cdn.alemi.dev/social/gradient.png";
25pub const NAME: &str = "μ";
26pub const DEFAULT_COLOR: &str = "#BF616A";
27pub const VERSION: &str = env!("CARGO_PKG_VERSION");
28
29use std::{ops::Deref, sync::Arc};
30use uriproxy::UriClass;
31
32pub type Doc = Arc<serde_json::Value>;
33
34pub mod cache {
35	use super::DashmapCache;
36	lazy_static::lazy_static! {
37		pub static ref OBJECTS: DashmapCache<super::Doc> = DashmapCache::default();
38		pub static ref WEBFINGER: DashmapCache<String> = DashmapCache::default();
39		pub static ref TIMELINES: DashmapCache<(Option<String>, Vec<String>)> = DashmapCache::default();
40	}
41}
42
43#[derive(Debug)]
44pub enum LookupStatus<T> {
45	Resolving, // TODO use this to avoid fetching twice!
46	Found(T),
47	NotFound,
48}
49
50impl<T> LookupStatus<T> {
51	fn inner(&self) -> Option<&T> {
52		if let Self::Found(x) = self {
53			return Some(x);
54		}
55		None
56	}
57}
58
59pub trait Cache {
60	type Item;
61
62	fn lookup(&self, key: &str) -> Option<impl Deref<Target = LookupStatus<Self::Item>>>;
63	fn store(&self, key: &str, value: Self::Item) -> Option<Self::Item>;
64	fn invalidate(&self, key: &str);
65	fn clear(&self);
66
67	fn get(&self, key: &str) -> Option<Self::Item> where Self::Item : Clone {
68		Some(self.lookup(key)?.deref().inner()?.clone())
69	}
70
71	fn get_or(&self, key: &str, or: Self::Item) -> Self::Item where Self::Item : Clone {
72		self.get(key).unwrap_or(or)
73	}
74
75	fn get_or_default(&self, key: &str) -> Self::Item where Self::Item : Clone + Default {
76		self.get(key).unwrap_or_default()
77	}
78}
79
80#[derive(Default, Clone)]
81pub struct DashmapCache<T>(Arc<dashmap::DashMap<String, LookupStatus<T>>>);
82
83impl<T> Cache for DashmapCache<T> {
84	type Item = T;
85
86	fn lookup(&self, key: &str) -> Option<impl Deref<Target = LookupStatus<Self::Item>>> {
87		self.0.get(key)
88	}
89
90	fn store(&self, key: &str, value: Self::Item) -> Option<Self::Item> {
91		self.0.insert(key.to_string(), LookupStatus::Found(value))
92			.and_then(|x| if let LookupStatus::Found(x) = x { Some(x) } else { None } )
93	}
94
95	fn invalidate(&self, key: &str) {
96		self.0.remove(key);
97	}
98
99	fn clear(&self) {
100		self.0.clear();
101	}
102}
103
104impl DashmapCache<Doc> {
105	pub async fn fetch(&self, key: &str, kind: UriClass, auth: Auth) -> Option<Doc> {
106		let full_key = Uri::full(kind, key);
107		tracing::debug!("resolving {key} -> {full_key}");
108		match self.get(&full_key) {
109			Some(x) => Some(x),
110			None => {
111				match Http::fetch::<serde_json::Value>(&Uri::api(kind, key, true), auth).await {
112					Ok(obj) => {
113						let obj = Arc::new(obj);
114						self.include(obj.clone());
115						Some(obj)
116					},
117					Err(e) => {
118						tracing::error!("failed loading object '{kind:?}({key})' from backend: {e}");
119						None
120					},
121				}
122			},
123		}
124	}
125
126	pub fn include(&self, obj: Doc) {
127		if let Ok(id) = obj.id() {
128			tracing::debug!("storing object {id}: {obj}");
129			cache::OBJECTS.store(&id, obj.clone());
130			if obj.actor_type().is_ok() {
131				if let Ok(url) = obj.url().id() {
132					cache::WEBFINGER.store(&url, id);
133				}
134			}
135		}
136		if let Ok(sub_obj) = obj.object().into_inner() {
137			if let Ok(sub_id) = sub_obj.id() {
138				tracing::debug!("storing sub object {sub_id}: {sub_obj}");
139				cache::OBJECTS.store(&sub_id, Arc::new(sub_obj));
140			}
141		}
142	}
143
144	pub async fn preload(&self, key: String, kind: UriClass, auth: Auth) -> Option<Doc> {
145		let doc = self.fetch(&key, kind, auth).await?;
146		let mut sub_tasks = Vec::new();
147
148		match kind {
149			UriClass::Activity => {
150				if let Ok(actor) = doc.actor().id() {
151					sub_tasks.push(self.preload(actor, UriClass::Actor, auth));
152				}
153				if let Ok(actor) = doc.attributed_to().id() {
154					sub_tasks.push(self.preload(actor, UriClass::Actor, auth));
155				}
156				let clazz = match doc.activity_type().unwrap_or(apb::ActivityType::Activity) {
157					// TODO activities like Announce or Update may be multiple things, we can't know before
158					apb::ActivityType::Accept(_) => UriClass::Activity,
159					apb::ActivityType::Reject(_) => UriClass::Activity,
160					apb::ActivityType::Undo => UriClass::Activity,
161					apb::ActivityType::Follow => UriClass::Actor,
162					_ => UriClass::Object,
163				};
164				if let Ok(object) = doc.object().id() {
165					sub_tasks.push(self.preload(object, clazz, auth));
166				}
167			},
168			UriClass::Object => {
169				if let Ok(actor) = doc.actor().id() {
170					sub_tasks.push(self.preload(actor, UriClass::Actor, auth));
171				}
172				if let Ok(actor) = doc.attributed_to().id() {
173					sub_tasks.push(self.preload(actor, UriClass::Actor, auth));
174				}
175				if let Ok(quote) = doc.quote_url().id() {
176					sub_tasks.push(self.preload(quote, UriClass::Object, auth));
177				}
178			},
179			_ => {},
180		}
181
182		futures::future::join_all(sub_tasks).await;
183
184		Some(doc)
185	}
186}
187
188impl DashmapCache<String> {
189	pub async fn blocking_resolve(&self, user: &str, domain: &str, auth: Auth) -> Option<String> {
190		if let Some(x) = self.resource(user, domain) { return Some(x); }
191		self.fetch(user, domain, auth).await;
192		self.resource(user, domain)
193	}
194
195	pub fn resolve(&self, user: &str, domain: &str, auth: Auth) -> Option<String> {
196		if let Some(x) = self.resource(user, domain) { return Some(x); }
197		let (_self, user, domain) = (self.clone(), user.to_string(), domain.to_string());
198		leptos::task::spawn_local(async move { _self.fetch(&user, &domain, auth).await });
199		None
200	}
201
202	fn resource(&self, user: &str, domain: &str) -> Option<String> {
203		let query = format!("{user}@{domain}");
204		self.get(&query)
205	}
206
207	async fn fetch(&self, user: &str, domain: &str, auth: Auth) {
208		let query = format!("{user}@{domain}");
209		self.0.insert(query.to_string(), LookupStatus::Resolving);
210		match crate::Http::fetch::<jrd::JsonResourceDescriptor>(&format!("{URL_BASE}/.well-known/webfinger?resource=acct:{query}"), auth).await {
211			Ok(doc) => {
212				if let Some(uid) = doc.links.into_iter().find(|x| x.rel == "self").and_then(|x| x.href) {
213					self.0.insert(query, LookupStatus::Found(uid));
214				} else {
215					self.0.insert(query, LookupStatus::NotFound);
216				}
217			},
218			Err(e) => {
219				tracing::error!("could not resolve webfinbger: {e:?}");
220				self.0.insert(query, LookupStatus::NotFound);
221			},
222		}
223	}
224}
225
226use leptos_router::params::Params; // TODO can i remove this?
227#[derive(Clone, leptos::Params, PartialEq)]
228pub struct IdParam {
229	id: Option<String>,
230}
231
232pub struct Http;
233
234impl Http {
235	// TODO not really great.... also checked only once
236	pub fn location() -> &'static str {
237		static LOCATION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
238		LOCATION.get_or_init(||
239			web_sys::window()
240				.expect("could not access window element")
241				.location()
242				.origin()
243				.expect("could not access location origin")
244		).as_str()
245	}
246
247	pub async fn request<T: serde::ser::Serialize>(
248		method: reqwest::Method,
249		url: &str,
250		data: Option<&T>,
251		auth: Auth,
252	) -> reqwest::Result<reqwest::Response> {
253		tracing::info!("making request to {url}");
254		use leptos::prelude::GetUntracked;
255
256		// TODO while in web environments it's ok (and i'd say good!) to fetch with relative urls,
257		//      rust-url crate doesn't allow it throwing errors while constructing the url object
258		//      itself. GET /nodeinfo/2.0.json is perfectly valid, but we have to convert it to
259		//      something like GET http://127.0.0.1:3000/nodeinfo/2.0.json (or actual instance url for
260		//      prod deployments). relevant issue: https://github.com/seanmonstar/reqwest/issues/1433
261		let mut url = url.to_string();
262		if !url.starts_with("http") {
263			url = format!("{}{url}", Self::location());
264		}
265
266		let mut req = reqwest::Client::new()
267			.request(method, url);
268
269		if let Some(auth) = auth.token.get_untracked().filter(|x| !x.is_empty()) {
270			req = req.header("Authorization", format!("Bearer {}", auth));
271		}
272
273		if let Some(data) = data {
274			req = req.json(data);
275		}
276
277		req.send().await
278	}
279
280	pub async fn fetch<T: serde::de::DeserializeOwned>(url: &str, token: Auth) -> reqwest::Result<T> {
281		Self::request::<()>(reqwest::Method::GET, url, None, token)
282			.await?
283			.error_for_status()?
284			.json::<T>()
285			.await
286	}
287
288	pub async fn post<T: serde::ser::Serialize>(url: &str, data: &T, token: Auth) -> reqwest::Result<()> {
289		Self::request(reqwest::Method::POST, url, Some(data), token)
290			.await?
291			.error_for_status()?;
292		Ok(())
293	}
294}
295
296pub struct Uri;
297
298impl Uri {
299	pub fn full(kind: UriClass, id: &str) -> String {
300		uriproxy::uri(URL_BASE, kind, id)
301	}
302
303	pub fn pretty(url: &str, len: usize) -> String {
304		let bare = url.replace("https://", "");
305		if bare.len() < len {
306			bare
307		} else {
308			format!("{}..", bare.get(..len).unwrap_or_default())
309		}
310			//.replace('/', "\u{200B}/\u{200B}")
311	}
312
313	pub fn short(url: &str) -> String {
314		if url.starts_with(Http::location()) || url.starts_with('/') {
315			uriproxy::decompose(url)
316		} else if url.starts_with("https://") || url.starts_with("http://") {
317			uriproxy::compact(url)
318		} else {
319			url.to_string()
320		}
321	}
322
323	/// convert url id to valid frontend view id:
324	///
325	/// accepts:
326	///
327	pub fn web(kind: UriClass, url: &str) -> String {
328		let kind = kind.as_ref();
329		format!("/web/{kind}/{}", Self::short(url))
330	}
331	
332	/// convert url id to valid backend api id
333	///
334	/// accepts:
335	///
336	pub fn api(kind: UriClass, url: &str, fetch: bool) -> String {
337		let kind = kind.as_ref();
338		format!("{URL_BASE}/{kind}/{}{}", Self::short(url), if fetch { "?fetch=true" } else { "" })
339	}
340
341	pub fn domain(full: &str) -> String {
342		full
343			.replacen("https://", "", 1)
344			.replacen("http://", "", 1)
345			.split('/')
346			.next()
347			.unwrap_or_default()
348			.to_string()
349	}
350}
351
352pub trait IconGradient {
353	fn icon_url_and_style(&self) -> (String, String);
354}
355
356impl IconGradient for Doc {
357	fn icon_url_and_style(&self) -> (String, String) {
358		use apb::Shortcuts;
359		match self.icon_url() {
360			Ok(url) => (url, "".to_string()),
361			Err(_e) => {
362				let (from, to) = crate::string_to_hex(&self.id().unwrap_or_default());
363				("".to_string(), format!("background: radial-gradient({from}, {to}); padding: .1em;"))
364			},
365		}
366	}
367}
368
369fn string_to_hex(inpt: &str) -> (String, String) {
370	use std::hash::{Hash, Hasher};
371
372	let mut hasher = std::hash::DefaultHasher::new();
373	inpt.hash(&mut hasher);
374	let raw = hasher.finish();
375
376	let from = raw as u32;
377	let to = (raw >> 32) as u32;
378
379	let from_str = format!(
380		"#{:06x}", from >> 8
381	);
382
383	let to_str = format!(
384		"#{:06x}", to >> 8
385	);
386	(from_str, to_str)
387}
388