Skip to content

Commit 21cae1f

Browse files
committed
og_image: Use Typst's --input instead of minijinja to pass in data
This makes the crate more secure while maintaining the same functionality, and it simplifies the implementation by removing the intermediate template rendering step.
1 parent 80e748b commit 21cae1f

13 files changed

+92
-969
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/crates_io_og_image/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ workspace = true
1212
anyhow = "=1.0.98"
1313
bytes = "=1.10.1"
1414
crates_io_env_vars = { path = "../crates_io_env_vars" }
15-
minijinja = { version = "=2.10.2", features = ["builtins"] }
1615
reqwest = "=0.12.20"
1716
serde = { version = "=1.0.219", features = ["derive"] }
17+
serde_json = "=1.0.140"
1818
tempfile = "=3.20.0"
1919
thiserror = "=2.0.12"
2020
tokio = { version = "=1.45.1", features = ["process", "fs"] }

crates/crates_io_og_image/src/error.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ pub enum OgImageError {
3030
source: std::io::Error,
3131
},
3232

33-
/// Template rendering error.
34-
#[error("Template rendering error: {0}")]
35-
TemplateError(#[from] minijinja::Error),
33+
/// JSON serialization error.
34+
#[error("JSON serialization error: {0}")]
35+
JsonSerializationError(#[source] serde_json::Error),
3636

3737
/// Typst compilation failed.
3838
#[error("Typst compilation failed: {stderr}")]

crates/crates_io_og_image/src/formatting.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
//! This module contains utility functions for formatting numbers in various ways,
44
//! such as human-readable byte sizes.
55
6+
use serde::Serializer;
7+
68
/// Formats a byte size value into a human-readable string.
79
///
810
/// The function follows these rules:
@@ -50,6 +52,10 @@ pub fn format_bytes(bytes: u32) -> String {
5052
}
5153
}
5254

55+
pub fn serialize_bytes<S: Serializer>(bytes: &u32, serializer: S) -> Result<S::Ok, S::Error> {
56+
serializer.serialize_str(&format_bytes(*bytes))
57+
}
58+
5359
/// Formats a number with "k" and "M" suffixes for thousands and millions.
5460
///
5561
/// The function follows these rules:
@@ -95,6 +101,20 @@ pub fn format_number(number: u32) -> String {
95101
}
96102
}
97103

104+
pub fn serialize_number<S: Serializer>(number: &u32, serializer: S) -> Result<S::Ok, S::Error> {
105+
serializer.serialize_str(&format_number(*number))
106+
}
107+
108+
pub fn serialize_optional_number<S: Serializer>(
109+
opt_number: &Option<u32>,
110+
serializer: S,
111+
) -> Result<S::Ok, S::Error> {
112+
match opt_number {
113+
Some(number) => serializer.serialize_str(&format_number(*number)),
114+
None => serializer.serialize_none(),
115+
}
116+
}
117+
98118
#[cfg(test)]
99119
mod tests {
100120
use super::*;

crates/crates_io_og_image/src/lib.rs

Lines changed: 20 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,17 @@ mod formatting;
55

66
pub use error::OgImageError;
77

8-
use crate::formatting::{format_bytes, format_number};
8+
use crate::formatting::{serialize_bytes, serialize_number, serialize_optional_number};
99
use bytes::Bytes;
1010
use crates_io_env_vars::var;
11-
use minijinja::{Environment, context};
1211
use serde::Serialize;
12+
use serde_json;
1313
use std::collections::HashMap;
1414
use std::path::PathBuf;
15-
use std::sync::LazyLock;
1615
use tempfile::NamedTempFile;
1716
use tokio::fs;
1817
use tokio::process::Command;
1918

20-
static TEMPLATE_ENV: LazyLock<Environment<'_>> = LazyLock::new(|| {
21-
let mut env = Environment::new();
22-
23-
// Add custom filter for escaping Typst special characters
24-
env.add_filter("typst_escape", |value: String| -> String {
25-
value
26-
.replace('\\', "\\\\") // Escape backslashes first
27-
.replace('"', "\\\"") // Escape double quotes
28-
// Note: No need to escape # characters when inside double-quoted strings
29-
});
30-
31-
// Add custom filter for formatting byte sizes
32-
env.add_filter("format_bytes", format_bytes);
33-
34-
// Add custom filter for formatting numbers with k/M suffixes
35-
env.add_filter("format_number", format_number);
36-
37-
let template_str = include_str!("../templates/og-image.typ.j2");
38-
env.add_template("og-image.typ", template_str).unwrap();
39-
env
40-
});
41-
4219
/// Data structure containing information needed to generate an OpenGraph image
4320
/// for a crates.io crate.
4421
#[derive(Debug, Clone, Serialize)]
@@ -56,10 +33,13 @@ pub struct OgImageData<'a> {
5633
/// Author information
5734
pub authors: &'a [OgImageAuthorData<'a>],
5835
/// Source lines of code count (optional)
36+
#[serde(serialize_with = "serialize_optional_number")]
5937
pub lines_of_code: Option<u32>,
6038
/// Package size in bytes
39+
#[serde(serialize_with = "serialize_bytes")]
6140
pub crate_size: u32,
6241
/// Total number of releases
42+
#[serde(serialize_with = "serialize_number")]
6343
pub releases: u32,
6444
}
6545

@@ -187,20 +167,6 @@ impl OgImageGenerator {
187167
Ok(avatar_map)
188168
}
189169

190-
/// Generates the Typst template content from the provided data.
191-
///
192-
/// This private method renders the Jinja2 template with the provided data
193-
/// and returns the resulting Typst markup as a string.
194-
fn generate_template(
195-
&self,
196-
data: &OgImageData<'_>,
197-
avatar_map: &HashMap<&str, String>,
198-
) -> Result<String, OgImageError> {
199-
let template = TEMPLATE_ENV.get_template("og-image.typ")?;
200-
let rendered = template.render(context! { data, avatar_map })?;
201-
Ok(rendered)
202-
}
203-
204170
/// Generates an OpenGraph image using the provided data.
205171
///
206172
/// This method creates a temporary directory with all the necessary files
@@ -258,19 +224,30 @@ impl OgImageGenerator {
258224
// Process avatars - download URLs and copy assets
259225
let avatar_map = self.process_avatars(&data, &assets_dir).await?;
260226

261-
// Create og-image.typ file using minijinja template
262-
let rendered = self.generate_template(&data, &avatar_map)?;
227+
// Copy the static Typst template file
228+
let template_content = include_str!("../templates/og-image.typ");
263229
let typ_file_path = temp_dir.path().join("og-image.typ");
264-
fs::write(&typ_file_path, rendered).await?;
230+
fs::write(&typ_file_path, template_content).await?;
265231

266232
// Create a named temp file for the output PNG
267233
let output_file = NamedTempFile::new().map_err(OgImageError::TempFileError)?;
268234

269-
// Run typst compile command
235+
// Serialize data and avatar_map to JSON
236+
let json_data = serde_json::to_string(&data);
237+
let json_data = json_data.map_err(OgImageError::JsonSerializationError)?;
238+
239+
let json_avatar_map = serde_json::to_string(&avatar_map);
240+
let json_avatar_map = json_avatar_map.map_err(OgImageError::JsonSerializationError)?;
241+
242+
// Run typst compile command with input data
270243
let output = Command::new(&self.typst_binary_path)
271244
.arg("compile")
272245
.arg("--format")
273246
.arg("png")
247+
.arg("--input")
248+
.arg(format!("data={}", json_data))
249+
.arg("--input")
250+
.arg(format!("avatar_map={}", json_avatar_map))
274251
.arg(&typ_file_path)
275252
.arg(output_file.path())
276253
.output()
@@ -313,22 +290,6 @@ mod tests {
313290
OgImageAuthorData::new(name, Some("test-avatar"))
314291
}
315292

316-
fn create_standard_test_data() -> OgImageData<'static> {
317-
static AUTHORS: &[OgImageAuthorData<'_>] = &[author_with_avatar("alice"), author("bob")];
318-
319-
OgImageData {
320-
name: "example-crate",
321-
version: "v2.1.0",
322-
description: "A comprehensive example crate showcasing various OpenGraph features",
323-
license: "MIT OR Apache-2.0",
324-
tags: &["web", "api", "async", "json", "http"],
325-
authors: AUTHORS,
326-
lines_of_code: Some(5500),
327-
crate_size: 128000,
328-
releases: 15,
329-
}
330-
}
331-
332293
fn create_minimal_test_data() -> OgImageData<'static> {
333294
static AUTHORS: &[OgImageAuthorData<'_>] = &[author("author")];
334295

@@ -428,12 +389,6 @@ mod tests {
428389
.is_err()
429390
}
430391

431-
fn generate_template(data: OgImageData<'_>, avatar_map: HashMap<&str, String>) -> String {
432-
OgImageGenerator::default()
433-
.generate_template(&data, &avatar_map)
434-
.expect("Failed to generate template")
435-
}
436-
437392
async fn generate_image(data: OgImageData<'_>) -> Option<Vec<u8>> {
438393
if skip_if_typst_unavailable() {
439394
return None;
@@ -449,33 +404,6 @@ mod tests {
449404
Some(std::fs::read(temp_file.path()).expect("Failed to read generated image"))
450405
}
451406

452-
#[test]
453-
fn test_generate_template_snapshot() {
454-
let data = create_standard_test_data();
455-
let avatar_map = HashMap::from([("test-avatar", "avatar_0.png".to_string())]);
456-
457-
let template_content = generate_template(data, avatar_map);
458-
insta::assert_snapshot!("generated_template.typ", template_content);
459-
}
460-
461-
#[test]
462-
fn test_generate_template_minimal_snapshot() {
463-
let data = create_minimal_test_data();
464-
let avatar_map = HashMap::new();
465-
466-
let template_content = generate_template(data, avatar_map);
467-
insta::assert_snapshot!("generated_template_minimal.typ", template_content);
468-
}
469-
470-
#[test]
471-
fn test_generate_template_escaping_snapshot() {
472-
let data = create_escaping_test_data();
473-
let avatar_map = HashMap::from([("test-avatar", "avatar_0.png".to_string())]);
474-
475-
let template_content = generate_template(data, avatar_map);
476-
insta::assert_snapshot!("generated_template_escaping.typ", template_content);
477-
}
478-
479407
#[tokio::test]
480408
async fn test_generate_og_image_snapshot() {
481409
let data = create_simple_test_data();
Loading

0 commit comments

Comments
 (0)