Skip to content

Commit a0faaa4

Browse files
committed
Implement dynamic OpenGraph image url support for crate pages
1 parent 1b6924a commit a0faaa4

File tree

9 files changed

+147
-22
lines changed

9 files changed

+147
-22
lines changed

.env.sample

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,15 @@ export GH_CLIENT_SECRET=
8686
# Credentials for connecting to the Sentry error reporting service.
8787
# export SENTRY_DSN_API=
8888
export SENTRY_ENV_API=local
89+
90+
# If set the server serves the frontend `index.html` for all
91+
# non-API requests using the Jinja template at the given path.
92+
# Setting this parameter requires setting
93+
# INDEX_HTML_TEMPLATE_PATH as well.
94+
export INDEX_HTML_TEMPLATE_PATH=dist/index.html
95+
96+
# Base URL for the service from which the OpenGraph images
97+
# for crates are loaded. Required if
98+
# INDEX_HTML_TEMPLATE_PATH is set. Make sure the URL ends
99+
# with a `/`.
100+
export OG_IMAGE_BASE_URL="http://localhost:3000/og/"

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ ipnetwork = "=0.21.1"
9292
json-subscriber = "=0.2.4"
9393
krata-tokio-tar = "=0.4.2"
9494
lettre = { version = "=0.11.11", default-features = false, features = ["file-transport", "smtp-transport", "hostname", "builder", "tokio1", "tokio1-native-tls"] }
95-
minijinja = "=2.6.0"
95+
minijinja = {version = "=2.6.0", features = ["loader"] }
9696
mockall = "=0.13.1"
9797
native-tls = "=0.2.13"
9898
oauth2 = "=5.0.0"

app/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
<meta name="google" content="notranslate" />
2525

26-
<meta property="og:image" content="https://crates.io/assets/og-image.png">
26+
<meta property="og:image" content="{{og_image_url}}">
2727
<meta name="twitter:card" content="summary_large_image">
2828
</head>
2929
<body>

src/config/server.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use anyhow::{anyhow, Context};
22
use ipnetwork::IpNetwork;
33
use oauth2::{ClientId, ClientSecret};
4+
use url::Url;
45

56
use crate::rate_limiter::{LimitedAction, RateLimiterConfig};
67
use crate::Env;
@@ -11,11 +12,12 @@ use crate::config::cdn_log_storage::CdnLogStorageConfig;
1112
use crate::config::CdnLogQueueConfig;
1213
use crate::middleware::cargo_compat::StatusCodeConfig;
1314
use crate::storage::StorageConfig;
14-
use crates_io_env_vars::{list, list_parsed, required_var, var, var_parsed};
15+
use crates_io_env_vars::{list, list_parsed, required_var, required_var_parsed, var, var_parsed};
1516
use http::HeaderValue;
1617
use std::collections::{HashMap, HashSet};
1718
use std::convert::Infallible;
1819
use std::net::IpAddr;
20+
use std::path::PathBuf;
1921
use std::str::FromStr;
2022
use std::time::Duration;
2123

@@ -71,9 +73,16 @@ pub struct Server {
7173
/// Should the server serve the frontend assets in the `dist` directory?
7274
pub serve_dist: bool,
7375

74-
/// Should the server serve the frontend `index.html` for all
75-
/// non-API requests?
76-
pub serve_html: bool,
76+
/// If set the server serves the frontend `index.html` for all
77+
/// non-API requests using the Jinja template at the given path.
78+
/// Setting this parameter requires setting
79+
/// [`Self::og_image_base_url`] as well.
80+
pub index_html_template_path: Option<PathBuf>,
81+
82+
/// Base URL for the service from which the OpenGraph images
83+
/// for crates are loaded. Required if
84+
/// [`Self::index_html_template_path`] is set.
85+
pub og_image_base_url: Option<Url>,
7786

7887
pub content_security_policy: Option<HeaderValue>,
7988
}
@@ -171,6 +180,15 @@ impl Server {
171180
cdn_domain = storage.cdn_prefix.as_ref().map(|cdn_prefix| format!("https://{cdn_prefix}")).unwrap_or_default()
172181
);
173182

183+
let index_html_template_path = var_parsed("INDEX_HTML_TEMPLATE_PATH")?;
184+
let og_image_base_url = match index_html_template_path {
185+
Some(_) => Some(
186+
required_var_parsed("OG_IMAGE_BASE_URL")
187+
.context("OG_IMAGE_BASE_URL must be set when using INDEX_HTML_TEMPLATE_PATH")?,
188+
),
189+
None => None,
190+
};
191+
174192
Ok(Server {
175193
db: DatabasePools::full_from_environment(&base)?,
176194
storage,
@@ -214,7 +232,8 @@ impl Server {
214232
cargo_compat_status_code_config: var_parsed("CARGO_COMPAT_STATUS_CODES")?
215233
.unwrap_or(StatusCodeConfig::AdjustAll),
216234
serve_dist: true,
217-
serve_html: true,
235+
index_html_template_path,
236+
og_image_base_url,
218237
content_security_policy: Some(content_security_policy.parse()?),
219238
})
220239
}

src/middleware.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,10 @@ pub fn apply_axum_middleware(state: AppState, router: Router<()>) -> Router {
7777
.layer(conditional_layer(config.serve_dist, || {
7878
from_fn(static_or_continue::serve_dist)
7979
}))
80-
.layer(conditional_layer(config.serve_html, || {
81-
from_fn_with_state(state.clone(), ember_html::serve_html)
82-
}))
80+
.layer(conditional_layer(
81+
config.index_html_template_path.is_some(),
82+
|| from_fn_with_state(state.clone(), ember_html::serve_html),
83+
))
8384
.layer(AddExtensionLayer::new(state.clone()));
8485

8586
router

src/middleware/ember_html.rs

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,45 @@
77
//! For now, there is an additional check to see if the `Accept` header contains "html". This is
88
//! likely to be removed in the future.
99
10+
use std::borrow::Cow;
11+
use std::path::Path;
12+
use std::sync::OnceLock;
13+
1014
use axum::extract::Request;
1115
use axum::middleware::Next;
1216
use axum::response::{IntoResponse, Response};
13-
use http::{header, StatusCode};
14-
use tower::ServiceExt;
15-
use tower_http::services::ServeFile;
17+
use futures_util::future::{BoxFuture, Shared};
18+
use futures_util::FutureExt;
19+
use http::{header, HeaderMap, HeaderValue, Method, StatusCode};
20+
use minijinja::{context, Environment};
1621

17-
pub async fn serve_html(request: Request, next: Next) -> Response {
18-
let path = &request.uri().path();
22+
use crate::app::AppState;
23+
24+
const OG_IMAGE_FALLBACK_URL: &str = "https://crates.io/assets/og-image.png";
25+
const INDEX_TEMPLATE_NAME: &str = "index_html";
26+
const PATH_PREFIX_CRATES: &str = "/crates/";
27+
28+
type TemplateEnvFut = Shared<BoxFuture<'static, minijinja::Environment<'static>>>;
29+
30+
/// Initialize [`minijinja::Environment`] given the path to the index.html file. This should
31+
/// only be done once as it will load said file from persistent storage.
32+
async fn init_template_env(
33+
index_html_template_path: impl AsRef<Path>,
34+
) -> minijinja::Environment<'static> {
35+
let template_j2 = tokio::fs::read_to_string(index_html_template_path.as_ref())
36+
.await
37+
.expect("Error loading index.html template. Is the frontend package built yet?");
38+
39+
let mut env = Environment::empty();
40+
env.add_template_owned(INDEX_TEMPLATE_NAME, template_j2)
41+
.expect("Error loading template");
42+
env
43+
}
44+
45+
pub async fn serve_html(state: AppState, request: Request, next: Next) -> Response {
46+
static TEMPLATE_ENV: OnceLock<TemplateEnvFut> = OnceLock::new();
1947

48+
let path = &request.uri().path();
2049
// The "/git/" prefix is only used in development (when within a docker container)
2150
if path.starts_with("/api/") || path.starts_with("/git/") {
2251
next.run(request).await
@@ -26,12 +55,58 @@ pub async fn serve_html(request: Request, next: Next) -> Response {
2655
.iter()
2756
.any(|val| val.to_str().unwrap_or_default().contains("html"))
2857
{
58+
if !matches!(*request.method(), Method::HEAD | Method::GET) {
59+
let headers =
60+
HeaderMap::from_iter([(header::ALLOW, HeaderValue::from_static("GET,HEAD"))]);
61+
return (StatusCode::METHOD_NOT_ALLOWED, headers).into_response();
62+
}
63+
64+
// Come up with an Open Graph image URL. In case a crate page is requested,
65+
// we use the crate's name and the OG image base URL from config to
66+
// generate one, otherwise we use the fallback image.
67+
let og_image_url: Cow<'_, _> = if let Some(suffix) = path.strip_prefix(PATH_PREFIX_CRATES) {
68+
let len = suffix.find('/').unwrap_or(suffix.len());
69+
let krate = &suffix[..len];
70+
71+
// `state.config.og_image_base_url` will always be `Some` as that's required
72+
// if `state.config.index_html_template_path` is `Some`, and otherwise this
73+
// middleware won't be executed; see `crate::middleware::apply_axum_middleware`.
74+
if let Ok(og_img_url) = state.config.og_image_base_url.as_ref().unwrap().join(krate) {
75+
Cow::from(og_img_url.to_string())
76+
} else {
77+
OG_IMAGE_FALLBACK_URL.into()
78+
}
79+
} else {
80+
OG_IMAGE_FALLBACK_URL.into()
81+
};
82+
83+
// `OnceLock::get_or_init` blocks as long as its intializer is running in another thread.
84+
// Note that this won't take long, as the constructed Futures are not awaited
85+
// during initialization.
86+
let template_env = TEMPLATE_ENV.get_or_init(|| {
87+
// At this point we can safely assume `state.config.index_html_template_path` is `Some`,
88+
// as this middleware won't be executed otherwise; see `crate::middleware::apply_axum_middleware`.
89+
init_template_env(state.config.index_html_template_path.clone().unwrap())
90+
.boxed()
91+
.shared()
92+
});
93+
94+
// TODO use moka caching here with og_image_url as key and the rendered html as value
95+
96+
// Render the HTML given the OG image URL
97+
let env = template_env.clone().await;
98+
let html = env
99+
.get_template(INDEX_TEMPLATE_NAME)
100+
.unwrap()
101+
.render(context! { og_image_url})
102+
.expect("Error rendering index");
103+
29104
// Serve static Ember page to bootstrap the frontend
30-
ServeFile::new("dist/index.html")
31-
.oneshot(request)
32-
.await
33-
.map(|response| response.map(axum::body::Body::new))
34-
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
105+
Response::builder()
106+
.header(header::CONTENT_TYPE, "text/html")
107+
.header(header::CONTENT_LENGTH, html.len())
108+
.body(axum::body::Body::new(html))
109+
.unwrap()
35110
} else {
36111
// Return a 404 to crawlers that don't send `Accept: text/hml`.
37112
// This is to preserve legacy behavior and will likely change.

src/middleware/static_or_continue.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ pub async fn serve_dist(request: Request, next: Next) -> Response {
1818
}
1919

2020
async fn serve<P: AsRef<Path>>(path: P, request: Request, next: Next) -> Response {
21-
if request.method() == Method::GET || request.method() == Method::HEAD {
21+
// index.html is a Jinja template, which is to be rendered by `ember_html::serve_html`.
22+
if matches!(*request.method(), Method::GET | Method::HEAD)
23+
&& !matches!(request.uri().path().as_bytes(), b"/" | b"/index.html")
24+
{
2225
let mut static_req = Request::new(());
2326
*static_req.method_mut() = request.method().clone();
2427
*static_req.uri_mut() = request.uri().clone();

src/tests/util/test_app.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,8 @@ fn simple_config() -> config::Server {
481481

482482
// The frontend code is not needed for the backend tests.
483483
serve_dist: false,
484-
serve_html: false,
484+
index_html_template_path: None,
485+
og_image_base_url: None,
485486
content_security_policy: None,
486487
}
487488
}

0 commit comments

Comments
 (0)