diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs index cfaac12..cdfb879 100644 --- a/crates/weaver-app/src/data.rs +++ b/crates/weaver-app/src/data.rs @@ -655,6 +655,30 @@ pub fn use_entries_from_ufos() -> ( async move { match fetcher.fetch_entries_from_ufos().await { Ok(entries) => { + // Cache blobs for each entry's embedded images + for arc in &entries { + let (view, entry, _) = arc.as_ref(); + if let Some(embeds) = &entry.embeds { + if let Some(images) = &embeds.images { + use jacquard::smol_str::ToSmolStr; + use jacquard::types::aturi::AtUri; + // Extract ident from the entry's at-uri + if let Ok(at_uri) = AtUri::new(view.uri.as_ref()) { + let ident = at_uri.authority().to_smolstr(); + for image in &images.images { + let cid = image.image.blob().cid(); + cache_blob( + ident.clone(), + cid.to_smolstr(), + image.name.as_ref().map(|n| n.to_smolstr()), + ) + .await + .ok(); + } + } + } + } + } Some( entries .iter() @@ -945,6 +969,26 @@ pub fn use_standalone_entry_data( async move { match fetcher.get_entry_by_rkey(ident(), rkey()).await { Ok(Some(data)) => { + // Cache blobs for embedded images + if let Some(embeds) = &data.entry.embeds { + if let Some(images) = &embeds.images { + use jacquard::smol_str::ToSmolStr; + use jacquard::types::aturi::AtUri; + if let Ok(at_uri) = AtUri::new(data.entry_view.uri.as_ref()) { + let ident_str = at_uri.authority().to_smolstr(); + for image in &images.images { + let cid = image.image.blob().cid(); + cache_blob( + ident_str.clone(), + cid.to_smolstr(), + image.name.as_ref().map(|n| n.to_smolstr()), + ) + .await + .ok(); + } + } + } + } let entry_json = serde_json::to_value(&data.entry).ok()?; let entry_view_json = serde_json::to_value(&data.entry_view).ok()?; let notebook_ctx_json = data.notebook_context.as_ref().map(|ctx| { diff --git a/crates/weaver-app/src/fetch.rs b/crates/weaver-app/src/fetch.rs index b69ef45..cff1f6e 100644 --- a/crates/weaver-app/src/fetch.rs +++ b/crates/weaver-app/src/fetch.rs @@ -352,6 +352,21 @@ impl AgentSession for Client { #[derive(Clone)] pub struct Fetcher { pub client: Arc, + #[cfg(feature = "server")] + book_cache: cache_impl::Cache< + (AtIdentifier<'static>, SmolStr), + Arc<(NotebookView<'static>, Vec>)>, + >, + #[cfg(feature = "server")] + entry_cache: cache_impl::Cache< + (AtIdentifier<'static>, SmolStr), + Arc<(BookEntryView<'static>, Entry<'static>)>, + >, + #[cfg(feature = "server")] + profile_cache: cache_impl::Cache, Arc>>, + #[cfg(feature = "server")] + standalone_entry_cache: + cache_impl::Cache<(AtIdentifier<'static>, SmolStr), Arc>, } //#[cfg(not(feature = "server"))] @@ -359,6 +374,14 @@ impl Fetcher { pub fn new(client: OAuthClient) -> Self { Self { client: Arc::new(Client::new(client)), + #[cfg(feature = "server")] + book_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)), + #[cfg(feature = "server")] + entry_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)), + #[cfg(feature = "server")] + profile_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(1800)), + #[cfg(feature = "server")] + standalone_entry_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)), } } @@ -396,6 +419,11 @@ impl Fetcher { ident: AtIdentifier<'static>, title: SmolStr, ) -> Result, Vec>)>>> { + #[cfg(feature = "server")] + if let Some(cached) = cache_impl::get(&self.book_cache, &(ident.clone(), title.clone())) { + return Ok(Some(cached)); + } + let client = self.get_client(); if let Some((notebook, entries)) = client .notebook_by_title(&ident, &title) @@ -403,6 +431,8 @@ impl Fetcher { .map_err(|e| dioxus::CapturedError::from_display(e))? { let stored = Arc::new((notebook, entries)); + #[cfg(feature = "server")] + cache_impl::insert(&self.book_cache, (ident, title), stored.clone()); Ok(Some(stored)) } else { Err(dioxus::CapturedError::from_display("Notebook not found")) @@ -415,6 +445,13 @@ impl Fetcher { book_title: SmolStr, entry_title: SmolStr, ) -> Result, Entry<'static>)>>> { + #[cfg(feature = "server")] + if let Some(cached) = + cache_impl::get(&self.entry_cache, &(ident.clone(), entry_title.clone())) + { + return Ok(Some(cached)); + } + if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { let (notebook, entries) = result.as_ref(); let client = self.get_client(); @@ -424,6 +461,8 @@ impl Fetcher { .map_err(|e| dioxus::CapturedError::from_display(e))? { let stored = Arc::new(entry); + #[cfg(feature = "server")] + cache_impl::insert(&self.entry_cache, (ident, entry_title), stored.clone()); Ok(Some(stored)) } else { Err(dioxus::CapturedError::from_display("Entry not found")) @@ -459,8 +498,6 @@ impl Fetcher { ); let uri = AtUri::new_owned(uri_str) .map_err(|e| dioxus::CapturedError::from_display(format!("Invalid URI: {}", e)))?; - - // Fetch the full notebook view (which hydrates authors) match client.view_notebook(&uri).await { Ok((notebook, entries)) => { let ident = uri.authority().clone().into_static(); @@ -471,6 +508,8 @@ impl Fetcher { .unwrap_or_else(|| SmolStr::new("Untitled")); let result = Arc::new((notebook, entries)); + #[cfg(feature = "server")] + cache_impl::insert(&self.book_cache, (ident, title), result.clone()); notebooks.push(result); } Err(_) => continue, // Skip notebooks that fail to load @@ -488,43 +527,34 @@ impl Fetcher { let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.entry"; - let response = reqwest::get(url) - .await - .map_err(|e| { - tracing::error!("[fetch_entries_from_ufos] request failed: {:?}", e); - dioxus::CapturedError::from_display(e) - })?; - - let mut records: Vec = response - .json() - .await - .map_err(|e| { - tracing::error!("[fetch_entries_from_ufos] json parse failed: {:?}", e); - dioxus::CapturedError::from_display(e) - })?; + let response = reqwest::get(url).await.map_err(|e| { + tracing::error!("[fetch_entries_from_ufos] request failed: {:?}", e); + dioxus::CapturedError::from_display(e) + })?; - // Sort by time_us descending (reverse chronological) + let mut records: Vec = response.json().await.map_err(|e| { + tracing::error!("[fetch_entries_from_ufos] json parse failed: {:?}", e); + dioxus::CapturedError::from_display(e) + })?; records.sort_by(|a, b| b.time_us.cmp(&a.time_us)); let mut entries = Vec::new(); let client = self.get_client(); for ufos_record in records { - // Parse DID let did = match Did::new(&ufos_record.did) { Ok(d) => d.into_static(), Err(e) => { - tracing::warn!("[fetch_entries_from_ufos] invalid DID {}: {:?}", ufos_record.did, e); + tracing::warn!( + "[fetch_entries_from_ufos] invalid DID {}: {:?}", + ufos_record.did, + e + ); continue; } }; let ident = AtIdentifier::Did(did); - - // Fetch the entry view - match client - .fetch_entry_by_rkey(&ident, &ufos_record.rkey) - .await - { + match client.fetch_entry_by_rkey(&ident, &ufos_record.rkey).await { Ok((entry_view, entry)) => { entries.push(Arc::new(( entry_view.into_static(), @@ -533,7 +563,11 @@ impl Fetcher { ))); } Err(e) => { - tracing::warn!("[fetch_entries_from_ufos] failed to load entry {}: {:?}", ufos_record.rkey, e); + tracing::warn!( + "[fetch_entries_from_ufos] failed to load entry {}: {:?}", + ufos_record.rkey, + e + ); continue; } } @@ -592,7 +626,16 @@ impl Fetcher { // View the notebook (which hydrates authors) match client.view_notebook(&record.uri).await { Ok((notebook, entries)) => { + let ident = record.uri.authority().clone().into_static(); + let title = notebook + .title + .as_ref() + .map(|t| SmolStr::new(t.as_ref())) + .unwrap_or_else(|| SmolStr::new("Untitled")); + let result = Arc::new((notebook, entries)); + #[cfg(feature = "server")] + cache_impl::insert(&self.book_cache, (ident, title), result.clone()); notebooks.push(result); } Err(_) => continue, // Skip notebooks that fail to load @@ -607,15 +650,46 @@ impl Fetcher { ident: AtIdentifier<'static>, book_title: SmolStr, ) -> Result>>> { + use jacquard::types::aturi::AtUri; + if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { - let (notebook, entries) = result.as_ref(); + let (notebook, entry_refs) = result.as_ref(); let mut book_entries = Vec::new(); let client = self.get_client(); - for index in 0..entries.len() { - match client.view_entry(notebook, entries, index).await { - Ok(book_entry) => book_entries.push(book_entry), - Err(_) => continue, // Skip entries that fail to load + for (index, entry_ref) in entry_refs.iter().enumerate() { + // Try to extract rkey from URI + let rkey = AtUri::new(entry_ref.uri.as_ref()) + .ok() + .and_then(|uri| uri.rkey().map(|r| SmolStr::new(r.as_ref()))); + + // Check cache first + #[cfg(feature = "server")] + if let Some(ref rkey) = rkey { + if let Some(cached) = + cache_impl::get(&self.entry_cache, &(ident.clone(), rkey.clone())) + { + book_entries.push(cached.0.clone()); + continue; + } + } + + // Fetch if not cached + if let Ok(book_entry) = client.view_entry(notebook, entry_refs, index).await { + // Try to populate cache by deserializing Entry from the view's record + #[cfg(feature = "server")] + if let Some(rkey) = rkey { + use jacquard::IntoStatic; + use weaver_api::sh_weaver::notebook::entry::Entry; + if let Ok(entry) = + jacquard::from_data::>(&book_entry.entry.record) + { + let cached = + Arc::new((book_entry.clone().into_static(), entry.into_static())); + cache_impl::insert(&self.entry_cache, (ident.clone(), rkey), cached); + } + } + book_entries.push(book_entry); } } @@ -629,6 +703,15 @@ impl Fetcher { &self, ident: &AtIdentifier<'_>, ) -> Result>> { + use jacquard::IntoStatic; + + let ident_static = ident.clone().into_static(); + + #[cfg(feature = "server")] + if let Some(cached) = cache_impl::get(&self.profile_cache, &ident_static) { + return Ok(cached); + } + let client = self.get_client(); let did = match ident { @@ -644,7 +727,11 @@ impl Fetcher { .await .map_err(|e| dioxus::CapturedError::from_display(e))?; - Ok(Arc::new(profile_view)) + let result = Arc::new(profile_view); + #[cfg(feature = "server")] + cache_impl::insert(&self.profile_cache, ident_static, result.clone()); + + Ok(result) } /// Fetch an entry by rkey with optional notebook context lookup. @@ -655,6 +742,13 @@ impl Fetcher { ) -> Result>> { use jacquard::types::aturi::AtUri; + #[cfg(feature = "server")] + if let Some(cached) = + cache_impl::get(&self.standalone_entry_cache, &(ident.clone(), rkey.clone())) + { + return Ok(Some(cached)); + } + let client = self.get_client(); // Fetch entry directly by rkey @@ -711,11 +805,15 @@ impl Fetcher { None }; - Ok(Some(Arc::new(StandaloneEntryData { + let result = Arc::new(StandaloneEntryData { entry, entry_view, notebook_context, - }))) + }); + #[cfg(feature = "server")] + cache_impl::insert(&self.standalone_entry_cache, (ident, rkey), result.clone()); + + Ok(Some(result)) } /// Fetch an entry by rkey within a specific notebook context. @@ -730,6 +828,11 @@ impl Fetcher { ) -> Result, Entry<'static>)>>> { use jacquard::types::aturi::AtUri; + #[cfg(feature = "server")] + if let Some(cached) = cache_impl::get(&self.entry_cache, &(ident.clone(), rkey.clone())) { + return Ok(Some(cached)); + } + let client = self.get_client(); // Fetch entry directly by rkey @@ -783,303 +886,14 @@ impl Fetcher { .build(); } - Ok(Some(Arc::new((book_entry_view.into_static(), entry)))) + let result = Arc::new((book_entry_view.into_static(), entry)); + #[cfg(feature = "server")] + cache_impl::insert(&self.entry_cache, (ident, rkey), result.clone()); + + Ok(Some(result)) } } -// #[cfg(feature = "server")] -// #[derive(Clone)] -// pub struct Fetcher { -// pub client: Arc, -// book_cache: cache_impl::Cache< -// (AtIdentifier<'static>, SmolStr), -// Arc<(NotebookView<'static>, Vec>)>, -// >, -// entry_cache: cache_impl::Cache< -// (AtIdentifier<'static>, SmolStr), -// Arc<(BookEntryView<'static>, Entry<'static>)>, -// >, -// profile_cache: cache_impl::Cache, Arc>>, -// } - -// // /// SAFETY: This isn't thread-safe on WASM, but we aren't multithreaded on WASM -// //#[cfg(feature = "server")] -// unsafe impl Sync for Fetcher {} - -// // /// SAFETY: This isn't thread-safe on WASM, but we aren't multithreaded on WASM -// //#[cfg(feature = "server")] -// unsafe impl Send for Fetcher {} - -// #[cfg(feature = "server")] -// impl Fetcher { -// pub fn new(client: OAuthClient) -> Self { -// Self { -// client: Arc::new(Client::new(client)), -// book_cache: cache_impl::new_cache(100, Duration::from_secs(30)), -// entry_cache: cache_impl::new_cache(100, Duration::from_secs(30)), -// profile_cache: cache_impl::new_cache(100, Duration::from_secs(1800)), -// } -// } - -// pub async fn upgrade_to_authenticated( -// &self, -// session: OAuthSession, -// ) { -// let mut session_slot = self.client.session.write().await; -// *session_slot = Some(Arc::new(Agent::new(session))); -// } - -// pub async fn downgrade_to_unauthenticated(&self) { -// let mut session_slot = self.client.session.write().await; -// if let Some(session) = session_slot.take() { -// session.inner().logout().await.ok(); -// } -// } - -// #[allow(dead_code)] -// pub async fn current_did(&self) -> Option> { -// let session_slot = self.client.session.read().await; -// if let Some(session) = session_slot.as_ref() { -// session.info().await.map(|(d, _)| d) -// } else { -// None -// } -// } - -// pub fn get_client(&self) -> Arc { -// self.client.clone() -// } - -// pub async fn get_notebook( -// &self, -// ident: AtIdentifier<'static>, -// title: SmolStr, -// ) -> Result, Vec>)>>> { -// if let Some(entry) = cache_impl::get(&self.book_cache, &(ident.clone(), title.clone())) { -// Ok(Some(entry)) -// } else { -// let client = self.get_client(); -// if let Some((notebook, entries)) = client -// .notebook_by_title(&ident, &title) -// .await -// .map_err(|e| dioxus::CapturedError::from_display(e))? -// { -// let stored = Arc::new((notebook, entries)); -// cache_impl::insert(&self.book_cache, (ident, title), stored.clone()); -// Ok(Some(stored)) -// } else { -// Ok(None) -// } -// } -// } - -// pub async fn get_entry( -// &self, -// ident: AtIdentifier<'static>, -// book_title: SmolStr, -// entry_title: SmolStr, -// ) -> Result, Entry<'static>)>>> { -// if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { -// let (notebook, entries) = result.as_ref(); -// if let Some(entry) = -// cache_impl::get(&self.entry_cache, &(ident.clone(), entry_title.clone())) -// { -// Ok(Some(entry)) -// } else { -// let client = self.get_client(); -// if let Some(entry) = client -// .entry_by_title(notebook, entries.as_ref(), &entry_title) -// .await -// .map_err(|e| dioxus::CapturedError::from_display(e))? -// { -// let stored = Arc::new(entry); -// cache_impl::insert(&self.entry_cache, (ident, entry_title), stored.clone()); -// Ok(Some(stored)) -// } else { -// Ok(None) -// } -// } -// } else { -// Ok(None) -// } -// } - -// pub async fn fetch_notebooks_from_ufos( -// &self, -// ) -> Result, Vec>)>>> { -// use jacquard::{IntoStatic, types::aturi::AtUri}; - -// let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.book"; -// let response = reqwest::get(url) -// .await -// .map_err(|e| dioxus::CapturedError::from_display(e))?; - -// let records: Vec = response -// .json() -// .await -// .map_err(|e| dioxus::CapturedError::from_display(e))?; - -// let mut notebooks = Vec::new(); -// let client = self.get_client(); - -// for ufos_record in records { -// // Construct URI -// let uri_str = format!( -// "at://{}/{}/{}", -// ufos_record.did, ufos_record.collection, ufos_record.rkey -// ); -// let uri = AtUri::new_owned(uri_str) -// .map_err(|e| dioxus::CapturedError::from_display(format!("Invalid URI: {}", e)))?; - -// // Fetch the full notebook view (which hydrates authors) -// match client.view_notebook(&uri).await { -// Ok((notebook, entries)) => { -// let ident = uri.authority().clone().into_static(); -// let title = notebook -// .title -// .as_ref() -// .map(|t| SmolStr::new(t.as_ref())) -// .unwrap_or_else(|| SmolStr::new("Untitled")); - -// let result = Arc::new((notebook, entries)); -// // Cache it -// cache_impl::insert(&self.book_cache, (ident, title), result.clone()); -// notebooks.push(result); -// } -// Err(_) => continue, // Skip notebooks that fail to load -// } -// } - -// Ok(notebooks) -// } - -// pub async fn fetch_notebooks_for_did( -// &self, -// ident: &AtIdentifier<'_>, -// ) -> Result, Vec>)>>> { -// use jacquard::{ -// IntoStatic, -// types::{collection::Collection, nsid::Nsid}, -// xrpc::XrpcExt, -// }; -// use weaver_api::{ -// com_atproto::repo::list_records::ListRecords, sh_weaver::notebook::book::Book, -// }; - -// let client = self.get_client(); - -// // Resolve DID and PDS -// let (repo_did, pds_url) = match ident { -// AtIdentifier::Did(did) => { -// let pds = client -// .pds_for_did(did) -// .await -// .map_err(|e| dioxus::CapturedError::from_display(e))?; -// (did.clone(), pds) -// } -// AtIdentifier::Handle(handle) => client -// .pds_for_handle(handle) -// .await -// .map_err(|e| dioxus::CapturedError::from_display(e))?, -// }; - -// // Fetch all notebook records for this repo -// let resp = client -// .xrpc(pds_url) -// .send( -// &ListRecords::new() -// .repo(repo_did) -// .collection(Nsid::raw(Book::NSID)) -// .limit(100) -// .build(), -// ) -// .await -// .map_err(|e| dioxus::CapturedError::from_display(e))?; - -// let mut notebooks = Vec::new(); - -// if let Ok(list) = resp.parse() { -// for record in list.records { -// // View the notebook (which hydrates authors) -// match client.view_notebook(&record.uri).await { -// Ok((notebook, entries)) => { -// let ident = record.uri.authority().clone().into_static(); -// let title = notebook -// .title -// .as_ref() -// .map(|t| SmolStr::new(t.as_ref())) -// .unwrap_or_else(|| SmolStr::new("Untitled")); - -// let result = Arc::new((notebook, entries)); -// // Cache it -// cache_impl::insert(&self.book_cache, (ident, title), result.clone()); -// notebooks.push(result); -// } -// Err(_) => continue, // Skip notebooks that fail to load -// } -// } -// } - -// Ok(notebooks) -// } - -// pub async fn list_notebook_entries( -// &self, -// ident: AtIdentifier<'static>, -// book_title: SmolStr, -// ) -> Result>>> { -// if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { -// let (notebook, entries) = result.as_ref(); -// let mut book_entries = Vec::new(); -// let client = self.get_client(); - -// for index in 0..entries.len() { -// match client.view_entry(notebook, entries, index).await { -// Ok(book_entry) => book_entries.push(book_entry), -// Err(_) => continue, // Skip entries that fail to load -// } -// } - -// Ok(Some(book_entries)) -// } else { -// Ok(None) -// } -// } - -// pub async fn fetch_profile( -// &self, -// ident: &AtIdentifier<'_>, -// ) -> Result>> { -// use jacquard::IntoStatic; - -// let ident_static = ident.clone().into_static(); - -// if let Some(cached) = cache_impl::get(&self.profile_cache, &ident_static) { -// return Ok(cached); -// } - -// let client = self.get_client(); - -// let did = match ident { -// AtIdentifier::Did(d) => d.clone(), -// AtIdentifier::Handle(h) => client -// .resolve_handle(h) -// .await -// .map_err(|e| dioxus::CapturedError::from_display(e))?, -// }; - -// let (_uri, profile_view) = client -// .hydrate_profile_view(&did) -// .await -// .map_err(|e| dioxus::CapturedError::from_display(e))?; - -// let result = Arc::new(profile_view); -// cache_impl::insert(&self.profile_cache, ident_static, result.clone()); - -// Ok(result) -// } -// } - impl HttpClient for Fetcher { type Error = IdentityError; diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index 57b9c48..a0f2902 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -31,14 +31,14 @@ mod auth; #[cfg(feature = "server")] mod blobcache; mod cache_impl; -#[cfg(feature = "server")] -mod og; /// Define a components module that contains all shared components for our app. mod components; mod config; mod data; mod env; mod fetch; +#[cfg(feature = "server")] +mod og; mod record_utils; mod service_worker; /// Define a views module that contains the UI for all Layouts and Routes for our app. @@ -207,6 +207,26 @@ fn main() { } } })) + // .layer(axum::middleware::from_fn( + // |request: Request, next: Next| async move { + // let mut res = next.run(request).await; + + // // Cache all HTML responses + // if res + // .headers() + // .get("content-type") + // .and_then(|v| v.to_str().ok()) + // .map(|t| t.contains("text/html")) + // .unwrap_or(false) + // { + // res.headers_mut().insert( + // http::header::CACHE_CONTROL, + // "public, max-age=300".parse().unwrap(), + // ); + // } + // res + // }, + // )) }; Ok(router) }); @@ -432,7 +452,10 @@ pub async fn og_image( entry_title: SmolStr, ) -> Result { use axum::{ - http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, + http::{ + StatusCode, + header::{CACHE_CONTROL, CONTENT_TYPE}, + }, response::IntoResponse, }; use weaver_api::sh_weaver::actor::ProfileDataViewInner; @@ -446,7 +469,9 @@ pub async fn og_image( }; // Fetch entry data - let entry_result = fetcher.get_entry(at_ident.clone(), book_title.clone(), entry_title.into()).await; + let entry_result = fetcher + .get_entry(at_ident.clone(), book_title.clone(), entry_title.into()) + .await; let arc_data = match entry_result { Ok(Some(data)) => data, @@ -470,7 +495,8 @@ pub async fn og_image( (CACHE_CONTROL, "public, max-age=3600"), ], cached, - ).into_response()); + ) + .into_response()); } // Extract metadata @@ -480,7 +506,10 @@ pub async fn og_image( // TODO: Could fetch actual notebook record to get display title let notebook_title_str: String = book_title.to_string(); - let author_handle = book_entry.entry.authors.first() + let author_handle = book_entry + .entry + .authors + .first() .map(|a| match &a.record.inner { ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), @@ -504,7 +533,9 @@ pub async fn og_image( // Build CDN URL let cdn_url = format!( "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", - did.as_str(), cid.as_ref(), format + did.as_str(), + cid.as_ref(), + format ); // Fetch the image @@ -513,13 +544,14 @@ pub async fn og_image( match response.bytes().await { Ok(bytes) => { use base64::Engine; - let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); + let base64_str = + base64::engine::general_purpose::STANDARD.encode(&bytes); Some(format!("data:{};base64,{}", mime, base64_str)) } - Err(_) => None + Err(_) => None, } } - _ => None + _ => None, } } else { None @@ -556,7 +588,10 @@ pub async fn og_image( match og::generate_hero_image(hero_data, title, ¬ebook_title_str, &author_handle) { Ok(bytes) => bytes, Err(e) => { - tracing::error!("Failed to generate hero OG image: {:?}, falling back to text", e); + tracing::error!( + "Failed to generate hero OG image: {:?}, falling back to text", + e + ); og::generate_text_only(title, &content_snippet, ¬ebook_title_str, &author_handle) .map_err(|e| { tracing::error!("Failed to generate text OG image: {:?}", e); @@ -570,7 +605,11 @@ pub async fn og_image( Ok(bytes) => bytes, Err(e) => { tracing::error!("Failed to generate OG image: {:?}", e); - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); + return Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate image", + ) + .into_response()); } } }; @@ -584,7 +623,8 @@ pub async fn og_image( (CACHE_CONTROL, "public, max-age=3600"), ], png_bytes, - ).into_response()) + ) + .into_response()) } // Route: /og/notebook/{ident}/{book_title}.png - OpenGraph image for notebook index @@ -595,7 +635,10 @@ pub async fn og_notebook_image( book_title: SmolStr, ) -> Result { use axum::{ - http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, + http::{ + StatusCode, + header::{CACHE_CONTROL, CONTENT_TYPE}, + }, response::IntoResponse, }; use weaver_api::sh_weaver::actor::ProfileDataViewInner; @@ -608,14 +651,20 @@ pub async fn og_notebook_image( }; // Fetch notebook data - let notebook_result = fetcher.get_notebook(at_ident.clone(), book_title.into()).await; + let notebook_result = fetcher + .get_notebook(at_ident.clone(), book_title.into()) + .await; let arc_data = match notebook_result { Ok(Some(data)) => data, Ok(None) => return Ok((StatusCode::NOT_FOUND, "Notebook not found").into_response()), Err(e) => { tracing::error!("Failed to fetch notebook for OG image: {:?}", e); - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch notebook").into_response()); + return Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch notebook", + ) + .into_response()); } }; let (notebook_view, _entries) = arc_data.as_ref(); @@ -632,16 +681,20 @@ pub async fn og_notebook_image( (CACHE_CONTROL, "public, max-age=3600"), ], cached, - ).into_response()); + ) + .into_response()); } // Extract metadata - let title = notebook_view.title + let title = notebook_view + .title .as_ref() .map(|t| t.as_ref()) .unwrap_or("Untitled Notebook"); - let author_handle = notebook_view.authors.first() + let author_handle = notebook_view + .authors + .first() .map(|a| match &a.record.inner { ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), @@ -651,7 +704,9 @@ pub async fn og_notebook_image( .unwrap_or_else(|| "unknown".to_string()); // Fetch entries to get entry titles and count - let entries_result = fetcher.list_notebook_entries(at_ident.clone(), book_title.into()).await; + let entries_result = fetcher + .list_notebook_entries(at_ident.clone(), book_title.into()) + .await; let (entry_count, entry_titles) = match entries_result { Ok(Some(entries)) => { let count = entries.len(); @@ -659,7 +714,8 @@ pub async fn og_notebook_image( .iter() .take(4) .map(|e| { - e.entry.title + e.entry + .title .as_ref() .map(|t| t.as_ref().to_string()) .unwrap_or_else(|| "Untitled".to_string()) @@ -671,11 +727,16 @@ pub async fn og_notebook_image( }; // Generate image - let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles) { + let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles) + { Ok(bytes) => bytes, Err(e) => { tracing::error!("Failed to generate notebook OG image: {:?}", e); - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); + return Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate image", + ) + .into_response()); } }; @@ -688,17 +749,19 @@ pub async fn og_notebook_image( (CACHE_CONTROL, "public, max-age=3600"), ], png_bytes, - ).into_response()) + ) + .into_response()) } // Route: /og/profile/{ident}.png - OpenGraph image for profile/repository #[cfg(all(feature = "fullstack-server", feature = "server"))] #[get("/og/profile/{ident}", fetcher: Extension>)] -pub async fn og_profile_image( - ident: SmolStr, -) -> Result { +pub async fn og_profile_image(ident: SmolStr) -> Result { use axum::{ - http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, + http::{ + StatusCode, + header::{CACHE_CONTROL, CONTENT_TYPE}, + }, response::IntoResponse, }; use weaver_api::sh_weaver::actor::ProfileDataViewInner; @@ -717,7 +780,9 @@ pub async fn og_profile_image( Ok(data) => data, Err(e) => { tracing::error!("Failed to fetch profile for OG image: {:?}", e); - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response()); + return Ok( + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response(), + ); } }; @@ -725,17 +790,29 @@ pub async fn og_profile_image( // Use DID as cache key since profiles don't have a CID field let (display_name, handle, bio, avatar_url, banner_url, cache_id) = match &profile_view.inner { ProfileDataViewInner::ProfileView(p) => ( - p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(), + p.display_name + .as_ref() + .map(|n| n.as_ref().to_string()) + .unwrap_or_default(), p.handle.as_ref().to_string(), - p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(), + p.description + .as_ref() + .map(|d| d.as_ref().to_string()) + .unwrap_or_default(), p.avatar.as_ref().map(|u| u.as_ref().to_string()), None::, p.did.as_ref().to_string(), ), ProfileDataViewInner::ProfileViewDetailed(p) => ( - p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(), + p.display_name + .as_ref() + .map(|n| n.as_ref().to_string()) + .unwrap_or_default(), p.handle.as_ref().to_string(), - p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(), + p.description + .as_ref() + .map(|d| d.as_ref().to_string()) + .unwrap_or_default(), p.avatar.as_ref().map(|u| u.as_ref().to_string()), p.banner.as_ref().map(|u| u.as_ref().to_string()), p.did.as_ref().to_string(), @@ -762,7 +839,8 @@ pub async fn og_profile_image( (CACHE_CONTROL, "public, max-age=3600"), ], cached, - ).into_response()); + ) + .into_response()); } // Fetch notebook count @@ -828,9 +906,18 @@ pub async fn og_profile_image( ) { Ok(bytes) => bytes, Err(e) => { - tracing::error!("Failed to generate profile banner OG image: {:?}, falling back", e); - og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) - .unwrap_or_default() + tracing::error!( + "Failed to generate profile banner OG image: {:?}, falling back", + e + ); + og::generate_profile_og( + &display_name, + &handle, + &bio, + avatar_data, + notebook_count, + ) + .unwrap_or_default() } } } else { @@ -842,7 +929,11 @@ pub async fn og_profile_image( Ok(bytes) => bytes, Err(e) => { tracing::error!("Failed to generate profile OG image: {:?}", e); - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); + return Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate image", + ) + .into_response()); } } }; @@ -856,7 +947,8 @@ pub async fn og_profile_image( (CACHE_CONTROL, "public, max-age=3600"), ], png_bytes, - ).into_response()) + ) + .into_response()) } // Route: /og/site.png - OpenGraph image for homepage @@ -864,19 +956,24 @@ pub async fn og_profile_image( #[get("/og/site.png")] pub async fn og_site_image() -> Result { use axum::{ - http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, + http::{ + StatusCode, + header::{CACHE_CONTROL, CONTENT_TYPE}, + }, response::IntoResponse, }; // Site OG is static, cache aggressively static SITE_OG_CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); - let png_bytes = SITE_OG_CACHE.get_or_init(|| { - og::generate_site_og().unwrap_or_default() - }); + let png_bytes = SITE_OG_CACHE.get_or_init(|| og::generate_site_og().unwrap_or_default()); if png_bytes.is_empty() { - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); + return Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate image", + ) + .into_response()); } Ok(( @@ -885,7 +982,8 @@ pub async fn og_site_image() -> Result { (CACHE_CONTROL, "public, max-age=86400"), ], png_bytes.clone(), - ).into_response()) + ) + .into_response()) } // #[server(endpoint = "static_routes", output = server_fn::codec::Json)]