1#![cfg(feature = "server")]
11
12use std::hash::{Hash, Hasher};
13use std::sync::LazyLock;
14use std::time::Duration;
15
16use axum::extract::Path;
17use axum::http::{header, HeaderValue, StatusCode};
18use axum::response::{IntoResponse, Response};
19use bytes::Bytes;
20
21use crate::models::settings::{SiteSettings, DEFAULT_HOME_SUBTITLE, DEFAULT_HOME_TITLE};
22use crate::models::share::{site_image_path, PostShareCard, ShareContext, SEPARATOR};
23use crate::og::{render_post, render_site, OgError, SiteShareCard};
24
25const CACHE_CONTROL: &str = "public, max-age=86400";
27const FALLBACK_CACHE_CONTROL: &str = "public, max-age=300";
28
29#[derive(Hash)]
30enum Card {
31 Post(PostShareCard),
32 Site(SiteShareCard),
33}
34
35static IMAGES: LazyLock<moka::future::Cache<u64, Bytes>> = LazyLock::new(|| {
36 moka::future::Cache::builder()
37 .max_capacity(16 * 1024 * 1024)
38 .weigher(|_, png: &Bytes| png.len() as u32)
39 .time_to_live(Duration::from_secs(24 * 60 * 60))
40 .build()
41});
42
43static RENDER_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2);
45
46pub async fn site_image() -> Response {
47 match site_png().await {
48 Ok(png) => png_response(png, CACHE_CONTROL),
49 Err(e) => {
50 tracing::error!("站点分享图渲染失败:{e}");
51 StatusCode::INTERNAL_SERVER_ERROR.into_response()
52 }
53 }
54}
55
56pub async fn post_image(Path(file): Path<String>) -> Response {
57 let Some(slug) = file.strip_suffix(".png") else {
58 return StatusCode::NOT_FOUND.into_response();
59 };
60 match post_card(slug).await {
61 Some(card) => match cached_render(Card::Post(card)).await {
62 Ok(png) => return png_response(png, CACHE_CONTROL),
63 Err(e) => tracing::warn!(slug, "文章分享图渲染失败,回退站点图:{e}"),
64 },
65 None => tracing::debug!(slug, "文章不存在,分享图回退站点图"),
66 }
67 match site_png().await {
68 Ok(png) => png_response(png, FALLBACK_CACHE_CONTROL),
69 Err(e) => {
70 tracing::error!("站点分享图渲染失败:{e}");
71 StatusCode::INTERNAL_SERVER_ERROR.into_response()
72 }
73 }
74}
75
76async fn post_card(slug: &str) -> Option<PostShareCard> {
77 let response = crate::api::posts::get_public_post_by_slug(slug.to_string())
78 .await
79 .ok()?;
80 let post = response.post?.into_display_post();
81 Some(PostShareCard::from_post(&post))
82}
83
84async fn site_png() -> Result<Bytes, OgError> {
85 let settings = crate::api::settings::get_site_settings()
86 .await
87 .unwrap_or_default();
88 let tagline = settings.home_tagline();
89 let footer = site_footer().await;
90 let card = SiteShareCard {
91 title: tagline.title,
92 subtitle: tagline.subtitle,
93 footer: footer.clone(),
94 };
95 match cached_render(Card::Site(card)).await {
96 Err(OgError::MissingGlyph(c)) => {
97 tracing::warn!("站点标语含分享图字体没有的字 {c:?},改用默认标语");
98 cached_render(Card::Site(SiteShareCard {
99 title: DEFAULT_HOME_TITLE.to_string(),
100 subtitle: DEFAULT_HOME_SUBTITLE.to_string(),
101 footer,
102 }))
103 .await
104 }
105 other => other,
106 }
107}
108
109async fn site_footer() -> String {
111 let base = base_url().await;
112 let host = base
113 .split_once("://")
114 .map_or(base.as_str(), |(_, rest)| rest)
115 .to_string();
116 let total = crate::api::posts::list_published_posts(1, 10)
118 .await
119 .ok()
120 .map(|r| format!("{} 篇文章", r.total));
121 [Some(host).filter(|h| !h.is_empty()), total]
122 .into_iter()
123 .flatten()
124 .collect::<Vec<_>>()
125 .join(SEPARATOR)
126}
127
128async fn cached_render(card: Card) -> Result<Bytes, OgError> {
129 let mut hasher = std::collections::hash_map::DefaultHasher::new();
130 card.hash(&mut hasher);
131 IMAGES
132 .try_get_with(hasher.finish(), async move {
133 let _slot = RENDER_SLOTS.acquire().await.map_err(|_| OgError::Encode)?;
134 tokio::task::spawn_blocking(move || match &card {
135 Card::Post(card) => render_post(card),
136 Card::Site(card) => render_site(card),
137 })
138 .await
139 .map_err(|_| OgError::Encode)?
140 .map(Bytes::from)
141 })
142 .await
143 .map_err(|e| (*e).clone())
144}
145
146fn png_response(png: Bytes, cache_control: &'static str) -> Response {
147 (
148 [
149 (header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
150 (
151 header::CACHE_CONTROL,
152 HeaderValue::from_static(cache_control),
153 ),
154 ],
155 png,
156 )
157 .into_response()
158}
159
160async fn base_url() -> String {
162 crate::api::settings::runtime_security_settings()
163 .await
164 .app_base_url
165 .trim()
166 .trim_end_matches('/')
167 .to_string()
168}
169
170pub async fn share_context() -> ShareContext {
172 let settings: SiteSettings = crate::api::settings::get_site_settings()
173 .await
174 .unwrap_or_default();
175 let tagline = settings.home_tagline();
176 ShareContext {
177 base_url: base_url().await,
178 description: settings.description().to_string(),
179 site_image: site_image_path(&tagline.title, &tagline.subtitle),
180 }
181}