tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
server side cache back in
Orual
1 month ago
e2b0d156
21e174ee
+329
-373
3 changed files
expand all
collapse all
unified
split
crates
weaver-app
src
data.rs
fetch.rs
main.rs
+44
crates/weaver-app/src/data.rs
···
655
async move {
656
match fetcher.fetch_entries_from_ufos().await {
657
Ok(entries) => {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
658
Some(
659
entries
660
.iter()
···
945
async move {
946
match fetcher.get_entry_by_rkey(ident(), rkey()).await {
947
Ok(Some(data)) => {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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>,
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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)),
0
0
0
0
0
0
0
0
362
}
363
}
364
···
396
ident: AtIdentifier<'static>,
397
title: SmolStr,
398
) -> Result<Option<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> {
0
0
0
0
0
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));
0
0
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>)>>> {
0
0
0
0
0
0
0
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);
0
0
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));
0
0
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)
0
0
0
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);
0
0
0
0
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);
0
0
0
0
537
continue;
538
}
539
}
···
592
// View the notebook (which hydrates authors)
593
match client.view_notebook(&record.uri).await {
594
Ok((notebook, entries)) => {
0
0
0
0
0
0
0
595
let result = Arc::new((notebook, entries));
0
0
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>>>> {
0
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
619
}
620
}
621
···
629
&self,
630
ident: &AtIdentifier<'_>,
631
) -> Result<Arc<ProfileDataView<'static>>> {
0
0
0
0
0
0
0
0
0
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))
0
0
0
0
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;
0
0
0
0
0
0
0
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
-
})))
0
0
0
0
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;
0
0
0
0
0
732
733
let client = self.get_client();
734
···
783
.build();
784
}
785
786
-
Ok(Some(Arc::new((book_entry_view.into_static(), entry))))
0
0
0
0
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)))?;
0
0
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
+
})?;
0
0
0
0
0
0
0
0
0
0
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 {
0
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 {
0
0
0
0
0
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
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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;
0
0
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
}))
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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},
0
0
0
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;
0
0
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());
0
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()
0
0
0
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
0
0
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);
0
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, ¬ebook_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);
0
0
0
560
og::generate_text_only(title, &content_snippet, ¬ebook_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());
0
0
0
0
574
}
575
}
576
};
···
584
(CACHE_CONTROL, "public, max-age=3600"),
585
],
586
png_bytes,
587
-
).into_response())
0
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},
0
0
0
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;
0
0
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());
0
0
0
0
619
}
620
};
621
let (notebook_view, _entries) = arc_data.as_ref();
···
632
(CACHE_CONTROL, "public, max-age=3600"),
633
],
634
cached,
635
-
).into_response());
0
636
}
637
638
// Extract metadata
639
-
let title = notebook_view.title
0
640
.as_ref()
641
.map(|t| t.as_ref())
642
.unwrap_or("Untitled Notebook");
643
644
-
let author_handle = notebook_view.authors.first()
0
0
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;
0
0
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
0
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) {
0
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());
0
0
0
0
679
}
680
};
681
···
688
(CACHE_CONTROL, "public, max-age=3600"),
689
],
690
png_bytes,
691
-
).into_response())
0
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},
0
0
0
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());
0
0
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(),
0
0
0
729
p.handle.as_ref().to_string(),
730
-
p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(),
0
0
0
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(),
0
0
0
737
p.handle.as_ref().to_string(),
738
-
p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(),
0
0
0
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());
0
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()
0
0
0
0
0
0
0
0
0
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());
0
0
0
0
846
}
847
}
848
};
···
856
(CACHE_CONTROL, "public, max-age=3600"),
857
],
858
png_bytes,
859
-
).into_response())
0
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},
0
0
0
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());
0
0
0
0
880
}
881
882
Ok((
···
885
(CACHE_CONTROL, "public, max-age=86400"),
886
],
887
png_bytes.clone(),
888
-
).into_response())
0
889
}
890
891
// #[server(endpoint = "static_routes", output = server_fn::codec::Json)]
···
31
#[cfg(feature = "server")]
32
mod blobcache;
33
mod cache_impl;
0
0
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, ¬ebook_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, ¬ebook_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> {
0
0
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());
0
0
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)]