atproto blogging
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("&", "&")
149 .replace("<", "<")
150 .replace(">", ">")
151 .replace(""", "\"")
152 .replace("'", "'")
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, ¬ebook_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, ¬ebook_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, ¬ebook_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}