Skip to content

Commit d60f7ff

Browse files
mbround18claude
andauthored
feat: support Thunderstore Basic Auth for mod downloads (#1484)
feat: support Thunderstore Basic Auth via THUNDERSTORE_USERNAME/PASSWORD Thunderstore's API supports HTTP Basic Auth. Adds a with_thunderstore_auth helper that attaches credentials to any outgoing request whose host is thunderstore.io, wired into version lookups, HEAD pre-checks, and both the single-stream and parallel-chunked mod downloads, plus the ValheimPlus config fetch. Requests to any other host are untouched. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 87f3e9a commit d60f7ff

5 files changed

Lines changed: 124 additions & 10 deletions

File tree

docs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ See the full guide: docs/tutorials/getting_started_with_mods.md
161161
| MODIFIERS | `` | FALSE | Comma-separated array of modifiers. EX: `combat=easy,raids=muchmore` |
162162
| SET_KEY | `` | FALSE | Can be any of the following: nobuildcost, playerevents, passivemobs, nomap |
163163
| MODS | `<nothing>` | FALSE | This is an array of mods separated by comma and a new line. [Examples](./docs/tutorials/getting_started_with_mods.md). Supported files are `zip`, `dll`, and `cfg`. |
164+
| THUNDERSTORE_USERNAME | `<nothing>` | FALSE | Optional. Combined with `THUNDERSTORE_PASSWORD`, sends HTTP Basic Auth on requests to `thunderstore.io` (mod listing, version resolution, and downloads). Both must be set to enable auth. |
165+
| THUNDERSTORE_PASSWORD | `<nothing>` | FALSE | Optional. See `THUNDERSTORE_USERNAME`. |
164166
| WEBHOOK_URL | `<nothing>` | FALSE | Set this to send status notifications to your webhook or Discord endpoint. [How to create a Discord webhook URL](https://help.dashe.io/en/articles/2521940-how-to-create-a-discord-webhook-url) |
165167
| WEBHOOK_INCLUDE_PUBLIC_IP | `0` | FALSE | Optionally include your server's public IP in webhook notifications, useful if not using a static IP address. NOTE: If your server is behind a NAT using PAT with more than one external IP address (very unlikely on a home network), this could be inaccurate if your NAT doesn't maintain your server to a single external IP. |
166168
| PLAYER_EVENT_NOTIFICATIONS | `0` | FALSE | Optional, if you have a webhook url supplied and turn this to one. It will post when a player joins/leaves the server. |

src/odin/mods/valheim_mod.rs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::errors::ValheimModError;
22
use crate::mods::manifest::Manifest;
33
use crate::utils::normalize_paths::normalize_paths;
4-
use crate::utils::{is_valid_url, parse_mod_string};
4+
use crate::utils::{is_valid_url, parse_mod_string, with_thunderstore_auth};
55
use crate::{
66
constants::SUPPORTED_FILE_TYPES,
77
utils::{common_paths, get_md5_hash, parse_file_name, url_parse_file_type},
@@ -100,7 +100,7 @@ async fn thunderstore_list_versions(
100100
for url in endpoints {
101101
for attempt in 1..=2 {
102102
log::debug!("Thunderstore version query attempt {}: {}", attempt, url);
103-
match client.get(&url).send().await {
103+
match with_thunderstore_auth(client.get(&url), &url).send().await {
104104
Ok(resp) => {
105105
if !resp.status().is_success() {
106106
last_err = Some(format!("status {} for {}", resp.status(), url));
@@ -136,7 +136,10 @@ async fn thunderstore_list_versions(
136136
"https://thunderstore.io/c/valheim/p/{}/{}/",
137137
namespace, name
138138
);
139-
match client.get(&page_url).send().await {
139+
match with_thunderstore_auth(client.get(&page_url), &page_url)
140+
.send()
141+
.await
142+
{
140143
Ok(resp) if resp.status().is_success() => match resp.text().await {
141144
Ok(html) => {
142145
let needle = format!("/package/download/{}/{}/", namespace, name);
@@ -414,8 +417,7 @@ impl ValheimMod {
414417

415418
join_set.spawn(async move {
416419
let _permit = sem.acquire().await.unwrap();
417-
let resp = client
418-
.get(url.as_str())
420+
let resp = with_thunderstore_auth(client.get(url.as_str()), &url)
419421
.header("Range", format!("bytes={}-{}", start_byte, end_byte))
420422
.send()
421423
.await
@@ -469,7 +471,10 @@ impl ValheimMod {
469471
// For Thunderstore download URLs, validate upfront that the URL isn't 404 to give fast feedback.
470472
if Self::is_thunderstore_download_url(&self.url) {
471473
let client = Client::new();
472-
match client.head(&self.url).send().await {
474+
match with_thunderstore_auth(client.head(&self.url), &self.url)
475+
.send()
476+
.await
477+
{
473478
Ok(resp) => {
474479
let status = resp.status();
475480
if status.is_client_error() {
@@ -542,8 +547,7 @@ impl ValheimMod {
542547
let parsed_url = Url::parse(&self.url).map_err(|_| ValheimModError::InvalidUrl)?;
543548
let client = Client::new();
544549
debug!("⬇️ Downloading from: {}", self.url);
545-
let response = client
546-
.get(parsed_url)
550+
let response = with_thunderstore_auth(client.get(parsed_url), &self.url)
547551
.send()
548552
.await
549553
.map_err(|e| ValheimModError::DownloadError(e.to_string()))?;

src/odin/mods/valheim_plus.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::errors::ValheimModError;
22
use crate::utils::common_paths;
3+
use crate::utils::with_thunderstore_auth;
34
use log::{debug, info, warn};
45
use reqwest::Client;
56
use reqwest::Url;
@@ -53,8 +54,7 @@ pub async fn ensure_valheim_plus_config_for_dll_url(
5354
info!("Downloading config from: '{}'", cfg_url);
5455

5556
let client = Client::new();
56-
let resp = client
57-
.get(&cfg_url)
57+
let resp = with_thunderstore_auth(client.get(&cfg_url), &cfg_url)
5858
.send()
5959
.await
6060
.map_err(|e| ValheimModError::DownloadError(e.to_string()))?;

src/odin/utils/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ pub use fetch_public_ip_address::fetch_public_address;
1111
pub mod is_valid_url;
1212
pub mod normalize_paths;
1313
pub mod parse_mod_string;
14+
pub mod thunderstore_auth;
1415

1516
pub use is_valid_url::is_valid_url;
1617
pub use parse_mod_string::parse_mod_string;
18+
pub use thunderstore_auth::with_thunderstore_auth;
1719

1820
use log::debug;
1921
use std::env;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use crate::utils::environment::fetch_var;
2+
use reqwest::RequestBuilder;
3+
4+
const THUNDERSTORE_USERNAME_VAR: &str = "THUNDERSTORE_USERNAME";
5+
const THUNDERSTORE_PASSWORD_VAR: &str = "THUNDERSTORE_PASSWORD";
6+
const THUNDERSTORE_HOST: &str = "thunderstore.io";
7+
8+
/// Returns `THUNDERSTORE_USERNAME`/`THUNDERSTORE_PASSWORD` when both are set, for
9+
/// authenticating against Thunderstore's API (see https://thunderstore.io/api/docs/).
10+
fn thunderstore_credentials() -> Option<(String, String)> {
11+
let username = fetch_var(THUNDERSTORE_USERNAME_VAR, "");
12+
let password = fetch_var(THUNDERSTORE_PASSWORD_VAR, "");
13+
if username.is_empty() || password.is_empty() {
14+
return None;
15+
}
16+
Some((username, password))
17+
}
18+
19+
/// Attaches HTTP Basic Auth to `builder` when `url` targets thunderstore.io and
20+
/// `THUNDERSTORE_USERNAME`/`THUNDERSTORE_PASSWORD` are both configured. Requests to
21+
/// any other host are returned unmodified.
22+
pub fn with_thunderstore_auth(builder: RequestBuilder, url: &str) -> RequestBuilder {
23+
let is_thunderstore = reqwest::Url::parse(url)
24+
.ok()
25+
.and_then(|u| {
26+
u.host_str()
27+
.map(|h| h.eq_ignore_ascii_case(THUNDERSTORE_HOST))
28+
})
29+
.unwrap_or(false);
30+
31+
if !is_thunderstore {
32+
return builder;
33+
}
34+
35+
match thunderstore_credentials() {
36+
Some((username, password)) => builder.basic_auth(username, Some(password)),
37+
None => builder,
38+
}
39+
}
40+
41+
#[cfg(test)]
42+
mod tests {
43+
use super::*;
44+
use serial_test::serial;
45+
use std::env::{remove_var, set_var};
46+
47+
#[test]
48+
#[serial]
49+
fn no_auth_when_credentials_missing() {
50+
remove_var(THUNDERSTORE_USERNAME_VAR);
51+
remove_var(THUNDERSTORE_PASSWORD_VAR);
52+
assert!(thunderstore_credentials().is_none());
53+
}
54+
55+
#[test]
56+
#[serial]
57+
fn no_auth_when_only_username_set() {
58+
set_var(THUNDERSTORE_USERNAME_VAR, "user");
59+
remove_var(THUNDERSTORE_PASSWORD_VAR);
60+
assert!(thunderstore_credentials().is_none());
61+
remove_var(THUNDERSTORE_USERNAME_VAR);
62+
}
63+
64+
#[test]
65+
#[serial]
66+
fn auth_present_when_both_set() {
67+
set_var(THUNDERSTORE_USERNAME_VAR, "user");
68+
set_var(THUNDERSTORE_PASSWORD_VAR, "pass");
69+
assert_eq!(
70+
thunderstore_credentials(),
71+
Some(("user".to_string(), "pass".to_string()))
72+
);
73+
remove_var(THUNDERSTORE_USERNAME_VAR);
74+
remove_var(THUNDERSTORE_PASSWORD_VAR);
75+
}
76+
77+
#[test]
78+
#[serial]
79+
fn non_thunderstore_host_untouched() {
80+
set_var(THUNDERSTORE_USERNAME_VAR, "user");
81+
set_var(THUNDERSTORE_PASSWORD_VAR, "pass");
82+
let client = reqwest::Client::new();
83+
let builder = with_thunderstore_auth(
84+
client.get("https://example.com/file.zip"),
85+
"https://example.com/file.zip",
86+
);
87+
let req = builder.build().unwrap();
88+
assert!(req.headers().get("authorization").is_none());
89+
remove_var(THUNDERSTORE_USERNAME_VAR);
90+
remove_var(THUNDERSTORE_PASSWORD_VAR);
91+
}
92+
93+
#[test]
94+
#[serial]
95+
fn thunderstore_host_gets_basic_auth() {
96+
set_var(THUNDERSTORE_USERNAME_VAR, "user");
97+
set_var(THUNDERSTORE_PASSWORD_VAR, "pass");
98+
let client = reqwest::Client::new();
99+
let url = "https://thunderstore.io/package/download/Author/Mod/1.0.0/";
100+
let builder = with_thunderstore_auth(client.get(url), url);
101+
let req = builder.build().unwrap();
102+
assert!(req.headers().get("authorization").is_some());
103+
remove_var(THUNDERSTORE_USERNAME_VAR);
104+
remove_var(THUNDERSTORE_PASSWORD_VAR);
105+
}
106+
}

0 commit comments

Comments
 (0)