at main 555 lines 19 kB view raw
1#[cfg(all(feature = "fullstack-server", feature = "server"))] 2use crate::fetch; 3#[cfg(all(feature = "fullstack-server", feature = "server"))] 4use crate::og; 5#[cfg(all(feature = "fullstack-server", feature = "server"))] 6use axum::Extension; 7#[cfg(all(feature = "fullstack-server", feature = "server"))] 8use dioxus::prelude::*; 9#[cfg(all(feature = "fullstack-server", feature = "server"))] 10use jacquard::smol_str::SmolStr; 11#[cfg(all(feature = "fullstack-server", feature = "server"))] 12use jacquard::types::string::AtIdentifier; 13#[cfg(all(feature = "fullstack-server", feature = "server"))] 14use std::sync::Arc; 15 16#[cfg(all(feature = "fullstack-server", feature = "server"))] 17use jacquard::smol_str::ToSmolStr; 18 19// Route: /og/{ident}/{book_title}/{entry_title} - OpenGraph image for entry 20#[cfg(all(feature = "fullstack-server", feature = "server"))] 21#[get("/og/{ident}/{book_title}/{entry_title}", fetcher: Extension<Arc<fetch::Fetcher>>)] 22pub async fn og_image( 23 ident: SmolStr, 24 book_title: SmolStr, 25 entry_title: SmolStr, 26) -> Result<axum::response::Response> { 27 use axum::{ 28 http::{ 29 StatusCode, 30 header::{CACHE_CONTROL, CONTENT_TYPE}, 31 }, 32 response::IntoResponse, 33 }; 34 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 35 use weaver_api::sh_weaver::notebook::Title; 36 37 // Strip .png extension if present 38 let entry_title = entry_title.strip_suffix(".png").unwrap_or(&entry_title); 39 40 let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 41 return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); 42 }; 43 44 // Fetch entry data 45 let entry_result = fetcher 46 .get_entry(at_ident.clone(), book_title.clone(), entry_title.into()) 47 .await; 48 49 let arc_data = match entry_result { 50 Ok(Some(data)) => data, 51 Ok(None) => return Ok((StatusCode::NOT_FOUND, "Entry not found").into_response()), 52 Err(e) => { 53 tracing::error!("Failed to fetch entry for OG image: {:?}", e); 54 return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch entry").into_response()); 55 } 56 }; 57 let (book_entry, entry) = arc_data.as_ref(); 58 59 // Build cache key using entry CID 60 let entry_cid = book_entry.entry.cid.as_ref(); 61 let cache_key = og::cache_key(&ident, &book_title, entry_title, entry_cid); 62 63 // Check cache first 64 if let Some(cached) = og::get_cached(&cache_key) { 65 return Ok(( 66 [ 67 (CONTENT_TYPE, "image/png"), 68 (CACHE_CONTROL, "public, max-age=3600"), 69 ], 70 cached, 71 ) 72 .into_response()); 73 } 74 75 // Extract metadata 76 let title: &str = entry.title.as_ref(); 77 78 // Use book_title from URL - it's the notebook slug/title 79 // TODO: Could fetch actual notebook record to get display title 80 let notebook_title_str: &str = book_title.as_ref(); 81 82 let author_handle = book_entry 83 .entry 84 .authors 85 .first() 86 .map(|a| match &a.record.inner { 87 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref(), 88 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref(), 89 ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref(), 90 _ => "unknown", 91 }) 92 .unwrap_or("unknown"); 93 94 // Check for hero image in embeds 95 let hero_image_data = if let Some(ref embeds) = entry.embeds { 96 if let Some(ref images) = embeds.images { 97 if let Some(first_image) = images.images.first() { 98 // Get DID from the entry URI 99 let did = book_entry.entry.uri.authority(); 100 101 let blob = first_image.image.blob(); 102 let cid = blob.cid(); 103 let mime = blob.mime_type.as_ref(); 104 let format = mime.strip_prefix("image/").unwrap_or("jpeg"); 105 106 // Build CDN URL 107 let cdn_url = format!( 108 "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 109 did.as_str(), 110 cid.as_ref(), 111 format 112 ); 113 114 // Fetch the image 115 match reqwest::get(&cdn_url).await { 116 Ok(response) if response.status().is_success() => { 117 match response.bytes().await { 118 Ok(bytes) => { 119 use base64::Engine; 120 let base64_str = 121 base64::engine::general_purpose::STANDARD.encode(&bytes); 122 Some(format!("data:{};base64,{}", mime, base64_str)) 123 } 124 Err(_) => None, 125 } 126 } 127 _ => None, 128 } 129 } else { 130 None 131 } 132 } else { 133 None 134 } 135 } else { 136 None 137 }; 138 139 // Extract content snippet - render markdown to HTML then strip tags 140 let content_snippet: String = { 141 let parser = markdown_weaver::Parser::new(entry.content.as_ref()); 142 let mut html = String::new(); 143 markdown_weaver::html::push_html(&mut html, parser); 144 // Strip HTML tags 145 regex_lite::Regex::new(r"<[^>]+>") 146 .unwrap() 147 .replace_all(&html, "") 148 .replace("&amp;", "&") 149 .replace("&lt;", "<") 150 .replace("&gt;", ">") 151 .replace("&quot;", "\"") 152 .replace("&#39;", "'") 153 .replace('\n', " ") 154 .split_whitespace() 155 .collect::<Vec<_>>() 156 .join(" ") 157 }; 158 159 // Generate image - hero or text-only based on available data 160 let png_bytes = if let Some(ref hero_data) = hero_image_data { 161 match og::generate_hero_image(hero_data, title, &notebook_title_str, &author_handle) { 162 Ok(bytes) => bytes, 163 Err(e) => { 164 tracing::error!( 165 "Failed to generate hero OG image: {:?}, falling back to text", 166 e 167 ); 168 og::generate_text_only(title, &content_snippet, &notebook_title_str, &author_handle) 169 .map_err(|e| { 170 tracing::error!("Failed to generate text OG image: {:?}", e); 171 }) 172 .ok() 173 .unwrap_or_default() 174 } 175 } 176 } else { 177 match og::generate_text_only(title, &content_snippet, &notebook_title_str, &author_handle) { 178 Ok(bytes) => bytes, 179 Err(e) => { 180 tracing::error!("Failed to generate OG image: {:?}", e); 181 return Ok(( 182 StatusCode::INTERNAL_SERVER_ERROR, 183 "Failed to generate image", 184 ) 185 .into_response()); 186 } 187 } 188 }; 189 190 // Cache the generated image 191 og::cache_image(cache_key, png_bytes.clone()); 192 193 Ok(( 194 [ 195 (CONTENT_TYPE, "image/png"), 196 (CACHE_CONTROL, "public, max-age=3600"), 197 ], 198 png_bytes, 199 ) 200 .into_response()) 201} 202 203// Route: /og/notebook/{ident}/{book_title}.png - OpenGraph image for notebook index 204#[cfg(all(feature = "fullstack-server", feature = "server"))] 205#[get("/og/notebook/{ident}/{book_title}", fetcher: Extension<Arc<fetch::Fetcher>>)] 206pub async fn og_notebook_image( 207 ident: SmolStr, 208 book_title: SmolStr, 209) -> Result<axum::response::Response> { 210 use axum::{ 211 http::{ 212 StatusCode, 213 header::{CACHE_CONTROL, CONTENT_TYPE}, 214 }, 215 response::IntoResponse, 216 }; 217 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 218 219 // Strip .png extension if present 220 let book_title = book_title.strip_suffix(".png").unwrap_or(&book_title); 221 222 let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 223 return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); 224 }; 225 226 // Fetch notebook data 227 let notebook_result = fetcher 228 .get_notebook(at_ident.clone(), book_title.into()) 229 .await; 230 231 let arc_data = match notebook_result { 232 Ok(Some(data)) => data, 233 Ok(None) => return Ok((StatusCode::NOT_FOUND, "Notebook not found").into_response()), 234 Err(e) => { 235 tracing::error!("Failed to fetch notebook for OG image: {:?}", e); 236 return Ok(( 237 StatusCode::INTERNAL_SERVER_ERROR, 238 "Failed to fetch notebook", 239 ) 240 .into_response()); 241 } 242 }; 243 let (notebook_view, _entries) = arc_data.as_ref(); 244 245 // Build cache key using notebook CID 246 let notebook_cid = notebook_view.cid.as_ref(); 247 let cache_key = og::notebook_cache_key(&ident, book_title, notebook_cid); 248 249 // Check cache first 250 if let Some(cached) = og::get_cached(&cache_key) { 251 return Ok(( 252 [ 253 (CONTENT_TYPE, "image/png"), 254 (CACHE_CONTROL, "public, max-age=3600"), 255 ], 256 cached, 257 ) 258 .into_response()); 259 } 260 261 // Extract metadata 262 let title = notebook_view 263 .title 264 .as_ref() 265 .map(|t| t.as_ref()) 266 .unwrap_or("Untitled Notebook"); 267 268 let author_handle = notebook_view 269 .authors 270 .first() 271 .map(|a| match &a.record.inner { 272 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref(), 273 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref(), 274 ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref(), 275 _ => "unknown", 276 }) 277 .unwrap_or("unknown"); 278 279 // Fetch entries to get entry titles and count 280 let entries_result = fetcher 281 .list_notebook_entries(at_ident.clone(), book_title.into()) 282 .await; 283 let (entry_count, entry_titles) = match entries_result { 284 Ok(Some(entries)) => { 285 let count = entries.len(); 286 let titles: Vec<String> = entries 287 .iter() 288 .take(4) 289 .map(|e| { 290 e.entry 291 .title 292 .as_ref() 293 .map(|t| t.as_ref().to_string()) 294 .unwrap_or_else(|| "Untitled".to_string()) 295 }) 296 .collect(); 297 (count, titles) 298 } 299 _ => (0, vec![]), 300 }; 301 302 // Generate image 303 let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles) 304 { 305 Ok(bytes) => bytes, 306 Err(e) => { 307 tracing::error!("Failed to generate notebook OG image: {:?}", e); 308 return Ok(( 309 StatusCode::INTERNAL_SERVER_ERROR, 310 "Failed to generate image", 311 ) 312 .into_response()); 313 } 314 }; 315 316 // Cache the generated image 317 og::cache_image(cache_key, png_bytes.clone()); 318 319 Ok(( 320 [ 321 (CONTENT_TYPE, "image/png"), 322 (CACHE_CONTROL, "public, max-age=3600"), 323 ], 324 png_bytes, 325 ) 326 .into_response()) 327} 328 329// Route: /og/profile/{ident}.png - OpenGraph image for profile/repository 330#[cfg(all(feature = "fullstack-server", feature = "server"))] 331#[get("/og/profile/{ident}", fetcher: Extension<Arc<fetch::Fetcher>>)] 332pub async fn og_profile_image(ident: SmolStr) -> Result<axum::response::Response> { 333 use axum::{ 334 http::{ 335 StatusCode, 336 header::{CACHE_CONTROL, CONTENT_TYPE}, 337 }, 338 response::IntoResponse, 339 }; 340 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 341 342 // Strip .png extension if present 343 let ident = ident.strip_suffix(".png").unwrap_or(&ident); 344 345 let Ok(at_ident) = AtIdentifier::new_owned(ident.to_string()) else { 346 return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); 347 }; 348 349 // Fetch profile data 350 let profile_result = fetcher.fetch_profile(&at_ident).await; 351 352 let profile_view = match profile_result { 353 Ok(data) => data, 354 Err(e) => { 355 tracing::error!("Failed to fetch profile for OG image: {:?}", e); 356 return Ok( 357 (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response(), 358 ); 359 } 360 }; 361 362 // Extract profile fields based on type 363 // Use DID as cache key since profiles don't have a CID field 364 let (display_name, handle, bio, avatar_url, banner_url, cache_id) = match &profile_view.inner { 365 ProfileDataViewInner::ProfileView(p) => ( 366 p.display_name 367 .as_ref() 368 .map(|n| n.as_ref()) 369 .unwrap_or_default(), 370 p.handle.as_ref(), 371 p.description 372 .as_ref() 373 .map(|d| d.as_ref()) 374 .unwrap_or_default(), 375 p.avatar.as_ref().map(|u| u.as_ref()), 376 None::<&str>, 377 p.did.as_ref(), 378 ), 379 ProfileDataViewInner::ProfileViewDetailed(p) => ( 380 p.display_name 381 .as_ref() 382 .map(|n| n.as_ref()) 383 .unwrap_or_default(), 384 p.handle.as_ref(), 385 p.description 386 .as_ref() 387 .map(|d| d.as_ref()) 388 .unwrap_or_default(), 389 p.avatar.as_ref().map(|u| u.as_ref()), 390 p.banner.as_ref().map(|u| u.as_ref()), 391 p.did.as_ref(), 392 ), 393 ProfileDataViewInner::TangledProfileView(p) => { 394 ("", p.handle.as_ref(), "", None, None, p.did.as_ref()) 395 } 396 _ => return Ok((StatusCode::NOT_FOUND, "Profile type not supported").into_response()), 397 }; 398 399 // Build cache key 400 let cache_key = og::profile_cache_key(ident, &cache_id); 401 402 // Check cache first 403 if let Some(cached) = og::get_cached(&cache_key) { 404 return Ok(( 405 [ 406 (CONTENT_TYPE, "image/png"), 407 (CACHE_CONTROL, "public, max-age=3600"), 408 ], 409 cached, 410 ) 411 .into_response()); 412 } 413 414 // Fetch notebook count 415 let notebooks_result = fetcher.fetch_notebooks_for_did(&at_ident).await; 416 let notebook_count = notebooks_result.map(|n| n.len()).unwrap_or(0); 417 418 // Fetch avatar as base64 if available 419 let avatar_data = if let Some(url) = avatar_url { 420 match reqwest::get(url).await { 421 Ok(response) if response.status().is_success() => { 422 let content_type = response 423 .headers() 424 .get("content-type") 425 .and_then(|v| v.to_str().ok()) 426 .unwrap_or("image/jpeg") 427 .to_smolstr(); 428 match response.bytes().await { 429 Ok(bytes) => { 430 use base64::Engine; 431 let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); 432 Some(format!("data:{};base64,{}", content_type, base64_str)) 433 } 434 Err(_) => None, 435 } 436 } 437 _ => None, 438 } 439 } else { 440 None 441 }; 442 443 // Check for banner and generate appropriate template 444 let png_bytes = if let Some(banner_url) = banner_url { 445 // Fetch banner image 446 let banner_data = match reqwest::get(banner_url).await { 447 Ok(response) if response.status().is_success() => { 448 let content_type = response 449 .headers() 450 .get("content-type") 451 .and_then(|v| v.to_str().ok()) 452 .unwrap_or("image/jpeg") 453 .to_smolstr(); 454 match response.bytes().await { 455 Ok(bytes) => { 456 use base64::Engine; 457 let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); 458 Some(format!("data:{};base64,{}", content_type, base64_str)) 459 } 460 Err(_) => None, 461 } 462 } 463 _ => None, 464 }; 465 466 if let Some(banner_data) = banner_data { 467 match og::generate_profile_banner_og( 468 &display_name, 469 &handle, 470 &bio, 471 banner_data, 472 avatar_data.clone(), 473 notebook_count, 474 ) { 475 Ok(bytes) => bytes, 476 Err(e) => { 477 tracing::error!( 478 "Failed to generate profile banner OG image: {:?}, falling back", 479 e 480 ); 481 og::generate_profile_og( 482 &display_name, 483 &handle, 484 &bio, 485 avatar_data, 486 notebook_count, 487 ) 488 .unwrap_or_default() 489 } 490 } 491 } else { 492 og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) 493 .unwrap_or_default() 494 } 495 } else { 496 match og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) { 497 Ok(bytes) => bytes, 498 Err(e) => { 499 tracing::error!("Failed to generate profile OG image: {:?}", e); 500 return Ok(( 501 StatusCode::INTERNAL_SERVER_ERROR, 502 "Failed to generate image", 503 ) 504 .into_response()); 505 } 506 } 507 }; 508 509 // Cache the generated image 510 og::cache_image(cache_key, png_bytes.clone()); 511 512 Ok(( 513 [ 514 (CONTENT_TYPE, "image/png"), 515 (CACHE_CONTROL, "public, max-age=3600"), 516 ], 517 png_bytes, 518 ) 519 .into_response()) 520} 521 522// Route: /og/site.png - OpenGraph image for homepage 523#[cfg(all(feature = "fullstack-server", feature = "server"))] 524#[get("/og/site.png")] 525pub async fn og_site_image() -> Result<axum::response::Response> { 526 use axum::{ 527 http::{ 528 StatusCode, 529 header::{CACHE_CONTROL, CONTENT_TYPE}, 530 }, 531 response::IntoResponse, 532 }; 533 534 // Site OG is static, cache aggressively 535 static SITE_OG_CACHE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new(); 536 537 let png_bytes = SITE_OG_CACHE.get_or_init(|| og::generate_site_og().unwrap_or_default()); 538 539 if png_bytes.is_empty() { 540 return Ok(( 541 StatusCode::INTERNAL_SERVER_ERROR, 542 "Failed to generate image", 543 ) 544 .into_response()); 545 } 546 547 Ok(( 548 [ 549 (CONTENT_TYPE, "image/png"), 550 (CACHE_CONTROL, "public, max-age=86400"), 551 ], 552 png_bytes.clone(), 553 ) 554 .into_response()) 555}