Skip to content

Commit 9996564

Browse files
committed
worker: Add OpenGraph image generation background job
1 parent 0682727 commit 9996564

10 files changed

+238
-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: 4 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;
@@ -89,6 +90,8 @@ fn main() -> anyhow::Result<()> {
8990

9091
let docs_rs = RealDocsRsClient::from_environment().map(|cl| Box::new(cl) as _);
9192

93+
let og_image_generator = Arc::new(OgImageGenerator::from_environment()?);
94+
9295
let deadpool = create_database_pool(&config.db.primary);
9396

9497
let environment = Environment::builder()
@@ -102,6 +105,7 @@ fn main() -> anyhow::Result<()> {
102105
.emails(emails)
103106
.maybe_docs_rs(docs_rs)
104107
.team_repo(Box::new(team_repo))
108+
.og_image_generator(og_image_generator)
105109
.build();
106110

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

src/tests/util/test_app.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use crates_io_docs_rs::MockDocsRsClient;
1616
use crates_io_github::MockGitHubClient;
1717
use crates_io_index::testing::UpstreamIndex;
1818
use crates_io_index::{Credentials, RepositoryConfig};
19+
use crates_io_og_image::OgImageGenerator;
1920
use crates_io_team_repo::MockTeamRepo;
2021
use crates_io_test_db::TestDatabase;
2122
use crates_io_trustpub::github::test_helpers::AUDIENCE;
@@ -306,6 +307,8 @@ impl TestAppBuilder {
306307
credentials: Credentials::Missing,
307308
};
308309

310+
let og_image_generator = Arc::new(OgImageGenerator::from_environment().unwrap());
311+
309312
let environment = Environment::builder()
310313
.config(app.config.clone())
311314
.repository_config(repository_config)
@@ -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+
.og_image_generator(og_image_generator)
317321
.build();
318322

319323
let runner = Runner::new(app.primary_database.clone(), Arc::new(environment))

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

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)