pakker/src/platform/curseforge.rs
NotAShelf 20ea3c680b
platform: add rustdoc to various methods
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic4d2bd6f3baf97ce30dbf8709331f6f66a6a6964
2026-04-21 19:27:31 +03:00

666 lines
18 KiB
Rust

use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use super::traits::PlatformClient;
use crate::{
error::{PakkerError, Result},
model::{Project, ProjectFile, ProjectSide, ProjectType, ReleaseType},
utils::generate_pakku_id,
};
const CURSEFORGE_API_BASE: &str = "https://api.curseforge.com/v1";
/// CurseForge game version type ID for loader versions (e.g., "fabric",
/// "forge")
const LOADER_VERSION_TYPE_ID: i32 = 68441;
/// CurseForge relation type ID for "required dependency" (mod embeds or
/// requires another mod)
const DEPENDENCY_RELATION_TYPE_REQUIRED: u32 = 3;
pub struct CurseForgePlatform {
client: Arc<Client>,
api_key: Option<String>,
}
impl CurseForgePlatform {
pub fn new(api_key: Option<String>) -> Self {
Self {
client: Arc::new(Client::new()),
api_key,
}
}
pub fn with_client(client: Arc<Client>, api_key: Option<String>) -> Self {
Self { client, api_key }
}
fn get_headers(&self) -> Result<reqwest::header::HeaderMap> {
let mut headers = reqwest::header::HeaderMap::new();
if let Some(api_key) = &self.api_key {
headers.insert(
"x-api-key",
reqwest::header::HeaderValue::from_str(api_key).map_err(|_| {
PakkerError::ConfigError("Invalid API key".to_string())
})?,
);
} else {
return Err(PakkerError::ConfigError(
"CurseForge API key required".to_string(),
));
}
Ok(headers)
}
const fn map_class_id(class_id: u32) -> ProjectType {
match class_id {
6 => ProjectType::Mod,
12 => ProjectType::ResourcePack,
6945 => ProjectType::DataPack,
6552 => ProjectType::Shader,
17 => ProjectType::World,
_ => ProjectType::Mod,
}
}
const fn map_release_type(release_type: u32) -> ReleaseType {
match release_type {
1 => ReleaseType::Release,
2 => ReleaseType::Beta,
3 => ReleaseType::Alpha,
_ => ReleaseType::Release,
}
}
/// Determine project side based on `CurseForge` categories.
/// `CurseForge` doesn't have explicit client/server fields like Modrinth,
/// so we infer from category names and IDs.
fn detect_side_from_categories(
categories: &[CurseForgeCategory],
) -> ProjectSide {
// Known client-only category indicators (slugs and partial name matches)
const CLIENT_INDICATORS: &[&str] = &[
"client",
"hud",
"gui",
"cosmetic",
"shader",
"optifine",
"resource-pack",
"texture",
"minimap",
"tooltip",
"inventory",
"quality-of-life", // Often client-side QoL
];
// Known server-only category indicators
const SERVER_INDICATORS: &[&str] = &[
"server-utility",
"bukkit",
"spigot",
"paper",
"admin-tools",
"anti-grief",
"economy",
"permissions",
"chat",
];
let mut client_score = 0;
let mut server_score = 0;
for category in categories {
let slug_lower = category.slug.to_lowercase();
let name_lower = category.name.to_lowercase();
for indicator in CLIENT_INDICATORS {
if slug_lower.contains(indicator) || name_lower.contains(indicator) {
client_score += 1;
}
}
for indicator in SERVER_INDICATORS {
if slug_lower.contains(indicator) || name_lower.contains(indicator) {
server_score += 1;
}
}
}
// Only assign a specific side if there's clear indication
// and not conflicting signals
if client_score > 0 && server_score == 0 {
ProjectSide::Client
} else if server_score > 0 && client_score == 0 {
ProjectSide::Server
} else {
// Default to Both - works on both client and server
ProjectSide::Both
}
}
fn convert_project(&self, cf_project: CurseForgeProject) -> Project {
let pakku_id = generate_pakku_id();
let project_type = Self::map_class_id(cf_project.class_id.unwrap_or(6));
// Detect side from categories
let side = Self::detect_side_from_categories(&cf_project.categories);
let mut project = Project::new(pakku_id, project_type, side);
project.add_platform(
"curseforge".to_string(),
cf_project.id.to_string(),
cf_project.slug.clone(),
cf_project.name,
);
project.redistributable = false;
project
}
fn convert_file(
&self,
cf_file: CurseForgeFile,
project_id: &str,
) -> ProjectFile {
let mut hashes = HashMap::new();
for hash in cf_file.hashes {
hashes.insert(hash.algo.to_lowercase(), hash.value.clone());
}
let mc_versions: Vec<String> = cf_file.game_versions.clone();
// Extract loaders from sortableGameVersions with LOADER_VERSION_TYPE_ID
let loaders: Vec<String> = cf_file
.sortable_game_versions
.iter()
.filter(|v| v.game_version_type_id == Some(LOADER_VERSION_TYPE_ID))
.map(|v| v.game_version_name.to_lowercase())
.collect();
ProjectFile {
file_type: "mod".to_string(),
file_name: cf_file.file_name.clone(),
mc_versions,
loaders,
release_type: Self::map_release_type(cf_file.release_type.unwrap_or(1)),
url: cf_file.download_url.clone().unwrap_or_else(|| {
format!(
"https://edge.forgecdn.net/files/{}/{}/{}",
cf_file.id / 1000,
cf_file.id % 1000,
cf_file.file_name
)
}),
id: cf_file.id.to_string(),
parent_id: project_id.to_string(),
hashes,
required_dependencies: cf_file
.dependencies
.iter()
.filter(|d| d.relation_type == DEPENDENCY_RELATION_TYPE_REQUIRED)
.map(|d| d.mod_id.to_string())
.collect(),
size: cf_file.file_length,
date_published: cf_file.file_date.clone(),
}
}
async fn search_project_by_slug(
&self,
slug: &str,
) -> Result<CurseForgeProject> {
let url =
format!("{CURSEFORGE_API_BASE}/mods/search?gameId=432&slug={slug}");
let response = self
.client
.get(&url)
.headers(self.get_headers()?)
.send()
.await?;
if !response.status().is_success() {
return Err(PakkerError::ProjectNotFound(slug.to_string()));
}
let result: CurseForgeSearchResponse = response.json().await?;
result
.data
.into_iter()
.find(|p| p.slug == slug)
.ok_or_else(|| PakkerError::ProjectNotFound(slug.to_string()))
}
}
#[async_trait]
impl PlatformClient for CurseForgePlatform {
async fn request_project(
&self,
identifier: &str,
_mc_versions: &[String],
_loaders: &[String],
) -> Result<Project> {
if let Ok(mod_id) = identifier.parse::<u32>() {
let url = format!("{CURSEFORGE_API_BASE}/mods/{mod_id}");
let response = self
.client
.get(&url)
.headers(self.get_headers()?)
.send()
.await?;
if response.status().is_success() {
let result: CurseForgeProjectResponse = response.json().await?;
return Ok(self.convert_project(result.data));
}
}
let cf_project = self.search_project_by_slug(identifier).await?;
Ok(self.convert_project(cf_project))
}
async fn request_project_files(
&self,
project_id: &str,
mc_versions: &[String],
loaders: &[String],
) -> Result<Vec<ProjectFile>> {
let mut url = format!("{CURSEFORGE_API_BASE}/mods/{project_id}/files");
// Add query parameters for server-side filtering (Pakku-compatible)
let mut query_params = Vec::new();
// Add gameVersionTypeId for each MC version (requires lookup)
if !mc_versions.is_empty() {
// Fetch game version type IDs
// Add MC version gameVersionTypeId = 73250 for Minecraft versions
for mc_version in mc_versions {
query_params.push(("gameVersion", mc_version.clone()));
}
query_params.push(("gameVersionTypeId", "73250".to_string()));
}
// Add mod loader types
if !loaders.is_empty() {
let loader_str = loaders.join(",");
query_params.push(("modLoaderTypes", loader_str));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
url = format!("{url}?{query_string}");
}
let response = self
.client
.get(&url)
.headers(self.get_headers()?)
.send()
.await?;
if !response.status().is_success() {
return Err(PakkerError::ProjectNotFound(project_id.to_string()));
}
let result: CurseForgeFilesResponse = response.json().await?;
let files: Vec<ProjectFile> = result
.data
.into_iter()
.map(|f| self.convert_file(f, project_id))
.collect();
Ok(files)
}
async fn request_project_with_files(
&self,
identifier: &str,
mc_versions: &[String],
loaders: &[String],
) -> Result<Project> {
let mut project = self
.request_project(identifier, mc_versions, loaders)
.await?;
let project_id = project
.get_platform_id("curseforge")
.ok_or_else(|| {
PakkerError::InternalError("Missing curseforge ID".to_string())
})?
.clone();
let files = self
.request_project_files(&project_id, mc_versions, loaders)
.await?;
project.files = files;
Ok(project)
}
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
// CurseForge uses Murmur2 hash for file fingerprints
let fingerprint = hash
.parse::<u32>()
.map_err(|_| PakkerError::InvalidHash(hash.to_string()))?;
let url = format!("{CURSEFORGE_API_BASE}/fingerprints");
let response = self
.client
.post(&url)
.headers(self.get_headers()?)
.json(&serde_json::json!({
"fingerprints": [fingerprint]
}))
.send()
.await?;
if !response.status().is_success() {
return Ok(None);
}
let response_data: serde_json::Value = response.json().await?;
if let Some(matches) = response_data["data"]["exactMatches"].as_array()
&& let Some(first_match) = matches.first()
&& let Some(file) = first_match["file"].as_object()
{
let mod_id = file["modId"]
.as_u64()
.ok_or_else(|| {
PakkerError::InvalidResponse("Missing modId".to_string())
})?
.to_string();
return self
.request_project_with_files(&mod_id, &[], &[])
.await
.map(Some);
}
Ok(None)
}
async fn request_project_from_slug(
&self,
slug: &str,
) -> Result<Option<Project>> {
// Try to fetch project by slug using search API
match self.search_project_by_slug(slug).await {
Ok(cf_project) => Ok(Some(self.convert_project(cf_project))),
Err(PakkerError::ProjectNotFound(_)) => Ok(None),
Err(e) => Err(e),
}
}
/// Uses CurseForge's `/fingerprints/432` endpoint to resolve projects by
/// their hashes in batch.
async fn request_projects_from_hashes(
&self,
hashes: &[String],
_algorithm: &str,
) -> Result<Vec<Project>> {
if hashes.is_empty() {
return Ok(Vec::new());
}
let fingerprints: Vec<u32> = hashes
.iter()
.filter_map(|h| h.parse::<u32>().ok())
.collect();
if fingerprints.is_empty() {
return Ok(Vec::new());
}
#[derive(Serialize)]
struct FingerprintRequest {
fingerprints: Vec<u32>,
}
let url = format!("{CURSEFORGE_API_BASE}/fingerprints/432");
let response = self
.client
.post(&url)
.headers(self.get_headers()?)
.json(&FingerprintRequest {
fingerprints: fingerprints.clone(),
})
.send()
.await?;
if !response.status().is_success() {
return Err(PakkerError::PlatformApiError(format!(
"CurseForge batch API error: {}",
response.status()
)));
}
let response_data: serde_json::Value = response.json().await?;
let matches = response_data["data"]["exactMatches"]
.as_array()
.cloned()
.unwrap_or_default();
let mut projects = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
for m in matches {
if let Some(file) = m["file"].as_object() {
if let Some(mod_id) = file["modId"].as_u64() {
let mod_id_str = mod_id.to_string();
if seen_ids.contains(&mod_id_str) {
continue;
}
seen_ids.insert(mod_id_str.clone());
if let Ok(project) =
self.request_project_with_files(&mod_id_str, &[], &[]).await
{
projects.push(project);
}
}
}
}
Ok(projects)
}
}
// CurseForge API models
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeProject {
id: u32,
name: String,
slug: String,
#[serde(rename = "classId")]
class_id: Option<u32>,
#[serde(default)]
categories: Vec<CurseForgeCategory>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeCategory {
id: u32,
name: String,
slug: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeGameVersion {
#[serde(rename = "gameVersionName")]
game_version_name: String,
#[serde(rename = "gameVersionTypeId")]
game_version_type_id: Option<i32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeHash {
algo: String,
value: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeDependency {
#[serde(rename = "modId")]
mod_id: u32,
#[serde(rename = "relationType")]
relation_type: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeProjectResponse {
data: CurseForgeProject,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeFile {
id: u32,
#[serde(rename = "fileName")]
file_name: String,
#[serde(rename = "downloadUrl")]
download_url: Option<String>,
#[serde(rename = "gameVersions")]
game_versions: Vec<String>,
#[serde(rename = "sortableGameVersions")]
sortable_game_versions: Vec<CurseForgeGameVersion>,
#[serde(rename = "releaseType")]
release_type: Option<u32>,
#[serde(rename = "fileLength")]
file_length: u64,
#[serde(rename = "fileDate")]
file_date: String,
hashes: Vec<CurseForgeHash>,
dependencies: Vec<CurseForgeDependency>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeFilesResponse {
data: Vec<CurseForgeFile>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CurseForgeSearchResponse {
data: Vec<CurseForgeProject>,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_category(id: u32, name: &str, slug: &str) -> CurseForgeCategory {
CurseForgeCategory {
id,
name: name.to_string(),
slug: slug.to_string(),
}
}
#[test]
fn test_detect_side_client_only() {
// HUD mod should be client-only
let categories = vec![
make_category(1, "HUD Mods", "hud"),
make_category(2, "Fabric", "fabric"),
];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Client);
}
#[test]
fn test_detect_side_server_only() {
// Server utility should be server-only
let categories = vec![
make_category(1, "Server Utility", "server-utility"),
make_category(2, "Bukkit Plugins", "bukkit"),
];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Server);
}
#[test]
fn test_detect_side_both() {
// Generic mod categories should be both
let categories = vec![
make_category(1, "Technology", "technology"),
make_category(2, "Fabric", "fabric"),
];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Both);
}
#[test]
fn test_detect_side_conflicting_signals() {
// Mixed categories should default to both
let categories = vec![
make_category(1, "Client HUD", "client-hud"),
make_category(2, "Server Utility", "server-utility"),
];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Both);
}
#[test]
fn test_detect_side_empty_categories() {
let categories = vec![];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Both);
}
#[test]
fn test_detect_side_gui_client() {
let categories =
vec![make_category(1, "GUI Enhancement", "gui-enhancement")];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Client);
}
#[test]
fn test_detect_side_permissions_server() {
let categories = vec![make_category(1, "Permissions", "permissions")];
let side = CurseForgePlatform::detect_side_from_categories(&categories);
assert_eq!(side, ProjectSide::Server);
}
#[test]
fn test_map_class_id() {
assert_eq!(CurseForgePlatform::map_class_id(6), ProjectType::Mod);
assert_eq!(
CurseForgePlatform::map_class_id(12),
ProjectType::ResourcePack
);
assert_eq!(
CurseForgePlatform::map_class_id(6945),
ProjectType::DataPack
);
assert_eq!(CurseForgePlatform::map_class_id(6552), ProjectType::Shader);
assert_eq!(CurseForgePlatform::map_class_id(17), ProjectType::World);
assert_eq!(CurseForgePlatform::map_class_id(9999), ProjectType::Mod); // Unknown
}
#[test]
fn test_map_release_type() {
assert_eq!(
CurseForgePlatform::map_release_type(1),
ReleaseType::Release
);
assert_eq!(CurseForgePlatform::map_release_type(2), ReleaseType::Beta);
assert_eq!(CurseForgePlatform::map_release_type(3), ReleaseType::Alpha);
assert_eq!(
CurseForgePlatform::map_release_type(99),
ReleaseType::Release
); // Unknown
}
}