server side cache back in

Orual e2b0d156 21e174ee

+329 -373
+44
crates/weaver-app/src/data.rs
··· 655 async move { 656 match fetcher.fetch_entries_from_ufos().await { 657 Ok(entries) => { 658 Some( 659 entries 660 .iter() ··· 945 async move { 946 match fetcher.get_entry_by_rkey(ident(), rkey()).await { 947 Ok(Some(data)) => { 948 let entry_json = serde_json::to_value(&data.entry).ok()?; 949 let entry_view_json = serde_json::to_value(&data.entry_view).ok()?; 950 let notebook_ctx_json = data.notebook_context.as_ref().map(|ctx| {
··· 655 async move { 656 match fetcher.fetch_entries_from_ufos().await { 657 Ok(entries) => { 658 + // Cache blobs for each entry's embedded images 659 + for arc in &entries { 660 + let (view, entry, _) = arc.as_ref(); 661 + if let Some(embeds) = &entry.embeds { 662 + if let Some(images) = &embeds.images { 663 + use jacquard::smol_str::ToSmolStr; 664 + use jacquard::types::aturi::AtUri; 665 + // Extract ident from the entry's at-uri 666 + if let Ok(at_uri) = AtUri::new(view.uri.as_ref()) { 667 + let ident = at_uri.authority().to_smolstr(); 668 + for image in &images.images { 669 + let cid = image.image.blob().cid(); 670 + cache_blob( 671 + ident.clone(), 672 + cid.to_smolstr(), 673 + image.name.as_ref().map(|n| n.to_smolstr()), 674 + ) 675 + .await 676 + .ok(); 677 + } 678 + } 679 + } 680 + } 681 + } 682 Some( 683 entries 684 .iter() ··· 969 async move { 970 match fetcher.get_entry_by_rkey(ident(), rkey()).await { 971 Ok(Some(data)) => { 972 + // Cache blobs for embedded images 973 + if let Some(embeds) = &data.entry.embeds { 974 + if let Some(images) = &embeds.images { 975 + use jacquard::smol_str::ToSmolStr; 976 + use jacquard::types::aturi::AtUri; 977 + if let Ok(at_uri) = AtUri::new(data.entry_view.uri.as_ref()) { 978 + let ident_str = at_uri.authority().to_smolstr(); 979 + for image in &images.images { 980 + let cid = image.image.blob().cid(); 981 + cache_blob( 982 + ident_str.clone(), 983 + cid.to_smolstr(), 984 + image.name.as_ref().map(|n| n.to_smolstr()), 985 + ) 986 + .await 987 + .ok(); 988 + } 989 + } 990 + } 991 + } 992 let entry_json = serde_json::to_value(&data.entry).ok()?; 993 let entry_view_json = serde_json::to_value(&data.entry_view).ok()?; 994 let notebook_ctx_json = data.notebook_context.as_ref().map(|ctx| {
+142 -328
crates/weaver-app/src/fetch.rs
··· 352 #[derive(Clone)] 353 pub struct Fetcher { 354 pub client: Arc<Client>, 355 } 356 357 //#[cfg(not(feature = "server"))] ··· 359 pub fn new(client: OAuthClient<JacquardResolver, AuthStore>) -> Self { 360 Self { 361 client: Arc::new(Client::new(client)), 362 } 363 } 364 ··· 396 ident: AtIdentifier<'static>, 397 title: SmolStr, 398 ) -> Result<Option<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> { 399 let client = self.get_client(); 400 if let Some((notebook, entries)) = client 401 .notebook_by_title(&ident, &title) ··· 403 .map_err(|e| dioxus::CapturedError::from_display(e))? 404 { 405 let stored = Arc::new((notebook, entries)); 406 Ok(Some(stored)) 407 } else { 408 Err(dioxus::CapturedError::from_display("Notebook not found")) ··· 415 book_title: SmolStr, 416 entry_title: SmolStr, 417 ) -> Result<Option<Arc<(BookEntryView<'static>, Entry<'static>)>>> { 418 if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { 419 let (notebook, entries) = result.as_ref(); 420 let client = self.get_client(); ··· 424 .map_err(|e| dioxus::CapturedError::from_display(e))? 425 { 426 let stored = Arc::new(entry); 427 Ok(Some(stored)) 428 } else { 429 Err(dioxus::CapturedError::from_display("Entry not found")) ··· 459 ); 460 let uri = AtUri::new_owned(uri_str) 461 .map_err(|e| dioxus::CapturedError::from_display(format!("Invalid URI: {}", e)))?; 462 - 463 - // Fetch the full notebook view (which hydrates authors) 464 match client.view_notebook(&uri).await { 465 Ok((notebook, entries)) => { 466 let ident = uri.authority().clone().into_static(); ··· 471 .unwrap_or_else(|| SmolStr::new("Untitled")); 472 473 let result = Arc::new((notebook, entries)); 474 notebooks.push(result); 475 } 476 Err(_) => continue, // Skip notebooks that fail to load ··· 488 489 let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.entry"; 490 491 - let response = reqwest::get(url) 492 - .await 493 - .map_err(|e| { 494 - tracing::error!("[fetch_entries_from_ufos] request failed: {:?}", e); 495 - dioxus::CapturedError::from_display(e) 496 - })?; 497 - 498 - let mut records: Vec<UfosRecord> = response 499 - .json() 500 - .await 501 - .map_err(|e| { 502 - tracing::error!("[fetch_entries_from_ufos] json parse failed: {:?}", e); 503 - dioxus::CapturedError::from_display(e) 504 - })?; 505 506 - // Sort by time_us descending (reverse chronological) 507 records.sort_by(|a, b| b.time_us.cmp(&a.time_us)); 508 509 let mut entries = Vec::new(); 510 let client = self.get_client(); 511 512 for ufos_record in records { 513 - // Parse DID 514 let did = match Did::new(&ufos_record.did) { 515 Ok(d) => d.into_static(), 516 Err(e) => { 517 - tracing::warn!("[fetch_entries_from_ufos] invalid DID {}: {:?}", ufos_record.did, e); 518 continue; 519 } 520 }; 521 let ident = AtIdentifier::Did(did); 522 - 523 - // Fetch the entry view 524 - match client 525 - .fetch_entry_by_rkey(&ident, &ufos_record.rkey) 526 - .await 527 - { 528 Ok((entry_view, entry)) => { 529 entries.push(Arc::new(( 530 entry_view.into_static(), ··· 533 ))); 534 } 535 Err(e) => { 536 - tracing::warn!("[fetch_entries_from_ufos] failed to load entry {}: {:?}", ufos_record.rkey, e); 537 continue; 538 } 539 } ··· 592 // View the notebook (which hydrates authors) 593 match client.view_notebook(&record.uri).await { 594 Ok((notebook, entries)) => { 595 let result = Arc::new((notebook, entries)); 596 notebooks.push(result); 597 } 598 Err(_) => continue, // Skip notebooks that fail to load ··· 607 ident: AtIdentifier<'static>, 608 book_title: SmolStr, 609 ) -> Result<Option<Vec<BookEntryView<'static>>>> { 610 if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { 611 - let (notebook, entries) = result.as_ref(); 612 let mut book_entries = Vec::new(); 613 let client = self.get_client(); 614 615 - for index in 0..entries.len() { 616 - match client.view_entry(notebook, entries, index).await { 617 - Ok(book_entry) => book_entries.push(book_entry), 618 - Err(_) => continue, // Skip entries that fail to load 619 } 620 } 621 ··· 629 &self, 630 ident: &AtIdentifier<'_>, 631 ) -> Result<Arc<ProfileDataView<'static>>> { 632 let client = self.get_client(); 633 634 let did = match ident { ··· 644 .await 645 .map_err(|e| dioxus::CapturedError::from_display(e))?; 646 647 - Ok(Arc::new(profile_view)) 648 } 649 650 /// Fetch an entry by rkey with optional notebook context lookup. ··· 654 rkey: SmolStr, 655 ) -> Result<Option<Arc<StandaloneEntryData>>> { 656 use jacquard::types::aturi::AtUri; 657 658 let client = self.get_client(); 659 ··· 711 None 712 }; 713 714 - Ok(Some(Arc::new(StandaloneEntryData { 715 entry, 716 entry_view, 717 notebook_context, 718 - }))) 719 } 720 721 /// Fetch an entry by rkey within a specific notebook context. ··· 729 rkey: SmolStr, 730 ) -> Result<Option<Arc<(BookEntryView<'static>, Entry<'static>)>>> { 731 use jacquard::types::aturi::AtUri; 732 733 let client = self.get_client(); 734 ··· 783 .build(); 784 } 785 786 - Ok(Some(Arc::new((book_entry_view.into_static(), entry)))) 787 } 788 } 789 - 790 - // #[cfg(feature = "server")] 791 - // #[derive(Clone)] 792 - // pub struct Fetcher { 793 - // pub client: Arc<Client>, 794 - // book_cache: cache_impl::Cache< 795 - // (AtIdentifier<'static>, SmolStr), 796 - // Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>, 797 - // >, 798 - // entry_cache: cache_impl::Cache< 799 - // (AtIdentifier<'static>, SmolStr), 800 - // Arc<(BookEntryView<'static>, Entry<'static>)>, 801 - // >, 802 - // profile_cache: cache_impl::Cache<AtIdentifier<'static>, Arc<ProfileDataView<'static>>>, 803 - // } 804 - 805 - // // /// SAFETY: This isn't thread-safe on WASM, but we aren't multithreaded on WASM 806 - // //#[cfg(feature = "server")] 807 - // unsafe impl Sync for Fetcher {} 808 - 809 - // // /// SAFETY: This isn't thread-safe on WASM, but we aren't multithreaded on WASM 810 - // //#[cfg(feature = "server")] 811 - // unsafe impl Send for Fetcher {} 812 - 813 - // #[cfg(feature = "server")] 814 - // impl Fetcher { 815 - // pub fn new(client: OAuthClient<JacquardResolver, AuthStore>) -> Self { 816 - // Self { 817 - // client: Arc::new(Client::new(client)), 818 - // book_cache: cache_impl::new_cache(100, Duration::from_secs(30)), 819 - // entry_cache: cache_impl::new_cache(100, Duration::from_secs(30)), 820 - // profile_cache: cache_impl::new_cache(100, Duration::from_secs(1800)), 821 - // } 822 - // } 823 - 824 - // pub async fn upgrade_to_authenticated( 825 - // &self, 826 - // session: OAuthSession<JacquardResolver, crate::auth::AuthStore>, 827 - // ) { 828 - // let mut session_slot = self.client.session.write().await; 829 - // *session_slot = Some(Arc::new(Agent::new(session))); 830 - // } 831 - 832 - // pub async fn downgrade_to_unauthenticated(&self) { 833 - // let mut session_slot = self.client.session.write().await; 834 - // if let Some(session) = session_slot.take() { 835 - // session.inner().logout().await.ok(); 836 - // } 837 - // } 838 - 839 - // #[allow(dead_code)] 840 - // pub async fn current_did(&self) -> Option<Did<'static>> { 841 - // let session_slot = self.client.session.read().await; 842 - // if let Some(session) = session_slot.as_ref() { 843 - // session.info().await.map(|(d, _)| d) 844 - // } else { 845 - // None 846 - // } 847 - // } 848 - 849 - // pub fn get_client(&self) -> Arc<Client> { 850 - // self.client.clone() 851 - // } 852 - 853 - // pub async fn get_notebook( 854 - // &self, 855 - // ident: AtIdentifier<'static>, 856 - // title: SmolStr, 857 - // ) -> Result<Option<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> { 858 - // if let Some(entry) = cache_impl::get(&self.book_cache, &(ident.clone(), title.clone())) { 859 - // Ok(Some(entry)) 860 - // } else { 861 - // let client = self.get_client(); 862 - // if let Some((notebook, entries)) = client 863 - // .notebook_by_title(&ident, &title) 864 - // .await 865 - // .map_err(|e| dioxus::CapturedError::from_display(e))? 866 - // { 867 - // let stored = Arc::new((notebook, entries)); 868 - // cache_impl::insert(&self.book_cache, (ident, title), stored.clone()); 869 - // Ok(Some(stored)) 870 - // } else { 871 - // Ok(None) 872 - // } 873 - // } 874 - // } 875 - 876 - // pub async fn get_entry( 877 - // &self, 878 - // ident: AtIdentifier<'static>, 879 - // book_title: SmolStr, 880 - // entry_title: SmolStr, 881 - // ) -> Result<Option<Arc<(BookEntryView<'static>, Entry<'static>)>>> { 882 - // if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { 883 - // let (notebook, entries) = result.as_ref(); 884 - // if let Some(entry) = 885 - // cache_impl::get(&self.entry_cache, &(ident.clone(), entry_title.clone())) 886 - // { 887 - // Ok(Some(entry)) 888 - // } else { 889 - // let client = self.get_client(); 890 - // if let Some(entry) = client 891 - // .entry_by_title(notebook, entries.as_ref(), &entry_title) 892 - // .await 893 - // .map_err(|e| dioxus::CapturedError::from_display(e))? 894 - // { 895 - // let stored = Arc::new(entry); 896 - // cache_impl::insert(&self.entry_cache, (ident, entry_title), stored.clone()); 897 - // Ok(Some(stored)) 898 - // } else { 899 - // Ok(None) 900 - // } 901 - // } 902 - // } else { 903 - // Ok(None) 904 - // } 905 - // } 906 - 907 - // pub async fn fetch_notebooks_from_ufos( 908 - // &self, 909 - // ) -> Result<Vec<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> { 910 - // use jacquard::{IntoStatic, types::aturi::AtUri}; 911 - 912 - // let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.book"; 913 - // let response = reqwest::get(url) 914 - // .await 915 - // .map_err(|e| dioxus::CapturedError::from_display(e))?; 916 - 917 - // let records: Vec<UfosRecord> = response 918 - // .json() 919 - // .await 920 - // .map_err(|e| dioxus::CapturedError::from_display(e))?; 921 - 922 - // let mut notebooks = Vec::new(); 923 - // let client = self.get_client(); 924 - 925 - // for ufos_record in records { 926 - // // Construct URI 927 - // let uri_str = format!( 928 - // "at://{}/{}/{}", 929 - // ufos_record.did, ufos_record.collection, ufos_record.rkey 930 - // ); 931 - // let uri = AtUri::new_owned(uri_str) 932 - // .map_err(|e| dioxus::CapturedError::from_display(format!("Invalid URI: {}", e)))?; 933 - 934 - // // Fetch the full notebook view (which hydrates authors) 935 - // match client.view_notebook(&uri).await { 936 - // Ok((notebook, entries)) => { 937 - // let ident = uri.authority().clone().into_static(); 938 - // let title = notebook 939 - // .title 940 - // .as_ref() 941 - // .map(|t| SmolStr::new(t.as_ref())) 942 - // .unwrap_or_else(|| SmolStr::new("Untitled")); 943 - 944 - // let result = Arc::new((notebook, entries)); 945 - // // Cache it 946 - // cache_impl::insert(&self.book_cache, (ident, title), result.clone()); 947 - // notebooks.push(result); 948 - // } 949 - // Err(_) => continue, // Skip notebooks that fail to load 950 - // } 951 - // } 952 - 953 - // Ok(notebooks) 954 - // } 955 - 956 - // pub async fn fetch_notebooks_for_did( 957 - // &self, 958 - // ident: &AtIdentifier<'_>, 959 - // ) -> Result<Vec<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> { 960 - // use jacquard::{ 961 - // IntoStatic, 962 - // types::{collection::Collection, nsid::Nsid}, 963 - // xrpc::XrpcExt, 964 - // }; 965 - // use weaver_api::{ 966 - // com_atproto::repo::list_records::ListRecords, sh_weaver::notebook::book::Book, 967 - // }; 968 - 969 - // let client = self.get_client(); 970 - 971 - // // Resolve DID and PDS 972 - // let (repo_did, pds_url) = match ident { 973 - // AtIdentifier::Did(did) => { 974 - // let pds = client 975 - // .pds_for_did(did) 976 - // .await 977 - // .map_err(|e| dioxus::CapturedError::from_display(e))?; 978 - // (did.clone(), pds) 979 - // } 980 - // AtIdentifier::Handle(handle) => client 981 - // .pds_for_handle(handle) 982 - // .await 983 - // .map_err(|e| dioxus::CapturedError::from_display(e))?, 984 - // }; 985 - 986 - // // Fetch all notebook records for this repo 987 - // let resp = client 988 - // .xrpc(pds_url) 989 - // .send( 990 - // &ListRecords::new() 991 - // .repo(repo_did) 992 - // .collection(Nsid::raw(Book::NSID)) 993 - // .limit(100) 994 - // .build(), 995 - // ) 996 - // .await 997 - // .map_err(|e| dioxus::CapturedError::from_display(e))?; 998 - 999 - // let mut notebooks = Vec::new(); 1000 - 1001 - // if let Ok(list) = resp.parse() { 1002 - // for record in list.records { 1003 - // // View the notebook (which hydrates authors) 1004 - // match client.view_notebook(&record.uri).await { 1005 - // Ok((notebook, entries)) => { 1006 - // let ident = record.uri.authority().clone().into_static(); 1007 - // let title = notebook 1008 - // .title 1009 - // .as_ref() 1010 - // .map(|t| SmolStr::new(t.as_ref())) 1011 - // .unwrap_or_else(|| SmolStr::new("Untitled")); 1012 - 1013 - // let result = Arc::new((notebook, entries)); 1014 - // // Cache it 1015 - // cache_impl::insert(&self.book_cache, (ident, title), result.clone()); 1016 - // notebooks.push(result); 1017 - // } 1018 - // Err(_) => continue, // Skip notebooks that fail to load 1019 - // } 1020 - // } 1021 - // } 1022 - 1023 - // Ok(notebooks) 1024 - // } 1025 - 1026 - // pub async fn list_notebook_entries( 1027 - // &self, 1028 - // ident: AtIdentifier<'static>, 1029 - // book_title: SmolStr, 1030 - // ) -> Result<Option<Vec<BookEntryView<'static>>>> { 1031 - // if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { 1032 - // let (notebook, entries) = result.as_ref(); 1033 - // let mut book_entries = Vec::new(); 1034 - // let client = self.get_client(); 1035 - 1036 - // for index in 0..entries.len() { 1037 - // match client.view_entry(notebook, entries, index).await { 1038 - // Ok(book_entry) => book_entries.push(book_entry), 1039 - // Err(_) => continue, // Skip entries that fail to load 1040 - // } 1041 - // } 1042 - 1043 - // Ok(Some(book_entries)) 1044 - // } else { 1045 - // Ok(None) 1046 - // } 1047 - // } 1048 - 1049 - // pub async fn fetch_profile( 1050 - // &self, 1051 - // ident: &AtIdentifier<'_>, 1052 - // ) -> Result<Arc<ProfileDataView<'static>>> { 1053 - // use jacquard::IntoStatic; 1054 - 1055 - // let ident_static = ident.clone().into_static(); 1056 - 1057 - // if let Some(cached) = cache_impl::get(&self.profile_cache, &ident_static) { 1058 - // return Ok(cached); 1059 - // } 1060 - 1061 - // let client = self.get_client(); 1062 - 1063 - // let did = match ident { 1064 - // AtIdentifier::Did(d) => d.clone(), 1065 - // AtIdentifier::Handle(h) => client 1066 - // .resolve_handle(h) 1067 - // .await 1068 - // .map_err(|e| dioxus::CapturedError::from_display(e))?, 1069 - // }; 1070 - 1071 - // let (_uri, profile_view) = client 1072 - // .hydrate_profile_view(&did) 1073 - // .await 1074 - // .map_err(|e| dioxus::CapturedError::from_display(e))?; 1075 - 1076 - // let result = Arc::new(profile_view); 1077 - // cache_impl::insert(&self.profile_cache, ident_static, result.clone()); 1078 - 1079 - // Ok(result) 1080 - // } 1081 - // } 1082 1083 impl HttpClient for Fetcher { 1084 type Error = IdentityError;
··· 352 #[derive(Clone)] 353 pub struct Fetcher { 354 pub client: Arc<Client>, 355 + #[cfg(feature = "server")] 356 + book_cache: cache_impl::Cache< 357 + (AtIdentifier<'static>, SmolStr), 358 + Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>, 359 + >, 360 + #[cfg(feature = "server")] 361 + entry_cache: cache_impl::Cache< 362 + (AtIdentifier<'static>, SmolStr), 363 + Arc<(BookEntryView<'static>, Entry<'static>)>, 364 + >, 365 + #[cfg(feature = "server")] 366 + profile_cache: cache_impl::Cache<AtIdentifier<'static>, Arc<ProfileDataView<'static>>>, 367 + #[cfg(feature = "server")] 368 + standalone_entry_cache: 369 + cache_impl::Cache<(AtIdentifier<'static>, SmolStr), Arc<StandaloneEntryData>>, 370 } 371 372 //#[cfg(not(feature = "server"))] ··· 374 pub fn new(client: OAuthClient<JacquardResolver, AuthStore>) -> Self { 375 Self { 376 client: Arc::new(Client::new(client)), 377 + #[cfg(feature = "server")] 378 + book_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)), 379 + #[cfg(feature = "server")] 380 + entry_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)), 381 + #[cfg(feature = "server")] 382 + profile_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(1800)), 383 + #[cfg(feature = "server")] 384 + standalone_entry_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)), 385 } 386 } 387 ··· 419 ident: AtIdentifier<'static>, 420 title: SmolStr, 421 ) -> Result<Option<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> { 422 + #[cfg(feature = "server")] 423 + if let Some(cached) = cache_impl::get(&self.book_cache, &(ident.clone(), title.clone())) { 424 + return Ok(Some(cached)); 425 + } 426 + 427 let client = self.get_client(); 428 if let Some((notebook, entries)) = client 429 .notebook_by_title(&ident, &title) ··· 431 .map_err(|e| dioxus::CapturedError::from_display(e))? 432 { 433 let stored = Arc::new((notebook, entries)); 434 + #[cfg(feature = "server")] 435 + cache_impl::insert(&self.book_cache, (ident, title), stored.clone()); 436 Ok(Some(stored)) 437 } else { 438 Err(dioxus::CapturedError::from_display("Notebook not found")) ··· 445 book_title: SmolStr, 446 entry_title: SmolStr, 447 ) -> Result<Option<Arc<(BookEntryView<'static>, Entry<'static>)>>> { 448 + #[cfg(feature = "server")] 449 + if let Some(cached) = 450 + cache_impl::get(&self.entry_cache, &(ident.clone(), entry_title.clone())) 451 + { 452 + return Ok(Some(cached)); 453 + } 454 + 455 if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { 456 let (notebook, entries) = result.as_ref(); 457 let client = self.get_client(); ··· 461 .map_err(|e| dioxus::CapturedError::from_display(e))? 462 { 463 let stored = Arc::new(entry); 464 + #[cfg(feature = "server")] 465 + cache_impl::insert(&self.entry_cache, (ident, entry_title), stored.clone()); 466 Ok(Some(stored)) 467 } else { 468 Err(dioxus::CapturedError::from_display("Entry not found")) ··· 498 ); 499 let uri = AtUri::new_owned(uri_str) 500 .map_err(|e| dioxus::CapturedError::from_display(format!("Invalid URI: {}", e)))?; 501 match client.view_notebook(&uri).await { 502 Ok((notebook, entries)) => { 503 let ident = uri.authority().clone().into_static(); ··· 508 .unwrap_or_else(|| SmolStr::new("Untitled")); 509 510 let result = Arc::new((notebook, entries)); 511 + #[cfg(feature = "server")] 512 + cache_impl::insert(&self.book_cache, (ident, title), result.clone()); 513 notebooks.push(result); 514 } 515 Err(_) => continue, // Skip notebooks that fail to load ··· 527 528 let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.entry"; 529 530 + let response = reqwest::get(url).await.map_err(|e| { 531 + tracing::error!("[fetch_entries_from_ufos] request failed: {:?}", e); 532 + dioxus::CapturedError::from_display(e) 533 + })?; 534 535 + let mut records: Vec<UfosRecord> = response.json().await.map_err(|e| { 536 + tracing::error!("[fetch_entries_from_ufos] json parse failed: {:?}", e); 537 + dioxus::CapturedError::from_display(e) 538 + })?; 539 records.sort_by(|a, b| b.time_us.cmp(&a.time_us)); 540 541 let mut entries = Vec::new(); 542 let client = self.get_client(); 543 544 for ufos_record in records { 545 let did = match Did::new(&ufos_record.did) { 546 Ok(d) => d.into_static(), 547 Err(e) => { 548 + tracing::warn!( 549 + "[fetch_entries_from_ufos] invalid DID {}: {:?}", 550 + ufos_record.did, 551 + e 552 + ); 553 continue; 554 } 555 }; 556 let ident = AtIdentifier::Did(did); 557 + match client.fetch_entry_by_rkey(&ident, &ufos_record.rkey).await { 558 Ok((entry_view, entry)) => { 559 entries.push(Arc::new(( 560 entry_view.into_static(), ··· 563 ))); 564 } 565 Err(e) => { 566 + tracing::warn!( 567 + "[fetch_entries_from_ufos] failed to load entry {}: {:?}", 568 + ufos_record.rkey, 569 + e 570 + ); 571 continue; 572 } 573 } ··· 626 // View the notebook (which hydrates authors) 627 match client.view_notebook(&record.uri).await { 628 Ok((notebook, entries)) => { 629 + let ident = record.uri.authority().clone().into_static(); 630 + let title = notebook 631 + .title 632 + .as_ref() 633 + .map(|t| SmolStr::new(t.as_ref())) 634 + .unwrap_or_else(|| SmolStr::new("Untitled")); 635 + 636 let result = Arc::new((notebook, entries)); 637 + #[cfg(feature = "server")] 638 + cache_impl::insert(&self.book_cache, (ident, title), result.clone()); 639 notebooks.push(result); 640 } 641 Err(_) => continue, // Skip notebooks that fail to load ··· 650 ident: AtIdentifier<'static>, 651 book_title: SmolStr, 652 ) -> Result<Option<Vec<BookEntryView<'static>>>> { 653 + use jacquard::types::aturi::AtUri; 654 + 655 if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { 656 + let (notebook, entry_refs) = result.as_ref(); 657 let mut book_entries = Vec::new(); 658 let client = self.get_client(); 659 660 + for (index, entry_ref) in entry_refs.iter().enumerate() { 661 + // Try to extract rkey from URI 662 + let rkey = AtUri::new(entry_ref.uri.as_ref()) 663 + .ok() 664 + .and_then(|uri| uri.rkey().map(|r| SmolStr::new(r.as_ref()))); 665 + 666 + // Check cache first 667 + #[cfg(feature = "server")] 668 + if let Some(ref rkey) = rkey { 669 + if let Some(cached) = 670 + cache_impl::get(&self.entry_cache, &(ident.clone(), rkey.clone())) 671 + { 672 + book_entries.push(cached.0.clone()); 673 + continue; 674 + } 675 + } 676 + 677 + // Fetch if not cached 678 + if let Ok(book_entry) = client.view_entry(notebook, entry_refs, index).await { 679 + // Try to populate cache by deserializing Entry from the view's record 680 + #[cfg(feature = "server")] 681 + if let Some(rkey) = rkey { 682 + use jacquard::IntoStatic; 683 + use weaver_api::sh_weaver::notebook::entry::Entry; 684 + if let Ok(entry) = 685 + jacquard::from_data::<Entry<'_>>(&book_entry.entry.record) 686 + { 687 + let cached = 688 + Arc::new((book_entry.clone().into_static(), entry.into_static())); 689 + cache_impl::insert(&self.entry_cache, (ident.clone(), rkey), cached); 690 + } 691 + } 692 + book_entries.push(book_entry); 693 } 694 } 695 ··· 703 &self, 704 ident: &AtIdentifier<'_>, 705 ) -> Result<Arc<ProfileDataView<'static>>> { 706 + use jacquard::IntoStatic; 707 + 708 + let ident_static = ident.clone().into_static(); 709 + 710 + #[cfg(feature = "server")] 711 + if let Some(cached) = cache_impl::get(&self.profile_cache, &ident_static) { 712 + return Ok(cached); 713 + } 714 + 715 let client = self.get_client(); 716 717 let did = match ident { ··· 727 .await 728 .map_err(|e| dioxus::CapturedError::from_display(e))?; 729 730 + let result = Arc::new(profile_view); 731 + #[cfg(feature = "server")] 732 + cache_impl::insert(&self.profile_cache, ident_static, result.clone()); 733 + 734 + Ok(result) 735 } 736 737 /// Fetch an entry by rkey with optional notebook context lookup. ··· 741 rkey: SmolStr, 742 ) -> Result<Option<Arc<StandaloneEntryData>>> { 743 use jacquard::types::aturi::AtUri; 744 + 745 + #[cfg(feature = "server")] 746 + if let Some(cached) = 747 + cache_impl::get(&self.standalone_entry_cache, &(ident.clone(), rkey.clone())) 748 + { 749 + return Ok(Some(cached)); 750 + } 751 752 let client = self.get_client(); 753 ··· 805 None 806 }; 807 808 + let result = Arc::new(StandaloneEntryData { 809 entry, 810 entry_view, 811 notebook_context, 812 + }); 813 + #[cfg(feature = "server")] 814 + cache_impl::insert(&self.standalone_entry_cache, (ident, rkey), result.clone()); 815 + 816 + Ok(Some(result)) 817 } 818 819 /// Fetch an entry by rkey within a specific notebook context. ··· 827 rkey: SmolStr, 828 ) -> Result<Option<Arc<(BookEntryView<'static>, Entry<'static>)>>> { 829 use jacquard::types::aturi::AtUri; 830 + 831 + #[cfg(feature = "server")] 832 + if let Some(cached) = cache_impl::get(&self.entry_cache, &(ident.clone(), rkey.clone())) { 833 + return Ok(Some(cached)); 834 + } 835 836 let client = self.get_client(); 837 ··· 886 .build(); 887 } 888 889 + let result = Arc::new((book_entry_view.into_static(), entry)); 890 + #[cfg(feature = "server")] 891 + cache_impl::insert(&self.entry_cache, (ident, rkey), result.clone()); 892 + 893 + Ok(Some(result)) 894 } 895 } 896 897 impl HttpClient for Fetcher { 898 type Error = IdentityError;
+143 -45
crates/weaver-app/src/main.rs
··· 31 #[cfg(feature = "server")] 32 mod blobcache; 33 mod cache_impl; 34 - #[cfg(feature = "server")] 35 - mod og; 36 /// Define a components module that contains all shared components for our app. 37 mod components; 38 mod config; 39 mod data; 40 mod env; 41 mod fetch; 42 mod record_utils; 43 mod service_worker; 44 /// Define a views module that contains the UI for all Layouts and Routes for our app. ··· 207 } 208 } 209 })) 210 }; 211 Ok(router) 212 }); ··· 432 entry_title: SmolStr, 433 ) -> Result<axum::response::Response> { 434 use axum::{ 435 - http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, 436 response::IntoResponse, 437 }; 438 use weaver_api::sh_weaver::actor::ProfileDataViewInner; ··· 446 }; 447 448 // Fetch entry data 449 - let entry_result = fetcher.get_entry(at_ident.clone(), book_title.clone(), entry_title.into()).await; 450 451 let arc_data = match entry_result { 452 Ok(Some(data)) => data, ··· 470 (CACHE_CONTROL, "public, max-age=3600"), 471 ], 472 cached, 473 - ).into_response()); 474 } 475 476 // Extract metadata ··· 480 // TODO: Could fetch actual notebook record to get display title 481 let notebook_title_str: String = book_title.to_string(); 482 483 - let author_handle = book_entry.entry.authors.first() 484 .map(|a| match &a.record.inner { 485 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), 486 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), ··· 504 // Build CDN URL 505 let cdn_url = format!( 506 "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 507 - did.as_str(), cid.as_ref(), format 508 ); 509 510 // Fetch the image ··· 513 match response.bytes().await { 514 Ok(bytes) => { 515 use base64::Engine; 516 - let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); 517 Some(format!("data:{};base64,{}", mime, base64_str)) 518 } 519 - Err(_) => None 520 } 521 } 522 - _ => None 523 } 524 } else { 525 None ··· 556 match og::generate_hero_image(hero_data, title, &notebook_title_str, &author_handle) { 557 Ok(bytes) => bytes, 558 Err(e) => { 559 - tracing::error!("Failed to generate hero OG image: {:?}, falling back to text", e); 560 og::generate_text_only(title, &content_snippet, &notebook_title_str, &author_handle) 561 .map_err(|e| { 562 tracing::error!("Failed to generate text OG image: {:?}", e); ··· 570 Ok(bytes) => bytes, 571 Err(e) => { 572 tracing::error!("Failed to generate OG image: {:?}", e); 573 - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); 574 } 575 } 576 }; ··· 584 (CACHE_CONTROL, "public, max-age=3600"), 585 ], 586 png_bytes, 587 - ).into_response()) 588 } 589 590 // Route: /og/notebook/{ident}/{book_title}.png - OpenGraph image for notebook index ··· 595 book_title: SmolStr, 596 ) -> Result<axum::response::Response> { 597 use axum::{ 598 - http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, 599 response::IntoResponse, 600 }; 601 use weaver_api::sh_weaver::actor::ProfileDataViewInner; ··· 608 }; 609 610 // Fetch notebook data 611 - let notebook_result = fetcher.get_notebook(at_ident.clone(), book_title.into()).await; 612 613 let arc_data = match notebook_result { 614 Ok(Some(data)) => data, 615 Ok(None) => return Ok((StatusCode::NOT_FOUND, "Notebook not found").into_response()), 616 Err(e) => { 617 tracing::error!("Failed to fetch notebook for OG image: {:?}", e); 618 - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch notebook").into_response()); 619 } 620 }; 621 let (notebook_view, _entries) = arc_data.as_ref(); ··· 632 (CACHE_CONTROL, "public, max-age=3600"), 633 ], 634 cached, 635 - ).into_response()); 636 } 637 638 // Extract metadata 639 - let title = notebook_view.title 640 .as_ref() 641 .map(|t| t.as_ref()) 642 .unwrap_or("Untitled Notebook"); 643 644 - let author_handle = notebook_view.authors.first() 645 .map(|a| match &a.record.inner { 646 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), 647 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), ··· 651 .unwrap_or_else(|| "unknown".to_string()); 652 653 // Fetch entries to get entry titles and count 654 - let entries_result = fetcher.list_notebook_entries(at_ident.clone(), book_title.into()).await; 655 let (entry_count, entry_titles) = match entries_result { 656 Ok(Some(entries)) => { 657 let count = entries.len(); ··· 659 .iter() 660 .take(4) 661 .map(|e| { 662 - e.entry.title 663 .as_ref() 664 .map(|t| t.as_ref().to_string()) 665 .unwrap_or_else(|| "Untitled".to_string()) ··· 671 }; 672 673 // Generate image 674 - let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles) { 675 Ok(bytes) => bytes, 676 Err(e) => { 677 tracing::error!("Failed to generate notebook OG image: {:?}", e); 678 - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); 679 } 680 }; 681 ··· 688 (CACHE_CONTROL, "public, max-age=3600"), 689 ], 690 png_bytes, 691 - ).into_response()) 692 } 693 694 // Route: /og/profile/{ident}.png - OpenGraph image for profile/repository 695 #[cfg(all(feature = "fullstack-server", feature = "server"))] 696 #[get("/og/profile/{ident}", fetcher: Extension<Arc<fetch::Fetcher>>)] 697 - pub async fn og_profile_image( 698 - ident: SmolStr, 699 - ) -> Result<axum::response::Response> { 700 use axum::{ 701 - http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, 702 response::IntoResponse, 703 }; 704 use weaver_api::sh_weaver::actor::ProfileDataViewInner; ··· 717 Ok(data) => data, 718 Err(e) => { 719 tracing::error!("Failed to fetch profile for OG image: {:?}", e); 720 - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response()); 721 } 722 }; 723 ··· 725 // Use DID as cache key since profiles don't have a CID field 726 let (display_name, handle, bio, avatar_url, banner_url, cache_id) = match &profile_view.inner { 727 ProfileDataViewInner::ProfileView(p) => ( 728 - p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(), 729 p.handle.as_ref().to_string(), 730 - p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(), 731 p.avatar.as_ref().map(|u| u.as_ref().to_string()), 732 None::<String>, 733 p.did.as_ref().to_string(), 734 ), 735 ProfileDataViewInner::ProfileViewDetailed(p) => ( 736 - p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(), 737 p.handle.as_ref().to_string(), 738 - p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(), 739 p.avatar.as_ref().map(|u| u.as_ref().to_string()), 740 p.banner.as_ref().map(|u| u.as_ref().to_string()), 741 p.did.as_ref().to_string(), ··· 762 (CACHE_CONTROL, "public, max-age=3600"), 763 ], 764 cached, 765 - ).into_response()); 766 } 767 768 // Fetch notebook count ··· 828 ) { 829 Ok(bytes) => bytes, 830 Err(e) => { 831 - tracing::error!("Failed to generate profile banner OG image: {:?}, falling back", e); 832 - og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) 833 - .unwrap_or_default() 834 } 835 } 836 } else { ··· 842 Ok(bytes) => bytes, 843 Err(e) => { 844 tracing::error!("Failed to generate profile OG image: {:?}", e); 845 - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); 846 } 847 } 848 }; ··· 856 (CACHE_CONTROL, "public, max-age=3600"), 857 ], 858 png_bytes, 859 - ).into_response()) 860 } 861 862 // Route: /og/site.png - OpenGraph image for homepage ··· 864 #[get("/og/site.png")] 865 pub async fn og_site_image() -> Result<axum::response::Response> { 866 use axum::{ 867 - http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, 868 response::IntoResponse, 869 }; 870 871 // Site OG is static, cache aggressively 872 static SITE_OG_CACHE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new(); 873 874 - let png_bytes = SITE_OG_CACHE.get_or_init(|| { 875 - og::generate_site_og().unwrap_or_default() 876 - }); 877 878 if png_bytes.is_empty() { 879 - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); 880 } 881 882 Ok(( ··· 885 (CACHE_CONTROL, "public, max-age=86400"), 886 ], 887 png_bytes.clone(), 888 - ).into_response()) 889 } 890 891 // #[server(endpoint = "static_routes", output = server_fn::codec::Json)]
··· 31 #[cfg(feature = "server")] 32 mod blobcache; 33 mod cache_impl; 34 /// Define a components module that contains all shared components for our app. 35 mod components; 36 mod config; 37 mod data; 38 mod env; 39 mod fetch; 40 + #[cfg(feature = "server")] 41 + mod og; 42 mod record_utils; 43 mod service_worker; 44 /// Define a views module that contains the UI for all Layouts and Routes for our app. ··· 207 } 208 } 209 })) 210 + // .layer(axum::middleware::from_fn( 211 + // |request: Request, next: Next| async move { 212 + // let mut res = next.run(request).await; 213 + 214 + // // Cache all HTML responses 215 + // if res 216 + // .headers() 217 + // .get("content-type") 218 + // .and_then(|v| v.to_str().ok()) 219 + // .map(|t| t.contains("text/html")) 220 + // .unwrap_or(false) 221 + // { 222 + // res.headers_mut().insert( 223 + // http::header::CACHE_CONTROL, 224 + // "public, max-age=300".parse().unwrap(), 225 + // ); 226 + // } 227 + // res 228 + // }, 229 + // )) 230 }; 231 Ok(router) 232 }); ··· 452 entry_title: SmolStr, 453 ) -> Result<axum::response::Response> { 454 use axum::{ 455 + http::{ 456 + StatusCode, 457 + header::{CACHE_CONTROL, CONTENT_TYPE}, 458 + }, 459 response::IntoResponse, 460 }; 461 use weaver_api::sh_weaver::actor::ProfileDataViewInner; ··· 469 }; 470 471 // Fetch entry data 472 + let entry_result = fetcher 473 + .get_entry(at_ident.clone(), book_title.clone(), entry_title.into()) 474 + .await; 475 476 let arc_data = match entry_result { 477 Ok(Some(data)) => data, ··· 495 (CACHE_CONTROL, "public, max-age=3600"), 496 ], 497 cached, 498 + ) 499 + .into_response()); 500 } 501 502 // Extract metadata ··· 506 // TODO: Could fetch actual notebook record to get display title 507 let notebook_title_str: String = book_title.to_string(); 508 509 + let author_handle = book_entry 510 + .entry 511 + .authors 512 + .first() 513 .map(|a| match &a.record.inner { 514 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), 515 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), ··· 533 // Build CDN URL 534 let cdn_url = format!( 535 "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 536 + did.as_str(), 537 + cid.as_ref(), 538 + format 539 ); 540 541 // Fetch the image ··· 544 match response.bytes().await { 545 Ok(bytes) => { 546 use base64::Engine; 547 + let base64_str = 548 + base64::engine::general_purpose::STANDARD.encode(&bytes); 549 Some(format!("data:{};base64,{}", mime, base64_str)) 550 } 551 + Err(_) => None, 552 } 553 } 554 + _ => None, 555 } 556 } else { 557 None ··· 588 match og::generate_hero_image(hero_data, title, &notebook_title_str, &author_handle) { 589 Ok(bytes) => bytes, 590 Err(e) => { 591 + tracing::error!( 592 + "Failed to generate hero OG image: {:?}, falling back to text", 593 + e 594 + ); 595 og::generate_text_only(title, &content_snippet, &notebook_title_str, &author_handle) 596 .map_err(|e| { 597 tracing::error!("Failed to generate text OG image: {:?}", e); ··· 605 Ok(bytes) => bytes, 606 Err(e) => { 607 tracing::error!("Failed to generate OG image: {:?}", e); 608 + return Ok(( 609 + StatusCode::INTERNAL_SERVER_ERROR, 610 + "Failed to generate image", 611 + ) 612 + .into_response()); 613 } 614 } 615 }; ··· 623 (CACHE_CONTROL, "public, max-age=3600"), 624 ], 625 png_bytes, 626 + ) 627 + .into_response()) 628 } 629 630 // Route: /og/notebook/{ident}/{book_title}.png - OpenGraph image for notebook index ··· 635 book_title: SmolStr, 636 ) -> Result<axum::response::Response> { 637 use axum::{ 638 + http::{ 639 + StatusCode, 640 + header::{CACHE_CONTROL, CONTENT_TYPE}, 641 + }, 642 response::IntoResponse, 643 }; 644 use weaver_api::sh_weaver::actor::ProfileDataViewInner; ··· 651 }; 652 653 // Fetch notebook data 654 + let notebook_result = fetcher 655 + .get_notebook(at_ident.clone(), book_title.into()) 656 + .await; 657 658 let arc_data = match notebook_result { 659 Ok(Some(data)) => data, 660 Ok(None) => return Ok((StatusCode::NOT_FOUND, "Notebook not found").into_response()), 661 Err(e) => { 662 tracing::error!("Failed to fetch notebook for OG image: {:?}", e); 663 + return Ok(( 664 + StatusCode::INTERNAL_SERVER_ERROR, 665 + "Failed to fetch notebook", 666 + ) 667 + .into_response()); 668 } 669 }; 670 let (notebook_view, _entries) = arc_data.as_ref(); ··· 681 (CACHE_CONTROL, "public, max-age=3600"), 682 ], 683 cached, 684 + ) 685 + .into_response()); 686 } 687 688 // Extract metadata 689 + let title = notebook_view 690 + .title 691 .as_ref() 692 .map(|t| t.as_ref()) 693 .unwrap_or("Untitled Notebook"); 694 695 + let author_handle = notebook_view 696 + .authors 697 + .first() 698 .map(|a| match &a.record.inner { 699 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), 700 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), ··· 704 .unwrap_or_else(|| "unknown".to_string()); 705 706 // Fetch entries to get entry titles and count 707 + let entries_result = fetcher 708 + .list_notebook_entries(at_ident.clone(), book_title.into()) 709 + .await; 710 let (entry_count, entry_titles) = match entries_result { 711 Ok(Some(entries)) => { 712 let count = entries.len(); ··· 714 .iter() 715 .take(4) 716 .map(|e| { 717 + e.entry 718 + .title 719 .as_ref() 720 .map(|t| t.as_ref().to_string()) 721 .unwrap_or_else(|| "Untitled".to_string()) ··· 727 }; 728 729 // Generate image 730 + let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles) 731 + { 732 Ok(bytes) => bytes, 733 Err(e) => { 734 tracing::error!("Failed to generate notebook OG image: {:?}", e); 735 + return Ok(( 736 + StatusCode::INTERNAL_SERVER_ERROR, 737 + "Failed to generate image", 738 + ) 739 + .into_response()); 740 } 741 }; 742 ··· 749 (CACHE_CONTROL, "public, max-age=3600"), 750 ], 751 png_bytes, 752 + ) 753 + .into_response()) 754 } 755 756 // Route: /og/profile/{ident}.png - OpenGraph image for profile/repository 757 #[cfg(all(feature = "fullstack-server", feature = "server"))] 758 #[get("/og/profile/{ident}", fetcher: Extension<Arc<fetch::Fetcher>>)] 759 + pub async fn og_profile_image(ident: SmolStr) -> Result<axum::response::Response> { 760 use axum::{ 761 + http::{ 762 + StatusCode, 763 + header::{CACHE_CONTROL, CONTENT_TYPE}, 764 + }, 765 response::IntoResponse, 766 }; 767 use weaver_api::sh_weaver::actor::ProfileDataViewInner; ··· 780 Ok(data) => data, 781 Err(e) => { 782 tracing::error!("Failed to fetch profile for OG image: {:?}", e); 783 + return Ok( 784 + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response(), 785 + ); 786 } 787 }; 788 ··· 790 // Use DID as cache key since profiles don't have a CID field 791 let (display_name, handle, bio, avatar_url, banner_url, cache_id) = match &profile_view.inner { 792 ProfileDataViewInner::ProfileView(p) => ( 793 + p.display_name 794 + .as_ref() 795 + .map(|n| n.as_ref().to_string()) 796 + .unwrap_or_default(), 797 p.handle.as_ref().to_string(), 798 + p.description 799 + .as_ref() 800 + .map(|d| d.as_ref().to_string()) 801 + .unwrap_or_default(), 802 p.avatar.as_ref().map(|u| u.as_ref().to_string()), 803 None::<String>, 804 p.did.as_ref().to_string(), 805 ), 806 ProfileDataViewInner::ProfileViewDetailed(p) => ( 807 + p.display_name 808 + .as_ref() 809 + .map(|n| n.as_ref().to_string()) 810 + .unwrap_or_default(), 811 p.handle.as_ref().to_string(), 812 + p.description 813 + .as_ref() 814 + .map(|d| d.as_ref().to_string()) 815 + .unwrap_or_default(), 816 p.avatar.as_ref().map(|u| u.as_ref().to_string()), 817 p.banner.as_ref().map(|u| u.as_ref().to_string()), 818 p.did.as_ref().to_string(), ··· 839 (CACHE_CONTROL, "public, max-age=3600"), 840 ], 841 cached, 842 + ) 843 + .into_response()); 844 } 845 846 // Fetch notebook count ··· 906 ) { 907 Ok(bytes) => bytes, 908 Err(e) => { 909 + tracing::error!( 910 + "Failed to generate profile banner OG image: {:?}, falling back", 911 + e 912 + ); 913 + og::generate_profile_og( 914 + &display_name, 915 + &handle, 916 + &bio, 917 + avatar_data, 918 + notebook_count, 919 + ) 920 + .unwrap_or_default() 921 } 922 } 923 } else { ··· 929 Ok(bytes) => bytes, 930 Err(e) => { 931 tracing::error!("Failed to generate profile OG image: {:?}", e); 932 + return Ok(( 933 + StatusCode::INTERNAL_SERVER_ERROR, 934 + "Failed to generate image", 935 + ) 936 + .into_response()); 937 } 938 } 939 }; ··· 947 (CACHE_CONTROL, "public, max-age=3600"), 948 ], 949 png_bytes, 950 + ) 951 + .into_response()) 952 } 953 954 // Route: /og/site.png - OpenGraph image for homepage ··· 956 #[get("/og/site.png")] 957 pub async fn og_site_image() -> Result<axum::response::Response> { 958 use axum::{ 959 + http::{ 960 + StatusCode, 961 + header::{CACHE_CONTROL, CONTENT_TYPE}, 962 + }, 963 response::IntoResponse, 964 }; 965 966 // Site OG is static, cache aggressively 967 static SITE_OG_CACHE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new(); 968 969 + let png_bytes = SITE_OG_CACHE.get_or_init(|| og::generate_site_og().unwrap_or_default()); 970 971 if png_bytes.is_empty() { 972 + return Ok(( 973 + StatusCode::INTERNAL_SERVER_ERROR, 974 + "Failed to generate image", 975 + ) 976 + .into_response()); 977 } 978 979 Ok(( ··· 982 (CACHE_CONTROL, "public, max-age=86400"), 983 ], 984 png_bytes.clone(), 985 + ) 986 + .into_response()) 987 } 988 989 // #[server(endpoint = "static_routes", output = server_fn::codec::Json)]