Skip to main content

yggdrasil/api/
og.rs

1//! 分享图端点与分享元信息上下文(server-only)。
2//!
3//! - `GET /og/site.png`:站点图,标语读站点设置,底部为站点地址与已发布篇数;
4//! - `GET /og/post/{slug}.png`:文章图,标题、摘要与「日期·时长·标签」。
5//!
6//! PNG 按展示内容缓存(moka,同一内容并发请求只渲染一次),渲染放进
7//! `spawn_blocking` 并限制同时渲染数。文章不存在或渲染失败时回退为站点图;站点
8//! 标语缺字时改用默认标语,保证总能返回一张图。地址里的 `?v=` 只用于让外部缓存
9//! 换图,服务端不读取。
10#![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
25/// 内容版本化的地址可以缓存久一些;回退图很快就该重试。
26const 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
43/// 渲染吃满一个核约几十毫秒,同时最多两张,避免挤占请求线程。
44static 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
109/// 「站点地址·N 篇文章」;地址只取 APP_BASE_URL 的主机部分,任一项取不到就省略。
110async 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    // 与首页第一页同参数,命中同一条文章列表缓存。
117    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
160/// 站点绝对地址(无尾斜杠),只取 APP_BASE_URL;未配置时为空,页面退回站内相对地址。
161async 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
170/// 前台页面服务端渲染时写分享元信息所需的站点信息,由中间件放进请求扩展。
171pub 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}