Skip to content

Commit 8bd5d65

Browse files
committed
worker: Add OpenGraph image generation background job
1 parent d9569f6 commit 8bd5d65

10 files changed

+264
-0
lines changed

Cargo.lock

Lines changed: 1 addition & 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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ crates_io_env_vars = { path = "crates/crates_io_env_vars" }
7171
crates_io_github = { path = "crates/crates_io_github" }
7272
crates_io_index = { path = "crates/crates_io_index" }
7373
crates_io_markdown = { path = "crates/crates_io_markdown" }
74+
crates_io_og_image = { path = "crates/crates_io_og_image" }
7475
crates_io_pagerduty = { path = "crates/crates_io_pagerduty" }
7576
crates_io_session = { path = "crates/crates_io_session" }
7677
crates_io_tarball = { path = "crates/crates_io_tarball" }

src/bin/background-worker.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crates_io::{Emails, config};
2424
use crates_io_docs_rs::RealDocsRsClient;
2525
use crates_io_env_vars::var;
2626
use crates_io_index::RepositoryConfig;
27+
use crates_io_og_image::OgImageGenerator;
2728
use crates_io_team_repo::TeamRepoImpl;
2829
use crates_io_worker::Runner;
2930
use object_store::prefix::PrefixStore;
@@ -102,6 +103,7 @@ fn main() -> anyhow::Result<()> {
102103
.emails(emails)
103104
.maybe_docs_rs(docs_rs)
104105
.team_repo(Box::new(team_repo))
106+
.og_image_generator(OgImageGenerator::from_environment()?)
105107
.build();
106108

107109
let environment = Arc::new(environment);

src/tests/util/test_app.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use crates_io_team_repo::MockTeamRepo;
2020
use crates_io_test_db::TestDatabase;
2121
use crates_io_trustpub::github::test_helpers::AUDIENCE;
2222
use crates_io_trustpub::keystore::{MockOidcKeyStore, OidcKeyStore};
23+
use crates_io_og_image::OgImageGenerator;
2324
use crates_io_worker::Runner;
2425
use diesel_async::AsyncPgConnection;
2526
use futures_util::TryStreamExt;
@@ -107,6 +108,7 @@ impl TestApp {
107108
github: None,
108109
docs_rs: None,
109110
oidc_key_stores: Default::default(),
111+
og_image_generator: None,
110112
}
111113
}
112114

@@ -255,6 +257,7 @@ pub struct TestAppBuilder {
255257
github: Option<MockGitHubClient>,
256258
docs_rs: Option<MockDocsRsClient>,
257259
oidc_key_stores: HashMap<String, Box<dyn OidcKeyStore>>,
260+
og_image_generator: Option<OgImageGenerator>,
258261
}
259262

260263
impl TestAppBuilder {
@@ -314,6 +317,7 @@ impl TestAppBuilder {
314317
.emails(app.emails.clone())
315318
.maybe_docs_rs(self.docs_rs.map(|cl| Box::new(cl) as _))
316319
.team_repo(Box::new(self.team_repo))
320+
.maybe_og_image_generator(self.og_image_generator)
317321
.build();
318322

319323
let runner = Runner::new(app.primary_database.clone(), Arc::new(environment))
@@ -423,6 +427,13 @@ impl TestAppBuilder {
423427
self
424428
}
425429

430+
pub fn with_og_image_generator(mut self) -> Self {
431+
let og_generator = OgImageGenerator::from_environment()
432+
.expect("Failed to create OG image generator for tests");
433+
self.og_image_generator = Some(og_generator);
434+
self
435+
}
436+
426437
pub fn with_replica(mut self) -> Self {
427438
let primary = &self.config.db.primary;
428439

src/worker/environment.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use anyhow::Context;
77
use bon::Builder;
88
use crates_io_docs_rs::DocsRsClient;
99
use crates_io_index::{Repository, RepositoryConfig};
10+
use crates_io_og_image::OgImageGenerator;
1011
use crates_io_team_repo::TeamRepo;
1112
use diesel_async::AsyncPgConnection;
1213
use diesel_async::pooled_connection::deadpool::Pool;
@@ -33,6 +34,7 @@ pub struct Environment {
3334
pub emails: Emails,
3435
pub team_repo: Box<dyn TeamRepo + Send + Sync>,
3536
pub docs_rs: Option<Box<dyn DocsRsClient>>,
37+
pub og_image_generator: Option<OgImageGenerator>,
3638

3739
/// A lazily initialised cache of the most popular crates ready to use in typosquatting checks.
3840
#[builder(skip)]

src/worker/jobs/generate_og_image.rs

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
use crate::models::OwnerKind;
2+
use crate::schema::*;
3+
use crate::worker::Environment;
4+
use anyhow::Context;
5+
use crates_io_og_image::{OgImageAuthorData, OgImageData};
6+
use crates_io_worker::BackgroundJob;
7+
use diesel::prelude::*;
8+
use diesel_async::{AsyncPgConnection, RunQueryDsl};
9+
use serde::{Deserialize, Serialize};
10+
use std::sync::Arc;
11+
use tokio::fs;
12+
use tracing::{error, info, instrument, warn};
13+
14+
#[derive(Serialize, Deserialize)]
15+
pub struct GenerateOgImage {
16+
crate_name: String,
17+
}
18+
19+
impl GenerateOgImage {
20+
pub fn new(crate_name: String) -> Self {
21+
Self { crate_name }
22+
}
23+
}
24+
25+
impl BackgroundJob for GenerateOgImage {
26+
const JOB_NAME: &'static str = "generate_og_image";
27+
const DEDUPLICATED: bool = true;
28+
29+
type Context = Arc<Environment>;
30+
31+
#[instrument(skip_all, fields(crate.name = %self.crate_name))]
32+
async fn run(&self, ctx: Self::Context) -> anyhow::Result<()> {
33+
let crate_name = &self.crate_name;
34+
35+
let Some(option) = &ctx.og_image_generator else {
36+
warn!("OG image generator is not configured, skipping job for crate {crate_name}");
37+
return Ok(());
38+
};
39+
40+
info!("Generating OG image for crate {crate_name}");
41+
42+
let mut conn = ctx.deadpool.get().await?;
43+
44+
// Fetch crate data
45+
let row = fetch_crate_data(crate_name, &mut conn).await;
46+
let row = row.context("Failed to fetch crate data")?;
47+
let Some(row) = row else {
48+
error!("Crate '{crate_name}' not found or has no default version");
49+
return Ok(());
50+
};
51+
52+
let keywords: Vec<&str> = row.keywords.iter().flatten().map(|k| k.as_str()).collect();
53+
54+
// Fetch user owners
55+
let owners = fetch_user_owners(row._crate_id, &mut conn).await;
56+
let owners = owners.context("Failed to fetch crate owners")?;
57+
let authors: Vec<OgImageAuthorData<'_>> = owners
58+
.iter()
59+
.map(|(login, avatar)| OgImageAuthorData::new(login, avatar.as_deref()))
60+
.collect();
61+
62+
// Build the OG image data
63+
let og_data = OgImageData {
64+
name: &row.crate_name,
65+
version: &row.version_num,
66+
description: row.description.as_deref(),
67+
license: row.license.as_deref(),
68+
tags: &keywords,
69+
authors: &authors,
70+
lines_of_code: None, // We don't track this yet
71+
crate_size: row.crate_size as u32,
72+
releases: row.num_versions as u32,
73+
};
74+
75+
// Generate the OG image
76+
let temp_file = option.generate(og_data).await?;
77+
78+
// Read the generated image
79+
let image_bytes = fs::read(temp_file.path()).await?;
80+
81+
// Upload to storage
82+
ctx.storage
83+
.upload_og_image(crate_name, image_bytes.into())
84+
.await?;
85+
86+
// Invalidate CDN cache for the OG image
87+
let og_image_path = format!("og-images/{crate_name}.png");
88+
ctx.invalidate_cdns(&og_image_path).await?;
89+
90+
info!("Successfully generated and uploaded OG image for crate {crate_name}");
91+
92+
Ok(())
93+
}
94+
}
95+
96+
#[derive(Queryable, Selectable)]
97+
#[diesel(check_for_backend(diesel::pg::Pg))]
98+
struct QueryRow {
99+
#[diesel(select_expression = crates::id)]
100+
_crate_id: i32,
101+
#[diesel(select_expression = crates::name)]
102+
crate_name: String,
103+
#[diesel(select_expression = versions::num)]
104+
version_num: String,
105+
#[diesel(select_expression = versions::description)]
106+
description: Option<String>,
107+
#[diesel(select_expression = versions::license)]
108+
license: Option<String>,
109+
#[diesel(select_expression = versions::crate_size)]
110+
crate_size: i32,
111+
#[diesel(select_expression = versions::keywords)]
112+
keywords: Vec<Option<String>>,
113+
#[diesel(select_expression = default_versions::num_versions.assume_not_null())]
114+
num_versions: i32,
115+
}
116+
117+
/// Fetches crate data and default version information by crate name
118+
async fn fetch_crate_data(
119+
crate_name: &str,
120+
conn: &mut AsyncPgConnection,
121+
) -> QueryResult<Option<QueryRow>> {
122+
crates::table
123+
.inner_join(default_versions::table)
124+
.inner_join(versions::table.on(default_versions::version_id.eq(versions::id)))
125+
.filter(crates::name.eq(crate_name))
126+
.select(QueryRow::as_select())
127+
.first(conn)
128+
.await
129+
.optional()
130+
}
131+
132+
/// Fetches user owners and their avatars for a crate by crate ID
133+
async fn fetch_user_owners(
134+
crate_id: i32,
135+
conn: &mut AsyncPgConnection,
136+
) -> QueryResult<Vec<(String, Option<String>)>> {
137+
crate_owners::table
138+
.inner_join(users::table.on(crate_owners::owner_id.eq(users::id)))
139+
.filter(crate_owners::crate_id.eq(crate_id))
140+
.filter(crate_owners::owner_kind.eq(OwnerKind::User))
141+
.filter(crate_owners::deleted.eq(false))
142+
.select((users::gh_login, users::gh_avatar))
143+
.load(conn)
144+
.await
145+
}
146+
147+
#[cfg(test)]
148+
mod tests {
149+
use super::*;
150+
use crate::tests::builders::CrateBuilder;
151+
use crate::tests::util::TestApp;
152+
use claims::{assert_err, assert_ok};
153+
use crates_io_env_vars::var;
154+
use crates_io_worker::BackgroundJob;
155+
use insta::assert_binary_snapshot;
156+
use std::process::Command;
157+
158+
fn is_ci() -> bool {
159+
var("CI").unwrap().is_some()
160+
}
161+
162+
fn typst_available() -> bool {
163+
Command::new("typst").arg("--version").spawn().is_ok()
164+
}
165+
166+
#[tokio::test(flavor = "multi_thread")]
167+
async fn test_generate_og_image_job() {
168+
let (app, _, user) = TestApp::full().with_og_image_generator().with_user().await;
169+
170+
if !is_ci() && !typst_available() {
171+
warn!("Skipping OG image generation test because 'typst' is not available");
172+
return;
173+
}
174+
175+
let mut conn = app.db_conn().await;
176+
177+
// Create a test crate with keywords using CrateBuilder
178+
CrateBuilder::new("test-crate", user.as_model().id)
179+
.description("A test crate for OG image generation")
180+
.keyword("testing")
181+
.keyword("rust")
182+
.expect_build(&mut conn)
183+
.await;
184+
185+
// Create and enqueue the job
186+
let job = GenerateOgImage::new("test-crate".to_string());
187+
job.enqueue(&mut conn).await.unwrap();
188+
189+
// Run the background job
190+
app.run_pending_background_jobs().await;
191+
192+
// Verify the OG image was uploaded to storage
193+
let storage = app.as_inner().storage.as_inner();
194+
let og_image_path = "og-images/test-crate.png";
195+
196+
// Try to download the image to verify it exists
197+
let download_result = storage.get(&og_image_path.into()).await;
198+
let result = assert_ok!(
199+
download_result,
200+
"OG image should be uploaded to storage at: {og_image_path}"
201+
);
202+
203+
// Verify it's a non-empty file
204+
let image_bytes = result.bytes().await.unwrap().to_vec();
205+
assert!(!image_bytes.is_empty(), "OG image should not be empty");
206+
207+
// Verify it starts with PNG magic bytes
208+
assert_eq!(
209+
&image_bytes[0..8],
210+
&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
211+
"Uploaded file should be a valid PNG"
212+
);
213+
214+
assert_binary_snapshot!("og-image.png", image_bytes);
215+
}
216+
217+
#[tokio::test(flavor = "multi_thread")]
218+
async fn test_generate_og_image_job_nonexistent_crate() {
219+
let (app, _, _) = TestApp::full().with_user().await;
220+
let mut conn = app.db_conn().await;
221+
222+
// Create and enqueue the job for a non-existent crate
223+
let job = GenerateOgImage::new("nonexistent-crate".to_string());
224+
job.enqueue(&mut conn).await.unwrap();
225+
226+
// Run the background job - should complete without error
227+
app.run_pending_background_jobs().await;
228+
229+
// Verify no OG image was uploaded
230+
let storage = app.as_inner().storage.as_inner();
231+
let og_image_path = "og-images/nonexistent-crate.png";
232+
let download_result = storage.get(&og_image_path.into()).await;
233+
assert_err!(
234+
download_result,
235+
"No OG image should be uploaded for nonexistent crate"
236+
);
237+
}
238+
}

src/worker/jobs/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod docs_rs_queue_rebuild;
55
mod downloads;
66
pub mod dump_db;
77
mod expiry_notification;
8+
mod generate_og_image;
89
mod index;
910
mod index_version_downloads_archive;
1011
mod invalidate_cdns;
@@ -25,6 +26,7 @@ pub use self::downloads::{
2526
};
2627
pub use self::dump_db::DumpDb;
2728
pub use self::expiry_notification::SendTokenExpiryNotifications;
29+
pub use self::generate_og_image::GenerateOgImage;
2830
pub use self::index::{NormalizeIndex, SquashIndex, SyncToGitIndex, SyncToSparseIndex};
2931
pub use self::index_version_downloads_archive::IndexVersionDownloadsArchive;
3032
pub use self::invalidate_cdns::InvalidateCdns;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: src/worker/jobs/generate_og_image.rs
3+
expression: image_bytes
4+
extension: png
5+
snapshot_kind: binary
6+
---

src/worker/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ impl RunnerExt for Runner<Arc<Environment>> {
2626
.register_job_type::<jobs::DeleteCrateFromStorage>()
2727
.register_job_type::<jobs::DocsRsQueueRebuild>()
2828
.register_job_type::<jobs::DumpDb>()
29+
.register_job_type::<jobs::GenerateOgImage>()
2930
.register_job_type::<jobs::IndexVersionDownloadsArchive>()
3031
.register_job_type::<jobs::InvalidateCdns>()
3132
.register_job_type::<jobs::NormalizeIndex>()

0 commit comments

Comments
 (0)