|
| 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 | +} |
0 commit comments