Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ic4d2bd6f3baf97ce30dbf8709331f6f66a6a6964
666 lines
18 KiB
Rust
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
|
|
}
|
|
}
|