use std::collections::HashMap; use std::io::{Cursor, Read}; use std::sync::Arc; use chrono::Utc; use eyre::{Result, eyre}; use hex::ToHex; use serde::Serialize; use sha1::Digest; use tokio::task::{spawn, spawn_blocking}; use tracing::{Instrument, info, info_span, warn}; use zip::ZipArchive; use crate::database::models::ids::{ DBAttributionGroupId, DBProjectId, DBVersionId, generate_attribution_group_id, }; use crate::database::models::moderation_external_item::ExternalLicense; use crate::database::models::{DBFileId, DBUserId, DBVersion}; use crate::database::{PgPool, PgTransaction, redis::RedisPool}; use crate::env::ENV; use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::error::ApiError; use crate::models::ids::FileId; use crate::models::projects::{ AttributionResolution, AttributionResolutionKind, DependencyAttribution, FlameProject, }; use crate::queue::moderation::{ ApprovalType, FingerprintResponse, FlameResponse, }; use crate::util::error::Context; use crate::util::http::HTTP_CLIENT; const PENDING_FILE_SCAN_BATCH_SIZE: i64 = 100; type FileScanResult<'a> = std::result::Result<(), ApiError<'a>>; #[derive(Clone)] struct PendingFileScan { file_id: DBFileId, url: String, project_id: DBProjectId, } #[derive(Debug, Default, Serialize, utoipa::ToSchema)] pub struct FileScanSummary { /// Number of attribution groups newly created by the scan. pub new_attribution_groups: u64, /// Number of attribution files newly created by the scan. pub new_attribution_files: u64, /// Override file paths found and scanned in the file archive. pub scanned_file_names: Vec, } /// Attribution enforcement is version/project-scoped, not file-hash-scoped. /// /// Versions or projects listed in `attributions_exemptions` predate this /// attribution system. They are not scanned for attribution requirements and /// must not cause missing-attribution withholding. A later non-exempt version /// can still contain the same override SHA1 and create attribution groups/files /// for that SHA1. Because of that, reverse lookups from override SHA1s to /// versions must go through the `attribution_enforced_versions` view so /// grandfathered versions and projects are ignored without making the SHA1 /// itself exempt. pub async fn scan_all_pending_files( db: &PgPool, redis: &RedisPool, file_host: Arc, ) -> Result<()> { let scan_concurrency = ENV.FILE_SCAN_CONCURRENCY.max(1); let total_to_scan = sqlx::query_scalar!( r#" select count(*) as "count!" from file_scans fa inner join files f on f.id = fa.file_id inner join attribution_enforced_versions aev on aev.id = f.version_id where fa.attributions_scanned_at is null "#, ) .fetch_one(db) .await .wrap_err("fetching number of files to scan")?; info!( "Found {total_to_scan} total pending files to scan, running in batches of {PENDING_FILE_SCAN_BATCH_SIZE} with concurrency {scan_concurrency}" ); loop { let scanned_count = scan_pending_files_batch( db, redis, file_host.clone(), scan_concurrency * PENDING_FILE_SCAN_BATCH_SIZE, ) .await?; if scanned_count == 0 { break; } } Ok(()) } async fn scan_pending_files_batch( db: &PgPool, redis: &RedisPool, file_host: Arc, scan_limit: i64, ) -> Result { let files_to_scan = sqlx::query!( r#" select fa.file_id as "file_id: DBFileId", f.url, v.mod_id as "project_id: DBProjectId" from file_scans fa inner join files f on f.id = fa.file_id inner join attribution_enforced_versions aev on aev.id = f.version_id inner join versions v on v.id = f.version_id where fa.attributions_scanned_at is null order by fa.file_id limit $1 "#, scan_limit, ) .fetch_all(db) .await .wrap_err("fetching files to scan")?; let fetched_count = files_to_scan.len(); info!( "Found {fetched_count} pending files to scan, splitting into jobs of {PENDING_FILE_SCAN_BATCH_SIZE}", ); let files_to_scan: Vec<_> = files_to_scan .into_iter() .map(|row| PendingFileScan { file_id: row.file_id, url: row.url, project_id: row.project_id, }) .collect(); let mut tasks = Vec::new(); for chunk in files_to_scan.chunks(PENDING_FILE_SCAN_BATCH_SIZE as usize) { let db = db.clone(); let redis = redis.clone(); let file_host = file_host.clone(); let chunk = chunk.to_vec(); tasks.push(spawn(async move { scan_pending_files_chunk(&db, &redis, &*file_host, chunk).await })); } let mut scanned_count = 0; let mut errors = Vec::new(); for task in tasks { match task.await.wrap_err("joining file scan task")? { Ok(count) => scanned_count += count, Err(err) => { errors.push(err); } } } if !errors.is_empty() { let error_count = errors.len(); let error_messages = errors .into_iter() .enumerate() .map(|(index, err)| format!("chunk {}: {err:?}", index + 1)) .collect::>() .join("\n\n"); return Err(eyre!( "failed to scan {error_count} pending file chunks:\n\n{error_messages}" )); } if fetched_count > 0 && scanned_count == 0 { return Err(eyre!( "file scan batch made no progress after fetching {fetched_count} files" )); } info!("Marked {} files as scanned", scanned_count); Ok(scanned_count) } async fn scan_pending_files_chunk( db: &PgPool, redis: &RedisPool, file_host: &dyn FileHost, files_to_scan: Vec, ) -> Result { info!("Scanning {} files", files_to_scan.len()); let mut scanned_count = 0; for row in files_to_scan { let human_file_id = FileId::from(row.file_id); let span = info_span!("scan", file_id = %human_file_id); let file_id = row.file_id; let result = async { info!("Scanning file"); let overrides = extract_override_files_from_storage( file_host, file_id, &row.url, ) .await .wrap_err_with(|| { eyre!("extracting overrides for file {file_id:?}") })?; if overrides.is_empty() { info!("Found no overrides"); return Ok(()); } info!("Found {} overrides", overrides.len()); let mut txn = db .begin() .await .wrap_err("beginning file scan transaction")?; let resolved = resolve_overrides(&overrides, &mut txn) .await .wrap_err_with(|| { eyre!("resolving overrides for file {file_id:?}") })?; info!("Resolved: {resolved:#?}"); persist_attribution_results( row.project_id, file_id, &overrides, &resolved, redis, &mut txn, ) .await .wrap_err_with(|| { eyre!("persisting attribution results for file {file_id:?}") })?; txn.commit() .await .wrap_err("committing file scan transaction")?; log_marked_override_projects(&resolved); eyre::Ok(()) } .instrument(span) .await; let scan_result = file_scan_result(&result); match result { Ok(()) => { info!(%human_file_id, "Successfully scanned file"); } Err(err) => { warn!(%human_file_id, "Failed to scan file: {err:?}"); } } update_file_scan_result(db, file_id, scan_result) .await .wrap_err("marking file as scanned")?; scanned_count += 1; } Ok(scanned_count) } pub async fn scan_file( txn: &mut PgTransaction<'_>, redis: &RedisPool, file_host: &dyn FileHost, project_id: DBProjectId, file_id: DBFileId, file_url: &str, ) -> Result { let result = scan_file_inner(txn, redis, file_host, project_id, file_id, file_url) .await; upsert_file_scan_result(txn, file_id, file_scan_result(&result)) .await .wrap_err("marking file as scanned")?; result } async fn scan_file_inner( txn: &mut PgTransaction<'_>, redis: &RedisPool, file_host: &dyn FileHost, project_id: DBProjectId, file_id: DBFileId, file_url: &str, ) -> Result { let overrides = extract_override_files_from_storage(file_host, file_id, file_url) .await .wrap_err_with(|| { eyre!("extracting overrides for file {file_id:?}") })?; let scanned_file_names = overrides.iter().map(|file| file.path.clone()).collect(); let mut summary = FileScanSummary { scanned_file_names, ..Default::default() }; if !overrides.is_empty() { let before = count_project_attributions(project_id, txn).await?; let resolved = resolve_overrides(&overrides, txn).await.wrap_err_with(|| { eyre!("resolving overrides for file {file_id:?}") })?; persist_attribution_results( project_id, file_id, &overrides, &resolved, redis, txn, ) .await .wrap_err_with(|| { eyre!("persisting attribution results for file {file_id:?}") })?; let after = count_project_attributions(project_id, txn).await?; summary.new_attribution_groups = after.groups.saturating_sub(before.groups); summary.new_attribution_files = after.files.saturating_sub(before.files); log_marked_override_projects(&resolved); } Ok(summary) } struct ProjectAttributionCounts { groups: u64, files: u64, } async fn count_project_attributions( project_id: DBProjectId, txn: &mut PgTransaction<'_>, ) -> Result { let row = sqlx::query!( r#" select ( select count(*) from project_attribution_groups where project_id = $1 ) as "groups!", ( select count(*) from project_attribution_files paf inner join project_attribution_groups pag on pag.id = paf.group_id where pag.project_id = $1 ) as "files!" "#, project_id as DBProjectId, ) .fetch_one(&mut *txn) .await .wrap_err("counting project attributions")?; Ok(ProjectAttributionCounts { groups: row.groups as u64, files: row.files as u64, }) } fn file_scan_result(result: &Result) -> FileScanResult<'static> { match result { Ok(_) => Ok(()), Err(err) => Err(ApiError { error: "internal_error", description: format!("{err:#}"), details: None, }), } } async fn update_file_scan_result( db: &PgPool, file_id: DBFileId, result: FileScanResult<'_>, ) -> Result<()> { let now = Utc::now(); sqlx::query!( r#" update file_scans set attributions_scanned_at = $2, attributions_scan_result = $3 where file_id = $1 "#, file_id.0, now, sqlx::types::Json(result) as _, ) .execute(db) .await .wrap_err("updating file scan result")?; Ok(()) } async fn upsert_file_scan_result( txn: &mut PgTransaction<'_>, file_id: DBFileId, result: FileScanResult<'_>, ) -> Result<()> { sqlx::query!( r#" insert into file_scans ( file_id, attributions_scanned_at, attributions_scan_result ) values ($1, now(), $2) on conflict (file_id) do update set attributions_scanned_at = now(), attributions_scan_result = $2 "#, file_id.0, sqlx::types::Json(result) as _, ) .execute(&mut *txn) .await .wrap_err("upserting file scan result")?; Ok(()) } pub async fn scan_override_files( file_host: &dyn FileHost, file_id: DBFileId, file_url: &str, ) -> Result> { extract_override_files_from_storage(file_host, file_id, file_url) .await .wrap_err_with(|| eyre!("extracting overrides for file {file_id:?}")) } async fn extract_override_files_from_storage( file_host: &dyn FileHost, file_id: DBFileId, file_url: &str, ) -> Result> { let key = file_url .strip_prefix(&ENV.CDN_URL) .unwrap_or(file_url) .trim_start_matches('/'); let key = urlencoding::decode(key).wrap_err("decoding file URL path")?; let file_data = file_host .read_file(&key, FileHostPublicity::Public) .await .wrap_err_with(|| { eyre!("reading file {file_id:?} from storage at {key}") })?; spawn_blocking(move || extract_override_files(&file_data)) .await .wrap_err("extracting override files")? .wrap_err("extracting override files") } #[derive(Debug)] pub struct OverrideFile { pub path: String, pub sha1: String, pub murmur2: u32, } #[derive(Debug)] pub enum OverrideResolution { ExternalLicense { id: i64, status: ApprovalType, link: Option, flame_project: Option, }, Flame(FlameProject), Unknown, } fn log_marked_override_projects( resolved: &HashMap, ) { let mut projects = resolved .values() .filter_map(|resolution| match resolution { OverrideResolution::ExternalLicense { flame_project: Some(flame_project), .. } | OverrideResolution::Flame(flame_project) => { Some(format!("{} ({})", flame_project.title, flame_project.id)) } OverrideResolution::ExternalLicense { .. } | OverrideResolution::Unknown => None, }) .collect::>(); projects.sort(); projects.dedup(); if !projects.is_empty() { info!(override_projects = ?projects, "Marked projects as overrides"); } } const OVERRIDE_PREFIXES: &[&str] = &[ "overrides/mods", "client-overrides/mods", "server-overrides/mods", "overrides/shaderpacks", "client-overrides/shaderpacks", "overrides/resourcepacks", "client-overrides/resourcepacks", ]; const OVERRIDE_ROOT_PREFIXES: &[&str] = &["overrides/", "client-overrides/", "server-overrides/"]; fn override_relative_name(name: &str) -> Option<&str> { // strip the root prefix let relative = OVERRIDE_ROOT_PREFIXES .iter() .find_map(|prefix| name.strip_prefix(prefix))?; // check if it matches any of the whitelisted scan prefixes OVERRIDE_PREFIXES .iter() .any(|prefix| { name.strip_prefix(prefix) // check the stripped prefix is actually a full segment, not something weird like "overrides/modsabce/file.jar" .is_some_and(|suffix| suffix.starts_with('/')) }) .then_some(relative) } fn should_scan(name: &str) -> bool { let name = name.to_lowercase(); let should_skip = name.starts_with("mods/.connector/") || name.starts_with(".sable/natives/") || name.starts_with("local/crash_assistant/") || name.starts_with("mods/mcef-libraries/") || name.starts_with("mods/mcef-cache/") || name.starts_with("config/super_resolution/libraries/") || name.starts_with("config/veinminer/update/") || name.starts_with("config/epicfight/native/") || name.starts_with("essential/") || name.ends_with(".rpo") || name.ends_with(".txt"); let is_archive = name.ends_with(".jar") || name.ends_with(".zip") || name.ends_with(".jar.disabled") || name.ends_with(".zip.disabled"); is_archive && !should_skip } fn extract_override_files(data: &[u8]) -> Result> { let reader = Cursor::new(data); let mut zip = ZipArchive::new(reader).wrap_err("creating zip archive reader")?; let mut files = Vec::new(); for i in 0..zip.len() { let mut file = zip .by_index(i) .wrap_err_with(|| eyre!("reading file {i}"))?; let name = file.name().to_string(); if file.is_dir() { continue; } let Some(relative_name) = override_relative_name(&name) else { continue; }; if !should_scan(relative_name) { continue; } let mut contents = Vec::new(); file.read_to_end(&mut contents)?; let sha1 = sha1::Sha1::digest(&contents).encode_hex::(); let murmur = hash_flame_murmur32(contents); files.push(OverrideFile { sha1, murmur2: murmur, path: name, }); } Ok(files) } async fn persist_attribution_results( project_id: DBProjectId, file_id: DBFileId, overrides: &[OverrideFile], resolved: &HashMap, redis: &RedisPool, txn: &mut PgTransaction<'_>, ) -> Result<()> { let all_sha1s: Vec> = overrides .iter() .map(|f| f.sha1.as_bytes().to_vec()) .collect(); let already_persisted: Vec> = sqlx::query_scalar!( " select paf.sha1 from project_attribution_files paf inner join project_attribution_groups pag on pag.id = paf.group_id where pag.project_id = $1 and paf.sha1 = ANY($2) ", project_id as DBProjectId, &all_sha1s, ) .fetch_all(&mut *txn) .await .wrap_err("checking existing attribution files")?; let mut flame_groups: HashMap< u32, (Vec<&OverrideFile>, Option<&OverrideResolution>), > = HashMap::new(); let mut external_license_files: Vec<( &OverrideFile, i64, ApprovalType, Option, Option, )> = Vec::new(); let mut unknown_files: Vec<&OverrideFile> = Vec::new(); for file in overrides { if already_persisted .iter() .any(|s| s.as_slice() == file.sha1.as_bytes()) { continue; } match resolved.get(&file.sha1) { Some(OverrideResolution::ExternalLicense { id, status, link, flame_project, }) => { external_license_files.push(( file, *id, *status, link.clone(), flame_project.clone(), )); } Some(res @ OverrideResolution::Flame(flame_project)) => { let entry = flame_groups.entry(flame_project.id).or_default(); entry.0.push(file); if entry.1.is_none() { entry.1 = Some(res); } } Some(OverrideResolution::Unknown) | None => { unknown_files.push(file); } } } let existing_flame_groups = sqlx::query!( r#" select id as "id: DBAttributionGroupId", flame_project from project_attribution_groups where project_id = $1 and flame_project is not null "#, project_id as DBProjectId, ) .fetch_all(&mut *txn) .await .wrap_err("fetching existing flame attribution groups")?; let mut existing_flame_group_ids = HashMap::new(); for group in existing_flame_groups { if let Some(flame_project) = group .flame_project .and_then(|fp| serde_json::from_value::(fp).ok()) { existing_flame_group_ids.insert(flame_project.id, group.id); } } for (file, external_license_id, status, link, flame_project) in external_license_files { if let Some(group_id) = flame_project .as_ref() .and_then(|fp| existing_flame_group_ids.get(&fp.id)) { sqlx::query!( r#" insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id) values ($1, $2, $3, $4) on conflict (group_id, sha1) do update set moderation_external_license_id = excluded.moderation_external_license_id "#, *group_id as DBAttributionGroupId, &file.path, &file.sha1.as_bytes().to_vec() as &[u8], external_license_id, ) .execute(&mut *txn) .await .wrap_err("inserting external license attribution file into existing flame group")?; continue; } let attribution = default_external_license_attribution(status, link); let flame_project = flame_project.and_then(|fp| serde_json::to_value(fp).ok()); let group_id = generate_attribution_group_id(&mut *txn).await?; sqlx::query!( " insert into project_attribution_groups (id, project_id, attribution, flame_project) values ($1, $2, $3, $4) ", group_id as DBAttributionGroupId, project_id as DBProjectId, attribution, flame_project, ) .execute(&mut *txn) .await .wrap_err("inserting external license attribution group")?; sqlx::query!( r#" insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id) values ($1, $2, $3, $4) on conflict (group_id, sha1) do update set moderation_external_license_id = excluded.moderation_external_license_id "#, group_id as DBAttributionGroupId, &file.path, &file.sha1.as_bytes().to_vec() as &[u8], external_license_id, ) .execute(&mut *txn) .await .wrap_err("inserting external license attribution file")?; } for (flame_project_id, (files, resolution)) in &flame_groups { let group_id = if let Some(group_id) = existing_flame_group_ids.get(flame_project_id) { *group_id } else { let fp = resolution .and_then(|r| { if let OverrideResolution::Flame(flame_project) = r { Some(serde_json::to_value(flame_project).ok()) } else { None } }) .flatten(); let id = generate_attribution_group_id(&mut *txn).await?; sqlx::query!( " insert into project_attribution_groups (id, project_id, flame_project) values ($1, $2, $3) ", id as DBAttributionGroupId, project_id as DBProjectId, fp, ) .execute(&mut *txn) .await .wrap_err("inserting attribution group")?; existing_flame_group_ids.insert(*flame_project_id, id); id }; let names: Vec = files.iter().map(|f| f.path.clone()).collect(); let sha1s: Vec> = files.iter().map(|f| f.sha1.as_bytes().to_vec()).collect(); sqlx::query!( r#" insert into project_attribution_files (group_id, name, sha1) select $1, unnest($2::text[]), unnest($3::bytea[]) on conflict (group_id, sha1) do nothing "#, group_id as DBAttributionGroupId, &names, &sha1s, ) .execute(&mut *txn) .await .wrap_err("inserting attribution files")?; } for file in &unknown_files { let group_id = generate_attribution_group_id(&mut *txn).await?; sqlx::query!( " insert into project_attribution_groups (id, project_id) values ($1, $2) ", group_id as DBAttributionGroupId, project_id as DBProjectId, ) .execute(&mut *txn) .await .wrap_err("inserting unknown attribution group")?; sqlx::query!( r#" insert into project_attribution_files (group_id, name, sha1) values ($1, $2, $3) on conflict (group_id, sha1) do nothing "#, group_id as DBAttributionGroupId, &file.path, &file.sha1.as_bytes().to_vec() as &[u8], ) .execute(&mut *txn) .await .wrap_err("inserting unknown attribution file")?; } if !all_sha1s.is_empty() { let file_paths: Vec = overrides.iter().map(|f| f.path.clone()).collect(); sqlx::query!( " INSERT INTO override_file_sources (sha1, file_id, file_path) SELECT source.sha1, $2, source.file_path FROM UNNEST($1::BYTEA[], $3::TEXT[]) AS source(sha1, file_path) ON CONFLICT DO NOTHING ", &all_sha1s, file_id as DBFileId, &file_paths, ) .execute(&mut *txn) .await .wrap_err("inserting override file sources")?; } let version_id = sqlx::query_scalar!( r#" select version_id as "version_id: DBVersionId" from files where id = $1 "#, file_id as DBFileId, ) .fetch_one(&mut *txn) .await .wrap_err("fetching scanned file version")?; DBVersion::clear_cache_ids(&[version_id], redis) .await .wrap_err("clearing version cache after attribution scan")?; Ok(()) } fn default_external_license_attribution( status: ApprovalType, link: Option, ) -> Option { match status { ApprovalType::Yes | ApprovalType::WithAttributionAndSource | ApprovalType::WithAttribution => link .and_then(|link| url::Url::parse(&link).ok()) .and_then(|link_to_work| { serde_json::to_value(AttributionResolution { kind: AttributionResolutionKind::GloballyAllowed { link_to_work, }, moderation_status: None, updated_by_moderator: false, notes: String::new(), image_urls: Vec::new(), }) .ok() }), ApprovalType::No => { let link_to_work = link.and_then(|link| url::Url::parse(&link).ok()); serde_json::to_value(AttributionResolution { kind: AttributionResolutionKind::NoPermission { link_to_work }, moderation_status: None, updated_by_moderator: false, notes: String::new(), image_urls: Vec::new(), }) .ok() } ApprovalType::PermanentNo | ApprovalType::Unidentified => None, } } async fn resolve_overrides( overrides: &[OverrideFile], txn: &mut PgTransaction<'_>, ) -> Result> { let mut results: HashMap = HashMap::new(); let mut remaining: Vec = (0..overrides.len()).collect(); if overrides.is_empty() { return Ok(results); } let rows = sqlx::query!( " SELECT encode(mef.sha1, 'escape') sha1, mel.id, mel.status status, mel.link FROM moderation_external_files mef INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id WHERE mef.sha1 = ANY($1) ", &remaining .iter() .map(|i| overrides[*i].sha1.as_bytes().to_vec()) .collect::>() ) .fetch_all(&mut *txn) .await .wrap_err("fetching external file licenses")?; let mut direct_external_licenses = HashMap::new(); for row in rows { if let Some(sha1) = row.sha1 { direct_external_licenses.insert( sha1, ( row.id, ApprovalType::from_string(&row.status) .unwrap_or(ApprovalType::Unidentified), row.link, ), ); } } let fingerprints: Vec = remaining.iter().map(|i| overrides[*i].murmur2).collect(); let res = HTTP_CLIENT .post(format!("{}/v1/fingerprints", ENV.FLAME_ANVIL_URL)) .json(&serde_json::json!({ "fingerprints": fingerprints })) .send() .await; if let Err(e) = &res { warn!("Flame fingerprint request failed: {e}"); } if let Ok(res) = res { let body = res .text() .await .wrap_err("reading Flame fingerprint response")?; let flame_files: Vec<_> = serde_json::from_str::>(&body) .ok() .map(|x| { x.data .exact_matches .into_iter() .map(|m| m.file) .collect::>() }) .unwrap_or_default(); let mut flame_matches: Vec<(String, u32)> = Vec::new(); for flame_file in &flame_files { if let Some(hash) = flame_file .hashes .iter() .find(|x| x.algo == 1) .map(|x| x.value.clone()) { flame_matches.push((hash, flame_file.mod_id)); } } let project_license_rows = sqlx::query!( " SELECT mel.id, mel.flame_project_id, mel.status status, mel.link FROM moderation_external_licenses mel WHERE mel.flame_project_id = ANY($1) ", &flame_matches.iter().map(|x| x.1 as i32).collect::>() ) .fetch_all(&mut *txn) .await .wrap_err("fetching Flame project licenses")?; let mut project_external_licenses = HashMap::new(); for row in project_license_rows { if let Some(flame_project_id) = row.flame_project_id { project_external_licenses.insert( flame_project_id as u32, ( row.id, ApprovalType::from_string(&row.status) .unwrap_or(ApprovalType::Unidentified), row.link, ), ); } } let flame_projects_res = HTTP_CLIENT .post(format!("{}/v1/mods", ENV.FLAME_ANVIL_URL)) .json(&serde_json::json!({ "modIds": flame_matches.iter().map(|x| x.1).collect::>() })) .send() .await; let flame_projects = match flame_projects_res { Ok(res) => res .text() .await .ok() .and_then(|t| { serde_json::from_str::< FlameResponse< Vec, >, >(&t) .ok() }) .map(|x| x.data) .unwrap_or_default(), Err(e) => { warn!("Flame projects request failed: {e}"); Vec::new() } }; let mut flame_project_metadata = HashMap::new(); for project in flame_projects { if flame_project_url_is_not_found(&project.links.website_url).await { info!( "Flame project {} at {:?} returned 404, ignoring", project.id, project.links.website_url, ); continue; } flame_project_metadata.insert( project.id, FlameProject { id: project.id, title: project.name, url: project.links.website_url, icon_url: project.logo.thumbnail_url, }, ); } let mut insert_hashes = Vec::new(); let mut insert_filenames = Vec::new(); let mut insert_ids = Vec::new(); for (sha1, flame_project_id) in &flame_matches { if let Some(remaining_pos) = remaining.iter().position(|i| overrides[*i].sha1 == *sha1) { let idx = remaining.remove(remaining_pos); let flame_project = flame_project_metadata.get(flame_project_id).cloned(); if let Some((id, status, link)) = direct_external_licenses.remove(&overrides[idx].sha1) { results.insert( overrides[idx].sha1.clone(), OverrideResolution::ExternalLicense { id, status, link, flame_project, }, ); } else if let Some((id, status, link)) = project_external_licenses.get(flame_project_id) { results.insert( overrides[idx].sha1.clone(), OverrideResolution::ExternalLicense { id: *id, status: *status, link: link.clone(), flame_project, }, ); insert_hashes.push(overrides[idx].sha1.as_bytes().to_vec()); insert_filenames.push(Some(overrides[idx].path.clone())); insert_ids.push(*id); } else if let Some(flame_project) = flame_project { results.insert( overrides[idx].sha1.clone(), OverrideResolution::Flame(flame_project), ); } } } if !insert_hashes.is_empty() { ExternalLicense::insert_files( &mut *txn, &insert_hashes, &insert_filenames, &insert_ids, DBUserId(0), ) .await .wrap_err("inserting external license files")?; } } remaining.retain(|idx| { if let Some((id, status, link)) = direct_external_licenses.remove(&overrides[*idx].sha1) { results.insert( overrides[*idx].sha1.clone(), OverrideResolution::ExternalLicense { id, status, link, flame_project: None, }, ); false } else { true } }); for idx in remaining { results .insert(overrides[idx].sha1.clone(), OverrideResolution::Unknown); } Ok(results) } async fn flame_project_url_is_not_found(url: &str) -> bool { match HTTP_CLIENT.get(url).send().await { Ok(response) => response.status() == reqwest::StatusCode::NOT_FOUND, Err(err) => { warn!("Flame project URL check failed for {url}: {err}"); false } } } fn hash_flame_murmur32(input: Vec) -> u32 { murmur2::murmur2( &input .into_iter() .filter(|x| *x != 9 && *x != 10 && *x != 13 && *x != 32) .collect::>(), 1, ) } pub async fn get_files_missing_attribution<'a, E>( exec: E, version_ids: &[DBVersionId], ) -> Result< std::collections::HashMap< DBVersionId, Vec<(DBFileId, Option)>, >, > where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { if version_ids.is_empty() { return Ok(std::collections::HashMap::new()); } let rows = sqlx::query!( r#" select distinct f.version_id as "version_id: DBVersionId", f.id as "file_id: DBFileId", pag.flame_project from files f inner join attribution_enforced_versions aev on aev.id = f.version_id inner join versions v on v.id = f.version_id inner join override_file_sources ofs on ofs.file_id = f.id inner join project_attribution_files paf on paf.sha1 = ofs.sha1 inner join project_attribution_groups pag on pag.id = paf.group_id where f.version_id = ANY($1) and pag.project_id = v.mod_id and ( pag.attribution is null or pag.attribution->>'kind' = 'no_permission' or coalesce( pag.attribution->'moderation_status'->>'kind', 'approved' ) != 'approved' ) "#, &version_ids.iter().map(|v| v.0).collect::>(), ) .fetch_all(exec) .await .wrap_err("fetching files missing attribution")?; let mut result = std::collections::HashMap::new(); for row in rows { let flame_project = row .flame_project .and_then(|v| serde_json::from_value(v).ok()); result .entry(row.version_id) .or_insert_with(Vec::new) .push((row.file_id, flame_project)); } Ok(result) } pub struct DependencyAttributionData { pub attribution: DependencyAttribution, } pub async fn get_dependency_attributions<'a, E>( exec: E, version_ids: &[DBVersionId], ) -> Result> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { if version_ids.is_empty() { return Ok(HashMap::new()); } let version_ids_vec: Vec<_> = version_ids.iter().map(|v| v.0).collect(); let rows = sqlx::query!( r#" select d.id as "dependency_id!", pag.attribution, pag.flame_project, pag.project_id as "project_id: DBProjectId" from dependencies d inner join files f on f.version_id = d.dependent_id inner join attribution_enforced_versions aev on aev.id = f.version_id inner join versions v on v.id = f.version_id inner join override_file_sources ofs on ofs.file_id = f.id inner join project_attribution_files paf on paf.sha1 = ofs.sha1 inner join project_attribution_groups pag on pag.id = paf.group_id where d.dependent_id = ANY($1) and pag.project_id = v.mod_id and d.dependency_file_name is not null and ( pag.flame_project is not null or pag.attribution is not null ) and split_part(paf.name, '/', -1) = d.dependency_file_name "#, &version_ids_vec, ) .fetch_all(exec) .await .wrap_err("fetching dependency attributions")?; let mut result = HashMap::new(); for row in rows { let attribution: Option = row.attribution.and_then(|v| serde_json::from_value(v).ok()); let flame_project: Option = row .flame_project .and_then(|v| serde_json::from_value(v).ok()); let resolution = attribution.map(|a| a.kind); result.insert( row.dependency_id, DependencyAttributionData { attribution: DependencyAttribution { flame_project, resolution, }, }, ); } Ok(result) }