feat(core): Add Curseforge API token to every curseforge request

This commit is contained in:
LynithDev 2026-07-16 18:40:41 +02:00
parent 8dca2550d1
commit 6eb16028d6
9 changed files with 344 additions and 25 deletions

117
Cargo.lock generated
View file

@ -2886,6 +2886,27 @@ version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51066092dc750f8cbcac4afa04592377bf7463a911d1beca430ddd788deb0757"
[[package]]
name = "htmd"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eee9b00ee2e599b4f86507157e3db786e7a3319fc225f0e9584151dbea2291d"
dependencies = [
"html5ever",
"markup5ever_rcdom",
"phf",
]
[[package]]
name = "html5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
dependencies = [
"log",
"markup5ever",
]
[[package]]
name = "http"
version = "0.2.12"
@ -3676,6 +3697,29 @@ dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "markup5ever_rcdom"
version = "0.38.0+unofficial"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "333171ccdf66e915257740d44e38ea5b1b19ce7b45d33cc35cb6f118fbd981ff"
dependencies = [
"html5ever",
"markup5ever",
"tendril",
"xml5ever",
]
[[package]]
name = "matchers"
version = "0.2.0"
@ -4556,6 +4600,7 @@ dependencies = [
"dunce",
"futures-lite",
"futures-util",
"htmd",
"image",
"indicatif",
"interfrost",
@ -4743,6 +4788,16 @@ dependencies = [
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
@ -4910,6 +4965,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.37"
@ -6465,6 +6526,31 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "string_cache"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
name = "stringprep"
version = "0.1.5"
@ -6579,6 +6665,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@ -7576,6 +7671,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
dependencies = [
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
@ -8283,6 +8390,16 @@ version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "xml5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dc9559429edf0cd3f327cc0afd9d6b36fa8cec6d93107b7fbe64f806b5f2d9"
dependencies = [
"log",
"markup5ever",
]
[[package]]
name = "y4m"
version = "0.8.0"

View file

@ -122,6 +122,7 @@ strum = {version = "=0.28.0", features = ["derive"]}
syn = {version = "=2.0.117"}
image = {version = "=0.25.10", default-features = false, features = ["png", "jpeg"]}
htmd = {version = "=0.5.4"}
arboard = {version = "=3.6.1"}
trash = {version = "=5.2.6"}

View file

@ -134,7 +134,7 @@ impl QueryCapability for PackageProjectQuery {
let state = LauncherState::get()?;
let provider = state.services.packages.get(keys.provider)?;
provider
.get_project(&keys.project_id, &state.services)
.get_project_with_body(&keys.project_id, &state.services)
.await
}
}

View file

@ -99,6 +99,9 @@ async-compression.workspace = true
# image decode/resize
image.workspace = true
# used to confirm html to markdown
htmd.workspace = true
# move-to-trash for deletions
trash.workspace = true

View file

@ -36,10 +36,6 @@ pub fn meta_url_base() -> String {
.to_string()
}
pub fn curseforge_headers() -> Vec<(String, String)> {
vec![("x-api-key".to_string(), curseforge_api_key())]
}
pub fn modrinth_headers() -> Vec<(String, String)> {
match modrinth_api_key() {
Some(token) => vec![("Authorization".to_string(), token)],

View file

@ -31,6 +31,42 @@ fn backoff_delay(attempt: u32) -> std::time::Duration {
std::time::Duration::from_millis(capped + jitter)
}
fn is_curseforge_host(url: &reqwest::Url) -> bool {
if url.scheme() != "https" {
return false;
}
let Some(host) = url.host_str() else {
return false;
};
let host = host.trim_end_matches('.').to_ascii_lowercase();
matches!(host.as_str(), "curseforge.com" | "forgecdn.net")
|| host.ends_with(".curseforge.com")
|| host.ends_with(".forgecdn.net")
}
fn apply_curseforge_auth(request: &mut reqwest::Request) -> Result<(), RequestError> {
const API_KEY_HEADER: reqwest::header::HeaderName =
reqwest::header::HeaderName::from_static("x-api-key");
if !is_curseforge_host(request.url()) {
return Ok(());
}
let headers = request.headers_mut();
if headers.contains_key(&API_KEY_HEADER) {
return Ok(());
}
let mut value =
reqwest::header::HeaderValue::try_from(crate::api_config::curseforge_api_key())?;
value.set_sensitive(true);
headers.insert(API_KEY_HEADER, value);
Ok(())
}
fn body_snippet(bytes: &[u8]) -> String {
const MAX: usize = 240;
if bytes.is_empty() {
@ -89,10 +125,12 @@ impl RequestClient {
impl RequestClient {
#[tracing::instrument(level = "debug", skip_all)]
pub async fn send(&self, request: impl Into<HttpRequest>) -> Result<Response, RequestError> {
let request: HttpRequest = request.into();
let mut request: HttpRequest = request.into();
let mut retries = 0;
let mut throttle_retries = 0u32;
apply_curseforge_auth(&mut request.request)?;
let cloned_backup = request.request.try_clone();
let cloneable = cloned_backup.is_some();
@ -235,3 +273,59 @@ impl RequestClient {
self.send_as(request).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request(url: &str) -> reqwest::Request {
reqwest::Request::new(reqwest::Method::GET, url.parse().unwrap())
}
fn api_key_of(url: &str) -> Option<String> {
let mut req = request(url);
apply_curseforge_auth(&mut req).unwrap();
req.headers()
.get("x-api-key")
.map(|v| v.to_str().unwrap().to_string())
}
#[test]
fn attaches_key_to_curseforge_api_and_cdn() {
for url in [
"https://api.curseforge.com/v1/mods/search",
"https://media.forgecdn.net/avatars/1/2/icon.png",
"https://edge.forgecdn.net/files/1/2/mod.jar",
"https://www.curseforge.com/minecraft",
"https://forgecdn.net/thing",
] {
assert_eq!(
api_key_of(url).as_deref(),
Some(crate::constants::CURSEFORGE_API_KEY),
"expected key on {url}"
);
}
}
#[test]
fn ignores_unrelated_and_insecure_hosts() {
for url in [
"https://api.modrinth.com/v2/search",
"https://cdn.modrinth.com/data/x/y.jar",
"https://notcurseforge.com/x",
"https://evil-forgecdn.net.attacker.com/x",
"http://media.forgecdn.net/insecure.png",
] {
assert_eq!(api_key_of(url), None, "unexpected key on {url}");
}
}
#[test]
fn does_not_override_explicit_key() {
let mut req = request("https://api.curseforge.com/v1/mods");
req.headers_mut()
.insert("x-api-key", reqwest::header::HeaderValue::from_static("mine"));
apply_curseforge_auth(&mut req).unwrap();
assert_eq!(req.headers()["x-api-key"], "mine");
}
}

View file

@ -6,8 +6,7 @@ use serde::Deserialize;
use url::Url;
use super::PackageProvider;
use super::http::fetch_json_with_headers;
use crate::api_config::curseforge_headers;
use super::http::fetch_json;
use crate::LauncherResult;
use crate::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID};
use crate::packages::domain::{ContentType, GameLoader, ProviderId};
@ -74,12 +73,11 @@ impl PackageProvider for CurseForgeProvider {
}
}
let response: CfPaged<Vec<CfMod>> = fetch_json_with_headers(
let response: CfPaged<Vec<CfMod>> = fetch_json(
&services.requester,
Method::GET,
url.as_str(),
None,
&curseforge_headers(),
)
.await?;
@ -101,17 +99,27 @@ impl PackageProvider for CurseForgeProvider {
project_id: &str,
services: &LauncherServices,
) -> LauncherResult<ProjectDetail> {
let response: CfData<CfMod> = fetch_json_with_headers(
let response: CfData<CfMod> = fetch_json(
&services.requester,
Method::GET,
&api_url(&format!("/mods/{project_id}")),
None,
&curseforge_headers(),
)
.await?;
Ok(response.data.into_detail())
}
#[tracing::instrument(level = "debug", skip(self, services))]
async fn get_project_with_body(
&self,
project_id: &str,
services: &LauncherServices,
) -> LauncherResult<ProjectDetail> {
let mut detail = self.get_project(project_id, services).await?;
detail.body = fetch_body(project_id, &detail, services).await;
Ok(detail)
}
#[tracing::instrument(level = "debug", skip(self, project_ids, services))]
async fn get_projects(
&self,
@ -120,12 +128,11 @@ impl PackageProvider for CurseForgeProvider {
) -> LauncherResult<Vec<ProjectDetail>> {
let mod_ids: Vec<u32> = project_ids.iter().filter_map(|s| s.parse().ok()).collect();
let body = serde_json::json!({ "modIds": mod_ids });
let response: CfData<Vec<CfMod>> = fetch_json_with_headers(
let response: CfData<Vec<CfMod>> = fetch_json(
&services.requester,
Method::POST,
&api_url("/mods"),
Some(body),
&curseforge_headers(),
)
.await?;
Ok(response.data.into_iter().map(CfMod::into_detail).collect())
@ -151,12 +158,11 @@ impl PackageProvider for CurseForgeProvider {
params.append_pair("modLoaderType", &t.to_string());
}
}
let response: CfPaged<Vec<CfFile>> = fetch_json_with_headers(
let response: CfPaged<Vec<CfFile>> = fetch_json(
&services.requester,
Method::GET,
url.as_str(),
None,
&curseforge_headers(),
)
.await?;
@ -192,12 +198,11 @@ impl PackageProvider for CurseForgeProvider {
url.query_pairs_mut()
.append_pair("gameId", &CURSEFORGE_GAME_ID.to_string())
.append_pair("classId", &cf_class_id(content_type).to_string());
let response: CfData<Vec<CfCategory>> = fetch_json_with_headers(
let response: CfData<Vec<CfCategory>> = fetch_json(
&services.requester,
Method::GET,
url.as_str(),
None,
&curseforge_headers(),
)
.await?;
Ok(response.data.into_iter().map(|c| c.name).collect())
@ -210,12 +215,11 @@ impl PackageProvider for CurseForgeProvider {
version_id: &str,
services: &LauncherServices,
) -> LauncherResult<VersionDetail> {
let response: CfData<CfFile> = fetch_json_with_headers(
let response: CfData<CfFile> = fetch_json(
&services.requester,
Method::GET,
&api_url(&format!("/mods/files/{version_id}")),
None,
&curseforge_headers(),
)
.await?;
Ok(response.data.into())
@ -229,12 +233,11 @@ impl PackageProvider for CurseForgeProvider {
) -> LauncherResult<Vec<VersionDetail>> {
let file_ids: Vec<u32> = version_ids.iter().filter_map(|s| s.parse().ok()).collect();
let body = serde_json::json!({ "fileIds": file_ids });
let response: CfData<Vec<CfFile>> = fetch_json_with_headers(
let response: CfData<Vec<CfFile>> = fetch_json(
&services.requester,
Method::POST,
&api_url("/mods/files"),
Some(body),
&curseforge_headers(),
)
.await?;
Ok(response.data.into_iter().map(Into::into).collect())
@ -270,12 +273,11 @@ impl PackageProvider for CurseForgeProvider {
file: CfFile,
}
let response: CfData<Response> = fetch_json_with_headers(
let response: CfData<Response> = fetch_json(
&services.requester,
Method::POST,
&api_url(&format!("/mods/fingerprints/{CURSEFORGE_GAME_ID}")),
Some(body),
&curseforge_headers(),
)
.await?;
@ -310,6 +312,54 @@ struct CfPaged<T> {
pagination: CfPagination,
}
#[tracing::instrument(level = "debug", skip(detail, services))]
async fn fetch_body(
project_id: &str,
detail: &ProjectDetail,
services: &LauncherServices,
) -> PackageBody {
let fallback = || {
detail
.links
.iter()
.find(|(label, _)| label == "Website")
.map(|(_, url)| PackageBody::Url(url.clone()))
.unwrap_or_else(|| PackageBody::Raw(String::new()))
};
let response: Result<CfData<String>, _> = fetch_json(
&services.requester,
Method::GET,
&api_url(&format!("/mods/{project_id}/description")),
None,
)
.await;
match response {
Ok(response) => match html_to_markdown(&response.data) {
Some(markdown) => PackageBody::Raw(markdown),
None => fallback(),
},
Err(err) => {
tracing::warn!(project_id, %err, "failed to fetch curseforge description");
fallback()
}
}
}
fn html_to_markdown(html: &str) -> Option<String> {
let markdown = htmd::convert(html).ok()?;
let markdown = markdown.trim();
(!markdown.is_empty()).then(|| absolutize_links(markdown))
}
fn absolutize_links(markdown: &str) -> String {
markdown
.replace("](/linkout?remoteUrl=", "](https://www.curseforge.com/linkout?remoteUrl=")
.replace("](/", "](https://www.curseforge.com/")
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfPagination {

View file

@ -32,6 +32,18 @@ pub trait PackageProvider: Send + Sync {
services: &LauncherServices,
) -> LauncherResult<ProjectDetail>;
/// Same as [`PackageProvider::get_project`], but guarantees [`ProjectDetail::body`]
/// is populated when the provider has one. Only worth calling when the body is
/// actually rendered: providers that serve descriptions separately pay an extra
/// request here, so install paths should stay on `get_project`.
async fn get_project_with_body(
&self,
project_id: &str,
services: &LauncherServices,
) -> LauncherResult<ProjectDetail> {
self.get_project(project_id, services).await
}
async fn get_projects(
&self,
project_ids: &[String],

View file

@ -1,7 +1,7 @@
use oneclient_core::dev;
use oneclient_core::packages::domain::{ContentType, ProviderId};
use oneclient_core::packages::types::SearchFilters;
use oneclient_core::packages::types::{PackageBody, SearchFilters};
#[test]
fn registry_get_unknown_provider_errors() {
@ -116,3 +116,49 @@ async fn modrinth_lookup_version_by_sha1() {
let (provider_id, _) = found.unwrap();
assert_eq!(provider_id, ProviderId::Modrinth);
}
#[tokio::test]
#[ignore = "requires network"]
async fn curseforge_get_project_fetches_markdown_body() {
let env = dev::ephemeral_services().await.unwrap();
let provider = env.packages.get(ProviderId::CurseForge).unwrap();
// 238222 = Just Enough Items (JEI)
let project = provider
.get_project_with_body("238222", &env)
.await
.unwrap();
assert_eq!(project.provider, ProviderId::CurseForge);
let body = match &project.body {
PackageBody::Raw(body) => body,
PackageBody::Url(url) => panic!("expected a fetched body, got a link to {url}"),
};
assert!(!body.is_empty(), "body should not be empty");
assert!(
!body.contains("<p>") && !body.contains("<br"),
"body should be markdown, not html: {body:.200}"
);
assert!(
!body.contains("](/"),
"body should not contain site-relative links: {body:.200}"
);
}
#[tokio::test]
#[ignore = "requires network"]
async fn modrinth_get_project_with_body_has_markdown() {
let env = dev::ephemeral_services().await.unwrap();
let provider = env.packages.get(ProviderId::Modrinth).unwrap();
let project = provider
.get_project_with_body("AANobbMI", &env)
.await
.unwrap();
match &project.body {
PackageBody::Raw(body) => assert!(!body.is_empty(), "body should not be empty"),
PackageBody::Url(url) => panic!("expected a fetched body, got a link to {url}"),
}
}