From 5c43e71d06d43f8464ebbdb33547ca21ec1f1265 Mon Sep 17 00:00:00 2001 From: Dildz Date: Wed, 29 Jul 2026 22:33:33 -0600 Subject: [PATCH 1/5] feat: GitHub release updates and Discord mod-update notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two features from Dildz/quma-custom: - GitHub release source (`src/github.rs`): mods installed from a GitHub release URL can now be checked for newer releases. The update check is derived from the source_url already stored in the DB — no new mod source type needed. Results are cached per-repo for 15 minutes to stay well under GitHub's anonymous rate limit. - Discord update notifications (`src/notify.rs`): background poller checks Forge and GitHub for mod updates on a timer (default 30 min, min 5 min) and posts Discord webhook embeds for newly available versions. Already- announced versions are tracked in a new `update_notifications` table so the poller does not repeat itself across restarts. No webhook configured = no poller started. Config: `discord_webhook_url` (string, optional) and `update_notify_interval` (seconds, default 1800). Both support `QUMA_DISCORD_WEBHOOK_URL` / `QUMA_UPDATE_NOTIFY_INTERVAL` env overrides. Upstream: https://github.com/Dildz/quma-custom (docker branch) Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations/021_update_notifications.sql | 9 ++ src/config.rs | 18 +++ src/db/mods.rs | 34 ++++ src/github.rs | 195 ++++++++++++++++++++++ src/lib.rs | 2 + src/main.rs | 2 + src/notify.rs | 204 ++++++++++++++++++++++++ src/web/mod.rs | 10 ++ 8 files changed, 474 insertions(+) create mode 100644 migrations/021_update_notifications.sql create mode 100644 src/github.rs create mode 100644 src/notify.rs diff --git a/migrations/021_update_notifications.sql b/migrations/021_update_notifications.sql new file mode 100644 index 00000000..e34bda32 --- /dev/null +++ b/migrations/021_update_notifications.sql @@ -0,0 +1,9 @@ +-- Remembers which mod updates have already been announced, so a restart (or the +-- next poll) does not re-notify about the same version. +CREATE TABLE IF NOT EXISTS update_notifications ( + mod_db_id INTEGER NOT NULL, + version TEXT NOT NULL, + notified_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (mod_db_id, version), + FOREIGN KEY (mod_db_id) REFERENCES installed_mods(id) ON DELETE CASCADE +); diff --git a/src/config.rs b/src/config.rs index 4d930a89..0fbff40c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -55,6 +55,10 @@ fn default_session_secret() -> String { String::new() } +fn default_update_notify_interval() -> u64 { + 1800 +} + fn default_update_check_interval() -> u64 { 300 } @@ -935,6 +939,16 @@ pub struct Config { #[serde(default = "default_update_check_interval")] pub update_check_interval: u64, + /// Discord webhook URL for announcing newly available mod updates. + /// Unset = the background update poller does not run at all. + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub discord_webhook_url: Option, + + /// How often the poller checks Forge and GitHub for updates, in seconds. + #[serde(default = "default_update_notify_interval")] + pub update_notify_interval: u64, + #[serde(default)] pub update_disabled_mods: bool, @@ -1029,6 +1043,8 @@ impl Default for Config { web_port: 9190, web_workers: None, update_check_interval: 300, + discord_webhook_url: None, + update_notify_interval: 1800, update_disabled_mods: false, forge_cache_ttl: Some(86400), headless: None, @@ -1217,6 +1233,8 @@ impl Config { env_override!(opt_parse: self.server_port, "QUMA_SERVER_PORT", u16); env_override!(parse: self.container_stop_timeout, "QUMA_CONTAINER_STOP_TIMEOUT", u64); env_override!(parse: self.update_check_interval, "QUMA_UPDATE_CHECK_INTERVAL", u64); + env_override!(opt_str: self.discord_webhook_url, "QUMA_DISCORD_WEBHOOK_URL"); + env_override!(parse: self.update_notify_interval, "QUMA_UPDATE_NOTIFY_INTERVAL", u64); env_override!(opt_parse: self.forge_cache_ttl, "QUMA_FORGE_CACHE_TTL", u64); env_override!(bool: self.auto_start_server, "QUMA_AUTO_START_SERVER"); env_override!(parse: self.on_exit, "QUMA_ON_EXIT", OnExit); diff --git a/src/db/mods.rs b/src/db/mods.rs index 4793af44..8664bf32 100644 --- a/src/db/mods.rs +++ b/src/db/mods.rs @@ -256,6 +256,40 @@ impl Database { ) } + /// Has this mod-version already been announced? Keeps the update poller from + /// repeating itself on every tick, and across restarts. + pub fn was_update_notified(&self, mod_db_id: i64, version: &str) -> rusqlite::Result { + self.conn + .query_row( + "SELECT 1 FROM update_notifications WHERE mod_db_id = ?1 AND version = ?2", + params![mod_db_id, version], + |_| Ok(()), + ) + .optional() + .map(|hit| hit.is_some()) + } + + pub fn mark_update_notified(&self, mod_db_id: i64, version: &str) -> rusqlite::Result { + self.conn.execute( + "INSERT OR IGNORE INTO update_notifications (mod_db_id, version) VALUES (?1, ?2)", + params![mod_db_id, version], + ) + } + + /// Record a new version for a mod that came from a URL rather than Forge. + pub fn update_mod_source( + &self, + id: i64, + version: &str, + source_url: &str, + ) -> rusqlite::Result { + self.conn.execute( + "UPDATE installed_mods SET version = ?1, source_url = ?2, updated_at = datetime('now') + WHERE id = ?3", + params![version, source_url, id], + ) + } + pub fn delete_mod(&self, id: i64) -> rusqlite::Result { self.conn .execute("DELETE FROM installed_mods WHERE id = ?1", params![id]) diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 00000000..10859d74 --- /dev/null +++ b/src/github.rs @@ -0,0 +1,195 @@ +//! GitHub release source for mods that are not on SPT Forge. +//! +//! quma already tracks non-Forge mods (`installed_mods.source = 'url'` + +//! `source_url`, added by migration 010) but nothing ever asked GitHub whether a +//! newer release existed. A release-download URL carries everything needed to do +//! that — owner, repo and the asset naming — so the update check is derived from +//! the URL we already store rather than a new source type. + +use anyhow::{bail, Context, Result}; + +/// A GitHub release-download URL, split into the parts we need. +/// +/// `https://github.com/{owner}/{repo}/releases/download/{tag}/{asset}` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseRef { + pub owner: String, + pub repo: String, + pub tag: String, + pub asset: String, +} + +/// The newest release of a repo, and the asset to download for it. +#[derive(Debug, Clone)] +pub struct LatestRelease { + pub version: String, + pub download_url: String, +} + +/// Parse a GitHub release-download URL. Returns `None` for any other URL, which +/// is how callers tell "this mod can be update-checked on GitHub" from "this mod +/// came from some other host". +pub fn parse_release_url(url: &str) -> Option { + let u = reqwest::Url::parse(url).ok()?; + if u.host_str()? != "github.com" { + return None; + } + let seg: Vec<&str> = u.path_segments()?.collect(); + // {owner}/{repo}/releases/download/{tag}/{asset} + match seg.as_slice() { + [owner, repo, "releases", "download", tag, asset] => Some(ReleaseRef { + owner: (*owner).to_string(), + repo: (*repo).to_string(), + tag: (*tag).to_string(), + asset: (*asset).to_string(), + }), + _ => None, + } +} + +/// Strip a leading `v` so tags and mod versions compare on equal terms +/// (`v0.12.5` and `0.12.5` are the same release). +pub fn normalize_version(s: &str) -> &str { + s.strip_prefix('v').unwrap_or(s) +} + +/// Ask GitHub for a repo's latest release and pick the asset to download. +/// +/// Asset choice mirrors the one already installed: same name with the version +/// swapped. Falls back to the only `.zip` in the release when that name is not +/// found, and gives up if the release is ambiguous — guessing the wrong asset +/// would install the wrong thing. +pub async fn latest_release(r: &ReleaseRef) -> Result { + #[derive(serde::Deserialize)] + struct Asset { + name: String, + browser_download_url: String, + } + #[derive(serde::Deserialize)] + struct Release { + tag_name: String, + #[serde(default)] + draft: bool, + #[serde(default)] + prerelease: bool, + #[serde(default)] + assets: Vec, + } + + let url = format!( + "https://api.github.com/repos/{}/{}/releases/latest", + r.owner, r.repo + ); + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(15)) + .read_timeout(std::time::Duration::from_secs(30)) + .user_agent(concat!("quartermaster/", env!("CARGO_PKG_VERSION"))) + .build() + .context("failed to build GitHub client")?; + + let rel: Release = client + .get(&url) + .header("Accept", "application/vnd.github+json") + .send() + .await + .with_context(|| format!("GitHub request failed for {}/{}", r.owner, r.repo))? + .error_for_status() + .with_context(|| format!("GitHub returned an error for {}/{}", r.owner, r.repo))? + .json() + .await + .context("failed to parse the GitHub release response")?; + + if rel.draft || rel.prerelease { + bail!("latest GitHub release is a draft/prerelease — not offering it as an update"); + } + + let version = normalize_version(&rel.tag_name).to_string(); + let want = r.asset.replace(normalize_version(&r.tag), &version); + + let asset = rel + .assets + .iter() + .find(|a| a.name == want) + .or_else(|| { + let zips: Vec<&Asset> = rel + .assets + .iter() + .filter(|a| a.name.ends_with(".zip")) + .collect(); + match zips.as_slice() { + [only] => Some(*only), + _ => None, + } + }) + .with_context(|| { + format!( + "release {} has no asset named {want} and no single .zip to fall back to", + rel.tag_name + ) + })?; + + Ok(LatestRelease { + version, + download_url: asset.browser_download_url.clone(), + }) +} + +// ponytail: one request per GitHub repo per 15 min — only a handful of mods are +// GitHub-sourced. Revisit only if someone runs dozens of them. +const RELEASE_TTL: std::time::Duration = std::time::Duration::from_secs(900); + +type ReleaseCache = parking_lot::Mutex< + std::collections::HashMap)>, +>; + +fn cache() -> &'static ReleaseCache { + static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +/// `latest_release`, memoised. Returns `None` when there is no answer to give — +/// callers treat that as "no update known", never as an error worth failing on. +pub async fn latest_release_cached(r: &ReleaseRef) -> Option { + let key = format!("{}/{}", r.owner, r.repo); + let hit = cache().lock().get(&key).cloned(); + if let Some((at, cached)) = hit { + if at.elapsed() < RELEASE_TTL { + return cached; + } + } + let fresh = latest_release(r).await.ok(); + cache() + .lock() + .insert(key, (std::time::Instant::now(), fresh.clone())); + fresh +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_release_download_url() { + let r = parse_release_url( + "https://github.com/Dildz/ModSync-for-SPT4.0/releases/download/v0.12.5/Corter-ModSync-v0.12.5.zip", + ) + .expect("should parse"); + assert_eq!(r.owner, "Dildz"); + assert_eq!(r.repo, "ModSync-for-SPT4.0"); + assert_eq!(r.tag, "v0.12.5"); + assert_eq!(r.asset, "Corter-ModSync-v0.12.5.zip"); + } + + #[test] + fn rejects_non_release_and_non_github_urls() { + assert!(parse_release_url("https://example.com/mod.zip").is_none()); + assert!(parse_release_url("https://github.com/Dildz/ModSync-for-SPT4.0").is_none()); + assert!(parse_release_url("not a url").is_none()); + } + + #[test] + fn version_normalizes_across_the_v_prefix() { + assert_eq!(normalize_version("v0.12.5"), "0.12.5"); + assert_eq!(normalize_version("0.12.5"), "0.12.5"); + } +} diff --git a/src/lib.rs b/src/lib.rs index ab0cfc2a..ad3ec965 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,10 +11,12 @@ pub mod db; pub mod dirs; pub mod fika; pub mod forge; +pub mod github; pub mod headless; pub mod health; pub mod invite; pub mod logging; +pub mod notify; pub mod numa; pub mod ops; pub mod overlay; diff --git a/src/main.rs b/src/main.rs index 437cf2b2..42aa98d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,10 +11,12 @@ mod db; mod dirs; mod fika; mod forge; +mod github; mod headless; mod health; mod invite; mod logging; +mod notify; mod numa; mod ops; mod overlay; diff --git a/src/notify.rs b/src/notify.rs new file mode 100644 index 00000000..ef3f3e51 --- /dev/null +++ b/src/notify.rs @@ -0,0 +1,204 @@ +//! Background mod-update poller with Discord notifications. +//! +//! Polls Forge and GitHub on a timer (default 30 min) so admins hear about +//! updates without watching the dashboard, and warms the same cache the page +//! reads so it is instant afterwards. +//! +//! Only runs when a webhook is configured — no webhook, no poller. + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use parking_lot::Mutex; + +use crate::db::Database; +use crate::forge::client::ForgeClient; +use crate::web::update_cache::UpdateCache; + +struct Available { + mod_db_id: i64, + name: String, + current: String, + new: String, + source: &'static str, +} + +/// Start the poller. Returns immediately; does nothing if no webhook is set. +pub fn spawn( + db: Arc>, + forge: ForgeClient, + update_cache: UpdateCache, + spt_version: String, + webhook_url: Option, + interval_secs: u64, +) { + let Some(webhook_url) = webhook_url.filter(|u| !u.trim().is_empty()) else { + tracing::debug!("no Discord webhook configured — update poller not started"); + return; + }; + // A tight loop would hammer Forge and burn GitHub's 60/hr anonymous budget. + let interval_secs = interval_secs.max(300); + + tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_secs(interval_secs)); + loop { + ticker.tick().await; + if let Err(e) = poll_once(&db, &forge, &update_cache, &spt_version, &webhook_url).await + { + tracing::warn!(err = %e, "mod update poll failed"); + } + } + }); + tracing::info!( + interval_secs, + "mod update poller started — new updates will be announced on Discord" + ); +} + +async fn poll_once( + db: &Arc>, + forge: &ForgeClient, + update_cache: &UpdateCache, + spt_version: &str, + webhook_url: &str, +) -> Result<()> { + let installed = { db.lock().list_mods()? }; + if installed.is_empty() { + return Ok(()); + } + + let mut available: Vec = Vec::new(); + + // Forge-sourced mods: one batched call, which also refreshes the page cache. + let forge_list: Vec<(i64, String)> = installed + .iter() + .filter_map(|m| m.forge_mod_id.map(|id| (id, m.version.clone()))) + .collect(); + if !forge_list.is_empty() { + match forge.check_updates(&forge_list, spt_version).await { + Ok(data) => { + for m in &installed { + let Some(u) = data.updates.iter().find(|u| { + m.forge_mod_id == Some(u.current_version.mod_id) + && u.recommended_version.version != m.version + }) else { + continue; + }; + available.push(Available { + mod_db_id: m.id, + name: m.name.clone(), + current: m.version.clone(), + new: u.recommended_version.version.clone(), + source: "Forge", + }); + } + update_cache.set(data); + } + Err(e) => tracing::warn!(err = %e, "Forge update check failed during poll"), + } + } + + // GitHub-sourced mods: one call per repo, memoised. + for m in &installed { + let Some(r) = m + .source_url + .as_deref() + .and_then(crate::github::parse_release_url) + else { + continue; + }; + let Some(rel) = crate::github::latest_release_cached(&r).await else { + continue; + }; + if rel.version != crate::github::normalize_version(&m.version) { + available.push(Available { + mod_db_id: m.id, + name: m.name.clone(), + current: m.version.clone(), + new: rel.version, + source: "GitHub", + }); + } + } + + // Announce only what has not been announced before. + let fresh: Vec = { + let db = db.lock(); + available + .into_iter() + .filter(|a| match db.was_update_notified(a.mod_db_id, &a.new) { + Ok(seen) => !seen, + Err(e) => { + tracing::warn!(err = %e, "failed to read notification state"); + false + } + }) + .collect() + }; + if fresh.is_empty() { + return Ok(()); + } + + post_to_discord(webhook_url, &fresh).await?; + + let db = db.lock(); + for a in &fresh { + if let Err(e) = db.mark_update_notified(a.mod_db_id, &a.new) { + tracing::warn!(mod_name = a.name, err = %e, "failed to record notification"); + } + } + tracing::info!(count = fresh.len(), "announced mod updates on Discord"); + Ok(()) +} + +const EMBED_COLOR: u32 = 0xC7_7B_2A; + +fn embed(u: &Available) -> serde_json::Value { + serde_json::json!({ + "embeds": [{ + "title": u.name, + "description": format!("**{}** → **{}**", u.current, u.new), + "color": EMBED_COLOR, + "footer": { "text": format!("Quartermaster · {}", u.source) }, + }] + }) +} + +async fn post_to_discord(webhook_url: &str, updates: &[Available]) -> Result<()> { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(15)) + .read_timeout(Duration::from_secs(30)) + .build()?; + + for u in updates { + client + .post(webhook_url) + .json(&embed(u)) + .send() + .await? + .error_for_status()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embed_names_both_versions_and_the_source() { + let e = embed(&Available { + mod_db_id: 2, + name: "Corter-ModSync".to_string(), + current: "0.12.5".to_string(), + new: "0.13.0".to_string(), + source: "GitHub", + }); + let embeds = e["embeds"].as_array().expect("embeds array"); + assert_eq!(embeds.len(), 1); + assert_eq!(embeds[0]["title"], "Corter-ModSync"); + assert_eq!(embeds[0]["description"], "**0.12.5** → **0.13.0**"); + assert_eq!(embeds[0]["footer"]["text"], "Quartermaster · GitHub"); + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 64bfc040..6b69a175 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1115,6 +1115,16 @@ pub async fn start_server(ctx: ServerContext, api_token: String) -> Result<()> { // Pre-warm mod ZIP cache in background app_state.mod_zip_cache.invalidate(); + // Poll Forge + GitHub for mod updates and announce them (no-op without a webhook). + crate::notify::spawn( + app_state.db.clone(), + app_state.forge.clone(), + app_state.update_cache.clone(), + app_state.spt_info.spt_version.clone(), + config.discord_webhook_url.clone(), + config.update_notify_interval, + ); + // One-time modsync-to-convoy migration { let config = app_state.config.read(); From 1ae44a248d6c638956454edd50ad09740a701180 Mon Sep 17 00:00:00 2001 From: Dildz Date: Wed, 29 Jul 2026 22:45:00 -0600 Subject: [PATCH 2/5] feat(mods): GitHub update checks in carousel and update flow Integrate GitHub-sourced mods into the full update UI: - Update carousel shows GitHub mods alongside Forge mods, with a link to the GitHub releases page instead of Forge. - Update badges count includes GitHub-sourced mods. - Update status partial shows available GitHub versions. - "Update" button on mod detail works for GitHub mods: downloads the latest release archive and applies it via update_mod_from_archive. - update_mod_from_archive now accepts Option for version_id and an optional source_url, so it can record either a Forge version bump or a GitHub URL change. All existing callers pass Some(id) + None. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/apply.rs | 3 +- src/cli/update.rs | 3 +- src/ops.rs | 23 +- src/web/handlers/mods.rs | 214 ++++++++++++++---- src/web/handlers/queue.rs | 3 +- templates/mods/partials/updates_carousel.html | 8 +- 6 files changed, 192 insertions(+), 62 deletions(-) diff --git a/src/cli/apply.rs b/src/cli/apply.rs index 7cb39693..adc16f43 100644 --- a/src/cli/apply.rs +++ b/src/cli/apply.rs @@ -403,9 +403,10 @@ pub async fn drain_all(ctx: &CliContext) -> Result { &ctx.dirs, &ctx.config, installed.id, - version_id, + Some(version_id), &version_str, archive, + None, ) { let remaining = pending.len() - applied - 1; eprintln!("\n Error: {e:#}"); diff --git a/src/cli/update.rs b/src/cli/update.rs index 6fdc14c0..fe0b1a67 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -362,9 +362,10 @@ pub async fn apply_update_by_version( &ctx.dirs, &ctx.config, installed.id, - target_version_id, + Some(target_version_id), &version_info.version, &archive_path, + None, )?; let file_count = ctx.db.get_files_for_mod(installed.id)?.len(); diff --git a/src/ops.rs b/src/ops.rs index dffaa125..4de66daa 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -343,14 +343,20 @@ pub fn install_addon_from_archive(req: &InstallAddonRequest<'_>) -> Result Ok(db_id) } +/// Replace a mod's files from an archive. +/// +/// `version_id` is the Forge version; a mod installed from a URL has none, and +/// passes its new download URL as `source_url` instead. +#[allow(clippy::too_many_arguments)] pub fn update_mod_from_archive( db: &Database, dirs: &QumaDirs, config: &crate::config::Config, mod_db_id: i64, - version_id: i64, + version_id: Option, version_str: &str, archive_path: &Path, + source_url: Option<&str>, ) -> Result<()> { tracing::info!( mod_db_id, @@ -383,7 +389,11 @@ pub fn update_mod_from_archive( let tx = db.begin_transaction()?; db.delete_files_for_mod(mod_db_id)?; record_extracted_files(db, mod_db_id, &extracted)?; - db.update_mod(mod_db_id, version_id, version_str)?; + match (version_id, source_url) { + (Some(id), _) => db.update_mod(mod_db_id, id, version_str)?, + (None, Some(url)) => db.update_mod_source(mod_db_id, version_str, url)?, + (None, None) => anyhow::bail!("update needs either a Forge version id or a source URL"), + }; tx.commit()?; Ok(()) } @@ -1979,9 +1989,10 @@ mod tests { &dirs, &Config::default(), db_id, - 300, + Some(300), "2.0.0", zip_v2.path(), + None, ) .unwrap(); @@ -2043,9 +2054,10 @@ mod tests { &dirs, &Config::default(), db_id, - 300, + Some(300), "2.0.0", zip_v2.path(), + None, ) .unwrap(); @@ -2833,9 +2845,10 @@ mod tests { &dirs, &Config::default(), db_id, - 201, + Some(201), "2.0.0", zip_v2.path(), + None, ) .unwrap(); diff --git a/src/web/handlers/mods.rs b/src/web/handlers/mods.rs index 0b7c83d0..12a13ed5 100644 --- a/src/web/handlers/mods.rs +++ b/src/web/handlers/mods.rs @@ -193,9 +193,8 @@ struct UpdateStatusTemplate { struct UpdatesCarouselEntry { db_id: i64, - forge_mod_id: i64, + source_link: Option, name: String, - slug: Option, current_version: String, new_version: String, update_reason: String, @@ -808,7 +807,7 @@ pub async fn check_updates_partial( }); let results = futures_util::future::join_all(version_futures).await; - mods_with_candidates + let forge_count = mods_with_candidates .iter() .zip(results) .filter(|(m, result)| { @@ -819,7 +818,17 @@ pub async fn check_updates_partial( .map(|v| &v.version) .is_some_and(|v| v != &m.version) }) - .count() + .count(); + + // Count GitHub-sourced mods with available updates too. + let mut github_count = 0; + for m in &installed { + if github_update(m).await.is_some() { + github_count += 1; + } + } + + forge_count + github_count } else { 0 }; @@ -912,6 +921,13 @@ pub async fn update_status_partial( version_map.insert(idx, new_ver); } + // GitHub-sourced mods aren't in the Forge response — check their repos too. + for (i, m) in installed.iter().enumerate() { + if let Some(rel) = github_update(m).await { + version_map.insert(i, Some(rel.version)); + } + } + let entries: Vec<_> = installed .iter() .enumerate() @@ -961,7 +977,7 @@ pub async fn updates_carousel_partial( // Match update entries to installed mods, filtering to those with real updates let mut updatable: Vec<( &crate::db::mods::InstalledMod, - &crate::forge::models::UpdateEntry, + Option<&crate::forge::models::UpdateEntry>, )> = installed .iter() .filter_map(|m| { @@ -972,9 +988,16 @@ pub async fn updates_carousel_partial( m.forge_mod_id == Some(u.current_version.mod_id) && u.recommended_version.version != m.version }) - .map(|u| (m, u)) + .map(|u| (m, Some(u))) }) .collect(); + + // GitHub-sourced mods have no Forge entry — check their repos so they appear here too. + for m in &installed { + if github_update(m).await.is_some() { + updatable.push((m, None)); + } + } updatable.sort_by_key(|a| a.0.name.to_lowercase()); let total = updatable.len(); @@ -985,48 +1008,71 @@ pub async fn updates_carousel_partial( } let clamped_index = index % total; - let (m, u) = updatable[clamped_index]; - - // Fika compat is already on the cached UpdateRecommendedVersion; - // only call get_versions for the SPT version constraint. - let fika_compat = u - .recommended_version - .fika_compatibility - .as_ref() - .map(|f| match f { - FikaCompat::Compatible => "compatible".to_string(), - FikaCompat::Incompatible => "incompatible".to_string(), - FikaCompat::Unknown => "unknown".to_string(), - }); - - let forge_mod_id = m - .forge_mod_id - .ok_or(WebError::BadRequest("Mod has no Forge ID".to_string()))?; - - let spt_version = match state - .forge - .get_versions(forge_mod_id, Some(&state.spt_info.spt_version)) - .await - { - Ok(versions) => versions - .iter() - .find(|v| v.version == u.recommended_version.version) - .and_then(|v| v.spt_version.clone()), - Err(_) => None, - }; + let (m, maybe_u) = updatable[clamped_index]; + + let entry = match maybe_u { + Some(u) => { + let fika_compat = u + .recommended_version + .fika_compatibility + .as_ref() + .map(|f| match f { + FikaCompat::Compatible => "compatible".to_string(), + FikaCompat::Incompatible => "incompatible".to_string(), + FikaCompat::Unknown => "unknown".to_string(), + }); + + let forge_mod_id = m + .forge_mod_id + .ok_or(WebError::BadRequest("Mod has no Forge ID".to_string()))?; + + let spt_version = match state + .forge + .get_versions(forge_mod_id, Some(&state.spt_info.spt_version)) + .await + { + Ok(versions) => versions + .iter() + .find(|v| v.version == u.recommended_version.version) + .and_then(|v| v.spt_version.clone()), + Err(_) => None, + }; - let entry = UpdatesCarouselEntry { - db_id: m.id, - forge_mod_id, - name: m.name.clone(), - slug: m.slug.clone(), - current_version: m.version.clone(), - new_version: u.recommended_version.version.clone(), - update_reason: u.update_reason.clone(), - spt_version, - fika_compat, - download_size: u.recommended_version.content_length.map(|s| s as i64), - csrf_token: csrf_token.clone(), + UpdatesCarouselEntry { + db_id: m.id, + source_link: m + .slug + .as_ref() + .map(|slug| format!("https://forge.sp-tarkov.com/mod/{forge_mod_id}/{slug}")), + name: m.name.clone(), + current_version: m.version.clone(), + new_version: u.recommended_version.version.clone(), + update_reason: u.update_reason.clone(), + spt_version, + fika_compat, + download_size: u.recommended_version.content_length.map(|s| s as i64), + csrf_token: csrf_token.clone(), + } + } + None => { + let rel = github_update(m).await.ok_or(WebError::NotFound)?; + UpdatesCarouselEntry { + db_id: m.id, + source_link: m.source_url.as_deref().and_then(|u| { + crate::github::parse_release_url(u).map(|r| { + format!("https://github.com/{}/{}/releases/latest", r.owner, r.repo) + }) + }), + name: m.name.clone(), + current_version: m.version.clone(), + new_version: rel.version, + update_reason: "newer_release_on_github".to_string(), + spt_version: None, + fika_compat: None, + download_size: None, + csrf_token: csrf_token.clone(), + } + } }; let prev_index = if clamped_index == 0 { @@ -1675,6 +1721,73 @@ pub async fn install_mod( .finish()) } +/// The newer GitHub release for a mod, if there is one. +/// +/// `None` covers every "no update to offer" case: not a GitHub release URL, +/// already current, or GitHub unreachable. +async fn github_update(installed: &InstalledMod) -> Option { + let r = crate::github::parse_release_url(installed.source_url.as_deref()?)?; + let rel = crate::github::latest_release_cached(&r).await?; + (rel.version != crate::github::normalize_version(&installed.version)).then_some(rel) +} + +/// Update a mod that was installed from a GitHub release. +async fn update_mod_from_github( + state: &Data, + session: &Session, + installed: &InstalledMod, +) -> actix_web::Result { + let mod_db_id = installed.id; + let back = format!("/quma/mods/{mod_db_id}"); + + let Some(rel) = github_update(installed).await else { + set_flash(session, "Already up to date", FlashType::Warning); + return Ok(HttpResponse::SeeOther() + .insert_header(("Location", back)) + .finish()); + }; + + let tmp_dir = tempfile::tempdir().map_err(WebError::from)?; + let archive_path = tmp_dir.path().join("mod.zip"); + state + .forge + .download_file(&rel.download_url, &archive_path) + .await + .map_err(WebError::from)?; + + let db = state.db.clone(); + let dirs = Arc::clone(&state.dirs); + let config = state.config_cloned(); + let version = rel.version.clone(); + let url = rel.download_url.clone(); + web::block(move || { + let db = db.lock(); + crate::ops::update_mod_from_archive( + &db, + &dirs, + &config, + mod_db_id, + None, + &version, + &archive_path, + Some(&url), + ) + }) + .await + .map_err(WebError::from)? + .map_err(WebError::from)?; + + state.integrity_cache.invalidate(); + set_flash( + session, + &format!("Updated {} to {}", installed.name, rel.version), + FlashType::Success, + ); + Ok(HttpResponse::SeeOther() + .insert_header(("Location", back)) + .finish()) +} + pub async fn update_mod( state: Data, path: Path, @@ -1699,9 +1812,10 @@ pub async fn update_mod( .map_err(WebError::from)? .ok_or(WebError::NotFound)?; - let forge_mod_id = installed - .forge_mod_id - .ok_or(WebError::BadRequest("Mod has no Forge ID".to_string()))?; + let forge_mod_id = match installed.forge_mod_id { + Some(id) => id, + None => return update_mod_from_github(&state, &session, &installed).await, + }; let versions = state .forge diff --git a/src/web/handlers/queue.rs b/src/web/handlers/queue.rs index 28b30a58..c0a8d4a1 100644 --- a/src/web/handlers/queue.rs +++ b/src/web/handlers/queue.rs @@ -510,9 +510,10 @@ pub(super) async fn apply_update(op: &PendingOperation, state: &AppState) -> any &dirs, &config, installed.id, - version_id, + Some(version_id), &version_str, &archive_owned, + None, ) }) .await??; diff --git a/templates/mods/partials/updates_carousel.html b/templates/mods/partials/updates_carousel.html index 72537aa5..bfca7d6e 100644 --- a/templates/mods/partials/updates_carousel.html +++ b/templates/mods/partials/updates_carousel.html @@ -21,16 +21,16 @@

Updates Available ({{ total }})

- {% if let Some(slug) = &e.slug %} - {{ e.name }} + {% if let Some(link) = &e.source_link %} + {{ e.name }} {% else %} {{ e.name }} {% endif %}
{{ e.current_version }} → {{ e.new_version }} - {% if let Some(slug) = &e.slug %} - changelog ↗ + {% if let Some(link) = &e.source_link %} + changelog ↗ {% endif %}
From 639c1d56020d9e26ea68720f622c1a00357e7088 Mon Sep 17 00:00:00 2001 From: Dildz Date: Wed, 29 Jul 2026 23:08:15 -0600 Subject: [PATCH 3/5] fix(web): require ModsUpdate permission to force an update re-check The refresh_updates endpoint only checked authentication but discarded the user without verifying they had ModsUpdate permission, allowing any logged-in user to invalidate the update cache. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web/handlers/mods.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/web/handlers/mods.rs b/src/web/handlers/mods.rs index 12a13ed5..5b05cf23 100644 --- a/src/web/handlers/mods.rs +++ b/src/web/handlers/mods.rs @@ -841,7 +841,8 @@ pub async fn refresh_updates( state: Data, req: HttpRequest, ) -> actix_web::Result { - let _user = require_auth(&req)?; + let user = require_auth(&req)?; + require_permission(&user, Permission::ModsUpdate)?; state.update_cache.invalidate(); Ok(HttpResponse::NoContent().finish()) } From 39137580cba25667a120433b4acfbe4b4ed5d410 Mon Sep 17 00:00:00 2001 From: Dildz Date: Wed, 29 Jul 2026 23:08:49 -0600 Subject: [PATCH 4/5] fix(fika): dial the SPT server, not fika.jsonc's bind address FikaClient was constructed using fika.jsonc's backend_ip (a bind address, typically 0.0.0.0), so in containerized deployments quma would try to dial itself instead of the SPT server. Use config.server_host/server_port (which know the actual reachable address) with fika.jsonc as fallback for native installs. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web/mod.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/web/mod.rs b/src/web/mod.rs index 6b69a175..c18be57f 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1018,11 +1018,14 @@ pub async fn start_server(ctx: ServerContext, api_token: String) -> Result<()> { let fika_config_path = crate::fika::config::fika_config_path(&dirs); match crate::fika::config::read_fika_config(&fika_config_path) { Ok(fika_config) if !fika_config.server.api_key.is_empty() => { - let base_url = format!( - "https://{}:{}", - fika_config.server.spt.http.backend_ip, - fika_config.server.spt.http.backend_port - ); + let host = config + .server_host + .clone() + .unwrap_or_else(|| fika_config.server.spt.http.backend_ip.clone()); + let port = config + .server_port + .unwrap_or(fika_config.server.spt.http.backend_port); + let base_url = format!("https://{host}:{port}"); match crate::fika::client::FikaClient::new(&base_url, fika_config.server.api_key) { Ok(client) => { tracing::info!("FikaClient initialized"); From 886eb01aa8f9d4dd720680ac381c30c22bfea8f6 Mon Sep 17 00:00:00 2001 From: Dildz Date: Wed, 29 Jul 2026 23:13:32 -0600 Subject: [PATCH 5/5] fix(detect): validate a dedicated SPT server, not a full client install validate_spt_dir() required SPT.Server.exe (Windows-only) and BepInEx/plugins (client-side), so it rejected the exact Linux dedicated server environment Quartermaster targets. Switch to platform-agnostic markers: - SPT.Server.deps.json (already parsed by read_spt_version) - SPT_Data/configs/core.json - SPT/user/mods Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/setup.rs | 6 ++---- src/dirs.rs | 15 +++++---------- src/spt/detect.rs | 30 ++++++++++++------------------ tests/dirs_integration.rs | 6 ++---- 4 files changed, 21 insertions(+), 36 deletions(-) diff --git a/src/cli/setup.rs b/src/cli/setup.rs index 711e1a56..4beab5f9 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -698,7 +698,7 @@ mod tests { // Create minimum SPT structure at root (legacy layout) std::fs::create_dir_all(spt_dir.join("SPT")).unwrap(); - std::fs::write(spt_dir.join("SPT/SPT.Server.exe"), b"").unwrap(); + std::fs::write(spt_dir.join("SPT/SPT.Server.deps.json"), b"{}").unwrap(); let configs_dir = spt_dir.join("SPT/SPT_Data/configs"); std::fs::create_dir_all(&configs_dir).unwrap(); std::fs::write( @@ -707,7 +707,6 @@ mod tests { ) .unwrap(); std::fs::create_dir_all(spt_dir.join("SPT/user/mods")).unwrap(); - std::fs::create_dir_all(spt_dir.join("BepInEx/plugins")).unwrap(); let state = classify_directory(spt_dir).unwrap(); assert!(matches!(state, DirState::ExistingSptLegacy)); @@ -721,7 +720,7 @@ mod tests { // Create minimum SPT structure in spt-server/ subdir (new layout) std::fs::create_dir_all(spt_dir.join("SPT")).unwrap(); - std::fs::write(spt_dir.join("SPT/SPT.Server.exe"), b"").unwrap(); + std::fs::write(spt_dir.join("SPT/SPT.Server.deps.json"), b"{}").unwrap(); let configs_dir = spt_dir.join("SPT/SPT_Data/configs"); std::fs::create_dir_all(&configs_dir).unwrap(); std::fs::write( @@ -730,7 +729,6 @@ mod tests { ) .unwrap(); std::fs::create_dir_all(spt_dir.join("SPT/user/mods")).unwrap(); - std::fs::create_dir_all(spt_dir.join("BepInEx/plugins")).unwrap(); let state = classify_directory(quma_root).unwrap(); assert!(matches!(state, DirState::ExistingSptNew)); diff --git a/src/dirs.rs b/src/dirs.rs index 73d3a5e3..1dd0e34f 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -479,8 +479,7 @@ mod tests { let dirs = QumaDirs::from_root(tmp.path().to_path_buf()); std::fs::create_dir_all(dirs.spt_server.join("SPT/SPT_Data/configs")).unwrap(); std::fs::create_dir_all(dirs.spt_server.join("SPT/user/mods")).unwrap(); - std::fs::create_dir_all(dirs.spt_server.join("BepInEx/plugins")).unwrap(); - std::fs::write(dirs.spt_server.join("SPT/SPT.Server.exe"), "").unwrap(); + std::fs::write(dirs.spt_server.join("SPT/SPT.Server.deps.json"), "{}").unwrap(); std::fs::write(dirs.spt_server.join("SPT/SPT_Data/configs/core.json"), "{}").unwrap(); assert!(crate::spt::detect::validate_spt_dir(&dirs.spt_server).is_ok()); @@ -494,8 +493,7 @@ mod tests { let spt = root.join("spt-server"); std::fs::create_dir_all(spt.join("SPT/SPT_Data/configs")).unwrap(); std::fs::create_dir_all(spt.join("SPT/user/mods")).unwrap(); - std::fs::create_dir_all(spt.join("BepInEx/plugins")).unwrap(); - std::fs::write(spt.join("SPT/SPT.Server.exe"), "").unwrap(); + std::fs::write(spt.join("SPT/SPT.Server.deps.json"), "{}").unwrap(); std::fs::write(spt.join("SPT/SPT_Data/configs/core.json"), "{}").unwrap(); let dirs = QumaDirs::detect(Some(root), None).unwrap(); @@ -509,8 +507,7 @@ mod tests { let root = tmp.path(); std::fs::create_dir_all(root.join("SPT/SPT_Data/configs")).unwrap(); std::fs::create_dir_all(root.join("SPT/user/mods")).unwrap(); - std::fs::create_dir_all(root.join("BepInEx/plugins")).unwrap(); - std::fs::write(root.join("SPT/SPT.Server.exe"), "").unwrap(); + std::fs::write(root.join("SPT/SPT.Server.deps.json"), "{}").unwrap(); std::fs::write(root.join("SPT/SPT_Data/configs/core.json"), "{}").unwrap(); let dirs = QumaDirs::detect(Some(root), None).unwrap(); @@ -526,8 +523,7 @@ mod tests { let spt = root.join("spt-server"); std::fs::create_dir_all(spt.join("SPT/SPT_Data/configs")).unwrap(); std::fs::create_dir_all(spt.join("SPT/user/mods")).unwrap(); - std::fs::create_dir_all(spt.join("BepInEx/plugins")).unwrap(); - std::fs::write(spt.join("SPT/SPT.Server.exe"), "").unwrap(); + std::fs::write(spt.join("SPT/SPT.Server.deps.json"), "{}").unwrap(); std::fs::write(spt.join("SPT/SPT_Data/configs/core.json"), "{}").unwrap(); temp_env::with_vars( @@ -587,8 +583,7 @@ mod tests { let root = tmp.path(); std::fs::create_dir_all(root.join("SPT/SPT_Data/configs")).unwrap(); std::fs::create_dir_all(root.join("SPT/user/mods")).unwrap(); - std::fs::create_dir_all(root.join("BepInEx/plugins")).unwrap(); - std::fs::write(root.join("SPT/SPT.Server.exe"), "").unwrap(); + std::fs::write(root.join("SPT/SPT.Server.deps.json"), "{}").unwrap(); std::fs::write(root.join("SPT/SPT_Data/configs/core.json"), "{}").unwrap(); temp_env::with_vars( diff --git a/src/spt/detect.rs b/src/spt/detect.rs index 4bc785a3..3aa193b6 100644 --- a/src/spt/detect.rs +++ b/src/spt/detect.rs @@ -32,18 +32,19 @@ struct DepsJson { libraries: Option>, } -/// Required markers that identify a valid SPT 4.0+ installation directory. +/// Required markers that identify a valid SPT 4.0+ server directory. +/// +/// Uses `SPT.Server.deps.json` (platform-agnostic, already parsed by +/// `read_spt_version`) instead of `SPT.Server.exe` which doesn't exist on +/// Linux dedicated servers. `BepInEx/plugins` is a client-side path that +/// may not exist on a headless/dedicated server. const REQUIRED_PATHS: &[&str] = &[ - "SPT/SPT.Server.exe", + "SPT/SPT.Server.deps.json", "SPT/SPT_Data/configs/core.json", "SPT/user/mods", - "BepInEx/plugins", ]; -/// Validate that `path` contains the expected SPT directory structure. -/// -/// Checks for the presence of SPT/SPT.Server.exe, the server config directory, -/// the user mods directory, and BepInEx plugins directory. +/// Validate that `path` contains the expected SPT server directory structure. pub fn validate_spt_dir(path: &Path) -> Result<()> { for entry in REQUIRED_PATHS { let full = path.join(entry); @@ -134,10 +135,6 @@ mod tests { fn create_fake_spt_dir(base: &Path) -> PathBuf { let spt_root = base.to_path_buf(); - // SPT/SPT.Server.exe - std::fs::create_dir_all(spt_root.join("SPT")).unwrap(); - std::fs::write(spt_root.join("SPT/SPT.Server.exe"), b"").unwrap(); - // SPT/SPT_Data/configs/core.json let configs_dir = spt_root.join("SPT/SPT_Data/configs"); std::fs::create_dir_all(&configs_dir).unwrap(); @@ -147,7 +144,7 @@ mod tests { ) .unwrap(); - // SPT/SPT.Server.deps.json (for version detection) + // SPT/SPT.Server.deps.json (for version detection + validation) std::fs::write( spt_root.join("SPT/SPT.Server.deps.json"), r#"{"libraries":{"SPT.Server/4.0.13-RELEASE+abc123.20260101":{}}}"#, @@ -157,9 +154,6 @@ mod tests { // SPT/user/mods/ std::fs::create_dir_all(spt_root.join("SPT/user/mods")).unwrap(); - // BepInEx/plugins/ - std::fs::create_dir_all(spt_root.join("BepInEx/plugins")).unwrap(); - spt_root } @@ -174,15 +168,15 @@ mod tests { fn validate_rejects_empty_dir() { let tmp = TempDir::new().unwrap(); let err = validate_spt_dir(tmp.path()).unwrap_err(); - assert!(err.to_string().contains("missing SPT/SPT.Server.exe")); + assert!(err.to_string().contains("missing SPT/SPT.Server.deps.json")); } #[test] fn validate_rejects_partial_dir() { let tmp = TempDir::new().unwrap(); - // Only create the exe — remaining markers are absent. + // Only create deps.json — remaining markers are absent. std::fs::create_dir_all(tmp.path().join("SPT")).unwrap(); - std::fs::write(tmp.path().join("SPT/SPT.Server.exe"), b"").unwrap(); + std::fs::write(tmp.path().join("SPT/SPT.Server.deps.json"), b"{}").unwrap(); let err = validate_spt_dir(tmp.path()).unwrap_err(); // Should fail on one of the missing dirs (core.json path). diff --git a/tests/dirs_integration.rs b/tests/dirs_integration.rs index 19edc2de..a2735b2e 100644 --- a/tests/dirs_integration.rs +++ b/tests/dirs_integration.rs @@ -10,8 +10,7 @@ fn new_layout_round_trip() { let dirs = QumaDirs::from_root(root.to_path_buf()); std::fs::create_dir_all(dirs.spt_server.join("SPT/SPT_Data/configs")).unwrap(); std::fs::create_dir_all(dirs.spt_server.join("SPT/user/mods")).unwrap(); - std::fs::create_dir_all(dirs.spt_server.join("BepInEx/plugins")).unwrap(); - std::fs::write(dirs.spt_server.join("SPT/SPT.Server.exe"), "").unwrap(); + std::fs::write(dirs.spt_server.join("SPT/SPT.Server.deps.json"), "{}").unwrap(); std::fs::write(dirs.spt_server.join("SPT/SPT_Data/configs/core.json"), "{}").unwrap(); std::fs::write(dirs.config_path(), "").unwrap(); @@ -28,8 +27,7 @@ fn legacy_layout_detected() { std::fs::create_dir_all(root.join("SPT/SPT_Data/configs")).unwrap(); std::fs::create_dir_all(root.join("SPT/user/mods")).unwrap(); - std::fs::create_dir_all(root.join("BepInEx/plugins")).unwrap(); - std::fs::write(root.join("SPT/SPT.Server.exe"), "").unwrap(); + std::fs::write(root.join("SPT/SPT.Server.deps.json"), "{}").unwrap(); std::fs::write(root.join("SPT/SPT_Data/configs/core.json"), "{}").unwrap(); let detected = QumaDirs::detect(Some(root), None).unwrap();