diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 326b37f6e17..cf0a2b8b9c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,8 +206,7 @@ jobs: - run: cargo test --workspace env: # Set the path to the Fira Sans font for Typst. - # The path is relative to the `crates_io_og_image` crate root. - TYPST_FONT_PATH: ../../Fira-4.202/otf + TYPST_FONT_PATH: ${{ github.workspace }}/Fira-4.202/otf frontend-lint: name: Frontend / Lint diff --git a/Cargo.lock b/Cargo.lock index 7169b712823..6848c788cd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1376,6 +1376,7 @@ dependencies = [ "crates_io_github", "crates_io_index", "crates_io_markdown", + "crates_io_og_image", "crates_io_pagerduty", "crates_io_session", "crates_io_tarball", diff --git a/Cargo.toml b/Cargo.toml index 4d674bc3ab5..86ade68d932 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ crates_io_env_vars = { path = "crates/crates_io_env_vars" } crates_io_github = { path = "crates/crates_io_github" } crates_io_index = { path = "crates/crates_io_index" } crates_io_markdown = { path = "crates/crates_io_markdown" } +crates_io_og_image = { path = "crates/crates_io_og_image" } crates_io_pagerduty = { path = "crates/crates_io_pagerduty" } crates_io_session = { path = "crates/crates_io_session" } crates_io_tarball = { path = "crates/crates_io_tarball" } diff --git a/src/bin/background-worker.rs b/src/bin/background-worker.rs index be1d149cda9..f1146b7530d 100644 --- a/src/bin/background-worker.rs +++ b/src/bin/background-worker.rs @@ -24,6 +24,7 @@ use crates_io::{Emails, config}; use crates_io_docs_rs::RealDocsRsClient; use crates_io_env_vars::var; use crates_io_index::RepositoryConfig; +use crates_io_og_image::OgImageGenerator; use crates_io_team_repo::TeamRepoImpl; use crates_io_worker::Runner; use object_store::prefix::PrefixStore; @@ -102,6 +103,7 @@ fn main() -> anyhow::Result<()> { .emails(emails) .maybe_docs_rs(docs_rs) .team_repo(Box::new(team_repo)) + .og_image_generator(OgImageGenerator::from_environment()?) .build(); let environment = Arc::new(environment); diff --git a/src/bin/crates-admin/backfill_og_images.rs b/src/bin/crates-admin/backfill_og_images.rs new file mode 100644 index 00000000000..5a4c5c7d33d --- /dev/null +++ b/src/bin/crates-admin/backfill_og_images.rs @@ -0,0 +1,123 @@ +use anyhow::Result; +use crates_io::db; +use crates_io::schema::{background_jobs, crates}; +use crates_io::worker::jobs::GenerateOgImage; +use crates_io_worker::BackgroundJob; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use tracing::{info, warn}; + +#[derive(clap::Parser, Debug)] +#[command( + name = "backfill-og-images", + about = "Enqueue OG image generation jobs for existing crates" +)] +pub struct Opts { + #[arg(long, default_value = "1000")] + /// Batch size for enqueueing crates (default: 1000) + batch_size: usize, + + #[arg(long)] + /// Only generate OG images for crates with names starting with this prefix + prefix: Option, + + #[arg(long)] + /// Offset to start enqueueing from (useful for resuming) + offset: Option, +} + +pub async fn run(opts: Opts) -> Result<()> { + let mut conn = db::oneoff_connection().await?; + + info!("Starting OG image backfill with options: {opts:?}"); + + // Helper function to build query + let build_query = |offset: i64| { + let mut query = crates::table + .select(crates::name) + .order(crates::name) + .into_boxed(); + + if let Some(prefix) = &opts.prefix { + query = query.filter(crates::name.like(format!("{prefix}%"))); + } + + query.offset(offset) + }; + + // Count total crates to process + let mut count_query = crates::table.into_boxed(); + if let Some(prefix) = &opts.prefix { + count_query = count_query.filter(crates::name.like(format!("{prefix}%"))); + } + let total_crates: i64 = count_query.count().get_result(&mut conn).await?; + + info!("Total crates to enqueue: {total_crates}"); + + let mut offset = opts.offset.unwrap_or(0); + let mut enqueued = 0; + let mut errors = 0; + + loop { + // Fetch batch of crate names + let crate_names: Vec = build_query(offset) + .limit(opts.batch_size as i64) + .load(&mut conn) + .await?; + + if crate_names.is_empty() { + break; + } + + let batch_size = crate_names.len(); + info!( + "Enqueueing batch {}-{} of {total_crates}", + offset + 1, + offset + batch_size as i64 + ); + + // Create batch of jobs + let jobs = crate_names + .into_iter() + .map(GenerateOgImage::new) + .map(|job| { + Ok(( + background_jobs::job_type.eq(GenerateOgImage::JOB_NAME), + background_jobs::data.eq(serde_json::to_value(job)?), + background_jobs::priority.eq(-10), + )) + }) + .collect::>>()?; + + // Batch insert all jobs + let result = diesel::insert_into(background_jobs::table) + .values(jobs) + .execute(&mut conn) + .await; + + match result { + Ok(inserted_count) => { + enqueued += inserted_count; + info!("Enqueued {enqueued} jobs so far..."); + } + Err(e) => { + errors += batch_size; + warn!("Failed to enqueue batch of OG image jobs: {e}"); + } + } + + // Break if we've processed fewer than batch_size (last batch) + if batch_size < opts.batch_size { + break; + } + + offset += opts.batch_size as i64; + } + + info!("Jobs enqueued: {enqueued}"); + if errors > 0 { + warn!("{errors} jobs failed to enqueue. Check logs above for details."); + } + + Ok(()) +} diff --git a/src/bin/crates-admin/enqueue_job.rs b/src/bin/crates-admin/enqueue_job.rs index 1f85346ef2e..cc04ab334bb 100644 --- a/src/bin/crates-admin/enqueue_job.rs +++ b/src/bin/crates-admin/enqueue_job.rs @@ -34,6 +34,12 @@ pub enum Command { #[arg()] name: String, }, + /// Generate OpenGraph images for the specified crates + GenerateOgImage { + /// Crate names to generate OpenGraph images for + #[arg(required = true)] + names: Vec, + }, ProcessCdnLogQueue(jobs::ProcessCdnLogQueue), SyncAdmins { /// Force a sync even if one is already in progress @@ -143,6 +149,11 @@ pub async fn run(command: Command) -> Result<()> { jobs::CheckTyposquat::new(&name).enqueue(&mut conn).await?; } + Command::GenerateOgImage { names } => { + for name in names { + jobs::GenerateOgImage::new(name).enqueue(&mut conn).await?; + } + } Command::SendTokenExpiryNotifications => { jobs::SendTokenExpiryNotifications .enqueue(&mut conn) diff --git a/src/bin/crates-admin/main.rs b/src/bin/crates-admin/main.rs index de6c1ce09f0..0e3b8cc1e1c 100644 --- a/src/bin/crates-admin/main.rs +++ b/src/bin/crates-admin/main.rs @@ -1,6 +1,7 @@ #[macro_use] extern crate tracing; +mod backfill_og_images; mod default_versions; mod delete_crate; mod delete_version; @@ -17,6 +18,7 @@ mod yank_version; #[derive(clap::Parser, Debug)] #[command(name = "crates-admin")] enum Command { + BackfillOgImages(backfill_og_images::Opts), DeleteCrate(delete_crate::Opts), DeleteVersion(delete_version::Opts), Populate(populate::Opts), @@ -46,6 +48,7 @@ async fn main() -> anyhow::Result<()> { span.record("command", tracing::field::debug(&command)); match command { + Command::BackfillOgImages(opts) => backfill_og_images::run(opts).await, Command::DeleteCrate(opts) => delete_crate::run(opts).await, Command::DeleteVersion(opts) => delete_version::run(opts).await, Command::Populate(opts) => populate::run(opts).await, diff --git a/src/controllers/krate/publish.rs b/src/controllers/krate/publish.rs index e34c0326c60..0aa70e17a94 100644 --- a/src/controllers/krate/publish.rs +++ b/src/controllers/krate/publish.rs @@ -3,7 +3,7 @@ use crate::app::AppState; use crate::auth::{AuthCheck, AuthHeader, Authentication}; use crate::worker::jobs::{ - self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion, + self, CheckTyposquat, GenerateOgImage, SendPublishNotificationsJob, UpdateDefaultVersion, }; use axum::Json; use axum::body::{Body, Bytes}; @@ -549,14 +549,14 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult AppResult String { + apply_cdn_prefix(&self.cdn_prefix, &og_image_path(name)) + } + /// Returns the URL of an uploaded RSS feed. pub fn feed_url(&self, feed_id: &FeedId<'_>) -> String { apply_cdn_prefix(&self.cdn_prefix, &feed_id.into()).replace('+', "%2B") @@ -240,6 +250,13 @@ impl Storage { self.store.delete(&path).await } + /// Deletes the Open Graph image for the given crate. + #[instrument(skip(self))] + pub async fn delete_og_image(&self, name: &str) -> Result<()> { + let path = og_image_path(name); + self.store.delete(&path).await + } + #[instrument(skip(self))] pub async fn delete_feed(&self, feed_id: &FeedId<'_>) -> Result<()> { let path = feed_id.into(); @@ -270,6 +287,19 @@ impl Storage { Ok(()) } + /// Uploads an Open Graph image for the given crate. + #[instrument(skip(self, bytes))] + pub async fn upload_og_image(&self, name: &str, bytes: Bytes) -> Result<()> { + let path = og_image_path(name); + let attributes = self.attrs([ + (Attribute::ContentType, CONTENT_TYPE_OG_IMAGE), + (Attribute::CacheControl, CACHE_CONTROL_OG_IMAGE), + ]); + let opts = attributes.into(); + self.store.put_opts(&path, bytes.into(), opts).await?; + Ok(()) + } + #[instrument(skip(self, channel))] pub async fn upload_feed( &self, @@ -385,6 +415,10 @@ fn readme_path(name: &str, version: &str) -> Path { format!("{PREFIX_READMES}/{name}/{name}-{version}.html").into() } +fn og_image_path(name: &str) -> Path { + format!("{PREFIX_OG_IMAGES}/{name}.png").into() +} + fn apply_cdn_prefix(cdn_prefix: &Option, path: &Path) -> String { match cdn_prefix { Some(cdn_prefix) if !cdn_prefix.starts_with("https://") => { @@ -484,6 +518,17 @@ mod tests { for (name, version, expected) in readme_tests { assert_eq!(storage.readme_location(name, version), expected); } + + let og_image_tests = vec![ + ("foo", "https://static.crates.io/og-images/foo.png"), + ( + "some-long-crate-name", + "https://static.crates.io/og-images/some-long-crate-name.png", + ), + ]; + for (name, expected) in og_image_tests { + assert_eq!(storage.og_image_location(name), expected); + } } #[test] @@ -661,4 +706,39 @@ mod tests { let expected_files = vec![target]; assert_eq!(stored_files(&s.store).await, expected_files); } + + #[tokio::test] + async fn upload_og_image() { + let s = Storage::from_config(&StorageConfig::in_memory()); + + let bytes = Bytes::from_static(b"fake png data"); + s.upload_og_image("foo", bytes.clone()).await.unwrap(); + + let expected_files = vec!["og-images/foo.png"]; + assert_eq!(stored_files(&s.store).await, expected_files); + + s.upload_og_image("some-long-crate-name", bytes) + .await + .unwrap(); + + let expected_files = vec!["og-images/foo.png", "og-images/some-long-crate-name.png"]; + assert_eq!(stored_files(&s.store).await, expected_files); + } + + #[tokio::test] + async fn delete_og_image() { + let s = Storage::from_config(&StorageConfig::in_memory()); + + let bytes = Bytes::from_static(b"fake png data"); + s.upload_og_image("foo", bytes.clone()).await.unwrap(); + s.upload_og_image("bar", bytes).await.unwrap(); + + let expected_files = vec!["og-images/bar.png", "og-images/foo.png"]; + assert_eq!(stored_files(&s.store).await, expected_files); + + s.delete_og_image("foo").await.unwrap(); + + let expected_files = vec!["og-images/bar.png"]; + assert_eq!(stored_files(&s.store).await, expected_files); + } } diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index cf0a8d21f7e..4f2370f7c95 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -16,6 +16,7 @@ use crates_io_docs_rs::MockDocsRsClient; use crates_io_github::MockGitHubClient; use crates_io_index::testing::UpstreamIndex; use crates_io_index::{Credentials, RepositoryConfig}; +use crates_io_og_image::OgImageGenerator; use crates_io_team_repo::MockTeamRepo; use crates_io_test_db::TestDatabase; use crates_io_trustpub::github::test_helpers::AUDIENCE; @@ -107,6 +108,7 @@ impl TestApp { github: None, docs_rs: None, oidc_key_stores: Default::default(), + og_image_generator: None, } } @@ -255,6 +257,7 @@ pub struct TestAppBuilder { github: Option, docs_rs: Option, oidc_key_stores: HashMap>, + og_image_generator: Option, } impl TestAppBuilder { @@ -314,6 +317,7 @@ impl TestAppBuilder { .emails(app.emails.clone()) .maybe_docs_rs(self.docs_rs.map(|cl| Box::new(cl) as _)) .team_repo(Box::new(self.team_repo)) + .maybe_og_image_generator(self.og_image_generator) .build(); let runner = Runner::new(app.primary_database.clone(), Arc::new(environment)) @@ -423,6 +427,13 @@ impl TestAppBuilder { self } + pub fn with_og_image_generator(mut self) -> Self { + let og_generator = OgImageGenerator::from_environment() + .expect("Failed to create OG image generator for tests"); + self.og_image_generator = Some(og_generator); + self + } + pub fn with_replica(mut self) -> Self { let primary = &self.config.db.primary; diff --git a/src/worker/environment.rs b/src/worker/environment.rs index 61dced1fcc2..9b98b5b63bc 100644 --- a/src/worker/environment.rs +++ b/src/worker/environment.rs @@ -7,6 +7,7 @@ use anyhow::Context; use bon::Builder; use crates_io_docs_rs::DocsRsClient; use crates_io_index::{Repository, RepositoryConfig}; +use crates_io_og_image::OgImageGenerator; use crates_io_team_repo::TeamRepo; use diesel_async::AsyncPgConnection; use diesel_async::pooled_connection::deadpool::Pool; @@ -33,6 +34,7 @@ pub struct Environment { pub emails: Emails, pub team_repo: Box, pub docs_rs: Option>, + pub og_image_generator: Option, /// A lazily initialised cache of the most popular crates ready to use in typosquatting checks. #[builder(skip)] diff --git a/src/worker/jobs/generate_og_image.rs b/src/worker/jobs/generate_og_image.rs new file mode 100644 index 00000000000..a5a04d36777 --- /dev/null +++ b/src/worker/jobs/generate_og_image.rs @@ -0,0 +1,238 @@ +use crate::models::OwnerKind; +use crate::schema::*; +use crate::worker::Environment; +use anyhow::Context; +use crates_io_og_image::{OgImageAuthorData, OgImageData}; +use crates_io_worker::BackgroundJob; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::fs; +use tracing::{error, info, instrument, warn}; + +#[derive(Serialize, Deserialize)] +pub struct GenerateOgImage { + crate_name: String, +} + +impl GenerateOgImage { + pub fn new(crate_name: String) -> Self { + Self { crate_name } + } +} + +impl BackgroundJob for GenerateOgImage { + const JOB_NAME: &'static str = "generate_og_image"; + const DEDUPLICATED: bool = true; + + type Context = Arc; + + #[instrument(skip_all, fields(crate.name = %self.crate_name))] + async fn run(&self, ctx: Self::Context) -> anyhow::Result<()> { + let crate_name = &self.crate_name; + + let Some(option) = &ctx.og_image_generator else { + warn!("OG image generator is not configured, skipping job for crate {crate_name}"); + return Ok(()); + }; + + info!("Generating OG image for crate {crate_name}"); + + let mut conn = ctx.deadpool.get().await?; + + // Fetch crate data + let row = fetch_crate_data(crate_name, &mut conn).await; + let row = row.context("Failed to fetch crate data")?; + let Some(row) = row else { + error!("Crate '{crate_name}' not found or has no default version"); + return Ok(()); + }; + + let keywords: Vec<&str> = row.keywords.iter().flatten().map(|k| k.as_str()).collect(); + + // Fetch user owners + let owners = fetch_user_owners(row._crate_id, &mut conn).await; + let owners = owners.context("Failed to fetch crate owners")?; + let authors: Vec> = owners + .iter() + .map(|(login, avatar)| OgImageAuthorData::new(login, avatar.as_ref().map(Into::into))) + .collect(); + + // Build the OG image data + let og_data = OgImageData { + name: &row.crate_name, + version: &row.version_num, + description: row.description.as_deref(), + license: row.license.as_deref(), + tags: &keywords, + authors: &authors, + lines_of_code: None, // We don't track this yet + crate_size: row.crate_size as u32, + releases: row.num_versions as u32, + }; + + // Generate the OG image + let temp_file = option.generate(og_data).await?; + + // Read the generated image + let image_bytes = fs::read(temp_file.path()).await?; + + // Upload to storage + ctx.storage + .upload_og_image(crate_name, image_bytes.into()) + .await?; + + // Invalidate CDN cache for the OG image + let og_image_path = format!("og-images/{crate_name}.png"); + ctx.invalidate_cdns(&og_image_path).await?; + + info!("Successfully generated and uploaded OG image for crate {crate_name}"); + + Ok(()) + } +} + +#[derive(Queryable, Selectable)] +#[diesel(check_for_backend(diesel::pg::Pg))] +struct QueryRow { + #[diesel(select_expression = crates::id)] + _crate_id: i32, + #[diesel(select_expression = crates::name)] + crate_name: String, + #[diesel(select_expression = versions::num)] + version_num: String, + #[diesel(select_expression = versions::description)] + description: Option, + #[diesel(select_expression = versions::license)] + license: Option, + #[diesel(select_expression = versions::crate_size)] + crate_size: i32, + #[diesel(select_expression = versions::keywords)] + keywords: Vec>, + #[diesel(select_expression = default_versions::num_versions.assume_not_null())] + num_versions: i32, +} + +/// Fetches crate data and default version information by crate name +async fn fetch_crate_data( + crate_name: &str, + conn: &mut AsyncPgConnection, +) -> QueryResult> { + crates::table + .inner_join(default_versions::table) + .inner_join(versions::table.on(default_versions::version_id.eq(versions::id))) + .filter(crates::name.eq(crate_name)) + .select(QueryRow::as_select()) + .first(conn) + .await + .optional() +} + +/// Fetches user owners and their avatars for a crate by crate ID +async fn fetch_user_owners( + crate_id: i32, + conn: &mut AsyncPgConnection, +) -> QueryResult)>> { + crate_owners::table + .inner_join(users::table.on(crate_owners::owner_id.eq(users::id))) + .filter(crate_owners::crate_id.eq(crate_id)) + .filter(crate_owners::owner_kind.eq(OwnerKind::User)) + .filter(crate_owners::deleted.eq(false)) + .select((users::gh_login, users::gh_avatar)) + .load(conn) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::builders::CrateBuilder; + use crate::tests::util::TestApp; + use claims::{assert_err, assert_ok}; + use crates_io_env_vars::var; + use crates_io_worker::BackgroundJob; + use insta::assert_binary_snapshot; + use std::process::Command; + + fn is_ci() -> bool { + var("CI").unwrap().is_some() + } + + fn typst_available() -> bool { + Command::new("typst").arg("--version").spawn().is_ok() + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_generate_og_image_job() { + let (app, _, user) = TestApp::full().with_og_image_generator().with_user().await; + + if !is_ci() && !typst_available() { + warn!("Skipping OG image generation test because 'typst' is not available"); + return; + } + + let mut conn = app.db_conn().await; + + // Create a test crate with keywords using CrateBuilder + CrateBuilder::new("test-crate", user.as_model().id) + .description("A test crate for OG image generation") + .keyword("testing") + .keyword("rust") + .expect_build(&mut conn) + .await; + + // Create and enqueue the job + let job = GenerateOgImage::new("test-crate".to_string()); + job.enqueue(&mut conn).await.unwrap(); + + // Run the background job + app.run_pending_background_jobs().await; + + // Verify the OG image was uploaded to storage + let storage = app.as_inner().storage.as_inner(); + let og_image_path = "og-images/test-crate.png"; + + // Try to download the image to verify it exists + let download_result = storage.get(&og_image_path.into()).await; + let result = assert_ok!( + download_result, + "OG image should be uploaded to storage at: {og_image_path}" + ); + + // Verify it's a non-empty file + let image_bytes = result.bytes().await.unwrap().to_vec(); + assert!(!image_bytes.is_empty(), "OG image should not be empty"); + + // Verify it starts with PNG magic bytes + assert_eq!( + &image_bytes[0..8], + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], + "Uploaded file should be a valid PNG" + ); + + assert_binary_snapshot!("og-image.png", image_bytes); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_generate_og_image_job_nonexistent_crate() { + let (app, _, _) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + // Create and enqueue the job for a non-existent crate + let job = GenerateOgImage::new("nonexistent-crate".to_string()); + job.enqueue(&mut conn).await.unwrap(); + + // Run the background job - should complete without error + app.run_pending_background_jobs().await; + + // Verify no OG image was uploaded + let storage = app.as_inner().storage.as_inner(); + let og_image_path = "og-images/nonexistent-crate.png"; + let download_result = storage.get(&og_image_path.into()).await; + assert_err!( + download_result, + "No OG image should be uploaded for nonexistent crate" + ); + } +} diff --git a/src/worker/jobs/mod.rs b/src/worker/jobs/mod.rs index af8a008a652..b38d970365f 100644 --- a/src/worker/jobs/mod.rs +++ b/src/worker/jobs/mod.rs @@ -5,6 +5,7 @@ mod docs_rs_queue_rebuild; mod downloads; pub mod dump_db; mod expiry_notification; +mod generate_og_image; mod index; mod index_version_downloads_archive; mod invalidate_cdns; @@ -25,6 +26,7 @@ pub use self::downloads::{ }; pub use self::dump_db::DumpDb; pub use self::expiry_notification::SendTokenExpiryNotifications; +pub use self::generate_og_image::GenerateOgImage; pub use self::index::{NormalizeIndex, SquashIndex, SyncToGitIndex, SyncToSparseIndex}; pub use self::index_version_downloads_archive::IndexVersionDownloadsArchive; pub use self::invalidate_cdns::InvalidateCdns; diff --git a/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap b/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap new file mode 100644 index 00000000000..cf5fa112c7b --- /dev/null +++ b/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap @@ -0,0 +1,6 @@ +--- +source: src/worker/jobs/generate_og_image.rs +expression: image_bytes +extension: png +snapshot_kind: binary +--- diff --git a/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap.png b/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap.png new file mode 100644 index 00000000000..efc5b0eec39 Binary files /dev/null and b/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap.png differ diff --git a/src/worker/jobs/update_default_version.rs b/src/worker/jobs/update_default_version.rs index 1186196673d..2f620ccec61 100644 --- a/src/worker/jobs/update_default_version.rs +++ b/src/worker/jobs/update_default_version.rs @@ -1,6 +1,10 @@ use crate::models::update_default_version; +use crate::schema::crates; use crate::worker::Environment; +use crate::worker::jobs::GenerateOgImage; use crates_io_worker::BackgroundJob; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::info; @@ -30,6 +34,17 @@ impl BackgroundJob for UpdateDefaultVersion { let mut conn = ctx.deadpool.get().await?; update_default_version(crate_id, &mut conn).await?; + // Get the crate name for OG image generation + let crate_name: String = crates::table + .filter(crates::id.eq(crate_id)) + .select(crates::name) + .first(&mut conn) + .await?; + + // Generate OG image after updating default version + info!("Enqueueing OG image generation for crate {crate_name}"); + GenerateOgImage::new(crate_name).enqueue(&mut conn).await?; + Ok(()) } } diff --git a/src/worker/mod.rs b/src/worker/mod.rs index f22bd29d0a6..8bed028cbc1 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -26,6 +26,7 @@ impl RunnerExt for Runner> { .register_job_type::() .register_job_type::() .register_job_type::() + .register_job_type::() .register_job_type::() .register_job_type::() .register_job_type::()