From f0f563e51586e6cefd874a315f7d2faec7d3b9e5 Mon Sep 17 00:00:00 2001 From: InvalidJoker <0limitdev@gmail.com> Date: Thu, 15 Oct 2026 23:45:57 +0200 Subject: [PATCH 1/4] feat: rework modpack manager to simplify usage --- .../20261015000000_server_modpack_files.sql | 20 + backend/src/db/server/resources.rs | 121 +- backend/src/resources/curseforge.rs | 43 +- backend/src/resources/hangar.rs | 1 + backend/src/resources/mod.rs | 84 ++ backend/src/resources/modpack.rs | 210 ++- backend/src/resources/modrinth.rs | 103 +- backend/src/resources/spigot.rs | 1 + backend/src/service/mod.rs | 6 + backend/src/service/modpack.rs | 1196 +++++++++++++---- backend/src/service/resource.rs | 328 ++++- backend/src/service/server.rs | 124 +- docs/security-open-issues.md | 4 +- frontend/actions/mod-browser.ts | 6 +- .../[server]/components/mods/browse-panel.tsx | 120 +- .../components/mods/content-panel.tsx | 712 ++++++++++ .../components/mods/installed-panel.tsx | 318 ----- .../components/mods/mod-plugin-browser.tsx | 246 ++-- .../components/mods/modpack-browse.tsx | 124 ++ .../[server]/components/mods/modpack-card.tsx | 461 +++++++ .../mods/modpack-install-dialog.tsx | 292 ++-- .../components/mods/modpack-panel.tsx | 602 --------- .../mods/resource-install-dialog.tsx | 31 +- .../[server]/components/mods/search-bar.tsx | 6 +- .../[server]/components/mods/shared.tsx | 13 + .../server/modpack-install-banner.tsx | 62 +- frontend/lib/api/index.ts | 2 +- frontend/lib/api/sdk.gen.ts | 50 +- frontend/lib/api/types.gen.ts | 160 ++- frontend/lib/mod-browser/types.ts | 15 + frontend/locales/de/servers-management.json | 118 +- frontend/locales/en/servers-management.json | 118 +- 32 files changed, 3996 insertions(+), 1701 deletions(-) create mode 100644 backend/migrations/20261015000000_server_modpack_files.sql create mode 100644 frontend/app/(app)/[slug]/servers/[server]/components/mods/content-panel.tsx delete mode 100644 frontend/app/(app)/[slug]/servers/[server]/components/mods/installed-panel.tsx create mode 100644 frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-browse.tsx create mode 100644 frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-card.tsx delete mode 100644 frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-panel.tsx diff --git a/backend/migrations/20261015000000_server_modpack_files.sql b/backend/migrations/20261015000000_server_modpack_files.sql new file mode 100644 index 0000000..12e0922 --- /dev/null +++ b/backend/migrations/20261015000000_server_modpack_files.sql @@ -0,0 +1,20 @@ +CREATE TABLE server_modpack_files ( + server char(6) NOT NULL REFERENCES server_modpacks (server) ON DELETE CASCADE, + path text NOT NULL, + project varchar(128), + version varchar(128), + title varchar(255), + icon_url text, + page_url text, + PRIMARY KEY (server, path) +); + +INSERT INTO server_modpack_files (server, path) +SELECT server, unnest(files) +FROM server_modpacks +ON CONFLICT DO NOTHING; + +ALTER TABLE server_modpacks + DROP COLUMN files, + ADD COLUMN game_version varchar(64), + ADD COLUMN loader_version varchar(64); diff --git a/backend/src/db/server/resources.rs b/backend/src/db/server/resources.rs index 46be847..6219725 100644 --- a/backend/src/db/server/resources.rs +++ b/backend/src/db/server/resources.rs @@ -152,6 +152,16 @@ pub(crate) async fn delete_installed( > 0) } +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub(crate) struct ModpackFileRecord { + pub(crate) path: String, + pub(crate) project: Option, + pub(crate) version: Option, + pub(crate) title: Option, + pub(crate) icon_url: Option, + pub(crate) page_url: Option, +} + #[derive(Debug, Clone)] pub(crate) struct ModpackRecord { pub(crate) provider: ResourceProvider, @@ -162,7 +172,9 @@ pub(crate) struct ModpackRecord { pub(crate) title: String, pub(crate) icon_url: Option, pub(crate) page_url: Option, - pub(crate) files: Vec, + pub(crate) game_version: Option, + pub(crate) loader_version: Option, + pub(crate) files: Vec, pub(crate) installed_at: i64, } @@ -170,41 +182,62 @@ pub(crate) async fn get_modpack( database: &PgPool, server: ServerId, ) -> Result, sqlx::Error> { - Ok(query!( + let Some(record) = query!( r#"SELECT provider, project, version, version_number, loader, title, - icon_url, page_url, files, installed_at + icon_url, page_url, game_version, loader_version, installed_at FROM server_modpacks WHERE server = $1"#, server as ServerId ) .fetch_optional(database) .await? - .and_then(|record| { - Some(ModpackRecord { - provider: ResourceProvider::parse_str(&record.provider)?, - project: record.project, - version: record.version, - version_number: record.version_number, - loader: record.loader.as_deref().and_then(ResourceLoader::parse_str), - title: record.title, - icon_url: record.icon_url, - page_url: record.page_url, - files: record.files, - installed_at: record.installed_at.timestamp(), - }) + else { + return Ok(None); + }; + + let Some(provider) = ResourceProvider::parse_str(&record.provider) else { + return Ok(None); + }; + + let files = query_as!( + ModpackFileRecord, + r#"SELECT path, project, version, title, icon_url, page_url + FROM server_modpack_files + WHERE server = $1 + ORDER BY path"#, + server as ServerId + ) + .fetch_all(database) + .await?; + + Ok(Some(ModpackRecord { + provider, + project: record.project, + version: record.version, + version_number: record.version_number, + loader: record.loader.as_deref().and_then(ResourceLoader::parse_str), + title: record.title, + icon_url: record.icon_url, + page_url: record.page_url, + game_version: record.game_version, + loader_version: record.loader_version, + files, + installed_at: record.installed_at.timestamp(), })) } -pub(crate) async fn upsert_modpack( +pub(crate) async fn replace_modpack( database: &PgPool, server: ServerId, modpack: &ModpackRecord, ) -> Result<(), sqlx::Error> { + let mut tx = database.begin().await?; + query!( r#"INSERT INTO server_modpacks (server, provider, project, version, version_number, loader, title, - icon_url, page_url, files) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + icon_url, page_url, game_version, loader_version) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (server) DO UPDATE SET provider = excluded.provider, project = excluded.project, @@ -214,7 +247,8 @@ pub(crate) async fn upsert_modpack( title = excluded.title, icon_url = excluded.icon_url, page_url = excluded.page_url, - files = excluded.files, + game_version = excluded.game_version, + loader_version = excluded.loader_version, installed_at = now()"#, server as ServerId, modpack.provider.as_str(), @@ -225,12 +259,53 @@ pub(crate) async fn upsert_modpack( modpack.title, modpack.icon_url, modpack.page_url, - &modpack.files + modpack.game_version, + modpack.loader_version ) - .execute(database) + .execute(&mut *tx) .await?; - Ok(()) + query!( + "DELETE FROM server_modpack_files WHERE server = $1", + server as ServerId + ) + .execute(&mut *tx) + .await?; + + let mut paths = Vec::with_capacity(modpack.files.len()); + let mut projects = Vec::with_capacity(modpack.files.len()); + let mut versions = Vec::with_capacity(modpack.files.len()); + let mut titles = Vec::with_capacity(modpack.files.len()); + let mut icons = Vec::with_capacity(modpack.files.len()); + let mut pages = Vec::with_capacity(modpack.files.len()); + for file in &modpack.files { + paths.push(file.path.clone()); + projects.push(file.project.clone()); + versions.push(file.version.clone()); + titles.push(file.title.clone()); + icons.push(file.icon_url.clone()); + pages.push(file.page_url.clone()); + } + + query!( + r#"INSERT INTO server_modpack_files + (server, path, project, version, title, icon_url, page_url) + SELECT $1, * + FROM UNNEST($2::text[], $3::varchar[], $4::varchar[], $5::varchar[], + $6::text[], $7::text[]) + ON CONFLICT DO NOTHING"#, + server as ServerId, + &paths, + &projects as &[Option], + &versions as &[Option], + &titles as &[Option], + &icons as &[Option], + &pages as &[Option] + ) + .execute(&mut *tx) + .await?; + + tx.commit().await } pub(crate) async fn delete_modpack(database: &PgPool, server: ServerId) -> Result<(), sqlx::Error> { diff --git a/backend/src/resources/curseforge.rs b/backend/src/resources/curseforge.rs index 49fe9e4..0f392b1 100644 --- a/backend/src/resources/curseforge.rs +++ b/backend/src/resources/curseforge.rs @@ -2,9 +2,9 @@ use reqwest::{Client, RequestBuilder}; use serde::Deserialize; use super::{ - ResolvedResource, ResourceError, ResourceKind, ResourceLoader, ResourceProject, + ProjectDetails, ResolvedResource, ResourceError, ResourceKind, ResourceLoader, ResourceProject, ResourceProvider, ResourceQuery, ResourceVersion, ResourceVersionType, encode_segment, - fetch_json, sanitize_file_name, + fetch_json, safe_page_url, sanitize_file_name, }; const API: &str = "https://api.curseforge.com/v1"; @@ -134,6 +134,12 @@ fn count(value: f64) -> u64 { } } +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ModLookup { + mod_ids: Vec, +} + const fn version_type(release_type: u8) -> ResourceVersionType { match release_type { 2 => ResourceVersionType::Beta, @@ -215,6 +221,7 @@ fn to_version(file: File) -> ResourceVersion { .collect(), downloads: count(file.download_count), published_at: file.file_date, + changelog: None, file_name: Some(file.file_name), downloadable: file.download_url.is_some(), } @@ -325,3 +332,35 @@ pub(super) async fn resolve( file_name: sanitize_file_name(&file.file_name, "curseforge-resource.jar"), }) } + +pub(super) async fn projects( + client: &Client, + api_key: Option<&str>, + ids: &[String], +) -> Result, ResourceError> { + let mod_ids = ids + .iter() + .filter_map(|id| id.parse::().ok()) + .collect::>(); + if mod_ids.is_empty() { + return Ok(Vec::new()); + } + + let response: Paged = fetch_json( + PROVIDER, + authorized(client.post(format!("{API}/mods")), api_key)?.json(&ModLookup { mod_ids }), + ) + .await?; + + Ok(response + .data + .into_iter() + .map(|entry| ProjectDetails { + provider: PROVIDER, + id: entry.id.to_string(), + title: entry.name, + icon_url: safe_page_url(entry.logo.and_then(|logo| logo.url)), + page_url: safe_page_url(entry.links.and_then(|links| links.website_url)), + }) + .collect()) +} diff --git a/backend/src/resources/hangar.rs b/backend/src/resources/hangar.rs index 7ac289e..db6e631 100644 --- a/backend/src/resources/hangar.rs +++ b/backend/src/resources/hangar.rs @@ -148,6 +148,7 @@ fn to_version(project: &str, version: Version) -> ResourceVersion { .map(|stats| stats.total_downloads) .unwrap_or_default(), published_at: version.created_at, + changelog: None, file_name, downloadable: download.is_some_and(|download| { download.download_url.is_some() || download.external_url.is_some() diff --git a/backend/src/resources/mod.rs b/backend/src/resources/mod.rs index b2bccee..51d3e36 100644 --- a/backend/src/resources/mod.rs +++ b/backend/src/resources/mod.rs @@ -11,6 +11,7 @@ use redis::aio::ConnectionManager; use reqwest::{Client, RequestBuilder, StatusCode, Url}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use tracing::{debug, error}; use crate::service::{ @@ -25,6 +26,8 @@ use crate::service::{ const SEARCH_TTL: u64 = 300; const VERSIONS_TTL: u64 = 300; const RESOLVE_TTL: u64 = 900; +const DETAILS_TTL: u64 = 3600; +const LOOKUP_CHUNK: usize = 100; pub(crate) const MAX_SEARCH_LIMIT: u8 = 50; pub(crate) const MAX_QUERY_LENGTH: usize = 120; @@ -218,10 +221,27 @@ pub(crate) struct ResourceVersion { pub(crate) loaders: Vec, pub(crate) downloads: u64, pub(crate) published_at: Option, + #[serde(default)] + pub(crate) changelog: Option, pub(crate) file_name: Option, pub(crate) downloadable: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub(crate) struct ProjectDetails { + pub(crate) provider: ResourceProvider, + pub(crate) id: String, + pub(crate) title: String, + pub(crate) icon_url: Option, + pub(crate) page_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ResourceDependency { + pub(crate) project: Option, + pub(crate) version: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct ResolvedResource { pub(crate) url: Url, @@ -542,4 +562,68 @@ impl ResourceClient<'_> { cache::cached(&mut self.cache, &key, RESOLVE_TTL, fetch).await } + + pub(crate) async fn dependencies( + &mut self, + provider: ResourceProvider, + version: &str, + ) -> Result, ResourceError> { + if provider != ResourceProvider::Modrinth { + return Ok(Vec::new()); + } + validate_id(version, 128)?; + + let key = format!("dependencies:{}:{version}", provider.as_str()); + let http = self.http; + cache::cached( + &mut self.cache, + &key, + RESOLVE_TTL, + modrinth::dependencies(http, version), + ) + .await + } + + pub(crate) async fn identify_modrinth( + &self, + sha1: &[String], + ) -> Result, ResourceError> { + let mut found = HashMap::new(); + for chunk in sha1.chunks(LOOKUP_CHUNK) { + found.extend(modrinth::identify(self.http, chunk).await?); + } + Ok(found) + } + + pub(crate) async fn projects( + &mut self, + provider: ResourceProvider, + ids: &[String], + ) -> Result, ResourceError> { + if !self.enabled(provider) { + return Err(ResourceError::ProviderDisabled); + } + + let mut details = Vec::new(); + for chunk in ids.chunks(LOOKUP_CHUNK) { + for id in chunk { + validate_id(id, 128)?; + } + let mut sorted = chunk.to_vec(); + sorted.sort_unstable(); + let key = format!("projects:{}:{}", provider.as_str(), sorted.join(",")); + let (http, api_key) = (self.http, self.curseforge_api_key); + let fetch = async move { + match provider { + ResourceProvider::Modrinth => modrinth::projects(http, &sorted).await, + ResourceProvider::Curseforge => { + curseforge::projects(http, api_key, &sorted).await + } + ResourceProvider::Hangar | ResourceProvider::Spigot => Ok(Vec::new()), + } + }; + details.extend(cache::cached(&mut self.cache, &key, DETAILS_TTL, fetch).await?); + } + Ok(details) + } } diff --git a/backend/src/resources/modpack.rs b/backend/src/resources/modpack.rs index bbf80f0..73fc12e 100644 --- a/backend/src/resources/modpack.rs +++ b/backend/src/resources/modpack.rs @@ -1,4 +1,6 @@ -use serde::Deserialize; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use super::{ResourceError, sanitize_file_name}; @@ -8,18 +10,73 @@ pub(crate) const SERVER_OVERRIDES_DIR: &str = "server-overrides"; pub(crate) const MODRINTH_INDEX: &str = "modrinth.index.json"; pub(crate) const CURSEFORGE_MANIFEST: &str = "manifest.json"; +const MODRINTH_DOWNLOAD_HOSTS: [&str; 4] = [ + "cdn.modrinth.com", + "github.com", + "raw.githubusercontent.com", + "gitlab.com", +]; + +const MAX_PLATFORM_VERSION: usize = 64; + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PackLoader { + Fabric, + Quilt, + Neoforge, + Forge, +} + +impl PackLoader { + pub(crate) fn parse_str(value: &str) -> Option { + match value { + "fabric" => Some(Self::Fabric), + "quilt" => Some(Self::Quilt), + "neoforge" => Some(Self::Neoforge), + "forge" => Some(Self::Forge), + _ => None, + } + } + + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Fabric => "Fabric", + Self::Quilt => "Quilt", + Self::Neoforge => "NeoForge", + Self::Forge => "Forge", + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub(crate) struct PackPlatform { + pub(crate) game_version: Option, + pub(crate) loader: Option, + pub(crate) loader_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PackSource { + Url(Url), + Curseforge { project: String, file: String }, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ModpackFile { pub(crate) path: String, - pub(crate) url: Option, - pub(crate) curseforge: Option<(String, String)>, + pub(crate) source: PackSource, + pub(crate) size: Option, + pub(crate) sha1: Option, } #[derive(Debug, Clone)] pub(crate) struct Modpack { pub(crate) name: Option, pub(crate) version: Option, + pub(crate) platform: PackPlatform, pub(crate) files: Vec, + pub(crate) rejected: Vec, } #[derive(Debug, Deserialize)] @@ -29,13 +86,19 @@ struct ModrinthIndex { version_id: Option, #[serde(default)] files: Vec, + #[serde(default)] + dependencies: HashMap, } #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] struct ModrinthFile { path: String, #[serde(default)] downloads: Vec, + #[serde(default)] + hashes: HashMap, + file_size: Option, env: Option, } @@ -49,10 +112,26 @@ struct ModrinthEnv { struct CurseforgeManifest { name: Option, version: Option, + minecraft: Option, #[serde(default)] files: Vec, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CurseforgeMinecraft { + version: Option, + #[serde(default)] + mod_loaders: Vec, +} + +#[derive(Debug, Deserialize)] +struct CurseforgeLoader { + id: String, + #[serde(default)] + primary: bool, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CurseforgeFile { @@ -91,37 +170,124 @@ pub(crate) fn safe_pack_path(path: &str) -> Option { Some(segments.join("/")) } +fn short_version(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() + && value.len() <= MAX_PLATFORM_VERSION + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '+' | '_'))) + .then(|| value.to_owned()) +} + +pub(crate) fn allowed_download(raw: &str) -> Option { + let url = Url::parse(raw).ok()?; + (url.scheme() == "https" + && url.port().is_none() + && url.username().is_empty() + && url + .host_str() + .is_some_and(|host| MODRINTH_DOWNLOAD_HOSTS.contains(&host))) + .then_some(url) +} + fn wanted_on_server(env: Option<&ModrinthEnv>) -> bool { env.and_then(|env| env.server.as_deref()) .is_none_or(|server| server != "unsupported") } +fn modrinth_platform(dependencies: &HashMap) -> PackPlatform { + let loader = [ + ("fabric-loader", PackLoader::Fabric), + ("quilt-loader", PackLoader::Quilt), + ("neoforge", PackLoader::Neoforge), + ("forge", PackLoader::Forge), + ] + .into_iter() + .find_map(|(key, loader)| { + dependencies + .get(key) + .map(|version| (loader, short_version(version))) + }); + + let (loader, loader_version) = + loader.map_or((None, None), |(loader, version)| (Some(loader), version)); + + PackPlatform { + game_version: dependencies + .get("minecraft") + .and_then(|version| short_version(version)), + loader, + loader_version, + } +} + pub(crate) fn parse_modrinth_index(raw: &[u8]) -> Result { let index: ModrinthIndex = serde_json::from_slice(raw).map_err(|_err| ResourceError::ProviderError)?; - let files = index - .files - .into_iter() - .filter(|file| wanted_on_server(file.env.as_ref())) - .filter_map(|file| { - let path = safe_pack_path(&file.path)?; - let url = file.downloads.into_iter().next()?; - Some(ModpackFile { - path, - url: Some(url), - curseforge: None, - }) - }) - .collect(); + let mut files = Vec::new(); + let mut rejected = Vec::new(); + for file in index.files { + if !wanted_on_server(file.env.as_ref()) { + continue; + } + let Some(path) = safe_pack_path(&file.path) else { + rejected.push(file.path); + continue; + }; + let Some(url) = file + .downloads + .iter() + .find_map(|download| allowed_download(download)) + else { + rejected.push(path); + continue; + }; + + files.push(ModpackFile { + path, + source: PackSource::Url(url), + size: file.file_size, + sha1: file + .hashes + .get("sha1") + .filter(|hash| hash.len() == 40 && hash.chars().all(|ch| ch.is_ascii_hexdigit())) + .map(|hash| hash.to_lowercase()), + }); + } Ok(Modpack { name: index.name, version: index.version_id, + platform: modrinth_platform(&index.dependencies), files, + rejected, }) } +fn curseforge_platform(minecraft: Option) -> PackPlatform { + let Some(minecraft) = minecraft else { + return PackPlatform::default(); + }; + + let primary = minecraft + .mod_loaders + .iter() + .find(|loader| loader.primary) + .or_else(|| minecraft.mod_loaders.first()); + let loader = primary.and_then(|loader| { + let (name, version) = loader.id.split_once('-')?; + Some((PackLoader::parse_str(name)?, short_version(version))) + }); + + PackPlatform { + game_version: minecraft.version.as_deref().and_then(short_version), + loader: loader.as_ref().map(|(loader, _)| *loader), + loader_version: loader.and_then(|(_, version)| version), + } +} + pub(crate) fn parse_curseforge_manifest(raw: &[u8]) -> Result { let manifest: CurseforgeManifest = serde_json::from_slice(raw).map_err(|_err| ResourceError::ProviderError)?; @@ -132,14 +298,20 @@ pub(crate) fn parse_curseforge_manifest(raw: &[u8]) -> Result, + changelog: Option, #[serde(default)] files: Vec, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Debug, Deserialize)] +struct Dependency { + version_id: Option, + project_id: Option, + #[serde(rename = "dependency_type")] + kind: String, +} + +#[derive(Debug, Deserialize)] +struct Project { + id: String, + slug: String, + title: String, + icon_url: Option, + #[serde(rename = "project_type")] + kind: String, +} + +#[derive(Debug, serde::Serialize)] +struct HashLookup<'a> { + hashes: &'a [String], + algorithm: &'static str, } #[derive(Debug, Deserialize, Clone)] @@ -143,6 +171,7 @@ fn to_version(version: Version) -> ResourceVersion { loaders: version.loaders, downloads: version.downloads, published_at: version.date_published, + changelog: version.changelog, file_name: file.as_ref().map(|file| file.filename.clone()), downloadable: file.is_some(), } @@ -256,3 +285,71 @@ pub(super) async fn resolve( file_name: sanitize_file_name(&file.filename, "modrinth-resource.jar"), }) } + +pub(super) async fn dependencies( + client: &Client, + version: &str, +) -> Result, ResourceError> { + let version: Version = fetch_json( + PROVIDER, + client.get(format!("{API}/version/{}", encode_segment(version))), + ) + .await?; + + Ok(version + .dependencies + .into_iter() + .filter(|dependency| dependency.kind == "required") + .filter(|dependency| dependency.project_id.is_some() || dependency.version_id.is_some()) + .map(|dependency| ResourceDependency { + project: dependency.project_id, + version: dependency.version_id, + }) + .collect()) +} + +pub(super) async fn identify( + client: &Client, + sha1: &[String], +) -> Result, ResourceError> { + let found: HashMap = fetch_json( + PROVIDER, + client + .post(format!("{API}/version_files")) + .json(&HashLookup { + hashes: sha1, + algorithm: "sha1", + }), + ) + .await?; + + Ok(found + .into_iter() + .map(|(hash, version)| (hash.to_lowercase(), (version.project_id, version.id))) + .collect()) +} + +pub(super) async fn projects( + client: &Client, + ids: &[String], +) -> Result, ResourceError> { + let ids = serde_json::to_string(ids).map_err(|_err| ResourceError::ProviderError)?; + let projects: Vec = fetch_json( + PROVIDER, + client + .get(format!("{API}/projects")) + .query(&[("ids", ids.as_str())]), + ) + .await?; + + Ok(projects + .into_iter() + .map(|project| ProjectDetails { + provider: PROVIDER, + page_url: safe_page_url(Some(format!("{SITE}/{}/{}", project.kind, project.slug))), + id: project.id, + title: project.title, + icon_url: safe_page_url(project.icon_url.filter(|url| !url.is_empty())), + }) + .collect()) +} diff --git a/backend/src/resources/spigot.rs b/backend/src/resources/spigot.rs index cc84258..8f41b00 100644 --- a/backend/src/resources/spigot.rs +++ b/backend/src/resources/spigot.rs @@ -84,6 +84,7 @@ fn to_version(resource: &Resource, version: &Version) -> ResourceVersion { .release_date .and_then(|seconds| DateTime::from_timestamp(seconds, 0)) .map(|date| date.to_rfc3339()), + changelog: None, file_name: Some(file_name(&resource.name, &version.name)), downloadable: INSTALLABLE, } diff --git a/backend/src/service/mod.rs b/backend/src/service/mod.rs index edefad6..f458c45 100644 --- a/backend/src/service/mod.rs +++ b/backend/src/service/mod.rs @@ -84,6 +84,12 @@ define_error!(resource { not_installed("The resource is not installed on this server" NOT_FOUND), }); +define_error!(modpack { + not_confirmed("Installing a modpack rewrites server files and must be confirmed" BAD_REQUEST), + already_running("A modpack installation is already running for this server" CONFLICT), + server_running("The server must be stopped before a modpack can be installed" BAD_REQUEST), +}); + define_error!(backup { storage_unavailable("Backup storage is currently in use" SERVICE_UNAVAILABLE), not_found("The specified backup was not found" NOT_FOUND), diff --git a/backend/src/service/modpack.rs b/backend/src/service/modpack.rs index 3fbf55a..5ab9ea4 100644 --- a/backend/src/service/modpack.rs +++ b/backend/src/service/modpack.rs @@ -4,45 +4,44 @@ use crate::auth::Authentication; use crate::auth::permissions::ServerPermissions; use crate::cluster::{JOB_LOCK_TTL, JOB_RETENTION}; use crate::db::server::resources::{ - ModpackRecord, delete_installed_files, delete_modpack, get_modpack, upsert_modpack, + ModpackFileRecord, ModpackRecord, delete_installed_files, delete_modpack, get_modpack, + list_installed, replace_modpack, }; use crate::db::server::resync::is_running_label; use crate::db::server::{Server, ServerId}; use crate::resources::modpack::{ - CURSEFORGE_MANIFEST, MODRINTH_INDEX, Modpack, ModpackFile, OVERRIDES_DIR, SERVER_OVERRIDES_DIR, - parse_curseforge_manifest, parse_modrinth_index, safe_pack_path, + CURSEFORGE_MANIFEST, MODRINTH_INDEX, Modpack, ModpackFile, OVERRIDES_DIR, PackLoader, + PackPlatform, PackSource, SERVER_OVERRIDES_DIR, parse_curseforge_manifest, + parse_modrinth_index, safe_pack_path, }; use crate::resources::{ - ResourceError, ResourceKind, ResourceLoader, ResourceProvider, ResourceType, ResourceVersion, - ResourceVersionType, safe_page_url, sanitize_file_name, + ProjectDetails, ResourceError, ResourceKind, ResourceLoader, ResourceProvider, ResourceType, + ResourceVersion, ResourceVersionType, safe_page_url, sanitize_file_name, }; use crate::service::modpack::modpack_service_error::{ InstallModpackError, ModpackStatusError, ModpackVersionsError, UnlinkModpackError, }; +use crate::service::server::BuildPushError; use crate::templates::Template as _; -use crate::templates::games::minecraft::MinecraftTemplate; +use crate::templates::games::minecraft::{ + MinecraftServerExtra, MinecraftServerModification, MinecraftTemplate, +}; use crate::utils::FileResponseExtension as _; use daemon_common::service::backup::backup_service_error::ServerCreateBackupOrderError; use daemon_common::service::files::files_service_error::{ - ServerCreateDirectoryError, ServerDownloadFileError, ServerListFilesError, - ServerUnpackArchiveError, ServerWriteFileByUrlError, + ServerCreateDirectoryError, ServerDeleteFileError, ServerDownloadFileError, + ServerListFilesError, ServerUnpackArchiveError, ServerWriteFileByUrlError, }; +use futures::StreamExt as _; +use futures::stream::FuturesUnordered; use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; use tokio::io::AsyncReadExt as _; use tracing::{error, info, warn}; -use zelus::error::define_error; use zelus::{define_query_variable, service}; define_query_variable!(allow_prerelease "Whether beta/alpha pack releases count as an update"); -define_error!(modpack { - not_confirmed("Installing a modpack rewrites server files and must be confirmed" BAD_REQUEST), - already_running("A modpack installation is already running for this server" BAD_REQUEST), - manifest_missing("The pack archive contains no usable manifest" BAD_GATEWAY), - backup_unavailable("No backup could be created, so the install was not started" BAD_REQUEST), - server_running("The server must be stopped before a modpack can be installed" BAD_REQUEST), -}); - const STAGING_DIR: &str = ".novium-modpack"; const MAX_MANIFEST_BYTES: u64 = 0x0080_0000; @@ -51,8 +50,20 @@ const MAX_PACK_TITLE: usize = 255; const MAX_PACK_VERSION_NUMBER: usize = 128; +const MAX_REPORTED_PATHS: usize = 50; + const CURSEFORGE_FILE_DIR: &str = "mods"; +const DOWNLOAD_ATTEMPTS: u32 = 3; + +const PARALLEL_DOWNLOADS: usize = 6; + +const DISABLED_SUFFIX: &str = ".disabled"; + +const FILE_TYPE_MASK: u32 = 0xF000; + +const DIRECTORY_MODE: u32 = 0x4000; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub(crate) enum ModpackJobState { @@ -61,35 +72,66 @@ pub(crate) enum ModpackJobState { Failed, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ModpackStep { + Queued, + Downloading, + Unpacking, + Reading, + Resolving, + Fetching, + BackingUp, + Platform, + Applying, + Configuring, + Finishing, + Done, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub(crate) struct ModpackJob { pub(crate) state: ModpackJobState, - pub(crate) step: String, + pub(crate) step: ModpackStep, pub(crate) done: u32, pub(crate) total: u32, pub(crate) failures: Vec, + pub(crate) skipped: Vec, pub(crate) error: Option, pub(crate) pack: Option, + pub(crate) platform: Option, + pub(crate) live_untouched: bool, pub(crate) started_at: i64, pub(crate) finished_at: Option, } impl ModpackJob { - fn new(step: &str) -> Self { + fn new(pack: Option) -> Self { Self { state: ModpackJobState::Running, - step: step.to_owned(), + step: ModpackStep::Queued, done: 0, total: 0, failures: Vec::new(), + skipped: Vec::new(), error: None, - pack: None, + pack, + platform: None, + live_untouched: true, started_at: chrono::Utc::now().timestamp(), finished_at: None, } } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ModpackInstallMode { + #[default] + Keep, + Reset, +} + #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct InstallModpackPayload { pub(crate) provider: ResourceProvider, @@ -107,7 +149,9 @@ pub(crate) struct InstallModpackPayload { #[serde(default)] pub(crate) backup: bool, #[serde(default)] - pub(crate) wipe: bool, + pub(crate) mode: ModpackInstallMode, + #[serde(default)] + pub(crate) sync_platform: bool, #[serde(default)] pub(crate) confirm: bool, } @@ -124,12 +168,88 @@ pub(crate) struct ModpackVersions { pub(crate) versions: Vec, } +pub(crate) fn job_lock(server: ServerId) -> String { + format!("modpack:lock:{server}") +} + fn job_key(server: ServerId) -> String { format!("modpack:job:{server}") } -fn job_lock(server: ServerId) -> String { - format!("modpack:lock:{server}") +fn staged_path(path: &str) -> String { + format!("{STAGING_DIR}/files/{path}") +} + +fn extracted_dir() -> String { + format!("{STAGING_DIR}/extracted") +} + +fn disabled_name(path: &str) -> String { + format!("{path}{DISABLED_SUFFIX}") +} + +fn parent_dir(path: &str) -> Option<&str> { + path.rsplit_once('/').map(|(parent, _name)| parent) +} + +fn report(paths: &[String]) -> String { + let mut listed: Vec<&str> = paths + .iter() + .take(MAX_REPORTED_PATHS) + .map(String::as_str) + .collect(); + if paths.len() > MAX_REPORTED_PATHS { + listed.push("..."); + } + listed.join(", ") +} + +struct PlatformPlan { + target: PackPlatform, + new_extra: Option, +} + +const fn software_for(loader: PackLoader) -> Option { + match loader { + PackLoader::Fabric => Some(MinecraftServerModification::Fabric), + PackLoader::Quilt => Some(MinecraftServerModification::Quilt), + PackLoader::Neoforge => Some(MinecraftServerModification::NeoForge), + PackLoader::Forge => None, + } +} + +fn describe(software: MinecraftServerModification, game: &str, build: Option<&str>) -> String { + build.map_or_else( + || format!("{software} {game}"), + |build| format!("{software} {build} on Minecraft {game}"), + ) +} + +#[derive(Debug)] +enum InstallFailure { + Reason(String), + Quota, +} + +impl From<&str> for InstallFailure { + fn from(value: &str) -> Self { + Self::Reason(value.to_owned()) + } +} + +impl From for InstallFailure { + fn from(value: String) -> Self { + Self::Reason(value) + } +} + +impl InstallFailure { + fn reason(self) -> String { + match self { + Self::Reason(reason) => reason, + Self::Quota => "The pack does not fit in the server's storage quota".to_owned(), + } + } } impl Backend { @@ -148,6 +268,10 @@ impl Backend { } } + async fn set_step(&self, server: ServerId, step: ModpackStep) { + self.update_job(server, |job| job.step = step).await; + } + async fn modpack_job(&self, server: ServerId) -> Result, redis::RedisError> { let Some(mut job) = self.load_shared::(&job_key(server)).await? else { return Ok(None); @@ -161,14 +285,8 @@ impl Backend { Ok(Some(job)) } - async fn fail_job(&self, server: ServerId, reason: &str) { - error!("Modpack install failed on server {server}: {reason}"); - self.update_job(server, |job| { - job.state = ModpackJobState::Failed; - job.error = Some(reason.to_owned()); - job.finished_at = Some(chrono::Utc::now().timestamp()); - }) - .await; + pub(crate) async fn modpack_installing(&self, server: ServerId) -> bool { + self.is_locked(&job_lock(server)).await } async fn read_server_file( @@ -193,53 +311,56 @@ impl Backend { Ok(buffer) } - async fn wipe_mods(&self, server: &Server) -> Result { - let entries = match server + async fn ensure_dir(&self, server: &Server, path: &str) -> Result<(), InstallFailure> { + match server .node - .server_list_files( - server.node_server_id, - ResourceType::Modification.directory(), - ) + .server_create_directory(server.node_server_id, path.to_owned(), true) .await { - Ok(entries) => entries, - Err(ServerListFilesError::FileNotFound) => return Ok(0), - Err(err) => return Err(err), - }; - - let mut removed = Vec::new(); - for entry in entries { - if !super::resource::is_resource_file(ResourceType::Modification, &entry.name) { - continue; + Ok(()) | Err(ServerCreateDirectoryError::FileAlreadyExists) => Ok(()), + Err(ServerCreateDirectoryError::StorageQuotaReached) => Err(InstallFailure::Quota), + Err(err) => { + warn!("Failed to create {path} for a modpack: {err:?}"); + Err(InstallFailure::from(format!( + "The directory {path} could not be created" + ))) } + } + } - let path = format!("{}{}", ResourceType::Modification.path_prefix(), entry.name); - match server - .node - .server_delete_file(server.node_server_id, path.clone(), false) - .await - { - Ok(()) => removed.push(entry.name), - Err(err) => warn!("Failed to remove {path} while wiping mods: {err:?}"), + async fn remove_path(&self, server: &Server, path: &str, recursive: bool) -> bool { + match server + .node + .server_delete_file(server.node_server_id, path.to_owned(), recursive) + .await + { + Ok(()) | Err(ServerDeleteFileError::FileNotFound) => true, + Err(err) => { + warn!("Failed to remove {path} during a modpack install: {err:?}"); + false } } + } - let bases: Vec = removed - .iter() - .map(|name| super::resource::base_file_name(name)) - .collect(); - if !bases.is_empty() - && let Err(err) = delete_installed_files(&self.database, server.id, &bases).await + async fn list_names(&self, server: &Server, dir: &str) -> Option> { + match server + .node + .server_list_files(server.node_server_id, dir.to_owned()) + .await { - warn!("Failed to forget wiped mod records: {err:?}"); + Ok(entries) => Some(entries.into_iter().map(|entry| entry.name).collect()), + Err(ServerListFilesError::FileNotFound) => Some(HashSet::new()), + Err(err) => { + warn!("Failed to list {dir} during a modpack install: {err:?}"); + None + } } - - Ok(u32::try_from(removed.len()).unwrap_or(u32::MAX)) } - async fn apply_overrides(&self, server: &Server, root: &str) -> u32 { + async fn apply_overrides(&self, server: &Server, root: &str) -> (u32, Vec) { let mut queue = vec![String::new()]; let mut copied = 0u32; + let mut failed = Vec::new(); while let Some(relative) = queue.pop() { let source_dir = if relative.is_empty() { @@ -257,6 +378,7 @@ impl Backend { Err(ServerListFilesError::FileNotFound) => continue, Err(err) => { warn!("Failed to list overrides at {source_dir}: {err:?}"); + failed.push(relative.clone()); continue; } }; @@ -276,14 +398,9 @@ impl Backend { }; let from = format!("{source_dir}/{}", entry.name); - if entry.mode & 0xF000 == 0x4000 { - if let Err(err) = server - .node - .server_create_directory(server.node_server_id, child.clone(), true) - .await - && !matches!(err, ServerCreateDirectoryError::FileAlreadyExists) - { - warn!("Failed to create override directory {child}: {err:?}"); + if entry.mode & FILE_TYPE_MASK == DIRECTORY_MODE { + if self.ensure_dir(server, &child).await.is_err() { + failed.push(child); continue; } queue.push(child); @@ -293,47 +410,72 @@ impl Backend { .server_copy_file(server.node_server_id, from.clone(), child.clone()) .await { - Ok(()) => copied += 1, - Err(err) => warn!("Failed to copy override {from} -> {child}: {err:?}"), + Ok(()) => copied = copied.saturating_add(1), + Err(err) => { + warn!("Failed to copy override {from} -> {child}: {err:?}"); + failed.push(child); + } } } } } - copied + (copied, failed) } - async fn resolve_curseforge_files(&self, files: Vec) -> Vec { + async fn resolve_curseforge_files( + &self, + files: Vec, + identities: &mut HashMap, + skipped: &mut Vec, + ) -> Result, InstallFailure> { let mut resolved = Vec::with_capacity(files.len()); + let mut unresolved = Vec::new(); for file in files { - let Some((project, version)) = file.curseforge.clone() else { + let PackSource::Curseforge { + project, + file: version, + } = &file.source + else { resolved.push(file); continue; }; match self .resource_client() - .resolve(ResourceProvider::Curseforge, &project, &version) + .resolve(ResourceProvider::Curseforge, project, version) .await { - Ok(entry) => resolved.push(ModpackFile { - path: format!("{CURSEFORGE_FILE_DIR}/{}", entry.file_name), - url: Some(entry.url.to_string()), - curseforge: None, - }), - Err(err) => { - warn!("Failed to resolve CurseForge pack file {project}/{version}: {err:?}"); + Ok(entry) => { + let path = format!("{CURSEFORGE_FILE_DIR}/{}", entry.file_name); + identities.insert(path.clone(), (project.clone(), version.clone())); resolved.push(ModpackFile { - path: format!("curseforge:{project}"), - url: None, - curseforge: None, + path, + source: PackSource::Url(entry.url), + size: None, + sha1: None, }); } + Err(ResourceError::DownloadUnavailable) => { + skipped.push(format!("CurseForge project {project}")); + } + Err(err) => { + warn!("Failed to resolve CurseForge pack file {project}/{version}: {err:?}"); + unresolved.push(format!("CurseForge project {project}")); + } } } - resolved + if unresolved.is_empty() { + Ok(resolved) + } else { + Err(InstallFailure::from(format!( + "CurseForge could not resolve {} pack file(s): {}", + unresolved.len(), + report(&unresolved) + ))) + } } async fn read_pack_manifest( @@ -358,207 +500,713 @@ impl Backend { parse_curseforge_manifest(&curseforge) } - async fn run_modpack_install( + async fn plan_platform( &self, - server: Server, - payload: InstallModpackPayload, - pack_file: String, - pack_url: url::Url, - ) { + server: &Server, + target: PackPlatform, + sync: bool, + ) -> Result { + let current: MinecraftServerExtra = serde_json::from_value(server.template_extra.clone()) + .map_err(|err| { + error!("Failed to read the server's Minecraft settings: {err:?}"); + InstallFailure::from("The server's Minecraft settings could not be read") + })?; + + let Some(loader) = target.loader else { + return Ok(PlatformPlan { + target, + new_extra: None, + }); + }; + let Some(software) = software_for(loader) else { + return Err(InstallFailure::from(format!( + "This pack needs {}, which this server cannot run", + loader.label() + ))); + }; + + let current_software = current.modification(); + let current_game = current.minecraft_version(); + let current_build = current.mod_build(); + let game = target + .game_version + .clone() + .unwrap_or_else(|| current_game.clone()); + + let software_differs = current_software != software || current_game != game; + let build_differs = target + .loader_version + .as_ref() + .is_some_and(|build| current_build.as_ref() != Some(build)); + + if !software_differs && !build_differs { + return Ok(PlatformPlan { + target, + new_extra: None, + }); + } + + if !sync { + if software_differs { + return Err(InstallFailure::from(format!( + "This pack needs {}, but the server runs {}. Allow the install to switch the platform, or change it in the build settings first", + describe(software, &game, target.loader_version.as_deref()), + describe(current_software, ¤t_game, current_build.as_deref()) + ))); + } + warn!( + "Pack wants loader {:?} but server {} keeps {current_build:?}", + target.loader_version, server.id + ); + return Ok(PlatformPlan { + target, + new_extra: None, + }); + } + + let build = target + .loader_version + .clone() + .or_else(|| (!software_differs).then(|| current_build.clone()).flatten()) + .ok_or_else(|| { + InstallFailure::from(format!( + "The pack does not say which {} version it needs", + loader.label() + )) + })?; + + let java = if current.java_pinned() == Some(true) && current_game == game { + format!("Java {}", current.java()) + } else { + "Recommended".to_owned() + }; + + let variables = HashMap::from([ + ("software".to_owned(), software.to_string()), + ("minecraft_version".to_owned(), game.clone()), + ("mod_build".to_owned(), build.clone()), + ("java".to_owned(), java), + ]); + + let new_extra = server + .template + .variables_to_extra_validated( + self.cache.clone(), + server.template_extra.clone(), + variables, + ) + .await + .map_err(|err| { + error!("Failed to validate the pack's platform: {err:?}"); + InstallFailure::from("The pack's platform could not be checked") + })? + .ok_or_else(|| { + InstallFailure::from(format!( + "{} is not available on this panel", + describe(software, &game, Some(&build)) + )) + })?; + + Ok(PlatformPlan { + target: PackPlatform { + game_version: Some(game), + loader: Some(loader), + loader_version: Some(build), + }, + new_extra: Some(new_extra), + }) + } + + async fn stage_file(&self, server: &Server, file: &ModpackFile) -> Result<(), InstallFailure> { + let PackSource::Url(url) = &file.source else { + return Err(InstallFailure::from("unresolved")); + }; + let target = staged_path(&file.path); + if let Some(parent) = parent_dir(&target) { + self.ensure_dir(server, parent).await?; + } + + let mut last = InstallFailure::from("download failed"); + for attempt in 1..=DOWNLOAD_ATTEMPTS { + match server + .node + .server_write_file_by_url(server.node_server_id, target.clone(), url.clone()) + .await + { + Ok(()) => {} + Err(ServerWriteFileByUrlError::StorageQuotaReached) => { + return Err(InstallFailure::Quota); + } + Err(err) => { + warn!( + "Attempt {attempt} to download {} failed: {err:?}", + file.path + ); + last = InstallFailure::from("download failed"); + continue; + } + } + + let Some(expected) = file.size else { + return Ok(()); + }; + match server + .node + .server_stat_file(server.node_server_id, target.clone()) + .await + { + Ok(entry) if entry.size == expected => return Ok(()), + Ok(entry) => { + warn!( + "Attempt {attempt}: {} has {} bytes, expected {expected}", + file.path, entry.size + ); + last = InstallFailure::from("size mismatch"); + } + Err(err) => { + warn!("Attempt {attempt}: could not check {}: {err:?}", file.path); + last = InstallFailure::from("could not be verified"); + } + } + } + + Err(last) + } + + async fn stage_entry( + &self, + server: &Server, + file: &ModpackFile, + ) -> (String, Result<(), InstallFailure>) { + (file.path.clone(), self.stage_file(server, file).await) + } + + async fn stage_files( + &self, + server: &Server, + files: &[ModpackFile], + ) -> Result<(), InstallFailure> { let server_id = server.id; - let staging = STAGING_DIR.to_owned(); - let archive = format!("{staging}/{pack_file}"); - let extracted = format!("{staging}/extracted"); + let mut pending = files.iter(); + let mut running = FuturesUnordered::new(); + for file in pending.by_ref().take(PARALLEL_DOWNLOADS) { + running.push(self.stage_entry(server, file)); + } - if payload.backup { + let mut failures = Vec::new(); + while let Some((path, outcome)) = running.next().await { + if let Some(file) = pending.next() { + running.push(self.stage_entry(server, file)); + } + + let failed = match outcome { + Ok(()) => None, + Err(InstallFailure::Quota) => return Err(InstallFailure::Quota), + Err(InstallFailure::Reason(reason)) => Some(format!("{path} ({reason})")), + }; + if let Some(entry) = &failed { + failures.push(entry.clone()); + } self.update_job(server_id, |job| { - "Creating backup".clone_into(&mut job.step); + job.done = job.done.saturating_add(1); + if let Some(entry) = failed { + job.failures.push(entry); + } }) .await; + } - if let Err(err) = server - .node - .server_create_backup_order(server.node_server_id, None) - .await - { - let reason = match err { - ServerCreateBackupOrderError::BackupStorageNoSuitableFound => { - "No backup storage is available on this node" + if failures.is_empty() { + Ok(()) + } else { + Err(InstallFailure::from(format!( + "{} pack file(s) could not be downloaded, so nothing on the server was changed", + failures.len() + ))) + } + } + + async fn pack_identities( + &self, + provider: ResourceProvider, + files: &[ModpackFile], + mut identities: HashMap, + ) -> Vec { + if provider == ResourceProvider::Modrinth { + let hashes: Vec = files.iter().filter_map(|file| file.sha1.clone()).collect(); + if !hashes.is_empty() { + match self.resource_client().identify_modrinth(&hashes).await { + Ok(found) => { + for file in files { + if let Some(identity) = + file.sha1.as_ref().and_then(|hash| found.get(hash)) + { + identities.insert(file.path.clone(), identity.clone()); + } + } } - _ => "The backup could not be created", - }; - error!("Modpack backup failed for {server_id}: {err:?}"); - self.fail_job(server_id, reason).await; - return; + Err(err) => warn!("Failed to identify pack files on Modrinth: {err:?}"), + } } } - self.update_job(server_id, |job| { - "Downloading pack".clone_into(&mut job.step); - }) - .await; + let mut projects: Vec = identities + .values() + .map(|(project, _version)| project.clone()) + .collect::>() + .into_iter() + .collect(); + projects.sort_unstable(); + + let details: HashMap = if projects.is_empty() { + HashMap::new() + } else { + match self.resource_client().projects(provider, &projects).await { + Ok(details) => details + .into_iter() + .map(|detail| (detail.id.clone(), detail)) + .collect(), + Err(err) => { + warn!("Failed to load pack project details: {err:?}"); + HashMap::new() + } + } + }; - if let Err(err) = server + files + .iter() + .map(|file| { + let identity = identities.get(&file.path); + let detail = identity.and_then(|(project, _version)| details.get(project)); + ModpackFileRecord { + path: file.path.clone(), + project: identity.map(|(project, _version)| project.clone()), + version: identity.map(|(_project, version)| version.clone()), + title: detail.map(|detail| detail.title.chars().take(MAX_PACK_TITLE).collect()), + icon_url: detail.and_then(|detail| detail.icon_url.clone()), + page_url: detail.and_then(|detail| detail.page_url.clone()), + } + }) + .collect() + } + + async fn server_running(&self, server: &Server) -> bool { + server .node - .server_create_directory(server.node_server_id, staging.clone(), true) + .server_status(server.node_server_id) .await - && !matches!(err, ServerCreateDirectoryError::FileAlreadyExists) - { - error!("Failed to create modpack staging directory: {err:?}"); - self.fail_job(server_id, "The staging directory could not be created") - .await; - return; + .is_ok_and(|status| is_running_label(&status.state.label)) + } + + async fn prepare_pack( + &self, + server: &Server, + pack_file: &str, + pack_url: url::Url, + ) -> Result { + let server_id = server.id; + let archive = format!("{STAGING_DIR}/{pack_file}"); + let extracted = extracted_dir(); + + if !self.remove_path(server, STAGING_DIR, true).await { + return Err(InstallFailure::from( + "A previous install's leftovers could not be cleared", + )); } + self.ensure_dir(server, STAGING_DIR).await?; - if let Err(err) = server + self.set_step(server_id, ModpackStep::Downloading).await; + server .node .server_write_file_by_url(server.node_server_id, archive.clone(), pack_url) .await - { - error!("Failed to download modpack archive: {err:?}"); - let reason = match err { - ServerWriteFileByUrlError::StorageQuotaReached => { - "The pack does not fit in the server's storage quota" + .map_err(|err| { + error!("Failed to download modpack archive: {err:?}"); + match err { + ServerWriteFileByUrlError::StorageQuotaReached => InstallFailure::Quota, + _ => InstallFailure::from("The pack archive could not be downloaded"), } - _ => "The pack archive could not be downloaded", - }; - self.fail_job(server_id, reason).await; - self.cleanup_staging(&server, &staging).await; - return; - } - - self.update_job(server_id, |job| { - "Unpacking".clone_into(&mut job.step); - }) - .await; + })?; - if let Err(err) = server + self.set_step(server_id, ModpackStep::Unpacking).await; + server .node - .server_unpack_archive(server.node_server_id, archive.clone(), extracted.clone()) + .server_unpack_archive(server.node_server_id, archive, extracted.clone()) .await - { - error!("Failed to unpack modpack archive: {err:?}"); - let reason = match err { - ServerUnpackArchiveError::CompressionUnsupported => { - "The pack archive format is not supported" - } - ServerUnpackArchiveError::StorageQuotaReached => { - "Unpacking the pack would exceed the storage quota" + .map_err(|err| { + error!("Failed to unpack modpack archive: {err:?}"); + match err { + ServerUnpackArchiveError::CompressionUnsupported => { + InstallFailure::from("The pack archive format is not supported") + } + ServerUnpackArchiveError::StorageQuotaReached => InstallFailure::Quota, + _ => InstallFailure::from("The pack archive could not be unpacked"), } - _ => "The pack archive could not be unpacked", - }; - self.fail_job(server_id, reason).await; - self.cleanup_staging(&server, &staging).await; - return; + })?; + + self.set_step(server_id, ModpackStep::Reading).await; + let pack = self + .read_pack_manifest(server, &extracted) + .await + .map_err(|err| { + error!("Failed to read modpack manifest: {err:?}"); + InstallFailure::from("The pack contains no usable manifest") + })?; + + if !pack.rejected.is_empty() { + return Err(InstallFailure::from(format!( + "The pack lists files with unsafe paths or untrusted download hosts: {}", + report(&pack.rejected) + ))); } - self.update_job(server_id, |job| { - "Reading manifest".clone_into(&mut job.step); - }) - .await; + Ok(pack) + } - let pack = match self.read_pack_manifest(&server, &extracted).await { - Ok(pack) => pack, - Err(err) => { - error!("Failed to read modpack manifest: {err:?}"); - self.fail_job(server_id, "The pack contains no usable manifest") - .await; - self.cleanup_staging(&server, &staging).await; - return; - } - }; + async fn remove_pack_file(&self, server: &Server, path: &str) -> bool { + self.remove_path(server, path, false).await + && self.remove_path(server, &disabled_name(path), false).await + } - let pack_name = pack.name.clone(); - let files = self.resolve_curseforge_files(pack.files.clone()).await; - let total = u32::try_from(files.len()).unwrap_or(u32::MAX); - self.update_job(server_id, |job| { - "Installing mods".clone_into(&mut job.step); - job.total = total; - job.pack = pack_name; - }) - .await; + async fn commit_pack( + &self, + server: &Server, + files: &[ModpackFileRecord], + provider: ResourceProvider, + previous: Option<&ModpackRecord>, + reset: bool, + ) -> Vec { + let mut failures = Vec::new(); + let new_paths: HashSet<&str> = files.iter().map(|file| file.path.as_str()).collect(); + + let mut listings: HashMap>> = HashMap::new(); + let mut dirs: HashSet = files + .iter() + .filter_map(|file| parent_dir(&file.path).map(str::to_owned)) + .collect(); + if let Some(previous) = previous { + dirs.extend( + previous + .files + .iter() + .filter_map(|file| parent_dir(&file.path).map(str::to_owned)), + ); + } + dirs.insert( + ResourceType::Modification + .path_prefix() + .trim_end_matches('/') + .to_owned(), + ); + for dir in dirs { + let names = self.list_names(server, &dir).await; + listings.insert(dir, names); + } + let is_disabled = |path: &str| { + let Some((dir, name)) = path.rsplit_once('/') else { + return false; + }; + listings + .get(dir) + .and_then(Option::as_ref) + .is_some_and(|names| names.contains(&disabled_name(name))) + }; - if payload.wipe { - self.update_job(server_id, |job| { - "Clearing existing mods".clone_into(&mut job.step); + let disabled_projects: HashSet<&str> = previous + .map(|previous| { + previous + .files + .iter() + .filter(|file| is_disabled(&file.path)) + .filter_map(|file| file.project.as_deref()) + .collect() }) - .await; - - if let Err(err) = delete_modpack(&self.database, server_id).await { - warn!("Failed to clear the previous modpack record: {err:?}"); + .unwrap_or_default(); + + if let Some(previous) = previous { + for file in &previous.files { + if !new_paths.contains(file.path.as_str()) + && !self.remove_pack_file(server, &file.path).await + { + failures.push(file.path.clone()); + } } + } - match self.wipe_mods(&server).await { - Ok(removed) => info!("Cleared {removed} mod(s) before installing a pack"), - Err(err) => { - error!("Failed to clear mods before a pack install: {err:?}"); - self.fail_job(server_id, "The existing mods could not be cleared") - .await; - self.cleanup_staging(&server, &staging).await; - return; + let pack_projects: HashSet<&str> = files + .iter() + .filter_map(|file| file.project.as_deref()) + .collect(); + match list_installed(&self.database, server.id).await { + Ok(added) => { + let mut superseded = Vec::new(); + for resource in added { + let path = format!( + "{}{}", + resource.resource_type.path_prefix(), + resource.file_name + ); + if resource.provider != provider + || !pack_projects.contains(resource.project.as_str()) + || new_paths.contains(path.as_str()) + { + continue; + } + if self.remove_pack_file(server, &path).await { + superseded.push(resource.file_name); + } else { + failures.push(path); + } + } + if !superseded.is_empty() + && let Err(err) = + delete_installed_files(&self.database, server.id, &superseded).await + { + warn!("Failed to forget mods the pack now ships: {err:?}"); } } - - self.update_job(server_id, |job| { - "Installing mods".clone_into(&mut job.step); - }) - .await; + Err(err) => warn!("Failed to check added mods against the pack: {err:?}"), } - let mut installed_paths = Vec::new(); + if reset { + let mods_dir = ResourceType::Modification + .path_prefix() + .trim_end_matches('/'); + let names: Vec = listings + .get(mods_dir) + .and_then(Option::as_ref) + .map(|names| names.iter().cloned().collect()) + .unwrap_or_default(); + let mut cleared = Vec::new(); + for name in names { + if !super::resource::is_resource_file(ResourceType::Modification, &name) { + continue; + } + let base = super::resource::base_file_name(&name); + if new_paths.contains(format!("{mods_dir}/{base}").as_str()) { + continue; + } + let path = format!("{mods_dir}/{name}"); + if self.remove_path(server, &path, false).await { + cleared.push(base); + } else { + failures.push(path); + } + } + if !cleared.is_empty() + && let Err(err) = delete_installed_files(&self.database, server.id, &cleared).await + { + warn!("Failed to forget cleared mod records: {err:?}"); + } + } for file in files { - let Some(url) = file.url.as_deref().and_then(|url| url.parse().ok()) else { - self.update_job(server_id, |job| { - job.failures.push(file.path.clone()); - job.done += 1; - }) - .await; - continue; + let disabled = !reset + && (is_disabled(&file.path) + || file + .project + .as_deref() + .is_some_and(|project| disabled_projects.contains(project))); + let destination = if disabled { + disabled_name(&file.path) + } else { + file.path.clone() }; - if let Some((parent, _)) = file.path.rsplit_once('/') - && let Err(err) = server - .node - .server_create_directory(server.node_server_id, parent.to_owned(), true) - .await - && !matches!(err, ServerCreateDirectoryError::FileAlreadyExists) + if let Some(parent) = parent_dir(&file.path) + && self.ensure_dir(server, parent).await.is_err() { - warn!("Failed to create {parent} for a pack file: {err:?}"); + failures.push(file.path.clone()); + continue; + } + if !self.remove_pack_file(server, &file.path).await { + failures.push(file.path.clone()); + continue; } - let outcome = server + if let Err(err) = server .node - .server_write_file_by_url(server.node_server_id, file.path.clone(), url) + .server_rename_file( + server.node_server_id, + staged_path(&file.path), + destination.clone(), + ) + .await + { + warn!("Failed to move {} into place: {err:?}", file.path); + failures.push(file.path.clone()); + } + } + + failures + } + + async fn run_modpack_install( + &self, + server: Server, + payload: InstallModpackPayload, + pack_file: String, + pack_url: url::Url, + ) { + let server_id = server.id; + let outcome = self + .install_pack(&server, &payload, &pack_file, pack_url) + .await; + + if !self.remove_path(&server, STAGING_DIR, true).await { + warn!("Failed to clean up the modpack staging directory on {server_id}"); + } + + match outcome { + Ok(()) => { + self.update_job(server_id, |job| { + job.step = ModpackStep::Done; + job.state = if job.failures.is_empty() { + ModpackJobState::Completed + } else { + job.error = Some( + "The pack was installed, but some files could not be put in place" + .to_owned(), + ); + ModpackJobState::Failed + }; + job.finished_at = Some(chrono::Utc::now().timestamp()); + }) .await; + } + Err(failure) => { + let reason = failure.reason(); + error!("Modpack install failed on server {server_id}: {reason}"); + self.update_job(server_id, |job| { + job.state = ModpackJobState::Failed; + job.error = Some(reason); + job.finished_at = Some(chrono::Utc::now().timestamp()); + }) + .await; + } + } + } + + async fn install_pack( + &self, + server: &Server, + payload: &InstallModpackPayload, + pack_file: &str, + pack_url: url::Url, + ) -> Result<(), InstallFailure> { + let server_id = server.id; + let pack = self.prepare_pack(server, pack_file, pack_url).await?; - match &outcome { - Ok(()) => installed_paths.push(file.path.clone()), - Err(err) => warn!("Failed to install pack file {}: {err:?}", file.path), + self.set_step(server_id, ModpackStep::Resolving).await; + let plan = self + .plan_platform(server, pack.platform.clone(), payload.sync_platform) + .await?; + let platform_changes = plan.new_extra.is_some(); + let pack_name = pack.name.clone(); + let target = plan.target.clone(); + self.update_job(server_id, |job| { + if job.pack.is_none() { + job.pack = pack_name; } + job.platform = platform_changes.then_some(target); + }) + .await; - self.update_job(server_id, |job| { - if outcome.is_err() { - job.failures.push(file.path.clone()); - } - job.done += 1; - }) + let mut identities = HashMap::new(); + let mut skipped = Vec::new(); + let files = self + .resolve_curseforge_files(pack.files.clone(), &mut identities, &mut skipped) + .await?; + let total = u32::try_from(files.len()).unwrap_or(u32::MAX); + self.update_job(server_id, |job| { + job.step = ModpackStep::Fetching; + job.total = total; + job.skipped = skipped; + }) + .await; + + self.stage_files(server, &files).await?; + + let records = self + .pack_identities(payload.provider, &files, identities) .await; + + if self.server_running(server).await { + return Err(InstallFailure::from( + "The server was started during the install, so nothing on it was changed", + )); + } + + if payload.backup { + self.set_step(server_id, ModpackStep::BackingUp).await; + server + .node + .server_create_backup_order(server.node_server_id, None) + .await + .map_err(|err| { + error!("Modpack backup failed for {server_id}: {err:?}"); + match err { + ServerCreateBackupOrderError::BackupStorageNoSuitableFound => { + InstallFailure::from("No backup storage is available on this node") + } + _ => InstallFailure::from("The backup could not be created"), + } + })?; + } + + if let Some(new_extra) = plan.new_extra { + self.set_step(server_id, ModpackStep::Platform).await; + self.push_template_extra(server, new_extra) + .await + .map_err(|err| { + error!("Failed to switch the platform for a pack on {server_id}: {err:?}"); + match err { + BuildPushError::Template(_) | BuildPushError::Database(_) => { + InstallFailure::from("The server's platform could not be changed") + } + BuildPushError::Node => InstallFailure::from( + "The node did not accept the new platform, so nothing was changed", + ), + } + })?; } self.update_job(server_id, |job| { - "Applying pack configuration".clone_into(&mut job.step); + job.step = ModpackStep::Applying; + job.live_untouched = false; }) .await; - let mut copied = self - .apply_overrides(&server, &format!("{extracted}/{OVERRIDES_DIR}")) + let previous = get_modpack(&self.database, server_id) + .await + .map_err(|err| { + error!("Failed to load the previous modpack: {err:?}"); + InstallFailure::from("The previous pack could not be loaded") + })?; + + let failures = self + .commit_pack( + server, + &records, + payload.provider, + previous.as_ref(), + payload.mode == ModpackInstallMode::Reset, + ) + .await; + + self.set_step(server_id, ModpackStep::Configuring).await; + let extracted = extracted_dir(); + let (mut copied, mut override_failures) = self + .apply_overrides(server, &format!("{extracted}/{OVERRIDES_DIR}")) .await; - copied += self - .apply_overrides(&server, &format!("{extracted}/{SERVER_OVERRIDES_DIR}")) + let (server_copied, server_failures) = self + .apply_overrides(server, &format!("{extracted}/{SERVER_OVERRIDES_DIR}")) .await; + copied = copied.saturating_add(server_copied); + override_failures.extend(server_failures); info!("Applied {copied} override file(s) for a pack on {server_id}"); + self.set_step(server_id, ModpackStep::Finishing).await; let record = ModpackRecord { provider: payload.provider, project: payload.project.clone(), @@ -579,31 +1227,25 @@ impl Backend { .collect(), icon_url: safe_page_url(payload.icon_url.clone()), page_url: safe_page_url(payload.page_url.clone()), - files: installed_paths, + game_version: plan.target.game_version.clone(), + loader_version: plan.target.loader_version.clone(), + files: records, installed_at: chrono::Utc::now().timestamp(), }; - if let Err(err) = upsert_modpack(&self.database, server_id, &record).await { + if let Err(err) = replace_modpack(&self.database, server_id, &record).await { error!("Failed to record the installed modpack: {err:?}"); + return Err(InstallFailure::from( + "The pack was installed, but it could not be linked to the server", + )); } - self.cleanup_staging(&server, &staging).await; - self.update_job(server_id, |job| { - job.state = ModpackJobState::Completed; - "Done".clone_into(&mut job.step); - job.finished_at = Some(chrono::Utc::now().timestamp()); + job.failures.extend(failures); + job.failures.extend(override_failures); }) .await; - } - async fn cleanup_staging(&self, server: &Server, staging: &str) { - if let Err(err) = server - .node - .server_delete_file(server.node_server_id, staging.to_owned(), true) - .await - { - warn!("Failed to clean up modpack staging directory: {err:?}"); - } + Ok(()) } } @@ -625,21 +1267,32 @@ pub(crate) impl ModpackService for Backend { Ok(self.modpack_job(server_id).await?) } - /// Installs a modpack in the background. + /// Installs, updates or reinstalls a modpack in the background. /// - /// This rewrites files on a live server, so `confirm` is mandatory. With - /// `backup` the server is backed up first and the install is abandoned if - /// that fails; with `wipe` the existing mod jars are cleared so the result - /// matches the pack author's intent, and without it the pack is merged into - /// what is already installed. + /// The install is staged: the pack and every file it lists are downloaded + /// into `.novium-modpack/` and checked against the sizes the pack declares + /// before anything on the server changes. If any download fails the job + /// stops there and the server is left exactly as it was. /// - /// Returns as soon as the job is accepted — a pack is one large archive plus - /// hundreds of downloads, so progress is polled from `GET /modpack/{id}`. - /// The server has to be stopped: the install rewrites the mod jars and the - /// pack's configuration underneath a running process. + /// Only then is the server switched over: files of the previously linked + /// pack that the new version no longer ships are removed, the new files are + /// moved into place and the pack's configuration overrides are applied. + /// Mods added on top of the pack are kept, and pack mods that were disabled + /// stay disabled. With `mode: reset` every mod that is not part of the pack is + /// removed as well, which returns the server to the pack's original state. + /// + /// A pack declares the loader and Minecraft version it was built for. When + /// the server runs something else, `sync_platform` switches the server's + /// software to match (this also needs [`ServerPermissions::BUILD_EDIT`]); + /// without it the install is refused instead of producing a server that + /// cannot start. `backup` creates a backup right before the switch-over. + /// + /// Returns as soon as the job is accepted. Progress is polled from + /// `GET /modpack/{id}`. The server has to stay stopped while it runs. /// /// # Permissions - /// Requires [`ServerPermissions::FILES_WRITE`]. + /// Requires [`ServerPermissions::FILES_WRITE`], and + /// [`ServerPermissions::BUILD_EDIT`] when `sync_platform` is set. #[route("/install", method = POST)] #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], cache::failure <- [redis:*], server::not_found, resource::{invalid_payload, provider_unavailable, provider_disabled, provider_error, download_unavailable, missing, wrong_template}, modpack::{not_confirmed, already_running, server_running})] async fn install_modpack( @@ -649,6 +1302,9 @@ pub(crate) impl ModpackService for Backend { payload: InstallModpackPayload, ) -> Result { auth.require::<_, _, InstallModpackError>(server_id, ServerPermissions::FILES_WRITE)?; + if payload.sync_platform { + auth.require::<_, _, InstallModpackError>(server_id, ServerPermissions::BUILD_EDIT)?; + } if !payload.confirm { return Err(InstallModpackError::ModpackNotConfirmed); @@ -669,16 +1325,11 @@ pub(crate) impl ModpackService for Backend { return Err(InstallModpackError::ResourceWrongTemplate); } - if server - .node - .server_status(server.node_server_id) - .await - .is_ok_and(|status| is_running_label(&status.state.label)) - { + if self.server_running(&server).await { return Err(InstallModpackError::ModpackServerRunning); } - if self.is_locked(&job_lock(server_id)).await { + if self.modpack_installing(server_id).await { return Err(InstallModpackError::ModpackAlreadyRunning); } @@ -694,7 +1345,7 @@ pub(crate) impl ModpackService for Backend { } let pack_file = sanitize_file_name(&resolved.file_name, "modpack.zip"); - let job = ModpackJob::new("Queued"); + let job = ModpackJob::new(payload.title.clone()); self.set_job(server_id, job.clone()).await; let backend = self.clone(); @@ -714,14 +1365,15 @@ pub(crate) impl ModpackService for Backend { /// Forgets the modpack this server is linked to. /// - /// Only the link is dropped — the pack's mods and configuration stay on + /// Only the link is dropped: the pack's mods and configuration stay on /// disk, so the server keeps running exactly as it did. Its jars stop being - /// reported as pack contents and show up as hand-uploaded files instead. + /// reported as pack contents and show up as hand-uploaded files instead, + /// and the loader and Minecraft version can be changed freely again. /// /// # Permissions /// Requires [`ServerPermissions::FILES_WRITE`]. #[route("", method = DELETE)] - #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], server::not_found)] + #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], server::not_found, modpack::already_running)] async fn unlink_modpack( &self, #[special] auth: Authentication, @@ -729,6 +1381,10 @@ pub(crate) impl ModpackService for Backend { ) -> Result<(), _> { auth.require::<_, _, UnlinkModpackError>(server_id, ServerPermissions::FILES_WRITE)?; + if self.modpack_installing(server_id).await { + return Err(UnlinkModpackError::ModpackAlreadyRunning); + } + delete_modpack(&self.database, server_id).await?; Ok(()) diff --git a/backend/src/service/resource.rs b/backend/src/service/resource.rs index 003d971..8590716 100644 --- a/backend/src/service/resource.rs +++ b/backend/src/service/resource.rs @@ -6,6 +6,7 @@ use crate::db::server::resources::{ InstalledResource, delete_installed, delete_installed_files, get_modpack, installed_file, list_installed, upsert_installed, }; + use crate::db::server::{Server, ServerId}; use crate::resources::{ MAX_QUERY_LENGTH, MAX_SEARCH_LIMIT, ResourceClient, ResourceError, ResourceKind, @@ -92,6 +93,8 @@ fn state_file_name(base: &str, enabled: bool) -> String { #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct ResourceContext { pub(crate) loaders: Vec, + pub(crate) modpack_loaders: Vec, + pub(crate) can_change_platform: bool, pub(crate) game_version: Option, pub(crate) backup_available: bool, } @@ -101,7 +104,27 @@ pub(crate) struct ResourceLoaderInfo { pub(crate) loader: ResourceLoader, pub(crate) resource_type: ResourceType, pub(crate) providers: Vec, - pub(crate) modpack_providers: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub(crate) struct ModpackLoaderInfo { + pub(crate) loader: ResourceLoader, + pub(crate) providers: Vec, + pub(crate) switches_platform: bool, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub(crate) struct ResourceInstallResult { + pub(crate) installed: InstalledResource, + pub(crate) dependencies: Vec, + pub(crate) unresolved: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub(crate) struct UnresolvedDependency { + pub(crate) project: Option, + pub(crate) title: Option, + pub(crate) reason: String, } #[derive(Debug, Deserialize, utoipa::ToSchema)] @@ -125,6 +148,10 @@ struct InstallRequest<'a> { page_url: Option, } +const MODPACK_LOADERS: [ResourceLoader; 2] = [ResourceLoader::Fabric, ResourceLoader::Neoforge]; + +const MAX_DEPENDENCIES: usize = 64; + const FEATURE_LOADERS: [(&str, ResourceLoader); 6] = [ ("minecraft_plugins_paper", ResourceLoader::Paper), ("minecraft_mods_fabric", ResourceLoader::Fabric), @@ -143,6 +170,11 @@ pub(crate) struct ModpackMod { pub(crate) resource_type: ResourceType, pub(crate) file_name: String, pub(crate) enabled: bool, + pub(crate) project: Option, + pub(crate) version: Option, + pub(crate) title: Option, + pub(crate) icon_url: Option, + pub(crate) page_url: Option, } #[derive(Debug, Serialize, utoipa::ToSchema)] @@ -155,8 +187,11 @@ pub(crate) struct InstalledModpack { pub(crate) title: String, pub(crate) icon_url: Option, pub(crate) page_url: Option, + pub(crate) game_version: Option, + pub(crate) loader_version: Option, pub(crate) installed_at: i64, pub(crate) mods: Vec, + pub(crate) missing: Vec, pub(crate) other_files: u32, } @@ -657,9 +692,162 @@ impl Backend { } } +impl Backend { + async fn dependency_title( + &self, + provider: ResourceProvider, + project: &str, + ) -> (Option, Option) { + match self + .resource_client() + .projects(provider, &[project.to_owned()]) + .await + { + Ok(details) => details + .into_iter() + .next() + .map_or((None, None), |detail| (Some(detail.title), detail.page_url)), + Err(err) => { + warn!("Failed to load dependency {project}: {err:?}"); + (None, None) + } + } + } + + async fn install_dependencies( + &self, + server: &Server, + primary: &InstalledResource, + loader: ResourceLoader, + game_version: Option<&str>, + ) -> (Vec, Vec) { + let provider = primary.provider; + let mut installed = Vec::new(); + let mut unresolved = Vec::new(); + + let mut known: HashSet = match list_installed(&self.database, server.id).await { + Ok(records) => records + .into_iter() + .filter(|record| record.provider == provider) + .map(|record| record.project) + .collect(), + Err(err) => { + warn!("Failed to list installed resources for dependencies: {err:?}"); + return (installed, unresolved); + } + }; + if let Ok(Some(pack)) = get_modpack(&self.database, server.id).await + && pack.provider == provider + { + known.extend(pack.files.into_iter().filter_map(|file| file.project)); + } + known.insert(primary.project.clone()); + + let mut visited = HashSet::new(); + let mut stack = vec![primary.version.clone()]; + while let Some(version) = stack.pop() { + if !visited.insert(version.clone()) || installed.len() >= MAX_DEPENDENCIES { + continue; + } + + let dependencies = match self + .resource_client() + .dependencies(provider, &version) + .await + { + Ok(dependencies) => dependencies, + Err(err) => { + warn!("Failed to read dependencies of {version}: {err:?}"); + continue; + } + }; + + for dependency in dependencies { + let Some(project) = dependency.project else { + unresolved.push(UnresolvedDependency { + project: None, + title: None, + reason: "The dependency does not name its project".to_owned(), + }); + continue; + }; + if !known.insert(project.clone()) { + continue; + } + + let (title, page_url) = self.dependency_title(provider, &project).await; + + let version = match dependency.version { + Some(version) => Some(version), + None => self + .resource_client() + .versions( + provider, + &project, + loader, + ResourceKind::Resource, + game_version, + ) + .await + .ok() + .and_then(|versions| newest_version(versions, false)) + .map(|version| version.id), + }; + let Some(version) = version else { + unresolved.push(UnresolvedDependency { + project: Some(project), + title, + reason: "No version fits this server's loader and Minecraft version" + .to_owned(), + }); + continue; + }; + + match self + .install_version( + server, + InstallRequest { + provider, + project: &project, + version: &version, + loader, + title: title.clone(), + page_url, + }, + ) + .await + { + Ok(resource) => { + stack.push(resource.version.clone()); + installed.push(resource); + } + Err(err) => { + warn!("Failed to install dependency {project}: {err:?}"); + unresolved.push(UnresolvedDependency { + project: Some(project), + title, + reason: err.reason().to_owned(), + }); + } + } + } + } + + (installed, unresolved) + } +} + #[service(path = "/resource/{server_id}", tag = "resource", no_sdk)] pub(crate) impl ResourceService for Backend { /// Reports which loaders and providers this server can browse. + /// + /// `modpack_loaders` lists the loaders whose packs can be installed. A pack + /// for another loader than the server's own switches the server's software + /// when it is installed, so those are only offered (`switches_platform`) + /// when the caller also holds [`ServerPermissions::BUILD_EDIT`]. + /// + /// # Permissions + /// Requires [`ServerPermissions::FILES_READ`]. #[route("", method = GET)] #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], cache::failure <- [redis:*], server::not_found, resource::wrong_template, template::{corrupted, unavailable})] async fn resource_context( @@ -685,7 +873,7 @@ pub(crate) impl ResourceService for Backend { .map_err(TemplateError::into::)?; let available = self.resource_client().available(); - let loaders = FEATURE_LOADERS + let loaders: Vec = FEATURE_LOADERS .into_iter() .filter(|(feature, _)| features.contains(feature)) .map(|(_, loader)| ResourceLoaderInfo { @@ -696,14 +884,31 @@ pub(crate) impl ResourceService for Backend { .copied() .filter(|provider| provider.supports(loader, ResourceKind::Resource)) .collect(), - modpack_providers: available - .iter() - .copied() - .filter(|provider| provider.supports(loader, ResourceKind::Modpack)) - .collect(), }) .collect(); + let can_switch = auth.has(server_id, ServerPermissions::BUILD_EDIT); + let modpack_loaders = if server.template.id() == MinecraftTemplate::ID { + MODPACK_LOADERS + .into_iter() + .filter_map(|loader| { + let current = loaders.iter().any(|entry| entry.loader == loader); + (current || can_switch).then(|| ModpackLoaderInfo { + loader, + providers: available + .iter() + .copied() + .filter(|provider| provider.supports(loader, ResourceKind::Modpack)) + .collect(), + switches_platform: !current, + }) + }) + .filter(|entry| !entry.providers.is_empty()) + .collect() + } else { + Vec::new() + }; + let backup_available = server.backup_limit != Some(0) && sqlx::query_scalar!( r#"SELECT COUNT(*) AS "count!" FROM node_backup_storages WHERE node = $1"#, @@ -715,6 +920,8 @@ pub(crate) impl ResourceService for Backend { Ok(ResourceContext { loaders, + modpack_loaders, + can_change_platform: can_switch, backup_available, game_version: server .template_extra @@ -833,7 +1040,7 @@ pub(crate) impl ResourceService for Backend { .map(|pack| { pack.files .iter() - .filter_map(|path| split_resource_path(path)) + .filter_map(|file| split_resource_path(&file.path)) .map(|(resource_type, name)| (resource_type, name.to_owned())) .collect() }) @@ -910,30 +1117,53 @@ pub(crate) impl ResourceService for Backend { let modpack = modpack.map(|pack| { let mut mods = Vec::new(); + let mut missing = Vec::new(); let mut other_files = 0u32; - for path in &pack.files { - let Some((resource_type, name)) = split_resource_path(path) else { - other_files += 1; + for file in &pack.files { + let Some((resource_type, name)) = split_resource_path(&file.path) else { + other_files = other_files.saturating_add(1); continue; }; let Some(files) = listings.get(&resource_type).and_then(Option::as_ref) else { continue; }; + let mut found_any = false; for (found, _size) in files { let (base, enabled) = split_disabled(found); if base == name { + found_any = true; mods.push(ModpackMod { resource_type, file_name: found.clone(), enabled, + project: file.project.clone(), + version: file.version.clone(), + title: file.title.clone(), + icon_url: file.icon_url.clone(), + page_url: file.page_url.clone(), }); } } + if !found_any { + missing.push(file.path.clone()); + } } - mods.sort_by(|left, right| left.file_name.cmp(&right.file_name)); + mods.sort_by(|left, right| { + let left = left + .title + .as_deref() + .unwrap_or(&left.file_name) + .to_lowercase(); + let right = right + .title + .as_deref() + .unwrap_or(&right.file_name) + .to_lowercase(); + left.cmp(&right) + }); InstalledModpack { provider: pack.provider, @@ -944,8 +1174,11 @@ pub(crate) impl ResourceService for Backend { title: pack.title, icon_url: pack.icon_url, page_url: pack.page_url, + game_version: pack.game_version, + loader_version: pack.loader_version, installed_at: pack.installed_at, mods, + missing, other_files, } }); @@ -964,13 +1197,13 @@ pub(crate) impl ResourceService for Backend { /// is written first, then the previous one is removed, so a failed download /// leaves the working version in place. #[route("/install", method = POST)] - #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], cache::failure <- [redis:*], server::not_found, node::failure, resource::{invalid_payload, provider_unavailable, provider_disabled, provider_error, download_unavailable, missing, wrong_template}, file::{storage_unavailable, not_found, sandbox_violated, is_directory, quota_exceeded, already_exists})] + #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], cache::failure <- [redis:*], server::not_found, node::failure, resource::{invalid_payload, provider_unavailable, provider_disabled, provider_error, download_unavailable, missing, wrong_template}, file::{storage_unavailable, not_found, sandbox_violated, is_directory, quota_exceeded, already_exists}, modpack::already_running)] async fn install_resource( &self, #[special] auth: Authentication, server_id: ServerId, payload: InstallResourcePayload, - ) -> Result { + ) -> Result { auth.require::<_, _, InstallResourceError>(server_id, ServerPermissions::FILES_WRITE)?; let server = self @@ -981,20 +1214,39 @@ pub(crate) impl ResourceService for Backend { if !template_supports_resources(server.template.id()) { return Err(InstallResourceError::ResourceWrongTemplate); } + if self.modpack_installing(server_id).await { + return Err(InstallResourceError::ModpackAlreadyRunning); + } - self.install_version( - &server, - InstallRequest { - provider: payload.provider, - project: &payload.project, - version: &payload.version, - loader: payload.loader, - title: payload.title, - page_url: payload.page_url, - }, - ) - .await - .map_err(InstallError::into::) + let installed = self + .install_version( + &server, + InstallRequest { + provider: payload.provider, + project: &payload.project, + version: &payload.version, + loader: payload.loader, + title: payload.title, + page_url: payload.page_url, + }, + ) + .await + .map_err(InstallError::into::)?; + + let game_version = server + .template_extra + .get("minecraft_version") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let (dependencies, unresolved) = self + .install_dependencies(&server, &installed, payload.loader, game_version.as_deref()) + .await; + + Ok(ResourceInstallResult { + installed, + dependencies, + unresolved, + }) } /// Lists installed resources that have a newer version available. @@ -1060,7 +1312,7 @@ pub(crate) impl ResourceService for Backend { /// Updates one installed resource to the newest compatible version. #[route("/update", method = POST, query = [ provider, project, allow_prerelease ])] - #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], cache::failure <- [redis:*], server::not_found, node::failure, template::{corrupted, unavailable}, resource::{invalid_payload, provider_unavailable, provider_disabled, provider_error, download_unavailable, missing, not_installed, wrong_template}, file::{storage_unavailable, not_found, sandbox_violated, is_directory, quota_exceeded, already_exists})] + #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], cache::failure <- [redis:*], server::not_found, node::failure, template::{corrupted, unavailable}, resource::{invalid_payload, provider_unavailable, provider_disabled, provider_error, download_unavailable, missing, not_installed, wrong_template}, file::{storage_unavailable, not_found, sandbox_violated, is_directory, quota_exceeded, already_exists}, modpack::already_running)] async fn update_resource( &self, #[special] auth: Authentication, @@ -1079,6 +1331,9 @@ pub(crate) impl ResourceService for Backend { if !template_supports_resources(server.template.id()) { return Err(UpdateResourceError::ResourceWrongTemplate); } + if self.modpack_installing(server_id).await { + return Err(UpdateResourceError::ModpackAlreadyRunning); + } let resource = list_installed(&self.database, server_id) .await? @@ -1125,7 +1380,7 @@ pub(crate) impl ResourceService for Backend { /// four providers, one unreachable provider or one distribution-blocked file /// should not stop the rest from updating. #[route("/update-all", method = POST, query = [ allow_prerelease ])] - #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], cache::failure <- [redis:*], server::not_found, resource::wrong_template, template::{corrupted, unavailable})] + #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], cache::failure <- [redis:*], server::not_found, resource::wrong_template, template::{corrupted, unavailable}, modpack::already_running)] async fn update_all_resources( &self, #[special] auth: Authentication, @@ -1142,6 +1397,9 @@ pub(crate) impl ResourceService for Backend { if !template_supports_resources(server.template.id()) { return Err(UpdateAllResourcesError::ResourceWrongTemplate); } + if self.modpack_installing(server_id).await { + return Err(UpdateAllResourcesError::ModpackAlreadyRunning); + } let recorded = list_installed(&self.database, server_id).await?; let (loaders, game_version) = self @@ -1210,7 +1468,7 @@ pub(crate) impl ResourceService for Backend { /// Removes an installed resource's file and its record. #[route("/installed", method = DELETE, query = [ provider, project ])] - #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], server::not_found, node::failure, resource::not_installed, file::{storage_unavailable, sandbox_violated, is_directory, not_directory, not_found})] + #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], server::not_found, node::failure, resource::not_installed, file::{storage_unavailable, sandbox_violated, is_directory, not_directory, not_found}, modpack::already_running)] async fn uninstall_resource( &self, #[special] auth: Authentication, @@ -1220,6 +1478,10 @@ pub(crate) impl ResourceService for Backend { ) -> Result<(), _> { auth.require::<_, _, UninstallResourceError>(server_id, ServerPermissions::FILES_WRITE)?; + if self.modpack_installing(server_id).await { + return Err(UninstallResourceError::ModpackAlreadyRunning); + } + let (file_name, resource_type) = installed_file(&self.database, server_id, provider, &project) .await? @@ -1278,7 +1540,7 @@ pub(crate) impl ResourceService for Backend { /// Idempotent — asking for the state a file is already in succeeds without /// touching the filesystem. #[route("/state", method = POST, query = [ resource_type, file, enabled ])] - #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], server::not_found, node::failure, resource::invalid_payload, file::{storage_unavailable, not_found, not_directory, sandbox_violated, already_exists})] + #[error(auth::{permission_denied, unreachable}, db::failure <- [sql:*], server::not_found, node::failure, resource::invalid_payload, file::{storage_unavailable, not_found, not_directory, sandbox_violated, already_exists}, modpack::already_running)] async fn set_resource_state( &self, #[special] auth: Authentication, @@ -1289,6 +1551,10 @@ pub(crate) impl ResourceService for Backend { ) -> Result { auth.require::<_, _, SetResourceStateError>(server_id, ServerPermissions::FILES_WRITE)?; + if self.modpack_installing(server_id).await { + return Err(SetResourceStateError::ModpackAlreadyRunning); + } + if matches!(resource_type, ResourceType::DataPack) { return Err(SetResourceStateError::ResourceInvalidPayload); } diff --git a/backend/src/service/server.rs b/backend/src/service/server.rs index bf519ef..fe2e15f 100644 --- a/backend/src/service/server.rs +++ b/backend/src/service/server.rs @@ -1045,60 +1045,15 @@ pub(crate) impl ServerService for Backend { .await .map_err(TemplateError::into::)?; - let mut new_build = server - .template - .extra_to_build_payload( - self.cache.clone(), - new_extra - .clone() - .ok_or(UpdateVariablesError::TemplateInputInvalid)?, - ) - .await - .map_err(TemplateError::into::)?; - - new_build - .environment - .extend(self.port_environment(&*self.database, server_id).await?); - - let new_build = server.customization().apply(new_build); - - let mut conn = self.database.acquire().await?; - detached(async move { - conn.transaction(|tx| { - Box::pin(async move { - query!( - "UPDATE servers SET template_extra = $1 WHERE id = $2", - new_extra, - &server_id - ) - .execute(&mut **tx) - .await - .inspect_err(|err| { - error!("Failed to update server variables (template_extra): {err:?}"); - })?; - - server - .node - .update_server( - server.node_server_id, - None, - Some(new_build), - None, - None, - None, - ) - .await - .map_err(|err| { - error!("Failed to update server variables: {err:?}"); - UpdateVariablesError::NodeFailure - })?; + let new_extra = new_extra.ok_or(UpdateVariablesError::TemplateInputInvalid)?; - Ok(()) - }) - }) + self.push_template_extra(&server, new_extra) .await - }) - .await + .map_err(|err| match err { + BuildPushError::Template(err) => err.into::(), + BuildPushError::Database(err) => UpdateVariablesError::from(err), + BuildPushError::Node => UpdateVariablesError::NodeFailure, + }) } /// Gets the server's custom environment variables. @@ -1354,3 +1309,68 @@ fn text_value_valid(value: &str, limit: usize) -> bool { .chars() .any(|character| character.is_control() && character != '\n' && character != '\t') } + +#[derive(Debug)] +pub(crate) enum BuildPushError { + Template(TemplateError), + Database(sqlx::Error), + Node, +} + +impl From for BuildPushError { + fn from(value: sqlx::Error) -> Self { + Self::Database(value) + } +} + +impl Backend { + pub(crate) async fn push_template_extra( + &self, + server: &crate::db::server::Server, + new_extra: serde_json::Value, + ) -> Result<(), BuildPushError> { + let mut new_build = server + .template + .extra_to_build_payload(self.cache.clone(), new_extra.clone()) + .await + .map_err(BuildPushError::Template)?; + + new_build + .environment + .extend(self.port_environment(&*self.database, server.id).await?); + + let new_build = server.customization().apply(new_build); + + let server_id = server.id; + let node = Arc::clone(&server.node); + let node_server_id = server.node_server_id; + let mut conn = self.database.acquire().await?; + detached(async move { + conn.transaction(|tx| { + Box::pin(async move { + query!( + "UPDATE servers SET template_extra = $1 WHERE id = $2", + new_extra, + &server_id + ) + .execute(&mut **tx) + .await + .inspect_err(|err| { + error!("Failed to update server variables (template_extra): {err:?}"); + })?; + + node.update_server(node_server_id, None, Some(new_build), None, None, None) + .await + .map_err(|err| { + error!("Failed to update server variables: {err:?}"); + BuildPushError::Node + })?; + + Ok(()) + }) + }) + .await + }) + .await + } +} diff --git a/docs/security-open-issues.md b/docs/security-open-issues.md index 52befd7..36e3781 100644 --- a/docs/security-open-issues.md +++ b/docs/security-open-issues.md @@ -9,7 +9,7 @@ The backend hands download URLs to the daemon (`server_write_file_by_url`), and URLs the user can influence: - **Hangar `external_url`** (`backend/src/resources/hangar.rs`, `resolve`): used when a version has no `download_url`. The plugin author picks it freely, so anyone who publishes on Hangar can point it at an internal address. -- **Modpack files** (`backend/src/service/modpack.rs`, `run_modpack_install`): the pack is unpacked into `.novium-modpack/` inside the server directory and `modrinth.index.json` is read back from there afterwards. Anyone who can write to the server's files (including the running server process) can replace the manifest between unpack and read and list arbitrary `downloads`. +- **Modpack files** (`backend/src/service/modpack.rs`, `prepare_pack`): the pack is unpacked into `.novium-modpack/` inside the server directory and `modrinth.index.json` is read back from there afterwards, so anyone who can write to the server's files can swap the manifest in between. Download URLs are checked against the Modrinth allowlist (`cdn.modrinth.com`, `github.com`, `raw.githubusercontent.com`, `gitlab.com`, `allowed_download` in `backend/src/resources/modpack.rs`), so a swapped manifest can only point at those hosts. Redirects from them are still followed by the daemon. Impact: requests from the node's network position (cloud metadata endpoints, services bound to the node or its private network) with the response readable by the user. @@ -17,7 +17,7 @@ Possible fixes: - Daemon: resolve the host and refuse non-global addresses (loopback, private, link-local, unique-local, metadata), check every redirect hop the same way, or disable redirects. - Backend: drop `external_url`, or only accept it from an allowlist of hosts. -- Backend: read the modpack manifest from the archive before it is written to user-writable storage, and only accept download hosts from the Modrinth allowlist (`cdn.modrinth.com`, `github.com`, `raw.githubusercontent.com`, `gitlab.com`). +- Backend: read the modpack manifest from the archive before it is written to user-writable storage. ## Setuid escalation inside server containers diff --git a/frontend/actions/mod-browser.ts b/frontend/actions/mod-browser.ts index 5e0178d..d56137e 100644 --- a/frontend/actions/mod-browser.ts +++ b/frontend/actions/mod-browser.ts @@ -219,7 +219,8 @@ export const installModpackAction = authActionClient iconUrl: z.string().optional(), pageUrl: z.string().optional(), backup: z.boolean(), - wipe: z.boolean(), + mode: z.enum(["keep", "reset"]), + syncPlatform: z.boolean(), }), ) .action(async ({ parsedInput }) => @@ -236,7 +237,8 @@ export const installModpackAction = authActionClient icon_url: parsedInput.iconUrl, page_url: parsedInput.pageUrl, backup: parsedInput.backup, - wipe: parsedInput.wipe, + mode: parsedInput.mode, + sync_platform: parsedInput.syncPlatform, // The dialog is the confirmation; the backend refuses without this. confirm: true, }, diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/browse-panel.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/browse-panel.tsx index cb012bd..66669db 100644 --- a/frontend/app/(app)/[slug]/servers/[server]/components/mods/browse-panel.tsx +++ b/frontend/app/(app)/[slug]/servers/[server]/components/mods/browse-panel.tsx @@ -1,6 +1,12 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { CheckCircle2, Download, PackageSearch, RefreshCw } from "lucide-react"; +import { + Boxes, + CheckCircle2, + Download, + PackageSearch, + RefreshCw, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -11,26 +17,120 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TaskLockNotice } from "@/components/server/task-lock-notice"; import type { + InstalledModpack, InstalledResource, ResourceLoader, ResourceProject, ResourceProvider, } from "@/lib/mod-browser/types"; +import { ModpackBrowse } from "./modpack-browse"; +import type { ModpackTarget } from "./modpack-install-dialog"; import { ProjectList } from "./project-list"; import { ResourceInstallDialog } from "./resource-install-dialog"; import { SearchBar } from "./search-bar"; import { canMutate, installKey, type ModsContext } from "./shared"; import { useResourceSearch } from "./use-resource-search"; +export type BrowseKind = "resource" | "modpack"; + type BrowsePanelProps = { ctx: ModsContext; + kind: BrowseKind; + onKindChange: (kind: BrowseKind) => void; installed: InstalledResource[]; + pack: InstalledModpack | null; onInstalled: () => void; + onInstallPack: (target: ModpackTarget) => void; }; -export function BrowsePanel({ ctx, installed, onInstalled }: BrowsePanelProps) { +export function BrowsePanel({ + ctx, + kind, + onKindChange, + installed, + pack, + onInstalled, + onInstallPack, +}: BrowsePanelProps) { + const { t } = useTranslation(); + const modpacksAvailable = ctx.modpackLoaders.length > 0; + const resourcesAvailable = ctx.loaders.length > 0; + const activeKind: BrowseKind = + kind === "modpack" && modpacksAvailable + ? "modpack" + : resourcesAvailable + ? "resource" + : "modpack"; + + return ( + + +
+
+ + {activeKind === "modpack" ? ( + + ) : ( + + )} + {activeKind === "modpack" + ? pack + ? t("servers-management:mods.pack.replaceTitle") + : t("servers-management:mods.pack.chooseTitle") + : t("servers-management:mods.browse.title")} + + + {activeKind === "modpack" + ? t("servers-management:mods.pack.chooseDescription") + : t("servers-management:mods.browse.description")} + +
+ + {modpacksAvailable && resourcesAvailable ? ( + onKindChange(value as BrowseKind)} + > + + + {t("servers-management:mods.browse.kindResources")} + + + {t("servers-management:mods.browse.kindModpacks")} + + + + ) : null} +
+
+ + {activeKind === "modpack" ? ( + + + + ) : ( + + )} +
+ ); +} + +function ResourceBrowse({ + ctx, + installed, + onInstalled, +}: { + ctx: ModsContext; + installed: InstalledResource[]; + onInstalled: () => void; +}) { const { t } = useTranslation(); const [loader, setLoader] = useState(null); const [provider, setProvider] = useState(null); @@ -71,18 +171,8 @@ export function BrowsePanel({ ctx, installed, onInstalled }: BrowsePanelProps) { : null; return ( - - -
- - - {t("servers-management:mods.browse.title")} - - - {t("servers-management:mods.browse.description")} - -
- + <> + -
+ ); } diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/content-panel.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/content-panel.tsx new file mode 100644 index 0000000..29d126d --- /dev/null +++ b/frontend/app/(app)/[slug]/servers/[server]/components/mods/content-panel.tsx @@ -0,0 +1,712 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ArrowUpCircle, + Boxes, + PackagePlus, + Power, + PowerOff, + Search, + Trash2, + TriangleAlert, + X, +} from "lucide-react"; +import { toast } from "sonner"; + +import { + setResourceStateAction, + uninstallResourceAction, + updateAllResourcesAction, + updateResourceAction, +} from "@/actions/mod-browser"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { TaskLockNotice } from "@/components/server/task-lock-notice"; +import { + PROVIDER_LABELS, + RESOURCE_TYPE_LABELS, + type InstalledModpack, + type InstalledResource, + type ModpackMod, + type ResourceUpdate, + type UnmanagedResource, +} from "@/lib/mod-browser/types"; +import { cn } from "@/lib/utils"; +import { ModpackCard } from "./modpack-card"; +import type { ModpackTarget } from "./modpack-install-dialog"; +import { + ProjectIcon, + ProjectTitleLink, + canMutate, + formatSize, + installKey, + type ModsContext, +} from "./shared"; + +type ResourceTypeName = "plugin" | "modification" | "data_pack"; + +type ContentRow = { + key: string; + title: string; + fileName: string; + resourceType: ResourceTypeName; + enabled: boolean; + installedAt: number | null; + size: number | null; + managed: InstalledResource | null; + update: ResourceUpdate | null; +}; + +type SortOrder = "name" | "added"; + +type ContentPanelProps = { + ctx: ModsContext; + pack: InstalledModpack | null; + installed: InstalledResource[]; + unmanaged: UnmanagedResource[]; + unreadable: string[]; + updates: ResourceUpdate[]; + allowPrerelease: boolean; + onAllowPrereleaseChange: (value: boolean) => void; + onChanged: () => Promise; + onBrowse: (kind: "resource" | "modpack") => void; + onInstallPack: (target: ModpackTarget, version?: string) => void; +}; + +export function ContentPanel({ + ctx, + pack, + installed, + unmanaged, + unreadable, + updates, + allowPrerelease, + onAllowPrereleaseChange, + onChanged, + onBrowse, + onInstallPack, +}: ContentPanelProps) { + const { t } = useTranslation(); + const [filter, setFilter] = useState(""); + const [sort, setSort] = useState("name"); + const [selected, setSelected] = useState>(() => new Set()); + const [busy, setBusy] = useState(false); + const [removing, setRemoving] = useState(null); + + const writable = canMutate(ctx) && !busy; + const modpacksAvailable = ctx.modpackLoaders.length > 0; + + const rows = useMemo(() => { + const updateByProject = new Map( + updates.map((entry) => [ + installKey(entry.provider, entry.project), + entry, + ]), + ); + const managedRows: ContentRow[] = installed.map((entry) => ({ + key: `managed:${installKey(entry.provider, entry.project)}`, + title: entry.title, + fileName: entry.enabled ? entry.file_name : `${entry.file_name}.disabled`, + resourceType: entry.resource_type, + enabled: entry.enabled, + installedAt: entry.installed_at, + size: null, + managed: entry, + update: + updateByProject.get(installKey(entry.provider, entry.project)) ?? null, + })); + const uploadedRows: ContentRow[] = unmanaged.map((entry) => ({ + key: `file:${entry.resource_type}:${entry.file_name}`, + title: entry.file_name.replace(/\.disabled$/i, ""), + fileName: entry.file_name, + resourceType: entry.resource_type, + enabled: entry.enabled, + installedAt: null, + size: entry.size, + managed: null, + update: null, + })); + + const needle = filter.trim().toLowerCase(); + const all = [...managedRows, ...uploadedRows].filter( + (row) => + !needle || + row.title.toLowerCase().includes(needle) || + row.fileName.toLowerCase().includes(needle), + ); + + return all.sort((left, right) => + sort === "added" + ? (right.installedAt ?? 0) - (left.installedAt ?? 0) + : left.title.localeCompare(right.title), + ); + }, [installed, unmanaged, updates, filter, sort]); + + const visibleKeys = new Set(rows.map((row) => row.key)); + const selectedRows = rows.filter((row) => selected.has(row.key)); + const allSelected = rows.length > 0 && selectedRows.length === rows.length; + const total = installed.length + unmanaged.length; + + const toggleSelected = (key: string, checked: boolean) => { + const next = new Set( + [...selected].filter((entry) => visibleKeys.has(entry)), + ); + if (checked) next.add(key); + else next.delete(key); + setSelected(next); + }; + + const run = async (work: () => Promise) => { + setBusy(true); + try { + await work(); + } finally { + setBusy(false); + await onChanged(); + } + }; + + const setEnabled = (targets: ContentRow[], enabled: boolean) => + run(async () => { + let changed = 0; + for (const row of targets) { + if (row.enabled === enabled || row.resourceType === "data_pack") { + continue; + } + const result = await setResourceStateAction({ + serverId: ctx.serverId, + resourceType: row.resourceType, + file: row.fileName, + enabled, + }); + if (result.serverError) { + toast.error(`${row.title}: ${result.serverError}`); + } else { + changed += 1; + } + } + if (changed > 0) { + toast.success( + enabled + ? t("servers-management:mods.toasts.enabledCount", { + count: changed, + }) + : t("servers-management:mods.toasts.disabledCount", { + count: changed, + }), + ); + } + setSelected(new Set()); + }); + + const updateRows = (targets: ContentRow[]) => + run(async () => { + let changed = 0; + for (const row of targets) { + if (!row.managed || !row.update) continue; + const result = await updateResourceAction({ + serverId: ctx.serverId, + provider: row.managed.provider, + project: row.managed.project, + allowPrerelease, + }); + if (result.serverError) { + toast.error(`${row.title}: ${result.serverError}`); + } else { + changed += 1; + } + } + if (changed > 0) { + toast.success( + t("servers-management:mods.toasts.updatedCount", { count: changed }), + ); + } + setSelected(new Set()); + }); + + const updateAll = () => + run(async () => { + const result = await updateAllResourcesAction({ + serverId: ctx.serverId, + allowPrerelease, + }); + if (result.serverError || !result.data) { + toast.error( + result.serverError ?? + t("servers-management:mods.toasts.updateFailed"), + ); + return; + } + for (const entry of result.data.filter((item) => item.error)) { + toast.error(`${entry.title}: ${entry.error}`); + } + const updated = result.data.filter((item) => item.version).length; + if (updated > 0) { + toast.success( + t("servers-management:mods.toasts.updatedCount", { count: updated }), + ); + } + }); + + const remove = (targets: ContentRow[]) => + run(async () => { + let removed = 0; + for (const row of targets) { + if (!row.managed) continue; + const result = await uninstallResourceAction({ + serverId: ctx.serverId, + provider: row.managed.provider, + project: row.managed.project, + }); + if (result.serverError) { + toast.error(`${row.title}: ${result.serverError}`); + } else { + removed += 1; + } + } + if (removed > 0) { + toast.success( + t("servers-management:mods.toasts.removedCount", { count: removed }), + ); + } + setSelected(new Set()); + }); + + const togglePackMod = async (entry: ModpackMod) => { + const result = await setResourceStateAction({ + serverId: ctx.serverId, + resourceType: entry.resource_type, + file: entry.file_name, + enabled: !entry.enabled, + }); + if (result.serverError) { + toast.error(result.serverError); + return; + } + await onChanged(); + }; + + const removable = selectedRows.filter((row) => row.managed); + const updatable = selectedRows.filter((row) => row.update); + + return ( +
+ {pack ? ( + void onChanged()} + onToggleMod={togglePackMod} + /> + ) : modpacksAvailable ? ( + + + + + +
+ + {t("servers-management:mods.pack.emptyTitle")} + + + {t("servers-management:mods.pack.emptyDescription")} + +
+ +
+
+ ) : null} + + + +
+ + {pack + ? t("servers-management:mods.content.addedTitle") + : t("servers-management:mods.content.title")} + {updates.length > 0 ? ( + + + {t("servers-management:mods.installed.updatesBadge", { + count: updates.length, + })} + + ) : null} + + + {total === 0 + ? pack + ? t("servers-management:mods.content.emptyWithPack") + : t("servers-management:mods.content.empty") + : t("servers-management:mods.content.count", { count: total })} + +
+ +
+ {updates.length > 0 ? ( + + ) : null} + +
+
+ + + + + {total > 0 ? ( +
+
+ + setFilter(event.target.value)} + className="pl-9" + placeholder={t( + "servers-management:mods.content.filterPlaceholder", + )} + /> +
+ +
+ + +
+
+ ) : null} + + {selectedRows.length > 0 ? ( +
+ + {t("servers-management:mods.content.selected", { + count: selectedRows.length, + })} + + + + {updatable.length > 0 ? ( + + ) : null} + {removable.length > 0 ? ( + + ) : null} + +
+ ) : null} + + {rows.length > 0 ? ( +
+ + {rows.map((row) => ( + toggleSelected(row.key, checked)} + onToggle={() => void setEnabled([row], !row.enabled)} + onUpdate={() => void updateRows([row])} + onRemove={() => setRemoving([row])} + /> + ))} +
+ ) : total > 0 ? ( +

+ {t("servers-management:mods.content.noMatches")} +

+ ) : null} +
+
+ + {unreadable.length > 0 ? ( + + + + + {t("servers-management:mods.unreadable.title")} + + + {t("servers-management:mods.unreadable.description", { + types: unreadable + .map((type) => RESOURCE_TYPE_LABELS[type] ?? type) + .join(", "), + })} + + + + ) : null} + + { + if (!open) setRemoving(null); + }} + > + + + + {t("servers-management:mods.content.removeConfirm.title", { + count: removing?.length ?? 0, + })} + + + {(removing ?? []).map((row) => row.title).join(", ")} + + + + {t("common:actions.cancel")} + { + const targets = removing ?? []; + setRemoving(null); + void remove(targets); + }} + > + {t("common:actions.remove")} + + + + +
+ ); +} + +type ContentRowItemProps = { + row: ContentRow; + selected: boolean; + writable: boolean; + onSelect: (checked: boolean) => void; + onToggle: () => void; + onUpdate: () => void; + onRemove: () => void; +}; + +function ContentRowItem({ + row, + selected, + writable, + onSelect, + onToggle, + onUpdate, + onRemove, +}: ContentRowItemProps) { + const { t } = useTranslation(); + + return ( +
+ onSelect(checked === true)} + aria-label={row.title} + /> + + +
+ + + {row.managed ? ( + + {PROVIDER_LABELS[row.managed.provider]} + + ) : ( + + {t("servers-management:mods.content.uploadedBadge")} + + )} + {row.resourceType === "modification" ? null : ( + + {RESOURCE_TYPE_LABELS[row.resourceType] ?? row.resourceType} + + )} + {row.enabled ? null : ( + {t("common:status.disabled")} + )} + {row.update ? ( + + + {row.update.latest_version_number} + + ) : null} + + + {row.fileName} + {row.size !== null ? ` · ${formatSize(row.size)}` : ""} + +
+ + + {row.update ? ( + + ) : null} + + {row.managed ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/installed-panel.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/installed-panel.tsx deleted file mode 100644 index add8a08..0000000 --- a/frontend/app/(app)/[slug]/servers/[server]/components/mods/installed-panel.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { - ArrowUpCircle, - FileQuestion, - Power, - PowerOff, - Trash2, - TriangleAlert, -} from "lucide-react"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { TaskLockNotice } from "@/components/server/task-lock-notice"; -import { - PROVIDER_LABELS, - RESOURCE_TYPE_LABELS, - type InstalledResource, - type ResourceUpdate, - type UnmanagedResource, -} from "@/lib/mod-browser/types"; -import { cn } from "@/lib/utils"; -import { - ProjectTitleLink, - canMutate, - formatSize, - installKey, - type ModsContext, -} from "./shared"; - -type InstalledPanelProps = { - ctx: ModsContext; - installed: InstalledResource[]; - unmanaged: UnmanagedResource[]; - unreadable: string[]; - updates: ResourceUpdate[]; - allowPrerelease: boolean; - onAllowPrereleaseChange: (value: boolean) => void; - onUpdate: (entry: InstalledResource) => void; - onUpdateAll: () => void; - onToggle: ( - resourceType: string, - file: string, - label: string, - enabled: boolean, - ) => void; - onUninstall: (entry: InstalledResource) => void; -}; - -export function InstalledPanel({ - ctx, - installed, - unmanaged, - unreadable, - updates, - allowPrerelease, - onAllowPrereleaseChange, - onUpdate, - onUpdateAll, - onToggle, - onUninstall, -}: InstalledPanelProps) { - const { t } = useTranslation(); - - const updateByProject = useMemo( - () => - new Map( - updates.map((entry) => [ - installKey(entry.provider, entry.project), - entry, - ]), - ), - [updates], - ); - - const writable = canMutate(ctx); - - return ( -
- - -
- - {t("servers-management:mods.installed.title")} - {updates.length > 0 ? ( - - - {t("servers-management:mods.installed.updatesBadge", { - count: updates.length, - })} - - ) : null} - - - {installed.length === 0 - ? t("servers-management:mods.installed.empty") - : t("servers-management:mods.installed.count", { - count: installed.length, - })} - -
- -
- {installed.length > 0 ? ( -
- - -
- ) : null} - {updates.length > 0 ? ( - - ) : null} -
-
- - {installed.length > 0 ? ( - - - {installed.map((entry) => { - const update = updateByProject.get( - installKey(entry.provider, entry.project), - ); - - return ( -
-
- - - - {PROVIDER_LABELS[entry.provider]} - - {entry.enabled ? null : ( - - {t("common:status.disabled")} - - )} - {update ? ( - - - {update.latest_version_number} - - ) : null} - - - {entry.file_name} - {entry.enabled ? "" : ".disabled"} - -
- - - {update ? ( - - ) : null} - - - -
- ); - })} -
- ) : null} -
- - {unmanaged.length > 0 ? ( - - - - - {t("servers-management:mods.unmanaged.title")} - - - {t("servers-management:mods.unmanaged.description", { - count: unmanaged.length, - })} - - - - {unmanaged.map((entry) => ( -
- - {entry.file_name} - - - {entry.enabled ? null : ( - - {t("common:status.disabled")} - - )} - - {RESOURCE_TYPE_LABELS[entry.resource_type] ?? - entry.resource_type} - - - {formatSize(entry.size)} - - {entry.resource_type === "data_pack" ? null : ( - - )} - -
- ))} -
-
- ) : null} - - {unreadable.length > 0 ? ( - - - - - {t("servers-management:mods.unreadable.title")} - - - {t("servers-management:mods.unreadable.description", { - types: unreadable - .map((type) => RESOURCE_TYPE_LABELS[type] ?? type) - .join(", "), - })} - - - - ) : null} -
- ); -} diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/mod-plugin-browser.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/mod-plugin-browser.tsx index a998da6..1e0c015 100644 --- a/frontend/app/(app)/[slug]/servers/[server]/components/mods/mod-plugin-browser.tsx +++ b/frontend/app/(app)/[slug]/servers/[server]/components/mods/mod-plugin-browser.tsx @@ -1,17 +1,12 @@ -import { useMemo, useState } from "react"; +import { useEffect, useEffectEvent, useMemo, useState } from "react"; import useSWR from "swr"; import { useTranslation } from "react-i18next"; -import { Boxes, PackageSearch, Wrench } from "lucide-react"; -import { toast } from "sonner"; +import { LayoutList, PackageSearch } from "lucide-react"; import { getResourceContextAction, listInstalledResourcesAction, listResourceUpdatesAction, - setResourceStateAction, - uninstallResourceAction, - updateAllResourcesAction, - updateResourceAction, } from "@/actions/mod-browser"; import { Badge } from "@/components/ui/badge"; import { @@ -25,30 +20,31 @@ import { useModpackJob } from "@/hooks/use-modpack-job"; import { allowsOfflineWrite } from "@/lib/server-status"; import { useServerStatus } from "@/stores/server-status"; import { useServerTaskLock } from "@/stores/server-tasks"; +import { BrowsePanel, type BrowseKind } from "./browse-panel"; +import { ContentPanel } from "./content-panel"; import { - LOADER_LABELS, - type InstalledResource, - type ModpackMod, -} from "@/lib/mod-browser/types"; -import { BrowsePanel } from "./browse-panel"; -import { InstalledPanel } from "./installed-panel"; -import { ModpackPanel } from "./modpack-panel"; -import type { ModsContext } from "./shared"; + ModpackInstallDialog, + type ModpackTarget, +} from "./modpack-install-dialog"; +import { LOADER_NAMES, type ModsContext } from "./shared"; type ModPluginBrowserProps = { serverId: string; canWriteFiles: boolean; }; -type ModsTab = "modpack" | "browse" | "installed"; +type ModsTab = "content" | "browse"; export function ModPluginBrowser({ serverId, canWriteFiles, }: ModPluginBrowserProps) { const { t } = useTranslation(); - const [tab, setTab] = useState(null); + const [tab, setTab] = useState("content"); + const [browseKind, setBrowseKind] = useState("resource"); const [allowPrerelease, setAllowPrerelease] = useState(false); + const [packTarget, setPackTarget] = useState(null); + const [packVersion, setPackVersion] = useState(); const storageLock = useServerTaskLock(serverId, "storage", "write"); const status = useServerStatus(serverId); @@ -82,130 +78,49 @@ export function ModPluginBrowser({ ); const loaders = useMemo(() => context?.loaders ?? [], [context]); + const modpackLoaders = useMemo( + () => context?.modpack_loaders ?? [], + [context], + ); const pack = contents?.modpack ?? null; const installed = useMemo(() => contents?.managed ?? [], [contents]); - const modpacksAvailable = loaders.some( - (entry) => entry.modpack_providers.length > 0, - ); const ctx: ModsContext = { serverId, canWriteFiles, + canChangePlatform: context?.can_change_platform ?? false, installing, serverStopped: allowsOfflineWrite(status), storageLock, backupAvailable: context?.backup_available ?? false, gameVersion: context?.game_version ?? null, loaders, + modpackLoaders, }; - const activeTab: ModsTab = - tab ?? (modpacksAvailable && pack ? "modpack" : "browse"); - const refreshAll = async () => { await Promise.all([refreshInstalled(), refreshUpdates()]); }; - const handleToggle = async ( - resourceType: string, - file: string, - label: string, - enabled: boolean, - ) => { - const result = await setResourceStateAction({ - serverId, - resourceType: resourceType as "plugin" | "modification" | "data_pack", - file, - enabled, - }); - - if (result.serverError) { - toast.error(result.serverError); - return; - } - - toast.success( - enabled - ? t("servers-management:mods.toasts.enabled", { label }) - : t("servers-management:mods.toasts.disabled", { label }), - ); - await refreshInstalled(); - }; - - const handleUpdate = async (entry: InstalledResource) => { - const result = await updateResourceAction({ - serverId, - provider: entry.provider, - project: entry.project, - allowPrerelease, - }); - - if (result.serverError) { - toast.error(result.serverError); - return; - } - - toast.success( - t("servers-management:mods.toasts.updated", { title: entry.title }), - ); - await refreshAll(); - }; - - const handleUpdateAll = async () => { - const result = await updateAllResourcesAction({ - serverId, - allowPrerelease, - }); - - if (result.serverError || !result.data) { - toast.error( - result.serverError ?? t("servers-management:mods.toasts.updateFailed"), - ); - return; - } + const refreshAfterJob = useEffectEvent(() => { + void refreshAll(); + }); - const updated = result.data.filter((entry) => entry.version); - for (const entry of result.data.filter((entry) => entry.error)) { - toast.error(`${entry.title}: ${entry.error}`); - } - if (updated.length > 0) { - toast.success( - t("servers-management:mods.toasts.updatedCount", { - count: updated.length, - }), - ); - } + useEffect(() => { + if (!installing) refreshAfterJob(); + }, [installing]); - await refreshAll(); + const browse = (kind: BrowseKind) => { + setBrowseKind(kind); + setTab("browse"); }; - const handleUninstall = async (entry: InstalledResource) => { - const result = await uninstallResourceAction({ - serverId, - provider: entry.provider, - project: entry.project, - }); - - if (result.serverError) { - toast.error(result.serverError); - return; - } - - toast.success( - t("servers-management:mods.toasts.removed", { title: entry.title }), - ); - await refreshAll(); + const openPackInstall = (target: ModpackTarget, version?: string) => { + setPackVersion(version); + setPackTarget(target); }; - const handlePackMod = (entry: ModpackMod) => - void handleToggle( - entry.resource_type, - entry.file_name, - entry.file_name, - !entry.enabled, - ); - - if (context && loaders.length === 0) { + if (context && loaders.length === 0 && modpackLoaders.length === 0) { return ( @@ -223,83 +138,78 @@ export function ModPluginBrowser({ return (
- setTab(value as ModsTab)} - > + setTab(value as ModsTab)}> - {modpacksAvailable ? ( - - - {t("servers-management:mods.tabs.modpack")} - - ) : null} - - - {t("servers-management:mods.tabs.browse")} - - - - {t("servers-management:mods.tabs.installed")} + + + {t("servers-management:mods.tabs.content")} {updates.length > 0 ? ( {updates.length} ) : null} + + + {t("servers-management:mods.tabs.browse")} +
- {ctx.gameVersion ? ( - {ctx.gameVersion} - ) : null} {loaders.length === 1 ? ( - - {LOADER_LABELS[loaders[0].loader]} + {LOADER_NAMES[loaders[0].loader]} + ) : null} + {ctx.gameVersion ? ( + + {t("servers-management:mods.pack.minecraftBadge", { + version: ctx.gameVersion, + })} ) : null}
- {activeTab === "modpack" && modpacksAvailable ? ( - { - void refreshJob(); - void refreshAll(); - }} - onUnlinked={() => void refreshAll()} - onToggleMod={handlePackMod} - /> - ) : null} - - {activeTab === "browse" ? ( - void refreshAll()} - /> - ) : null} - - {activeTab === "installed" ? ( - void handleUpdate(entry)} - onUpdateAll={() => void handleUpdateAll()} - onToggle={(resourceType, file, label, enabled) => - void handleToggle(resourceType, file, label, enabled) - } - onUninstall={(entry) => void handleUninstall(entry)} + onChanged={refreshAll} + onBrowse={browse} + onInstallPack={openPackInstall} + /> + ) : ( + void refreshAll()} + onInstallPack={(target) => openPackInstall(target)} /> - ) : null} + )} + + { + if (!open) setPackTarget(null); + }} + initialVersionId={packVersion} + pack={pack} + onStarted={() => { + setTab("content"); + void refreshJob(); + void refreshAll(); + }} + />
); } diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-browse.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-browse.tsx new file mode 100644 index 0000000..c37df22 --- /dev/null +++ b/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-browse.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { CheckCircle2, Download, Repeat } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { + InstalledModpack, + ResourceLoader, + ResourceProvider, +} from "@/lib/mod-browser/types"; +import type { ModpackTarget } from "./modpack-install-dialog"; +import { ProjectList } from "./project-list"; +import { SearchBar } from "./search-bar"; +import { LOADER_NAMES, canMutate, type ModsContext } from "./shared"; +import { useResourceSearch } from "./use-resource-search"; + +type ModpackBrowseProps = { + ctx: ModsContext; + pack: InstalledModpack | null; + onInstall: (target: ModpackTarget) => void; +}; + +export function ModpackBrowse({ ctx, pack, onInstall }: ModpackBrowseProps) { + const { t } = useTranslation(); + const [loader, setLoader] = useState(null); + const [provider, setProvider] = useState(null); + const [compatibleOnly, setCompatibleOnly] = useState(true); + + const activeLoader = + ctx.modpackLoaders.find((entry) => entry.loader === loader) ?? + ctx.modpackLoaders.find((entry) => !entry.switches_platform) ?? + ctx.modpackLoaders[0]; + const providers = activeLoader?.providers ?? []; + const activeProvider = + providers.find((entry) => entry === provider) ?? providers[0] ?? null; + const switches = activeLoader?.switches_platform ?? false; + + const gameVersion = + compatibleOnly && ctx.gameVersion && !switches + ? ctx.gameVersion + : undefined; + + const search = useResourceSearch({ + serverId: ctx.serverId, + loader: activeLoader?.loader ?? null, + provider: activeProvider, + kind: "modpack", + gameVersion, + }); + + return ( +
+ entry.loader)} + loader={activeLoader?.loader ?? null} + onLoaderChange={setLoader} + loaderLabels={LOADER_NAMES} + gameVersion={switches ? null : ctx.gameVersion} + compatibleOnly={compatibleOnly} + onCompatibleOnlyChange={setCompatibleOnly} + /> + + {switches && activeLoader ? ( +

+ + {t("servers-management:mods.pack.switchesPlatform", { + loader: LOADER_NAMES[activeLoader.loader], + })} +

+ ) : null} + + + pack && + pack.provider === project.provider && + pack.project === project.id ? ( + + + {t("servers-management:mods.badges.installed")} + + ) : null + } + action={(project) => ( + + )} + /> +
+ ); +} diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-card.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-card.tsx new file mode 100644 index 0000000..71d99c4 --- /dev/null +++ b/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-card.tsx @@ -0,0 +1,461 @@ +import { useMemo, useState } from "react"; +import useSWR from "swr"; +import { useTranslation } from "react-i18next"; +import { + ArrowUpCircle, + Boxes, + CheckCircle2, + EllipsisVertical, + History, + ListTree, + RefreshCw, + RotateCcw, + Search, + TriangleAlert, + Unlink, +} from "lucide-react"; +import { toast } from "sonner"; + +import { + modpackVersionsAction, + unlinkModpackAction, +} from "@/actions/mod-browser"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Switch } from "@/components/ui/switch"; +import { + PROVIDER_LABELS, + type InstalledModpack, + type ModpackMod, +} from "@/lib/mod-browser/types"; +import { cn } from "@/lib/utils"; +import type { ModpackTarget } from "./modpack-install-dialog"; +import { + LOADER_NAMES, + ProjectIcon, + ProjectTitleLink, + canMutate, + formatTimestamp, + type ModsContext, +} from "./shared"; + +type ModpackCardProps = { + ctx: ModsContext; + pack: InstalledModpack; + onInstall: (target: ModpackTarget, version?: string) => void; + onUnlinked: () => void; + onToggleMod: (entry: ModpackMod) => Promise; +}; + +export function ModpackCard({ + ctx, + pack, + onInstall, + onUnlinked, + onToggleMod, +}: ModpackCardProps) { + const { t } = useTranslation(); + const [allowPrerelease, setAllowPrerelease] = useState(false); + const [contentsOpen, setContentsOpen] = useState(false); + const [unlinkOpen, setUnlinkOpen] = useState(false); + + const { + data: versions, + isValidating, + mutate: refreshVersions, + } = useSWR( + ["modpack-versions", ctx.serverId, pack.project, allowPrerelease], + async () => { + const result = await modpackVersionsAction({ + serverId: ctx.serverId, + allowPrerelease, + }); + if (result.serverError) throw new Error(result.serverError); + return result.data ?? null; + }, + ); + + const loader = pack.loader ?? null; + const target: ModpackTarget | null = loader + ? { + provider: pack.provider, + project: pack.project, + title: pack.title, + loader, + iconUrl: pack.icon_url, + pageUrl: pack.page_url, + } + : null; + + const writable = canMutate(ctx) && ctx.serverStopped; + const disabledMods = pack.mods.filter((entry) => !entry.enabled).length; + + const unlink = async () => { + const result = await unlinkModpackAction({ serverId: ctx.serverId }); + + if (result.serverError) { + toast.error(result.serverError); + return; + } + + toast.success(t("servers-management:mods.pack.toasts.unlinked")); + onUnlinked(); + }; + + return ( + + + + +
+ + {t("servers-management:mods.pack.linkedLabel")} + + + + + + {PROVIDER_LABELS[pack.provider]} + {pack.version_number ? ( + {pack.version_number} + ) : null} + {loader ? ( + + {LOADER_NAMES[loader]} + {pack.loader_version ? ` ${pack.loader_version}` : ""} + + ) : null} + {pack.game_version ? ( + + {t("servers-management:mods.pack.minecraftBadge", { + version: pack.game_version, + })} + + ) : null} + + + {t("servers-management:mods.pack.installedOn", { + date: formatTimestamp(pack.installed_at), + })} + +
+ +
+ + + + + } + > + + + + void refreshVersions()} + > + + {t("servers-management:mods.pack.checkUpdatesButton")} + + + target && onInstall({ ...target, reset: true }, pack.version) + } + > + + {t("servers-management:mods.pack.reinstallButton")} + + + setUnlinkOpen(true)} + > + + {t("servers-management:mods.pack.unlinkButton")} + + + +
+
+ + + {versions?.update_available && versions.latest ? ( +
+ + + {t("servers-management:mods.pack.updateAvailable", { + version: versions.latest.version_number, + })} + + +
+ ) : versions ? ( +

+ + {t("servers-management:mods.pack.upToDate")} +

+ ) : null} + +
+
+ + +
+ {disabledMods > 0 ? ( + + {t("servers-management:mods.pack.disabledCount", { + count: disabledMods, + })} + + ) : null} +
+ + {pack.missing.length > 0 ? ( +

+ + {t("servers-management:mods.pack.missingFiles", { + count: pack.missing.length, + })} +

+ ) : null} + + {loader ? null : ( +

+ + {t("servers-management:mods.pack.unlinkedLoader")} +

+ )} + + {ctx.serverStopped ? null : ( +

+ + {t("servers-management:mods.pack.stopRequired")} +

+ )} +
+ + + + + + + + {t("servers-management:mods.pack.unlinkConfirm.title", { + title: pack.title, + })} + + + {t("servers-management:mods.pack.unlinkConfirm.description")} + + + + {t("common:actions.cancel")} + { + setUnlinkOpen(false); + void unlink(); + }} + > + {t("servers-management:mods.pack.unlinkButton")} + + + + +
+ ); +} + +type PackContentsDialogProps = { + ctx: ModsContext; + pack: InstalledModpack; + open: boolean; + onOpenChange: (open: boolean) => void; + onToggleMod: (entry: ModpackMod) => Promise; +}; + +function PackContentsDialog({ + ctx, + pack, + open, + onOpenChange, + onToggleMod, +}: PackContentsDialogProps) { + const { t } = useTranslation(); + const [filter, setFilter] = useState(""); + const [busy, setBusy] = useState(null); + + const mods = useMemo(() => { + const needle = filter.trim().toLowerCase(); + if (!needle) return pack.mods; + return pack.mods.filter((entry) => + `${entry.title ?? ""} ${entry.file_name}`.toLowerCase().includes(needle), + ); + }, [filter, pack.mods]); + + const toggle = async (entry: ModpackMod) => { + setBusy(entry.file_name); + await onToggleMod(entry); + setBusy(null); + }; + + return ( + + + + + + {t("servers-management:mods.pack.contentsTitle", { + title: pack.title, + })} + + + {t("servers-management:mods.pack.contentsDescription", { + mods: pack.mods.length, + files: pack.other_files, + })} + + + +
+ + setFilter(event.target.value)} + className="pl-9" + placeholder={t("servers-management:mods.content.filterPlaceholder")} + /> +
+ + +
+ {mods.length === 0 ? ( +

+ {pack.mods.length === 0 + ? t("servers-management:mods.pack.noModsOnDisk") + : t("servers-management:mods.content.noMatches")} +

+ ) : ( + mods.map((entry) => ( +
+ + + + {entry.title ? ( + + {entry.file_name} + + ) : null} + + void toggle(entry)} + /> +
+ )) + )} +
+
+
+
+ ); +} diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-install-dialog.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-install-dialog.tsx index e1847ef..989acfe 100644 --- a/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-install-dialog.tsx +++ b/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-install-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import useSWR from "swr"; import { useTranslation } from "react-i18next"; import { Boxes, Download, TriangleAlert } from "lucide-react"; @@ -7,6 +7,7 @@ import { installModpackAction, listResourceVersionsAction, } from "@/actions/mod-browser"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -17,6 +18,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, @@ -27,11 +29,18 @@ import { import { Switch } from "@/components/ui/switch"; import { PROVIDER_LABELS } from "@/lib/mod-browser/types"; import type { + InstalledModpack, ResourceLoader, ResourceProvider, ResourceVersion, } from "@/lib/mod-browser/types"; -import { ProjectIcon, formatDate, versionLabel } from "./shared"; +import { + LOADER_NAMES, + ProjectIcon, + formatDate, + versionLabel, + type ModsContext, +} from "./shared"; export type ModpackTarget = { provider: ResourceProvider; @@ -40,48 +49,54 @@ export type ModpackTarget = { loader: ResourceLoader; iconUrl?: string | null; pageUrl?: string | null; + reset?: boolean; }; type ModpackInstallDialogProps = { - serverId: string; + ctx: ModsContext; target: ModpackTarget | null; onOpenChange: (open: boolean) => void; initialVersionId?: string; - installedVersion?: string | null; - backupAvailable: boolean; - serverStopped: boolean; + pack: InstalledModpack | null; onStarted: () => void; }; +type Choice = { + targetKey: string; + versionId?: string; + backup?: boolean; + reset?: boolean; + syncPlatform?: boolean; +}; + export function ModpackInstallDialog({ - serverId, + ctx, target, onOpenChange, initialVersionId, - installedVersion, - backupAvailable, - serverStopped, + pack, onStarted, }: ModpackInstallDialogProps) { const { t } = useTranslation(); const targetKey = target ? `${target.provider}:${target.project}` : ""; - const [choice, setChoice] = useState<{ - targetKey: string; - versionId?: string; - backup?: boolean; - }>({ targetKey }); - const ownChoice = choice.targetKey === targetKey ? choice : { targetKey }; - const [wipe, setWipe] = useState(true); + const [choice, setChoice] = useState({ targetKey }); + const ownChoice: Choice = + choice.targetKey === targetKey ? choice : { targetKey }; const [isStarting, setIsStarting] = useState(false); const { data: versions = [], isLoading } = useSWR( target - ? ["modpack-install-versions", serverId, target.provider, target.project] + ? [ + "modpack-install-versions", + ctx.serverId, + target.provider, + target.project, + ] : null, async () => { if (!target) return []; const result = await listResourceVersionsAction({ - serverId, + serverId: ctx.serverId, provider: target.provider, project: target.project, loader: target.loader, @@ -92,25 +107,41 @@ export function ModpackInstallDialog({ }, ); + const samePack = Boolean( + pack && + target && + pack.provider === target.provider && + pack.project === target.project, + ); + const switchesLoader = ctx.modpackLoaders.some( + (entry) => entry.loader === target?.loader && entry.switches_platform, + ); + const versionId = ownChoice.versionId || initialVersionId || versions[0]?.id || ""; - const backup = ownChoice.backup ?? backupAvailable; - const setVersionId = (next: string) => - setChoice({ ...ownChoice, versionId: next }); - const setBackup = (next: boolean) => - setChoice({ ...ownChoice, backup: next }); + const backup = ownChoice.backup ?? ctx.backupAvailable; + const reset = ownChoice.reset ?? target?.reset ?? !samePack; + const syncPlatform = ownChoice.syncPlatform ?? ctx.canChangePlatform; + const update = (next: Partial) => + setChoice({ ...ownChoice, ...next }); const version = versions.find((entry) => entry.id === versionId) ?? null; - const isCurrent = Boolean( - installedVersion && version && installedVersion === version.id, + const isCurrent = samePack && pack?.version === version?.id; + const gameMismatch = Boolean( + version && + ctx.gameVersion && + version.game_versions.length > 0 && + !version.game_versions.includes(ctx.gameVersion), ); + const platformChanges = switchesLoader || gameMismatch; + const blockedByPlatform = platformChanges && !syncPlatform; const start = async () => { if (!target || !version) return; setIsStarting(true); const result = await installModpackAction({ - serverId, + serverId: ctx.serverId, provider: target.provider, project: target.project, version: version.id, @@ -120,7 +151,8 @@ export function ModpackInstallDialog({ iconUrl: target.iconUrl ?? undefined, pageUrl: target.pageUrl ?? undefined, backup, - wipe, + mode: reset ? "reset" : "keep", + syncPlatform: syncPlatform && ctx.canChangePlatform, }); setIsStarting(false); @@ -136,16 +168,26 @@ export function ModpackInstallDialog({ onStarted(); }; + const actionLabel = isCurrent + ? t("servers-management:mods.pack.reinstallButton") + : samePack + ? t("servers-management:mods.pack.switchButton") + : t("servers-management:mods.installButton"); + return ( - + {target - ? t("servers-management:mods.pack.dialog.title", { - title: target.title, - }) + ? samePack + ? t("servers-management:mods.pack.dialog.changeTitle", { + title: target.title, + }) + : t("servers-management:mods.pack.dialog.title", { + title: target.title, + }) : null} @@ -160,7 +202,8 @@ export function ModpackInstallDialog({
{target.title} - {PROVIDER_LABELS[target.provider]} + {PROVIDER_LABELS[target.provider]} ·{" "} + {LOADER_NAMES[target.loader]}
@@ -178,7 +221,7 @@ export function ModpackInstallDialog({ })), ]} value={versionId} - onValueChange={(value) => setVersionId(value ?? "")} + onValueChange={(value) => update({ versionId: value ?? "" })} disabled={isLoading || versions.length === 0} > @@ -194,54 +237,83 @@ export function ModpackInstallDialog({ {versions.map((entry) => ( {versionLabel(entry)} + {samePack && entry.id === pack?.version + ? ` · ${t("servers-management:mods.pack.currentSuffix")}` + : ""} ))} {version ? : null} + {version?.changelog ? ( + +

+ {version.changelog} +

+
+ ) : null} - + ) : null} - + {platformChanges && syncPlatform ? ( + + {t("servers-management:mods.pack.dialog.platformChange", { + loader: target ? LOADER_NAMES[target.loader] : "", + gameVersion: + version?.game_versions.slice(0, 3).join(", ") ?? "", + })} + + ) : null} - {wipe && !backup ? ( -

- {t("servers-management:mods.pack.dialog.wipeWarning")} -

+ {blockedByPlatform ? ( + + {ctx.canChangePlatform + ? t("servers-management:mods.pack.dialog.platformBlocked") + : t("servers-management:mods.pack.dialog.platformNoPermission")} + + ) : null} + + {reset && !backup ? ( + + {t("servers-management:mods.pack.dialog.resetWarning")} + ) : null} - {!serverStopped ? ( -

- + {!ctx.serverStopped ? ( + {t("servers-management:mods.pack.stopRequired")} -

+ ) : null} {version?.downloadable === false ? ( @@ -261,16 +333,13 @@ export function ModpackInstallDialog({ disabled={ !version || version.downloadable === false || - !serverStopped || + !ctx.serverStopped || + blockedByPlatform || isStarting } > - {isCurrent - ? t("servers-management:mods.pack.reinstallButton") - : installedVersion - ? t("servers-management:mods.pack.switchButton") - : t("servers-management:mods.installButton")} + {actionLabel}
@@ -278,12 +347,71 @@ export function ModpackInstallDialog({ ); } -function VersionMeta({ version }: { version: ResourceVersion }) { - const parts = [ - version.version_type, - version.published_at ? formatDate(version.published_at) : null, - version.game_versions.slice(0, 3).join(", ") || null, - ].filter(Boolean); +type OptionSwitchProps = { + checked: boolean; + disabled?: boolean; + onCheckedChange: (value: boolean) => void; + label: string; + hint: string; +}; + +function OptionSwitch({ + checked, + disabled, + onCheckedChange, + label, + hint, +}: OptionSwitchProps) { + return ( + + ); +} + +const NOTICE_TONES = { + info: "border-sky-500/40 bg-sky-500/5", + warning: "border-amber-500/40 bg-amber-500/5", + danger: "border-destructive/40 bg-destructive/5", +} as const; - return

{parts.join(" · ")}

; +function Notice({ + tone, + children, +}: { + tone: keyof typeof NOTICE_TONES; + children: ReactNode; +}) { + return ( +

+ + {children} +

+ ); +} + +function VersionMeta({ version }: { version: ResourceVersion }) { + return ( +
+ {version.version_type} + {version.game_versions.slice(0, 4).map((gameVersion) => ( + + {gameVersion} + + ))} + {version.published_at ? ( + {formatDate(version.published_at)} + ) : null} +
+ ); } diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-panel.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-panel.tsx deleted file mode 100644 index 8b4d70a..0000000 --- a/frontend/app/(app)/[slug]/servers/[server]/components/mods/modpack-panel.tsx +++ /dev/null @@ -1,602 +0,0 @@ -import { useMemo, useState } from "react"; -import useSWR from "swr"; -import { useTranslation } from "react-i18next"; -import { - ArrowUpCircle, - Boxes, - CheckCircle2, - ChevronDown, - Download, - Power, - PowerOff, - RefreshCw, - Repeat, - TriangleAlert, - Unlink, -} from "lucide-react"; -import { toast } from "sonner"; - -import { - modpackVersionsAction, - unlinkModpackAction, -} from "@/actions/mod-browser"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; -import { Switch } from "@/components/ui/switch"; -import { TaskLockNotice } from "@/components/server/task-lock-notice"; -import { - LOADER_LABELS, - PROVIDER_LABELS, - type InstalledModpack, - type ModpackMod, - type ModpackVersions, - type ResourceLoader, - type ResourceProvider, -} from "@/lib/mod-browser/types"; -import { cn } from "@/lib/utils"; -import { - ModpackInstallDialog, - type ModpackTarget, -} from "./modpack-install-dialog"; -import { ProjectList } from "./project-list"; -import { SearchBar } from "./search-bar"; -import { - ProjectIcon, - ProjectTitleLink, - canMutate, - formatTimestamp, - versionLabel, - type ModsContext, -} from "./shared"; -import { useResourceSearch } from "./use-resource-search"; - -type ModpackPanelProps = { - ctx: ModsContext; - pack: InstalledModpack | null; - onInstallStarted: () => void; - onUnlinked: () => void; - onToggleMod: (entry: ModpackMod) => void; -}; - -export function ModpackPanel({ - ctx, - pack, - onInstallStarted, - onUnlinked, - onToggleMod, -}: ModpackPanelProps) { - const { t } = useTranslation(); - const [target, setTarget] = useState(null); - const [initialVersion, setInitialVersion] = useState(); - const [browsing, setBrowsing] = useState(false); - const [allowPrerelease, setAllowPrerelease] = useState(false); - - const { - data: packVersions, - isValidating, - mutate: refreshVersions, - } = useSWR( - pack ? ["modpack-versions", ctx.serverId, allowPrerelease] : null, - async () => { - const result = await modpackVersionsAction({ - serverId: ctx.serverId, - allowPrerelease, - }); - if (result.serverError) throw new Error(result.serverError); - return result.data ?? null; - }, - ); - - const openInstall = (next: ModpackTarget, version?: string) => { - setInitialVersion(version); - setTarget(next); - }; - - const started = () => { - setBrowsing(false); - void refreshVersions(); - onInstallStarted(); - }; - - const unlink = async () => { - const result = await unlinkModpackAction({ serverId: ctx.serverId }); - - if (result.serverError) { - toast.error(result.serverError); - return; - } - - toast.success(t("servers-management:mods.pack.toasts.unlinked")); - onUnlinked(); - }; - - return ( - <> - {pack && !browsing ? ( - void refreshVersions()} - onInstall={openInstall} - onReplace={() => setBrowsing(true)} - onUnlink={unlink} - onToggleMod={onToggleMod} - /> - ) : ( - setBrowsing(false)} - onInstall={openInstall} - /> - )} - - { - if (!open) setTarget(null); - }} - initialVersionId={initialVersion} - installedVersion={pack?.version ?? null} - backupAvailable={ctx.backupAvailable} - serverStopped={ctx.serverStopped} - onStarted={started} - /> - - ); -} - -type LinkedPackProps = { - ctx: ModsContext; - pack: InstalledModpack; - versions: ModpackVersions | null; - isChecking: boolean; - allowPrerelease: boolean; - onAllowPrereleaseChange: (value: boolean) => void; - onCheck: () => void; - onInstall: (target: ModpackTarget, version?: string) => void; - onReplace: () => void; - onUnlink: () => Promise; - onToggleMod: (entry: ModpackMod) => void; -}; - -function LinkedPack({ - ctx, - pack, - versions, - isChecking, - allowPrerelease, - onAllowPrereleaseChange, - onCheck, - onInstall, - onReplace, - onUnlink, - onToggleMod, -}: LinkedPackProps) { - const { t } = useTranslation(); - const [selected, setSelected] = useState(""); - const [contentsOpen, setContentsOpen] = useState(false); - const [unlinkOpen, setUnlinkOpen] = useState(false); - - const loader = pack.loader ?? null; - const target: ModpackTarget | null = loader - ? { - provider: pack.provider, - project: pack.project, - title: pack.title, - loader, - iconUrl: pack.icon_url, - pageUrl: pack.page_url, - } - : null; - - const available = versions?.versions ?? []; - const versionId = selected || versions?.installed_version || ""; - const isInstalledVersion = versionId === pack.version; - const writable = canMutate(ctx) && ctx.serverStopped; - - return ( - - - - -
- - - {PROVIDER_LABELS[pack.provider]} - {loader ? ( - {LOADER_LABELS[loader]} - ) : null} - - - {t("servers-management:mods.pack.installedSummary", { - version: pack.version_number ?? pack.version, - date: formatTimestamp(pack.installed_at), - })} - -
- -
- - -
-
- - - {versions?.update_available && versions.latest ? ( -
- - - {t("servers-management:mods.pack.updateAvailable", { - version: versions.latest.version_number, - })} - - -
- ) : versions ? ( -

- - {t("servers-management:mods.pack.upToDate")} -

- ) : null} - -
-
- - -
- - - - -
- -
- - -
- - {loader ? null : ( -

- - {t("servers-management:mods.pack.unlinkedLoader")} -

- )} - - {ctx.serverStopped ? null : ( -

- - {t("servers-management:mods.pack.stopRequired")} -

- )} - - - - - - - - } - > - - - {t("servers-management:mods.pack.contents", { - mods: pack.mods.length, - files: pack.other_files, - })} - - - - - {pack.mods.length === 0 ? ( -

- {t("servers-management:mods.pack.noModsOnDisk")} -

- ) : ( - pack.mods.map((entry) => ( -
- - {entry.file_name} - - - {entry.enabled ? null : ( - - {t("common:status.disabled")} - - )} - - -
- )) - )} -
-
-
- - - - - - {t("servers-management:mods.pack.unlinkConfirm.title", { - title: pack.title, - })} - - - {t("servers-management:mods.pack.unlinkConfirm.description")} - - - - {t("common:actions.cancel")} - { - setUnlinkOpen(false); - void onUnlink(); - }} - > - {t("servers-management:mods.pack.unlinkButton")} - - - - -
- ); -} - -type PackBrowserProps = { - ctx: ModsContext; - hasPack: boolean; - onCancel: () => void; - onInstall: (target: ModpackTarget) => void; -}; - -function PackBrowser({ ctx, hasPack, onCancel, onInstall }: PackBrowserProps) { - const { t } = useTranslation(); - const [loader, setLoader] = useState(null); - const [provider, setProvider] = useState(null); - const [compatibleOnly, setCompatibleOnly] = useState(true); - - const packLoaders = useMemo( - () => ctx.loaders.filter((entry) => entry.modpack_providers.length > 0), - [ctx.loaders], - ); - const activeLoader = - packLoaders.find((entry) => entry.loader === loader) ?? packLoaders[0]; - const providers = activeLoader?.modpack_providers ?? []; - const activeProvider = - providers.find((entry) => entry === provider) ?? providers[0] ?? null; - - const gameVersion = - compatibleOnly && ctx.gameVersion ? ctx.gameVersion : undefined; - - const search = useResourceSearch({ - serverId: ctx.serverId, - loader: activeLoader?.loader ?? null, - provider: activeProvider, - kind: "modpack", - gameVersion, - }); - - return ( - - -
-
- - - {hasPack - ? t("servers-management:mods.pack.replaceTitle") - : t("servers-management:mods.pack.chooseTitle")} - - - {t("servers-management:mods.pack.chooseDescription")} - -
- {hasPack ? ( - - ) : null} -
- - entry.loader)} - loader={activeLoader?.loader ?? null} - onLoaderChange={setLoader} - gameVersion={ctx.gameVersion} - compatibleOnly={compatibleOnly} - onCompatibleOnlyChange={setCompatibleOnly} - /> -
- - - ( - - )} - /> - -
- ); -} diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/resource-install-dialog.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/resource-install-dialog.tsx index 0d1ed18..00f9d3e 100644 --- a/frontend/app/(app)/[slug]/servers/[server]/components/mods/resource-install-dialog.tsx +++ b/frontend/app/(app)/[slug]/servers/[server]/components/mods/resource-install-dialog.tsx @@ -111,14 +111,33 @@ export function ResourceInstallDialog({ }); setIsInstalling(false); - if (result.serverError) { - toast.error(result.serverError); + if (result.serverError || !result.data) { + toast.error( + result.serverError ?? t("servers-management:mods.toasts.installFailed"), + ); return; } + const { dependencies, unresolved } = result.data; toast.success( - t("servers-management:mods.toasts.installed", { title: project.title }), + dependencies.length > 0 + ? t("servers-management:mods.toasts.installedWithDependencies", { + title: project.title, + count: dependencies.length, + list: dependencies.map((entry) => entry.title).join(", "), + }) + : t("servers-management:mods.toasts.installed", { + title: project.title, + }), ); + for (const entry of unresolved) { + toast.warning( + t("servers-management:mods.toasts.dependencyMissing", { + title: entry.title ?? entry.project ?? "?", + reason: entry.reason, + }), + ); + } onOpenChange(false); onInstalled(); }; @@ -209,6 +228,12 @@ export function ResourceInstallDialog({ ) : null} + {project.provider === "modrinth" ? ( +

+ {t("servers-management:mods.dependenciesHint")} +

+ ) : null} + {version?.downloadable === false ? (

{project.provider === "spigot" diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/search-bar.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/search-bar.tsx index aa00f2a..1f2503b 100644 --- a/frontend/app/(app)/[slug]/servers/[server]/components/mods/search-bar.tsx +++ b/frontend/app/(app)/[slug]/servers/[server]/components/mods/search-bar.tsx @@ -32,6 +32,7 @@ type SearchBarProps = { loaders?: ResourceLoader[]; loader?: ResourceLoader | null; onLoaderChange?: (value: ResourceLoader) => void; + loaderLabels?: Record; gameVersion?: string | null; compatibleOnly?: boolean; onCompatibleOnlyChange?: (value: boolean) => void; @@ -50,6 +51,7 @@ export function SearchBar({ loaders = [], loader, onLoaderChange, + loaderLabels = LOADER_LABELS, gameVersion, compatibleOnly, onCompatibleOnlyChange, @@ -80,7 +82,7 @@ export function SearchBar({ items={[ ...loaders.map((entry) => ({ value: entry, - label: LOADER_LABELS[entry], + label: loaderLabels[entry], })), ]} value={loader} @@ -92,7 +94,7 @@ export function SearchBar({ {loaders.map((entry) => ( - {LOADER_LABELS[entry]} + {loaderLabels[entry]} ))} diff --git a/frontend/app/(app)/[slug]/servers/[server]/components/mods/shared.tsx b/frontend/app/(app)/[slug]/servers/[server]/components/mods/shared.tsx index d1dbafb..8a0c513 100644 --- a/frontend/app/(app)/[slug]/servers/[server]/components/mods/shared.tsx +++ b/frontend/app/(app)/[slug]/servers/[server]/components/mods/shared.tsx @@ -4,6 +4,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import type { TaskLock } from "@/lib/server-tasks"; import { cn } from "@/lib/utils"; import type { + ModpackLoaderInfo, + ResourceLoader, ResourceLoaderInfo, ResourceProvider, ResourceVersion, @@ -12,12 +14,23 @@ import type { export type ModsContext = { serverId: string; canWriteFiles: boolean; + canChangePlatform: boolean; installing: boolean; serverStopped: boolean; storageLock: TaskLock; backupAvailable: boolean; gameVersion: string | null; loaders: ResourceLoaderInfo[]; + modpackLoaders: ModpackLoaderInfo[]; +}; + +export const LOADER_NAMES: Record = { + paper: "Paper", + fabric: "Fabric", + forge: "Forge", + neoforge: "NeoForge", + velocity: "Velocity", + bungeecord: "BungeeCord", }; export function canMutate(ctx: ModsContext) { diff --git a/frontend/components/server/modpack-install-banner.tsx b/frontend/components/server/modpack-install-banner.tsx index bdf3959..38c5f5e 100644 --- a/frontend/components/server/modpack-install-banner.tsx +++ b/frontend/components/server/modpack-install-banner.tsx @@ -1,10 +1,18 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Boxes, CheckCircle2, Loader2, TriangleAlert, X } from "lucide-react"; +import { + Boxes, + CheckCircle2, + Loader2, + ShieldCheck, + TriangleAlert, + X, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { useModpackJob } from "@/hooks/use-modpack-job"; +import { PACK_LOADER_LABELS } from "@/lib/mod-browser/types"; import { cn } from "@/lib/utils"; type ModpackInstallBannerProps = { @@ -12,7 +20,14 @@ type ModpackInstallBannerProps = { enabled?: boolean; }; -const FAILURES_SHOWN = 4; +const PATHS_SHOWN = 4; + +function shortList(entries: string[]) { + return ( + entries.slice(0, PATHS_SHOWN).join(", ") + + (entries.length > PATHS_SHOWN ? ", …" : "") + ); +} export function ModpackInstallBanner({ serverId, @@ -28,10 +43,11 @@ export function ModpackInstallBanner({ const running = job.state === "running"; const failed = job.state === "failed"; - const percent = - job.total > 0 ? Math.round((job.done / job.total) * 100) : null; + const fetching = running && job.step === "fetching" && job.total > 0; + const percent = fetching ? Math.round((job.done / job.total) * 100) : null; const pack = job.pack ?? t("servers-management:modpackInstall.fallbackPackName"); + const platform = job.platform; return (

{running ? ( - {job.step} + + {t(`servers-management:modpackInstall.steps.${job.step}`)} + ) : null}

{running - ? job.total > 0 + ? fetching ? t("servers-management:modpackInstall.runningFiles", { done: job.done, total: job.total, @@ -88,10 +106,27 @@ export function ModpackInstallBanner({ ? (job.error ?? t("servers-management:modpackInstall.failedDefault")) : t("servers-management:modpackInstall.doneFiles", { - count: job.done, + count: job.total, })}

+ {failed && job.live_untouched ? ( +

+ + {t("servers-management:modpackInstall.untouched")} +

+ ) : null} + + {platform?.loader && platform.game_version ? ( +

+ {t("servers-management:modpackInstall.platform", { + loader: PACK_LOADER_LABELS[platform.loader], + loaderVersion: platform.loader_version ?? "", + gameVersion: platform.game_version, + })} +

+ ) : null} + {running ? ( {t("servers-management:modpackInstall.failures", { count: job.failures.length, - list: - job.failures.slice(0, FAILURES_SHOWN).join(", ") + - (job.failures.length > FAILURES_SHOWN ? ", …" : ""), + list: shortList(job.failures), + })} +

+ ) : null} + + {!running && job.skipped.length > 0 ? ( +

+ {t("servers-management:modpackInstall.skipped", { + count: job.skipped.length, + list: shortList(job.skipped), })}

) : null} diff --git a/frontend/lib/api/index.ts b/frontend/lib/api/index.ts index 355b6a4..0647dbd 100644 --- a/frontend/lib/api/index.ts +++ b/frontend/lib/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { acceptServerLicense, addNodePort, addPort, addSshKey, allowDomainProject, archiveFile, assignGroupNode, authPassword, authPublickey, availablePorts, banUser, callback, changeServerTemplate, checkProjectSlug, chmodFile, chownFile, copyFile, createApiKey, createBackup, createBackupWithStorage, createDatabaseHost, createDeployConfig, createDirectory, createGroup, createInstanceDatabase, createInstanceUser, createNode, createProject, createProjectInstance, createRoute, createRouteDomain, createSchedule, createServer, daemonConfig, daemonRegister, daemonRootKey, databaseAgentConfig, databaseAgentRegister, databaseAgentRootKey, deleteAnyApiKey, deleteApiKey, deleteBackup, deleteDatabaseHost, deleteDatabaseInstance, deleteDeployConfig, deleteFile, deleteGroup, deleteInstanceDatabase, deleteInstanceUser, deleteNode, deleteNodePort, deleteOtherSessions, deletePort, deleteProject, deleteProjectInstance, deleteProjectLogo, deleteRoute, deleteRouteDomain, deleteSchedule, deleteServer, deleteSession, deleteSshKey, deleteUser, denyDomainProject, deopUser, deployServer, downloadBackup, downloadUser, extendDatabaseHostRegistration, extendNodeRegistration, getConfig, getProject, getUser, installModpack, installResource, instancePolicy, integrityReport, integritySchema, killServer, listAllApiKeys, listApiKeys, listBackups, listDatabaseHostRegistrations, listDatabaseHosts, listDatabaseInstances, listDeployConfigs, listDomainRoutes, listFiles, listGroups, listInstalledResources, listInstanceDatabases, listInstanceUsers, listIntegrityChecks, listNodeGroups, listNodeRegistrations, listNodes, listProjectApiKeys, listProjectInstances, listProjects, listProjectUsers, listResourceUpdates, listResourceVersions, listRouteDomains, listRouteDomainsAdmin, listRoutes, listSchedules, listServers, listSessions, listSshKeys, listTemplates, listTemplateUsage, listUserProjects, listUsers, login, loginDev, logout, me, metricsNode, modpackStatus, modpackVersions, moveServer, type Options, opUser, pardonUser, powerProjectInstance, realtimeDownloadBackup, refreshTemplateCache, removeGroupNode, removeProjectUser, renameFile, rerollDaemonPassword, resolveDomainZone, resourceContext, restartServer, restoreBackup, resyncTemplate, rotateInstanceUserPassword, routeBackends, runIntegrityChecks, runSchedule, searchResources, serverInfo, serverLicenses, serverStartup, serverStats, serverTerminal, serverVariables, setConfig, setInstanceUserGrants, setProjectLogo, setProjectUser, setResourceState, startServer, statFile, stopServer, symlinkFile, templateInfo, unarchiveFile, uninstallResource, unlinkModpack, updateAllResources, updateApiKey, updateBackup, updateDatabaseHost, updateDatabaseHostRegistration, updateDatabaseInstance, updateDeployConfig, updateMaintenance, updateNode, updateNodeOverallocation, updateNodeRegistration, updatePort, updateProject, updateResource, updateRouteDomain, updateSchedule, updateServer, updateStartup, updateUser, updateVariables, uploadProjectLogo, uploadUser, wipeServer } from './sdk.gen'; -export { type AcceptServerLicenseData, type AcceptServerLicenseError, type AcceptServerLicenseErrors, type AcceptServerLicenseResponse, type AcceptServerLicenseResponses, type AddNodePortData, type AddNodePortError, type AddNodePortErrors, type AddNodePortPayload, type AddNodePortResponse, type AddNodePortResponses, type AddPortData, type AddPortError, type AddPortErrors, type AddPortPayload, type AddPortResponse, type AddPortResponses, type AddSshKeyData, type AddSshKeyError, type AddSshKeyErrors, type AddSshKeyPayload, type AddSshKeyResponse, type AddSshKeyResponses, type AgentRegistrationRequest, type AllowDomainProjectData, type AllowDomainProjectError, type AllowDomainProjectErrors, type AllowDomainProjectResponse, type AllowDomainProjectResponses, type ApiInstance, type ArchiveFileData, type ArchiveFileError, type ArchiveFileErrors, type ArchiveFilePayload, type ArchiveFileResponse, type ArchiveFileResponses, type AssignGroupNodeData, type AssignGroupNodeError, type AssignGroupNodeErrors, type AssignGroupNodeResponse, type AssignGroupNodeResponses, type AuthPasswordData, type AuthPasswordError, type AuthPasswordErrors, type AuthPasswordResponse, type AuthPasswordResponses, type AuthPublickeyData, type AuthPublickeyError, type AuthPublickeyErrors, type AuthPublickeyResponse, type AuthPublickeyResponses, type AvailablePortsData, type AvailablePortsError, type AvailablePortsErrors, type AvailablePortsResponse, type AvailablePortsResponses, type BackupRetention, type BackupStorage, type BanUserData, type BanUserError, type BanUserErrors, type BanUserResponse, type BanUserResponses, type Bool, type CallbackData, type CallbackError, type CallbackErrors, type ChangeServerTemplateData, type ChangeServerTemplateError, type ChangeServerTemplateErrors, type ChangeServerTemplatePayload, type ChangeServerTemplateResponse, type ChangeServerTemplateResponses, type CheckProjectSlugData, type CheckProjectSlugError, type CheckProjectSlugErrors, type CheckProjectSlugResponse, type CheckProjectSlugResponses, type ChmodFileData, type ChmodFileError, type ChmodFileErrors, type ChmodFileResponse, type ChmodFileResponses, type ChownFileData, type ChownFileError, type ChownFileErrors, type ChownFileResponse, type ChownFileResponses, type ClientOptions, ContainerState, type CopyFileData, type CopyFileError, type CopyFileErrors, type CopyFileResponse, type CopyFileResponses, type CreateApiKeyData, type CreateApiKeyError, type CreateApiKeyErrors, type CreateApiKeyPayload, type CreateApiKeyResponse, type CreateApiKeyResponses, type CreateBackupData, type CreateBackupError, type CreateBackupErrors, type CreateBackupResponse, type CreateBackupResponses, type CreateBackupWithStorageData, type CreateBackupWithStorageError, type CreateBackupWithStorageErrors, type CreateBackupWithStorageResponse, type CreateBackupWithStorageResponses, type CreateDatabaseHost, type CreateDatabaseHostData, type CreateDatabaseHostError, type CreateDatabaseHostErrors, type CreateDatabaseHostResponse, type CreateDatabaseHostResponses, type CreateDeployConfigData, type CreateDeployConfigError, type CreateDeployConfigErrors, type CreateDeployConfigResponse, type CreateDeployConfigResponses, type CreateDirectoryData, type CreateDirectoryError, type CreateDirectoryErrors, type CreateDirectoryResponse, type CreateDirectoryResponses, type CreateGroupData, type CreateGroupError, type CreateGroupErrors, type CreateGroupPayload, type CreateGroupResponse, type CreateGroupResponses, type CreateInstanceDatabase, type CreateInstanceDatabaseData, type CreateInstanceDatabaseError, type CreateInstanceDatabaseErrors, type CreateInstanceDatabaseResponse, type CreateInstanceDatabaseResponses, type CreateInstanceUser, type CreateInstanceUserData, type CreateInstanceUserError, type CreateInstanceUserErrors, type CreateInstanceUserResponse, type CreateInstanceUserResponses, type CreateNodeData, type CreateNodeError, type CreateNodeErrors, type CreateNodePayload, type CreateNodeResponse, type CreateNodeResponses, type CreateProjectData, type CreateProjectError, type CreateProjectErrors, type CreateProjectInstance, type CreateProjectInstanceData, type CreateProjectInstanceError, type CreateProjectInstanceErrors, type CreateProjectInstanceResponse, type CreateProjectInstanceResponses, type CreateProjectPayload, type CreateProjectResponse, type CreateProjectResponses, type CreateRouteData, type CreateRouteDomainData, type CreateRouteDomainError, type CreateRouteDomainErrors, type CreateRouteDomainResponse, type CreateRouteDomainResponses, type CreateRouteError, type CreateRouteErrors, type CreateRouteResponse, type CreateRouteResponses, type CreateScheduleData, type CreateScheduleError, type CreateScheduleErrors, type CreateScheduleResponse, type CreateScheduleResponses, type CreateServerData, type CreateServerError, type CreateServerErrors, type CreateServerPayload, type CreateServerResponse, type CreateServerResponses, type DaemonConfigData, type DaemonConfigError, type DaemonConfigErrors, type DaemonConfigResponse, type DaemonConfigResponses, type DaemonRegisterData, type DaemonRegisterError, type DaemonRegisterErrors, type DaemonRegisterResponse, type DaemonRegisterResponses, type DaemonRootKeyData, type DaemonRootKeyError, type DaemonRootKeyErrors, type DaemonRootKeyResponse, type DaemonRootKeyResponses, DatabaseAccess, type DatabaseAgentConfigData, type DatabaseAgentConfigError, type DatabaseAgentConfigErrors, type DatabaseAgentConfigResponse, type DatabaseAgentConfigResponses, type DatabaseAgentRegisterData, type DatabaseAgentRegisterError, type DatabaseAgentRegisterErrors, type DatabaseAgentRegisterResponse, type DatabaseAgentRegisterResponses, type DatabaseAgentRootKeyData, type DatabaseAgentRootKeyError, type DatabaseAgentRootKeyErrors, type DatabaseAgentRootKeyResponse, type DatabaseAgentRootKeyResponses, DatabaseType, type DeleteAnyApiKeyData, type DeleteAnyApiKeyError, type DeleteAnyApiKeyErrors, type DeleteAnyApiKeyResponse, type DeleteAnyApiKeyResponses, type DeleteApiKeyData, type DeleteApiKeyError, type DeleteApiKeyErrors, type DeleteApiKeyResponse, type DeleteApiKeyResponses, type DeleteBackupData, type DeleteBackupError, type DeleteBackupErrors, type DeleteBackupResponse, type DeleteBackupResponses, type DeleteDatabaseHostData, type DeleteDatabaseHostError, type DeleteDatabaseHostErrors, type DeleteDatabaseHostResponse, type DeleteDatabaseHostResponses, type DeleteDatabaseInstanceData, type DeleteDatabaseInstanceError, type DeleteDatabaseInstanceErrors, type DeleteDatabaseInstanceResponse, type DeleteDatabaseInstanceResponses, type DeleteDeployConfigData, type DeleteDeployConfigError, type DeleteDeployConfigErrors, type DeleteDeployConfigResponse, type DeleteDeployConfigResponses, type DeleteFileData, type DeleteFileError, type DeleteFileErrors, type DeleteFileResponse, type DeleteFileResponses, type DeleteGroupData, type DeleteGroupError, type DeleteGroupErrors, type DeleteGroupResponse, type DeleteGroupResponses, type DeleteInstanceDatabaseData, type DeleteInstanceDatabaseError, type DeleteInstanceDatabaseErrors, type DeleteInstanceDatabaseResponse, type DeleteInstanceDatabaseResponses, type DeleteInstanceUserData, type DeleteInstanceUserError, type DeleteInstanceUserErrors, type DeleteInstanceUserResponse, type DeleteInstanceUserResponses, type DeleteNodeData, type DeleteNodeError, type DeleteNodeErrors, type DeleteNodePortData, type DeleteNodePortError, type DeleteNodePortErrors, type DeleteNodePortResponse, type DeleteNodePortResponses, type DeleteNodeResponse, type DeleteNodeResponses, type DeleteOtherSessionsData, type DeleteOtherSessionsError, type DeleteOtherSessionsErrors, type DeleteOtherSessionsResponse, type DeleteOtherSessionsResponses, type DeletePortData, type DeletePortError, type DeletePortErrors, type DeletePortResponse, type DeletePortResponses, type DeleteProjectData, type DeleteProjectError, type DeleteProjectErrors, type DeleteProjectInstanceData, type DeleteProjectInstanceError, type DeleteProjectInstanceErrors, type DeleteProjectInstanceResponse, type DeleteProjectInstanceResponses, type DeleteProjectLogoData, type DeleteProjectLogoError, type DeleteProjectLogoErrors, type DeleteProjectLogoResponse, type DeleteProjectLogoResponses, type DeleteProjectResponse, type DeleteProjectResponses, type DeleteRouteData, type DeleteRouteDomainData, type DeleteRouteDomainError, type DeleteRouteDomainErrors, type DeleteRouteDomainResponse, type DeleteRouteDomainResponses, type DeleteRouteError, type DeleteRouteErrors, type DeleteRouteResponse, type DeleteRouteResponses, type DeleteScheduleData, type DeleteScheduleError, type DeleteScheduleErrors, type DeleteScheduleResponse, type DeleteScheduleResponses, type DeleteServerData, type DeleteServerError, type DeleteServerErrors, type DeleteServerResponse, type DeleteServerResponses, type DeleteSessionData, type DeleteSessionError, type DeleteSessionErrors, type DeleteSessionResponse, type DeleteSessionResponses, type DeleteSshKeyData, type DeleteSshKeyError, type DeleteSshKeyErrors, type DeleteSshKeyResponse, type DeleteSshKeyResponses, type DeleteUserData, type DeleteUserError, type DeleteUserErrors, type DeleteUserResponse, type DeleteUserResponses, type DenyDomainProjectData, type DenyDomainProjectError, type DenyDomainProjectErrors, type DenyDomainProjectResponse, type DenyDomainProjectResponses, type DeopUserData, type DeopUserError, type DeopUserErrors, type DeopUserResponse, type DeopUserResponses, type DeployConfigPayload, type DeployServerData, type DeployServerError, type DeployServerErrors, type DeployServerPayload, type DeployServerResponse, type DeployServerResponses, type DownloadBackupData, type DownloadBackupError, type DownloadBackupErrors, type DownloadBackupResponse, type DownloadBackupResponses, type DownloadUserData, type DownloadUserError, type DownloadUserErrors, type DownloadUserResponse, type DownloadUserResponses, type ExtendDatabaseHostRegistrationData, type ExtendDatabaseHostRegistrationError, type ExtendDatabaseHostRegistrationErrors, type ExtendDatabaseHostRegistrationResponse, type ExtendDatabaseHostRegistrationResponses, type ExtendNodeRegistrationData, type ExtendNodeRegistrationError, type ExtendNodeRegistrationErrors, type ExtendNodeRegistrationResponse, type ExtendNodeRegistrationResponses, type GetConfigData, type GetConfigError, type GetConfigErrors, type GetConfigResponses, type GetProjectData, type GetProjectError, type GetProjectErrors, type GetProjectResponse, type GetProjectResponses, type GetUserData, type GetUserError, type GetUserErrors, type GetUserResponse, type GetUserResponses, type GroupLimits, type GroupNodeInfo, type GroupRef, type HashMap, type HostAndPort, type IndexSetString, type InstalledModpack, type InstalledResource, type InstallModpackData, type InstallModpackError, type InstallModpackErrors, type InstallModpackPayload, type InstallModpackResponse, type InstallModpackResponses, type InstallResourceData, type InstallResourceError, type InstallResourceErrors, type InstallResourcePayload, type InstallResourceResponse, type InstallResourceResponses, type InstancePolicyData, type InstancePolicyError, type InstancePolicyErrors, type InstancePolicyResponse, type InstancePolicyResponses, IntegrityCategory, type IntegrityCheckInfo, type IntegrityCheckResult, type IntegrityFinding, type IntegrityReport, type IntegrityReportData, type IntegrityReportError, type IntegrityReportErrors, type IntegrityReportResponse, type IntegrityReportResponses, IntegrityRunState, type IntegritySchema, type IntegritySchemaData, type IntegritySchemaError, type IntegritySchemaErrors, type IntegritySchemaResponse, type IntegritySchemaResponses, type IntegritySchemaTable, IntegritySeverity, type IntegritySource, type IntegritySourceReport, type KillServerData, type KillServerError, type KillServerErrors, type KillServerResponse, type KillServerResponses, type LightNodeInfo, type ListAllApiKeysData, type ListAllApiKeysError, type ListAllApiKeysErrors, type ListAllApiKeysResponse, type ListAllApiKeysResponses, type ListApiKeysData, type ListApiKeysError, type ListApiKeysErrors, type ListApiKeysResponse, type ListApiKeysResponses, type ListBackupsData, type ListBackupsError, type ListBackupsErrors, type ListBackupsResponse, type ListBackupsResponses, type ListDatabaseHostRegistrationsData, type ListDatabaseHostRegistrationsError, type ListDatabaseHostRegistrationsErrors, type ListDatabaseHostRegistrationsResponse, type ListDatabaseHostRegistrationsResponses, type ListDatabaseHostsData, type ListDatabaseHostsError, type ListDatabaseHostsErrors, type ListDatabaseHostsResponse, type ListDatabaseHostsResponses, type ListDatabaseInstancesData, type ListDatabaseInstancesError, type ListDatabaseInstancesErrors, type ListDatabaseInstancesResponse, type ListDatabaseInstancesResponses, type ListDeployConfigsData, type ListDeployConfigsError, type ListDeployConfigsErrors, type ListDeployConfigsResponse, type ListDeployConfigsResponses, type ListDomainRoutesData, type ListDomainRoutesError, type ListDomainRoutesErrors, type ListDomainRoutesResponse, type ListDomainRoutesResponses, type ListFilesData, type ListFilesError, type ListFilesErrors, type ListFilesResponse, type ListFilesResponses, type ListGroupsData, type ListGroupsError, type ListGroupsErrors, type ListGroupsResponse, type ListGroupsResponses, type ListInstalledResourcesData, type ListInstalledResourcesError, type ListInstalledResourcesErrors, type ListInstalledResourcesResponse, type ListInstalledResourcesResponses, type ListInstanceDatabasesData, type ListInstanceDatabasesError, type ListInstanceDatabasesErrors, type ListInstanceDatabasesResponse, type ListInstanceDatabasesResponses, type ListInstanceUsersData, type ListInstanceUsersError, type ListInstanceUsersErrors, type ListInstanceUsersResponse, type ListInstanceUsersResponses, type ListIntegrityChecksData, type ListIntegrityChecksError, type ListIntegrityChecksErrors, type ListIntegrityChecksResponse, type ListIntegrityChecksResponses, type ListNodeGroupsData, type ListNodeGroupsError, type ListNodeGroupsErrors, type ListNodeGroupsResponse, type ListNodeGroupsResponses, type ListNodeRegistrationsData, type ListNodeRegistrationsError, type ListNodeRegistrationsErrors, type ListNodeRegistrationsResponse, type ListNodeRegistrationsResponses, type ListNodesData, type ListNodesError, type ListNodesErrors, type ListNodesResponse, type ListNodesResponses, type ListProjectApiKeysData, type ListProjectApiKeysError, type ListProjectApiKeysErrors, type ListProjectApiKeysResponse, type ListProjectApiKeysResponses, type ListProjectInstancesData, type ListProjectInstancesError, type ListProjectInstancesErrors, type ListProjectInstancesResponse, type ListProjectInstancesResponses, type ListProjectsData, type ListProjectsError, type ListProjectsErrors, type ListProjectsResponse, type ListProjectsResponses, type ListProjectUsersData, type ListProjectUsersError, type ListProjectUsersErrors, type ListProjectUsersResponse, type ListProjectUsersResponses, type ListResourceUpdatesData, type ListResourceUpdatesError, type ListResourceUpdatesErrors, type ListResourceUpdatesResponse, type ListResourceUpdatesResponses, type ListResourceVersionsData, type ListResourceVersionsError, type ListResourceVersionsErrors, type ListResourceVersionsResponse, type ListResourceVersionsResponses, type ListRouteDomainsAdminData, type ListRouteDomainsAdminError, type ListRouteDomainsAdminErrors, type ListRouteDomainsAdminResponse, type ListRouteDomainsAdminResponses, type ListRouteDomainsData, type ListRouteDomainsError, type ListRouteDomainsErrors, type ListRouteDomainsResponse, type ListRouteDomainsResponses, type ListRoutesData, type ListRoutesError, type ListRoutesErrors, type ListRoutesResponse, type ListRoutesResponses, type ListSchedulesData, type ListSchedulesError, type ListSchedulesErrors, type ListSchedulesResponse, type ListSchedulesResponses, type ListServersData, type ListServersError, type ListServersErrors, type ListServersResponse, type ListServersResponses, type ListSessionsData, type ListSessionsError, type ListSessionsErrors, type ListSessionsResponse, type ListSessionsResponses, type ListSshKeysData, type ListSshKeysError, type ListSshKeysErrors, type ListSshKeysResponse, type ListSshKeysResponses, type ListTemplatesData, type ListTemplatesError, type ListTemplatesErrors, type ListTemplatesResponse, type ListTemplatesResponses, type ListTemplateUsageData, type ListTemplateUsageError, type ListTemplateUsageErrors, type ListTemplateUsageResponse, type ListTemplateUsageResponses, type ListUserProjectsData, type ListUserProjectsError, type ListUserProjectsErrors, type ListUserProjectsResponse, type ListUserProjectsResponses, type ListUsersData, type ListUsersError, type ListUsersErrors, type ListUsersResponse, type ListUsersResponses, type LoginData, type LoginDevData, type LoginDevError, type LoginDevErrors, type LoginError, type LoginErrors, type LogoutData, type LogoutError, type LogoutErrors, type LogoutResponse, type LogoutResponses, type MeData, type MeError, type MeErrors, type MeResponse, type MeResponses, type MetricsNodeData, type MetricsNodeError, type MetricsNodeErrors, type MetricsNodeResponse, type MetricsNodeResponses, ModpackJobState, type ModpackMod, type ModpackStatusData, type ModpackStatusError, type ModpackStatusErrors, type ModpackStatusResponse, type ModpackStatusResponses, type ModpackVersionsData, type ModpackVersionsError, type ModpackVersionsErrors, type ModpackVersionsResponse, type ModpackVersionsResponses, type MoveServerData, type MoveServerError, type MoveServerErrors, type MoveServerResponse, type MoveServerResponses, type NanoId, type NetworkPort, NetworkProtocol, type NodePortAllocation, type NodeRegistrationRequest, type Option, type OpUserData, type OpUserError, type OpUserErrors, type OpUserResponse, type OpUserResponses, type PardonUserData, type PardonUserError, type PardonUserErrors, type PardonUserResponse, type PardonUserResponses, type PortRange, PowerAction, type PowerProjectInstanceData, type PowerProjectInstanceError, type PowerProjectInstanceErrors, type PowerProjectInstanceResponse, type PowerProjectInstanceResponses, type PrimaryPortInfo, type RealtimeDownloadBackupData, type RealtimeDownloadBackupError, type RealtimeDownloadBackupErrors, type RealtimeDownloadBackupResponse, type RealtimeDownloadBackupResponses, type RefreshTemplateCacheData, type RefreshTemplateCacheError, type RefreshTemplateCacheErrors, type RefreshTemplateCachePayload, type RefreshTemplateCacheResponse, type RefreshTemplateCacheResponses, type RemoveGroupNodeData, type RemoveGroupNodeError, type RemoveGroupNodeErrors, type RemoveGroupNodeResponse, type RemoveGroupNodeResponses, type RemoveProjectUserData, type RemoveProjectUserError, type RemoveProjectUserErrors, type RemoveProjectUserResponse, type RemoveProjectUserResponses, type RenameFileData, type RenameFileError, type RenameFileErrors, type RenameFileResponse, type RenameFileResponses, type RerollDaemonPasswordData, type RerollDaemonPasswordError, type RerollDaemonPasswordErrors, type RerollDaemonPasswordResponse, type RerollDaemonPasswordResponses, type ResolveDomainZoneData, type ResolveDomainZoneError, type ResolveDomainZoneErrors, type ResolveDomainZoneResponse, type ResolveDomainZoneResponses, type ResourceContextData, type ResourceContextError, type ResourceContextErrors, type ResourceContextResponse, type ResourceContextResponses, ResourceKind, ResourceLoader, type ResourceLoaderInfo, ResourceProvider, type ResourceSubset, ResourceType, type ResourceUsage, type ResourceVersion, ResourceVersionType, type RestartServerData, type RestartServerError, type RestartServerErrors, type RestartServerResponse, type RestartServerResponses, type RestoreBackupData, type RestoreBackupError, type RestoreBackupErrors, type RestoreBackupResponse, type RestoreBackupResponses, type ResyncTemplateData, type ResyncTemplateError, type ResyncTemplateErrors, type ResyncTemplatePayload, type ResyncTemplateResponse, type ResyncTemplateResponses, Role, type RotateInstanceUserPasswordData, type RotateInstanceUserPasswordError, type RotateInstanceUserPasswordErrors, type RotateInstanceUserPasswordResponse, type RotateInstanceUserPasswordResponses, type RouteBackendsData, type RouteBackendsError, type RouteBackendsErrors, type RouteBackendsResponse, type RouteBackendsResponses, type RouteDomainPayload, type RouteDomainUpdatePayload, RouteKind, type RoutePayload, type RouteRecord, RouteStatus, type RunIntegrityChecksData, type RunIntegrityChecksError, type RunIntegrityChecksErrors, type RunIntegrityChecksPayload, type RunIntegrityChecksResponse, type RunIntegrityChecksResponses, type RunScheduleData, type RunScheduleError, type RunScheduleErrors, type RunScheduleResponse, type RunScheduleResponses, type SchedulePayloadRequest, SchedulePolicy, type ScheduleTask, type SearchResourcesData, type SearchResourcesError, type SearchResourcesErrors, type SearchResourcesResponse, type SearchResourcesResponses, type ServerGroupChange, type ServerInfoData, type ServerInfoError, type ServerInfoErrors, type ServerInfoResponse, type ServerInfoResponses, type ServerLicensesData, type ServerLicensesError, type ServerLicensesErrors, type ServerLicensesResponse, type ServerLicensesResponses, type ServerLimits, type ServerListStatus, type ServerNetworkInfo, type ServerResourcePayload, type ServerResyncResult, ServerResyncStatus, type ServerStartupData, type ServerStartupEnvironmentInfo, type ServerStartupError, type ServerStartupErrors, type ServerStartupResponse, type ServerStartupResponses, type ServerStats, type ServerStatsData, type ServerStatsError, type ServerStatsErrors, type ServerStatsResponse, type ServerStatsResponses, type ServerStorage, type ServerTerminalData, type ServerTerminalError, type ServerTerminalErrors, type ServerTerminalResponse, type ServerTerminalResponses, type ServerVariablesData, type ServerVariablesError, type ServerVariablesErrors, type ServerVariablesResponse, type ServerVariablesResponses, type SetConfigData, type SetConfigError, type SetConfigErrors, type SetConfigResponse, type SetConfigResponses, type SetInstanceUserGrants, type SetInstanceUserGrantsData, type SetInstanceUserGrantsError, type SetInstanceUserGrantsErrors, type SetInstanceUserGrantsResponse, type SetInstanceUserGrantsResponses, type SetProjectLogo, type SetProjectLogoData, type SetProjectLogoError, type SetProjectLogoErrors, type SetProjectLogoResponse, type SetProjectLogoResponses, type SetProjectUserData, type SetProjectUserError, type SetProjectUserErrors, type SetProjectUserPayload, type SetProjectUserResponse, type SetProjectUserResponses, type SetResourceStateData, type SetResourceStateError, type SetResourceStateErrors, type SetResourceStateResponse, type SetResourceStateResponses, type SetupNodeConfigRequest, type SshEndpointRequest, type StartServerData, type StartServerError, type StartServerErrors, type StartServerResponse, type StartServerResponses, type StatFileData, type StatFileError, type StatFileErrors, type StatFileResponse, type StatFileResponses, type StopServerData, type StopServerError, type StopServerErrors, type StopServerResponse, type StopServerResponses, type StorageUsage, type StorageUsageEntry, type StoredDatabase, type StoredInstance, type StoredUser, type SymlinkFileData, type SymlinkFileError, type SymlinkFileErrors, type SymlinkFileResponse, type SymlinkFileResponses, type TemplateInfoData, type TemplateInfoError, type TemplateInfoErrors, type TemplateInfoResponse, type TemplateInfoResponses, type TemplatePortDefinition, type U16, type UnarchiveFileData, type UnarchiveFileError, type UnarchiveFileErrors, type UnarchiveFileResponse, type UnarchiveFileResponses, type UninstallResourceData, type UninstallResourceError, type UninstallResourceErrors, type UninstallResourceResponse, type UninstallResourceResponses, type UnlinkModpackData, type UnlinkModpackError, type UnlinkModpackErrors, type UnlinkModpackResponse, type UnlinkModpackResponses, type UnmanagedResource, type UpdateAllResourcesData, type UpdateAllResourcesError, type UpdateAllResourcesErrors, type UpdateAllResourcesResponse, type UpdateAllResourcesResponses, type UpdateApiKeyData, type UpdateApiKeyError, type UpdateApiKeyErrors, type UpdateApiKeyPayload, type UpdateApiKeyResponse, type UpdateApiKeyResponses, type UpdateBackupData, type UpdateBackupError, type UpdateBackupErrors, type UpdateBackupResponse, type UpdateBackupResponses, type UpdateDatabaseHost, type UpdateDatabaseHostData, type UpdateDatabaseHostError, type UpdateDatabaseHostErrors, type UpdateDatabaseHostRegistrationData, type UpdateDatabaseHostRegistrationError, type UpdateDatabaseHostRegistrationErrors, type UpdateDatabaseHostRegistrationResponse, type UpdateDatabaseHostRegistrationResponses, type UpdateDatabaseHostResponse, type UpdateDatabaseHostResponses, type UpdateDatabaseInstance, type UpdateDatabaseInstanceData, type UpdateDatabaseInstanceError, type UpdateDatabaseInstanceErrors, type UpdateDatabaseInstanceResponse, type UpdateDatabaseInstanceResponses, type UpdateDeployConfigData, type UpdateDeployConfigError, type UpdateDeployConfigErrors, type UpdateDeployConfigResponse, type UpdateDeployConfigResponses, type UpdateMaintenanceData, type UpdateMaintenanceError, type UpdateMaintenanceErrors, type UpdateMaintenanceResponse, type UpdateMaintenanceResponses, type UpdateNodeData, type UpdateNodeError, type UpdateNodeErrors, type UpdateNodeOverallocationData, type UpdateNodeOverallocationError, type UpdateNodeOverallocationErrors, type UpdateNodeOverallocationResponse, type UpdateNodeOverallocationResponses, type UpdateNodeRegistrationData, type UpdateNodeRegistrationError, type UpdateNodeRegistrationErrors, type UpdateNodeRegistrationPayload, type UpdateNodeRegistrationResponse, type UpdateNodeRegistrationResponses, type UpdateNodeResponse, type UpdateNodeResponses, type UpdatePortData, type UpdatePortError, type UpdatePortErrors, type UpdatePortPayload, type UpdatePortResponse, type UpdatePortResponses, type UpdateProjectData, type UpdateProjectError, type UpdateProjectErrors, type UpdateProjectPayload, type UpdateProjectResponse, type UpdateProjectResponses, type UpdateResourceData, type UpdateResourceError, type UpdateResourceErrors, type UpdateResourceResponse, type UpdateResourceResponses, type UpdateRouteDomainData, type UpdateRouteDomainError, type UpdateRouteDomainErrors, type UpdateRouteDomainResponse, type UpdateRouteDomainResponses, type UpdateScheduleData, type UpdateScheduleError, type UpdateScheduleErrors, type UpdateScheduleResponse, type UpdateScheduleResponses, type UpdateServerData, type UpdateServerError, type UpdateServerErrors, type UpdateServerPayload, type UpdateServerResponse, type UpdateServerResponses, type UpdateStartupData, type UpdateStartupError, type UpdateStartupErrors, type UpdateStartupPayload, type UpdateStartupResponse, type UpdateStartupResponses, type UpdateUserData, type UpdateUserError, type UpdateUserErrors, type UpdateUserPayload, type UpdateUserResponse, type UpdateUserResponses, type UpdateVariablesData, type UpdateVariablesError, type UpdateVariablesErrors, type UpdateVariablesResponse, type UpdateVariablesResponses, type UploadProjectLogoData, type UploadProjectLogoError, type UploadProjectLogoErrors, type UploadProjectLogoResponse, type UploadProjectLogoResponses, type UploadUserData, type UploadUserError, type UploadUserErrors, type UploadUserResponse, type UploadUserResponses, type UserGrant, type Value, type VariableField, type VariableGroup, type VariablePayload, type VariableType, type VolumeMapping, type WipeServerData, type WipeServerError, type WipeServerErrors, type WipeServerResponse, type WipeServerResponses } from './types.gen'; +export { type AcceptServerLicenseData, type AcceptServerLicenseError, type AcceptServerLicenseErrors, type AcceptServerLicenseResponse, type AcceptServerLicenseResponses, type AddNodePortData, type AddNodePortError, type AddNodePortErrors, type AddNodePortPayload, type AddNodePortResponse, type AddNodePortResponses, type AddPortData, type AddPortError, type AddPortErrors, type AddPortPayload, type AddPortResponse, type AddPortResponses, type AddSshKeyData, type AddSshKeyError, type AddSshKeyErrors, type AddSshKeyPayload, type AddSshKeyResponse, type AddSshKeyResponses, type AgentRegistrationRequest, type AllowDomainProjectData, type AllowDomainProjectError, type AllowDomainProjectErrors, type AllowDomainProjectResponse, type AllowDomainProjectResponses, type ApiInstance, type ArchiveFileData, type ArchiveFileError, type ArchiveFileErrors, type ArchiveFilePayload, type ArchiveFileResponse, type ArchiveFileResponses, type AssignGroupNodeData, type AssignGroupNodeError, type AssignGroupNodeErrors, type AssignGroupNodeResponse, type AssignGroupNodeResponses, type AuthPasswordData, type AuthPasswordError, type AuthPasswordErrors, type AuthPasswordResponse, type AuthPasswordResponses, type AuthPublickeyData, type AuthPublickeyError, type AuthPublickeyErrors, type AuthPublickeyResponse, type AuthPublickeyResponses, type AvailablePortsData, type AvailablePortsError, type AvailablePortsErrors, type AvailablePortsResponse, type AvailablePortsResponses, type BackupRetention, type BackupStorage, type BanUserData, type BanUserError, type BanUserErrors, type BanUserResponse, type BanUserResponses, type Bool, type CallbackData, type CallbackError, type CallbackErrors, type ChangeServerTemplateData, type ChangeServerTemplateError, type ChangeServerTemplateErrors, type ChangeServerTemplatePayload, type ChangeServerTemplateResponse, type ChangeServerTemplateResponses, type CheckProjectSlugData, type CheckProjectSlugError, type CheckProjectSlugErrors, type CheckProjectSlugResponse, type CheckProjectSlugResponses, type ChmodFileData, type ChmodFileError, type ChmodFileErrors, type ChmodFileResponse, type ChmodFileResponses, type ChownFileData, type ChownFileError, type ChownFileErrors, type ChownFileResponse, type ChownFileResponses, type ClientOptions, ContainerState, type CopyFileData, type CopyFileError, type CopyFileErrors, type CopyFileResponse, type CopyFileResponses, type CreateApiKeyData, type CreateApiKeyError, type CreateApiKeyErrors, type CreateApiKeyPayload, type CreateApiKeyResponse, type CreateApiKeyResponses, type CreateBackupData, type CreateBackupError, type CreateBackupErrors, type CreateBackupResponse, type CreateBackupResponses, type CreateBackupWithStorageData, type CreateBackupWithStorageError, type CreateBackupWithStorageErrors, type CreateBackupWithStorageResponse, type CreateBackupWithStorageResponses, type CreateDatabaseHost, type CreateDatabaseHostData, type CreateDatabaseHostError, type CreateDatabaseHostErrors, type CreateDatabaseHostResponse, type CreateDatabaseHostResponses, type CreateDeployConfigData, type CreateDeployConfigError, type CreateDeployConfigErrors, type CreateDeployConfigResponse, type CreateDeployConfigResponses, type CreateDirectoryData, type CreateDirectoryError, type CreateDirectoryErrors, type CreateDirectoryResponse, type CreateDirectoryResponses, type CreateGroupData, type CreateGroupError, type CreateGroupErrors, type CreateGroupPayload, type CreateGroupResponse, type CreateGroupResponses, type CreateInstanceDatabase, type CreateInstanceDatabaseData, type CreateInstanceDatabaseError, type CreateInstanceDatabaseErrors, type CreateInstanceDatabaseResponse, type CreateInstanceDatabaseResponses, type CreateInstanceUser, type CreateInstanceUserData, type CreateInstanceUserError, type CreateInstanceUserErrors, type CreateInstanceUserResponse, type CreateInstanceUserResponses, type CreateNodeData, type CreateNodeError, type CreateNodeErrors, type CreateNodePayload, type CreateNodeResponse, type CreateNodeResponses, type CreateProjectData, type CreateProjectError, type CreateProjectErrors, type CreateProjectInstance, type CreateProjectInstanceData, type CreateProjectInstanceError, type CreateProjectInstanceErrors, type CreateProjectInstanceResponse, type CreateProjectInstanceResponses, type CreateProjectPayload, type CreateProjectResponse, type CreateProjectResponses, type CreateRouteData, type CreateRouteDomainData, type CreateRouteDomainError, type CreateRouteDomainErrors, type CreateRouteDomainResponse, type CreateRouteDomainResponses, type CreateRouteError, type CreateRouteErrors, type CreateRouteResponse, type CreateRouteResponses, type CreateScheduleData, type CreateScheduleError, type CreateScheduleErrors, type CreateScheduleResponse, type CreateScheduleResponses, type CreateServerData, type CreateServerError, type CreateServerErrors, type CreateServerPayload, type CreateServerResponse, type CreateServerResponses, type DaemonConfigData, type DaemonConfigError, type DaemonConfigErrors, type DaemonConfigResponse, type DaemonConfigResponses, type DaemonRegisterData, type DaemonRegisterError, type DaemonRegisterErrors, type DaemonRegisterResponse, type DaemonRegisterResponses, type DaemonRootKeyData, type DaemonRootKeyError, type DaemonRootKeyErrors, type DaemonRootKeyResponse, type DaemonRootKeyResponses, DatabaseAccess, type DatabaseAgentConfigData, type DatabaseAgentConfigError, type DatabaseAgentConfigErrors, type DatabaseAgentConfigResponse, type DatabaseAgentConfigResponses, type DatabaseAgentRegisterData, type DatabaseAgentRegisterError, type DatabaseAgentRegisterErrors, type DatabaseAgentRegisterResponse, type DatabaseAgentRegisterResponses, type DatabaseAgentRootKeyData, type DatabaseAgentRootKeyError, type DatabaseAgentRootKeyErrors, type DatabaseAgentRootKeyResponse, type DatabaseAgentRootKeyResponses, DatabaseType, type DeleteAnyApiKeyData, type DeleteAnyApiKeyError, type DeleteAnyApiKeyErrors, type DeleteAnyApiKeyResponse, type DeleteAnyApiKeyResponses, type DeleteApiKeyData, type DeleteApiKeyError, type DeleteApiKeyErrors, type DeleteApiKeyResponse, type DeleteApiKeyResponses, type DeleteBackupData, type DeleteBackupError, type DeleteBackupErrors, type DeleteBackupResponse, type DeleteBackupResponses, type DeleteDatabaseHostData, type DeleteDatabaseHostError, type DeleteDatabaseHostErrors, type DeleteDatabaseHostResponse, type DeleteDatabaseHostResponses, type DeleteDatabaseInstanceData, type DeleteDatabaseInstanceError, type DeleteDatabaseInstanceErrors, type DeleteDatabaseInstanceResponse, type DeleteDatabaseInstanceResponses, type DeleteDeployConfigData, type DeleteDeployConfigError, type DeleteDeployConfigErrors, type DeleteDeployConfigResponse, type DeleteDeployConfigResponses, type DeleteFileData, type DeleteFileError, type DeleteFileErrors, type DeleteFileResponse, type DeleteFileResponses, type DeleteGroupData, type DeleteGroupError, type DeleteGroupErrors, type DeleteGroupResponse, type DeleteGroupResponses, type DeleteInstanceDatabaseData, type DeleteInstanceDatabaseError, type DeleteInstanceDatabaseErrors, type DeleteInstanceDatabaseResponse, type DeleteInstanceDatabaseResponses, type DeleteInstanceUserData, type DeleteInstanceUserError, type DeleteInstanceUserErrors, type DeleteInstanceUserResponse, type DeleteInstanceUserResponses, type DeleteNodeData, type DeleteNodeError, type DeleteNodeErrors, type DeleteNodePortData, type DeleteNodePortError, type DeleteNodePortErrors, type DeleteNodePortResponse, type DeleteNodePortResponses, type DeleteNodeResponse, type DeleteNodeResponses, type DeleteOtherSessionsData, type DeleteOtherSessionsError, type DeleteOtherSessionsErrors, type DeleteOtherSessionsResponse, type DeleteOtherSessionsResponses, type DeletePortData, type DeletePortError, type DeletePortErrors, type DeletePortResponse, type DeletePortResponses, type DeleteProjectData, type DeleteProjectError, type DeleteProjectErrors, type DeleteProjectInstanceData, type DeleteProjectInstanceError, type DeleteProjectInstanceErrors, type DeleteProjectInstanceResponse, type DeleteProjectInstanceResponses, type DeleteProjectLogoData, type DeleteProjectLogoError, type DeleteProjectLogoErrors, type DeleteProjectLogoResponse, type DeleteProjectLogoResponses, type DeleteProjectResponse, type DeleteProjectResponses, type DeleteRouteData, type DeleteRouteDomainData, type DeleteRouteDomainError, type DeleteRouteDomainErrors, type DeleteRouteDomainResponse, type DeleteRouteDomainResponses, type DeleteRouteError, type DeleteRouteErrors, type DeleteRouteResponse, type DeleteRouteResponses, type DeleteScheduleData, type DeleteScheduleError, type DeleteScheduleErrors, type DeleteScheduleResponse, type DeleteScheduleResponses, type DeleteServerData, type DeleteServerError, type DeleteServerErrors, type DeleteServerResponse, type DeleteServerResponses, type DeleteSessionData, type DeleteSessionError, type DeleteSessionErrors, type DeleteSessionResponse, type DeleteSessionResponses, type DeleteSshKeyData, type DeleteSshKeyError, type DeleteSshKeyErrors, type DeleteSshKeyResponse, type DeleteSshKeyResponses, type DeleteUserData, type DeleteUserError, type DeleteUserErrors, type DeleteUserResponse, type DeleteUserResponses, type DenyDomainProjectData, type DenyDomainProjectError, type DenyDomainProjectErrors, type DenyDomainProjectResponse, type DenyDomainProjectResponses, type DeopUserData, type DeopUserError, type DeopUserErrors, type DeopUserResponse, type DeopUserResponses, type DeployConfigPayload, type DeployServerData, type DeployServerError, type DeployServerErrors, type DeployServerPayload, type DeployServerResponse, type DeployServerResponses, type DownloadBackupData, type DownloadBackupError, type DownloadBackupErrors, type DownloadBackupResponse, type DownloadBackupResponses, type DownloadUserData, type DownloadUserError, type DownloadUserErrors, type DownloadUserResponse, type DownloadUserResponses, type ExtendDatabaseHostRegistrationData, type ExtendDatabaseHostRegistrationError, type ExtendDatabaseHostRegistrationErrors, type ExtendDatabaseHostRegistrationResponse, type ExtendDatabaseHostRegistrationResponses, type ExtendNodeRegistrationData, type ExtendNodeRegistrationError, type ExtendNodeRegistrationErrors, type ExtendNodeRegistrationResponse, type ExtendNodeRegistrationResponses, type GetConfigData, type GetConfigError, type GetConfigErrors, type GetConfigResponses, type GetProjectData, type GetProjectError, type GetProjectErrors, type GetProjectResponse, type GetProjectResponses, type GetUserData, type GetUserError, type GetUserErrors, type GetUserResponse, type GetUserResponses, type GroupLimits, type GroupNodeInfo, type GroupRef, type HashMap, type HostAndPort, type IndexSetString, type InstalledModpack, type InstalledResource, type InstallModpackData, type InstallModpackError, type InstallModpackErrors, type InstallModpackPayload, type InstallModpackResponse, type InstallModpackResponses, type InstallResourceData, type InstallResourceError, type InstallResourceErrors, type InstallResourcePayload, type InstallResourceResponse, type InstallResourceResponses, type InstancePolicyData, type InstancePolicyError, type InstancePolicyErrors, type InstancePolicyResponse, type InstancePolicyResponses, IntegrityCategory, type IntegrityCheckInfo, type IntegrityCheckResult, type IntegrityFinding, type IntegrityReport, type IntegrityReportData, type IntegrityReportError, type IntegrityReportErrors, type IntegrityReportResponse, type IntegrityReportResponses, IntegrityRunState, type IntegritySchema, type IntegritySchemaData, type IntegritySchemaError, type IntegritySchemaErrors, type IntegritySchemaResponse, type IntegritySchemaResponses, type IntegritySchemaTable, IntegritySeverity, type IntegritySource, type IntegritySourceReport, type KillServerData, type KillServerError, type KillServerErrors, type KillServerResponse, type KillServerResponses, type LightNodeInfo, type ListAllApiKeysData, type ListAllApiKeysError, type ListAllApiKeysErrors, type ListAllApiKeysResponse, type ListAllApiKeysResponses, type ListApiKeysData, type ListApiKeysError, type ListApiKeysErrors, type ListApiKeysResponse, type ListApiKeysResponses, type ListBackupsData, type ListBackupsError, type ListBackupsErrors, type ListBackupsResponse, type ListBackupsResponses, type ListDatabaseHostRegistrationsData, type ListDatabaseHostRegistrationsError, type ListDatabaseHostRegistrationsErrors, type ListDatabaseHostRegistrationsResponse, type ListDatabaseHostRegistrationsResponses, type ListDatabaseHostsData, type ListDatabaseHostsError, type ListDatabaseHostsErrors, type ListDatabaseHostsResponse, type ListDatabaseHostsResponses, type ListDatabaseInstancesData, type ListDatabaseInstancesError, type ListDatabaseInstancesErrors, type ListDatabaseInstancesResponse, type ListDatabaseInstancesResponses, type ListDeployConfigsData, type ListDeployConfigsError, type ListDeployConfigsErrors, type ListDeployConfigsResponse, type ListDeployConfigsResponses, type ListDomainRoutesData, type ListDomainRoutesError, type ListDomainRoutesErrors, type ListDomainRoutesResponse, type ListDomainRoutesResponses, type ListFilesData, type ListFilesError, type ListFilesErrors, type ListFilesResponse, type ListFilesResponses, type ListGroupsData, type ListGroupsError, type ListGroupsErrors, type ListGroupsResponse, type ListGroupsResponses, type ListInstalledResourcesData, type ListInstalledResourcesError, type ListInstalledResourcesErrors, type ListInstalledResourcesResponse, type ListInstalledResourcesResponses, type ListInstanceDatabasesData, type ListInstanceDatabasesError, type ListInstanceDatabasesErrors, type ListInstanceDatabasesResponse, type ListInstanceDatabasesResponses, type ListInstanceUsersData, type ListInstanceUsersError, type ListInstanceUsersErrors, type ListInstanceUsersResponse, type ListInstanceUsersResponses, type ListIntegrityChecksData, type ListIntegrityChecksError, type ListIntegrityChecksErrors, type ListIntegrityChecksResponse, type ListIntegrityChecksResponses, type ListNodeGroupsData, type ListNodeGroupsError, type ListNodeGroupsErrors, type ListNodeGroupsResponse, type ListNodeGroupsResponses, type ListNodeRegistrationsData, type ListNodeRegistrationsError, type ListNodeRegistrationsErrors, type ListNodeRegistrationsResponse, type ListNodeRegistrationsResponses, type ListNodesData, type ListNodesError, type ListNodesErrors, type ListNodesResponse, type ListNodesResponses, type ListProjectApiKeysData, type ListProjectApiKeysError, type ListProjectApiKeysErrors, type ListProjectApiKeysResponse, type ListProjectApiKeysResponses, type ListProjectInstancesData, type ListProjectInstancesError, type ListProjectInstancesErrors, type ListProjectInstancesResponse, type ListProjectInstancesResponses, type ListProjectsData, type ListProjectsError, type ListProjectsErrors, type ListProjectsResponse, type ListProjectsResponses, type ListProjectUsersData, type ListProjectUsersError, type ListProjectUsersErrors, type ListProjectUsersResponse, type ListProjectUsersResponses, type ListResourceUpdatesData, type ListResourceUpdatesError, type ListResourceUpdatesErrors, type ListResourceUpdatesResponse, type ListResourceUpdatesResponses, type ListResourceVersionsData, type ListResourceVersionsError, type ListResourceVersionsErrors, type ListResourceVersionsResponse, type ListResourceVersionsResponses, type ListRouteDomainsAdminData, type ListRouteDomainsAdminError, type ListRouteDomainsAdminErrors, type ListRouteDomainsAdminResponse, type ListRouteDomainsAdminResponses, type ListRouteDomainsData, type ListRouteDomainsError, type ListRouteDomainsErrors, type ListRouteDomainsResponse, type ListRouteDomainsResponses, type ListRoutesData, type ListRoutesError, type ListRoutesErrors, type ListRoutesResponse, type ListRoutesResponses, type ListSchedulesData, type ListSchedulesError, type ListSchedulesErrors, type ListSchedulesResponse, type ListSchedulesResponses, type ListServersData, type ListServersError, type ListServersErrors, type ListServersResponse, type ListServersResponses, type ListSessionsData, type ListSessionsError, type ListSessionsErrors, type ListSessionsResponse, type ListSessionsResponses, type ListSshKeysData, type ListSshKeysError, type ListSshKeysErrors, type ListSshKeysResponse, type ListSshKeysResponses, type ListTemplatesData, type ListTemplatesError, type ListTemplatesErrors, type ListTemplatesResponse, type ListTemplatesResponses, type ListTemplateUsageData, type ListTemplateUsageError, type ListTemplateUsageErrors, type ListTemplateUsageResponse, type ListTemplateUsageResponses, type ListUserProjectsData, type ListUserProjectsError, type ListUserProjectsErrors, type ListUserProjectsResponse, type ListUserProjectsResponses, type ListUsersData, type ListUsersError, type ListUsersErrors, type ListUsersResponse, type ListUsersResponses, type LoginData, type LoginDevData, type LoginDevError, type LoginDevErrors, type LoginError, type LoginErrors, type LogoutData, type LogoutError, type LogoutErrors, type LogoutResponse, type LogoutResponses, type MeData, type MeError, type MeErrors, type MeResponse, type MeResponses, type MetricsNodeData, type MetricsNodeError, type MetricsNodeErrors, type MetricsNodeResponse, type MetricsNodeResponses, ModpackInstallMode, ModpackJobState, type ModpackLoaderInfo, type ModpackMod, type ModpackStatusData, type ModpackStatusError, type ModpackStatusErrors, type ModpackStatusResponse, type ModpackStatusResponses, ModpackStep, type ModpackVersionsData, type ModpackVersionsError, type ModpackVersionsErrors, type ModpackVersionsResponse, type ModpackVersionsResponses, type MoveServerData, type MoveServerError, type MoveServerErrors, type MoveServerResponse, type MoveServerResponses, type NanoId, type NetworkPort, NetworkProtocol, type NodePortAllocation, type NodeRegistrationRequest, type Option, type OpUserData, type OpUserError, type OpUserErrors, type OpUserResponse, type OpUserResponses, PackLoader, type PackPlatform, type PardonUserData, type PardonUserError, type PardonUserErrors, type PardonUserResponse, type PardonUserResponses, type PortRange, PowerAction, type PowerProjectInstanceData, type PowerProjectInstanceError, type PowerProjectInstanceErrors, type PowerProjectInstanceResponse, type PowerProjectInstanceResponses, type PrimaryPortInfo, type RealtimeDownloadBackupData, type RealtimeDownloadBackupError, type RealtimeDownloadBackupErrors, type RealtimeDownloadBackupResponse, type RealtimeDownloadBackupResponses, type RefreshTemplateCacheData, type RefreshTemplateCacheError, type RefreshTemplateCacheErrors, type RefreshTemplateCachePayload, type RefreshTemplateCacheResponse, type RefreshTemplateCacheResponses, type RemoveGroupNodeData, type RemoveGroupNodeError, type RemoveGroupNodeErrors, type RemoveGroupNodeResponse, type RemoveGroupNodeResponses, type RemoveProjectUserData, type RemoveProjectUserError, type RemoveProjectUserErrors, type RemoveProjectUserResponse, type RemoveProjectUserResponses, type RenameFileData, type RenameFileError, type RenameFileErrors, type RenameFileResponse, type RenameFileResponses, type RerollDaemonPasswordData, type RerollDaemonPasswordError, type RerollDaemonPasswordErrors, type RerollDaemonPasswordResponse, type RerollDaemonPasswordResponses, type ResolveDomainZoneData, type ResolveDomainZoneError, type ResolveDomainZoneErrors, type ResolveDomainZoneResponse, type ResolveDomainZoneResponses, type ResourceContextData, type ResourceContextError, type ResourceContextErrors, type ResourceContextResponse, type ResourceContextResponses, ResourceKind, ResourceLoader, type ResourceLoaderInfo, ResourceProvider, type ResourceSubset, ResourceType, type ResourceUsage, type ResourceVersion, ResourceVersionType, type RestartServerData, type RestartServerError, type RestartServerErrors, type RestartServerResponse, type RestartServerResponses, type RestoreBackupData, type RestoreBackupError, type RestoreBackupErrors, type RestoreBackupResponse, type RestoreBackupResponses, type ResyncTemplateData, type ResyncTemplateError, type ResyncTemplateErrors, type ResyncTemplatePayload, type ResyncTemplateResponse, type ResyncTemplateResponses, Role, type RotateInstanceUserPasswordData, type RotateInstanceUserPasswordError, type RotateInstanceUserPasswordErrors, type RotateInstanceUserPasswordResponse, type RotateInstanceUserPasswordResponses, type RouteBackendsData, type RouteBackendsError, type RouteBackendsErrors, type RouteBackendsResponse, type RouteBackendsResponses, type RouteDomainPayload, type RouteDomainUpdatePayload, RouteKind, type RoutePayload, type RouteRecord, RouteStatus, type RunIntegrityChecksData, type RunIntegrityChecksError, type RunIntegrityChecksErrors, type RunIntegrityChecksPayload, type RunIntegrityChecksResponse, type RunIntegrityChecksResponses, type RunScheduleData, type RunScheduleError, type RunScheduleErrors, type RunScheduleResponse, type RunScheduleResponses, type SchedulePayloadRequest, SchedulePolicy, type ScheduleTask, type SearchResourcesData, type SearchResourcesError, type SearchResourcesErrors, type SearchResourcesResponse, type SearchResourcesResponses, type ServerGroupChange, type ServerInfoData, type ServerInfoError, type ServerInfoErrors, type ServerInfoResponse, type ServerInfoResponses, type ServerLicensesData, type ServerLicensesError, type ServerLicensesErrors, type ServerLicensesResponse, type ServerLicensesResponses, type ServerLimits, type ServerListStatus, type ServerNetworkInfo, type ServerResourcePayload, type ServerResyncResult, ServerResyncStatus, type ServerStartupData, type ServerStartupEnvironmentInfo, type ServerStartupError, type ServerStartupErrors, type ServerStartupResponse, type ServerStartupResponses, type ServerStats, type ServerStatsData, type ServerStatsError, type ServerStatsErrors, type ServerStatsResponse, type ServerStatsResponses, type ServerStorage, type ServerTerminalData, type ServerTerminalError, type ServerTerminalErrors, type ServerTerminalResponse, type ServerTerminalResponses, type ServerVariablesData, type ServerVariablesError, type ServerVariablesErrors, type ServerVariablesResponse, type ServerVariablesResponses, type SetConfigData, type SetConfigError, type SetConfigErrors, type SetConfigResponse, type SetConfigResponses, type SetInstanceUserGrants, type SetInstanceUserGrantsData, type SetInstanceUserGrantsError, type SetInstanceUserGrantsErrors, type SetInstanceUserGrantsResponse, type SetInstanceUserGrantsResponses, type SetProjectLogo, type SetProjectLogoData, type SetProjectLogoError, type SetProjectLogoErrors, type SetProjectLogoResponse, type SetProjectLogoResponses, type SetProjectUserData, type SetProjectUserError, type SetProjectUserErrors, type SetProjectUserPayload, type SetProjectUserResponse, type SetProjectUserResponses, type SetResourceStateData, type SetResourceStateError, type SetResourceStateErrors, type SetResourceStateResponse, type SetResourceStateResponses, type SetupNodeConfigRequest, type SshEndpointRequest, type StartServerData, type StartServerError, type StartServerErrors, type StartServerResponse, type StartServerResponses, type StatFileData, type StatFileError, type StatFileErrors, type StatFileResponse, type StatFileResponses, type StopServerData, type StopServerError, type StopServerErrors, type StopServerResponse, type StopServerResponses, type StorageUsage, type StorageUsageEntry, type StoredDatabase, type StoredInstance, type StoredUser, type SymlinkFileData, type SymlinkFileError, type SymlinkFileErrors, type SymlinkFileResponse, type SymlinkFileResponses, type TemplateInfoData, type TemplateInfoError, type TemplateInfoErrors, type TemplateInfoResponse, type TemplateInfoResponses, type TemplatePortDefinition, type U16, type UnarchiveFileData, type UnarchiveFileError, type UnarchiveFileErrors, type UnarchiveFileResponse, type UnarchiveFileResponses, type UninstallResourceData, type UninstallResourceError, type UninstallResourceErrors, type UninstallResourceResponse, type UninstallResourceResponses, type UnlinkModpackData, type UnlinkModpackError, type UnlinkModpackErrors, type UnlinkModpackResponse, type UnlinkModpackResponses, type UnmanagedResource, type UnresolvedDependency, type UpdateAllResourcesData, type UpdateAllResourcesError, type UpdateAllResourcesErrors, type UpdateAllResourcesResponse, type UpdateAllResourcesResponses, type UpdateApiKeyData, type UpdateApiKeyError, type UpdateApiKeyErrors, type UpdateApiKeyPayload, type UpdateApiKeyResponse, type UpdateApiKeyResponses, type UpdateBackupData, type UpdateBackupError, type UpdateBackupErrors, type UpdateBackupResponse, type UpdateBackupResponses, type UpdateDatabaseHost, type UpdateDatabaseHostData, type UpdateDatabaseHostError, type UpdateDatabaseHostErrors, type UpdateDatabaseHostRegistrationData, type UpdateDatabaseHostRegistrationError, type UpdateDatabaseHostRegistrationErrors, type UpdateDatabaseHostRegistrationResponse, type UpdateDatabaseHostRegistrationResponses, type UpdateDatabaseHostResponse, type UpdateDatabaseHostResponses, type UpdateDatabaseInstance, type UpdateDatabaseInstanceData, type UpdateDatabaseInstanceError, type UpdateDatabaseInstanceErrors, type UpdateDatabaseInstanceResponse, type UpdateDatabaseInstanceResponses, type UpdateDeployConfigData, type UpdateDeployConfigError, type UpdateDeployConfigErrors, type UpdateDeployConfigResponse, type UpdateDeployConfigResponses, type UpdateMaintenanceData, type UpdateMaintenanceError, type UpdateMaintenanceErrors, type UpdateMaintenanceResponse, type UpdateMaintenanceResponses, type UpdateNodeData, type UpdateNodeError, type UpdateNodeErrors, type UpdateNodeOverallocationData, type UpdateNodeOverallocationError, type UpdateNodeOverallocationErrors, type UpdateNodeOverallocationResponse, type UpdateNodeOverallocationResponses, type UpdateNodeRegistrationData, type UpdateNodeRegistrationError, type UpdateNodeRegistrationErrors, type UpdateNodeRegistrationPayload, type UpdateNodeRegistrationResponse, type UpdateNodeRegistrationResponses, type UpdateNodeResponse, type UpdateNodeResponses, type UpdatePortData, type UpdatePortError, type UpdatePortErrors, type UpdatePortPayload, type UpdatePortResponse, type UpdatePortResponses, type UpdateProjectData, type UpdateProjectError, type UpdateProjectErrors, type UpdateProjectPayload, type UpdateProjectResponse, type UpdateProjectResponses, type UpdateResourceData, type UpdateResourceError, type UpdateResourceErrors, type UpdateResourceResponse, type UpdateResourceResponses, type UpdateRouteDomainData, type UpdateRouteDomainError, type UpdateRouteDomainErrors, type UpdateRouteDomainResponse, type UpdateRouteDomainResponses, type UpdateScheduleData, type UpdateScheduleError, type UpdateScheduleErrors, type UpdateScheduleResponse, type UpdateScheduleResponses, type UpdateServerData, type UpdateServerError, type UpdateServerErrors, type UpdateServerPayload, type UpdateServerResponse, type UpdateServerResponses, type UpdateStartupData, type UpdateStartupError, type UpdateStartupErrors, type UpdateStartupPayload, type UpdateStartupResponse, type UpdateStartupResponses, type UpdateUserData, type UpdateUserError, type UpdateUserErrors, type UpdateUserPayload, type UpdateUserResponse, type UpdateUserResponses, type UpdateVariablesData, type UpdateVariablesError, type UpdateVariablesErrors, type UpdateVariablesResponse, type UpdateVariablesResponses, type UploadProjectLogoData, type UploadProjectLogoError, type UploadProjectLogoErrors, type UploadProjectLogoResponse, type UploadProjectLogoResponses, type UploadUserData, type UploadUserError, type UploadUserErrors, type UploadUserResponse, type UploadUserResponses, type UserGrant, type Value, type VariableField, type VariableGroup, type VariablePayload, type VariableType, type VolumeMapping, type WipeServerData, type WipeServerError, type WipeServerErrors, type WipeServerResponse, type WipeServerResponses } from './types.gen'; diff --git a/frontend/lib/api/sdk.gen.ts b/frontend/lib/api/sdk.gen.ts index 69d4f3b..2da9907 100644 --- a/frontend/lib/api/sdk.gen.ts +++ b/frontend/lib/api/sdk.gen.ts @@ -1255,7 +1255,15 @@ export const deopUser = (options: Options< /** * Reports which loaders and providers this server can browse. * - * Reports which loaders and providers this server can browse. + * Reports which loaders and providers this server can browse.
+ *
+ * `modpack_loaders` lists the loaders whose packs can be installed. A pack
+ * for another loader than the server's own switches the server's software
+ * when it is installed, so those are only offered (`switches_platform`)
+ * when the caller also holds `ServerPermissions::BUILD_EDIT`.
+ *
+ * # Permissions
+ * Requires `ServerPermissions::FILES_READ`. */ export const resourceContext = (options: Options): RequestResult => (options.client ?? client).get({ url: '/resource/{server_id}', ...options }); @@ -1376,9 +1384,10 @@ export const setResourceState = (options: * * Forgets the modpack this server is linked to.
*
- * Only the link is dropped — the pack's mods and configuration stay on
+ * Only the link is dropped: the pack's mods and configuration stay on
* disk, so the server keeps running exactly as it did. Its jars stop being
- * reported as pack contents and show up as hand-uploaded files instead.
+ * reported as pack contents and show up as hand-uploaded files instead,
+ * and the loader and Minecraft version can be changed freely again.
*
* # Permissions
* Requires `ServerPermissions::FILES_WRITE`. @@ -1396,23 +1405,34 @@ export const unlinkModpack = (options: Opt export const modpackStatus = (options: Options): RequestResult => (options.client ?? client).get({ url: '/modpack/{server_id}', ...options }); /** - * Installs a modpack in the background. + * Installs, updates or reinstalls a modpack in the background. * - * Installs a modpack in the background.
+ * Installs, updates or reinstalls a modpack in the background.
*
- * This rewrites files on a live server, so `confirm` is mandatory. With
- * `backup` the server is backed up first and the install is abandoned if
- * that fails; with `wipe` the existing mod jars are cleared so the result
- * matches the pack author's intent, and without it the pack is merged into
- * what is already installed.
+ * The install is staged: the pack and every file it lists are downloaded
+ * into `.novium-modpack/` and checked against the sizes the pack declares
+ * before anything on the server changes. If any download fails the job
+ * stops there and the server is left exactly as it was.
*
- * Returns as soon as the job is accepted — a pack is one large archive plus
- * hundreds of downloads, so progress is polled from `GET /modpack/{id}`.
- * The server has to be stopped: the install rewrites the mod jars and the
- * pack's configuration underneath a running process.
+ * Only then is the server switched over: files of the previously linked
+ * pack that the new version no longer ships are removed, the new files are
+ * moved into place and the pack's configuration overrides are applied.
+ * Mods added on top of the pack are kept, and pack mods that were disabled
+ * stay disabled. With `mode: reset` every mod that is not part of the pack is
+ * removed as well, which returns the server to the pack's original state.
+ *
+ * A pack declares the loader and Minecraft version it was built for. When
+ * the server runs something else, `sync_platform` switches the server's
+ * software to match (this also needs `ServerPermissions::BUILD_EDIT`);
+ * without it the install is refused instead of producing a server that
+ * cannot start. `backup` creates a backup right before the switch-over.
+ *
+ * Returns as soon as the job is accepted. Progress is polled from
+ * `GET /modpack/{id}`. The server has to stay stopped while it runs.
*
* # Permissions
- * Requires `ServerPermissions::FILES_WRITE`. + * Requires `ServerPermissions::FILES_WRITE`, and
+ * `ServerPermissions::BUILD_EDIT` when `sync_platform` is set. */ export const installModpack = (options: Options): RequestResult => (options.client ?? client).post({ url: '/modpack/{server_id}/install', diff --git a/frontend/lib/api/types.gen.ts b/frontend/lib/api/types.gen.ts index 16be0c0..64e9fe1 100644 --- a/frontend/lib/api/types.gen.ts +++ b/frontend/lib/api/types.gen.ts @@ -234,13 +234,14 @@ export type InstallModpackPayload = { confirm?: boolean; icon_url?: string | null; loader: ResourceLoader; + mode?: ModpackInstallMode; page_url?: string | null; project: string; provider: ResourceProvider; + sync_platform?: boolean; title?: string | null; version: string; version_number?: string | null; - wipe?: boolean; }; export type InstallResourcePayload = { @@ -253,9 +254,12 @@ export type InstallResourcePayload = { }; export type InstalledModpack = { + game_version?: string | null; icon_url?: string | null; installed_at: number; loader?: null | ResourceLoader; + loader_version?: string | null; + missing: Array; mods: Array; other_files: number; page_url?: string | null; @@ -385,6 +389,10 @@ export type LightNodeInfo = { ssh_port?: number | null; }; +export const ModpackInstallMode = { KEEP: 'keep', RESET: 'reset' } as const; + +export type ModpackInstallMode = typeof ModpackInstallMode[keyof typeof ModpackInstallMode]; + export const ModpackJobState = { RUNNING: 'running', COMPLETED: 'completed', @@ -393,11 +401,39 @@ export const ModpackJobState = { export type ModpackJobState = typeof ModpackJobState[keyof typeof ModpackJobState]; +export type ModpackLoaderInfo = { + loader: ResourceLoader; + providers: Array; + switches_platform: boolean; +}; + export type ModpackMod = { enabled: boolean; file_name: string; + icon_url?: string | null; + page_url?: string | null; + project?: string | null; resource_type: ResourceType; -}; + title?: string | null; + version?: string | null; +}; + +export const ModpackStep = { + QUEUED: 'queued', + DOWNLOADING: 'downloading', + UNPACKING: 'unpacking', + READING: 'reading', + RESOLVING: 'resolving', + FETCHING: 'fetching', + BACKING_UP: 'backing_up', + PLATFORM: 'platform', + APPLYING: 'applying', + CONFIGURING: 'configuring', + FINISHING: 'finishing', + DONE: 'done' +} as const; + +export type ModpackStep = typeof ModpackStep[keyof typeof ModpackStep]; export type NanoId = string; @@ -433,6 +469,21 @@ export type NodeRegistrationRequest = { export type Option = null | boolean; +export const PackLoader = { + FABRIC: 'fabric', + QUILT: 'quilt', + NEOFORGE: 'neoforge', + FORGE: 'forge' +} as const; + +export type PackLoader = typeof PackLoader[keyof typeof PackLoader]; + +export type PackPlatform = { + game_version?: string | null; + loader?: null | PackLoader; + loader_version?: string | null; +}; + export type PortRange = { end: number; start: number; @@ -473,7 +524,6 @@ export type ResourceLoader = typeof ResourceLoader[keyof typeof ResourceLoader]; export type ResourceLoaderInfo = { loader: ResourceLoader; - modpack_providers: Array; providers: Array; resource_type: ResourceType; }; @@ -520,6 +570,7 @@ export type ResourceUsage = { }; export type ResourceVersion = { + changelog?: string | null; downloadable: boolean; downloads: number; file_name?: string | null; @@ -836,6 +887,12 @@ export type UnmanagedResource = { size: number; }; +export type UnresolvedDependency = { + project?: string | null; + reason: string; + title?: string | null; +}; + export type UpdateApiKeyPayload = { name?: string | null; permissions?: number | null; @@ -11013,8 +11070,10 @@ export type ResourceContextResponses = { */ 200: { backup_available: boolean; + can_change_platform: boolean; game_version?: string | null; loaders: Array; + modpack_loaders: Array; }; }; @@ -11359,6 +11418,7 @@ export type ListResourceVersionsResponses = { * Success */ 200: Array<{ + changelog?: string | null; downloadable: boolean; downloads: number; file_name?: string | null; @@ -11476,6 +11536,15 @@ export type UninstallResourceErrors = { msg?: 'The specified file was not found'; }; }; + /** + * A modpack installation is already running for this server + */ + '409⁠': { + error?: { + id?: 'modpack/already_running'; + msg?: 'A modpack installation is already running for this server'; + }; + }; /** * Authentication storage is unreachable */ @@ -11770,6 +11839,15 @@ export type InstallResourceErrors = { msg?: 'The specified file was not found'; }; }; + /** + * A modpack installation is already running for this server + */ + '409⁩': { + error?: { + id?: 'modpack/already_running'; + msg?: 'A modpack installation is already running for this server'; + }; + }; /** * Authentication storage is unreachable */ @@ -11842,15 +11920,9 @@ export type InstallResourceResponses = { * Success */ 200: { - enabled: boolean; - file_name: string; - installed_at: number; - page_url?: string | null; - project: string; - provider: ResourceProvider; - resource_type: ResourceType; - title: string; - version: string; + dependencies: Array; + installed: InstalledResource; + unresolved: Array; }; }; @@ -12133,6 +12205,15 @@ export type UpdateResourceErrors = { msg?: 'The specified file was not found'; }; }; + /** + * A modpack installation is already running for this server + */ + '409': { + error?: { + id?: 'modpack/already_running'; + msg?: 'A modpack installation is already running for this server'; + }; + }; /** * Authentication storage is unreachable */ @@ -12297,6 +12378,15 @@ export type UpdateAllResourcesErrors = { msg?: 'Server not found'; }; }; + /** + * A modpack installation is already running for this server + */ + '409‬': { + error?: { + id?: 'modpack/already_running'; + msg?: 'A modpack installation is already running for this server'; + }; + }; /** * Authentication storage is unreachable */ @@ -12465,6 +12555,15 @@ export type SetResourceStateErrors = { msg?: 'The specified file was not found'; }; }; + /** + * A modpack installation is already running for this server + */ + '409⁠': { + error?: { + id?: 'modpack/already_running'; + msg?: 'A modpack installation is already running for this server'; + }; + }; /** * Authentication storage is unreachable */ @@ -12564,6 +12663,15 @@ export type UnlinkModpackErrors = { msg?: 'Server not found'; }; }; + /** + * A modpack installation is already running for this server + */ + '409‎': { + error?: { + id?: 'modpack/already_running'; + msg?: 'A modpack installation is already running for this server'; + }; + }; /** * Authentication storage is unreachable */ @@ -12681,10 +12789,13 @@ export type ModpackStatusResponses = { error?: string | null; failures: Array; finished_at?: number | null; + live_untouched: boolean; pack?: string | null; + platform?: null | PackPlatform; + skipped: Array; started_at: number; state: ModpackJobState; - step: string; + step: ModpackStep; total: number; }; }; @@ -12755,15 +12866,6 @@ export type InstallModpackErrors = { msg?: 'Installing a modpack rewrites server files and must be confirmed'; }; }; - /** - * A modpack installation is already running for this server - */ - '400⁢': { - error?: { - id?: 'modpack/already_running'; - msg?: 'A modpack installation is already running for this server'; - }; - }; /** * The server must be stopped before a modpack can be installed */ @@ -12809,6 +12911,15 @@ export type InstallModpackErrors = { msg?: 'The specified resource is missing'; }; }; + /** + * A modpack installation is already running for this server + */ + '409⁢': { + error?: { + id?: 'modpack/already_running'; + msg?: 'A modpack installation is already running for this server'; + }; + }; /** * Authentication storage is unreachable */ @@ -12867,10 +12978,13 @@ export type InstallModpackResponses = { error?: string | null; failures: Array; finished_at?: number | null; + live_untouched: boolean; pack?: string | null; + platform?: null | PackPlatform; + skipped: Array; started_at: number; state: ModpackJobState; - step: string; + step: ModpackStep; total: number; }; }; diff --git a/frontend/lib/mod-browser/types.ts b/frontend/lib/mod-browser/types.ts index c660c85..d0756c1 100644 --- a/frontend/lib/mod-browser/types.ts +++ b/frontend/lib/mod-browser/types.ts @@ -1,6 +1,10 @@ import type { InstallResourcePayload, + InstallResourceResponse, InstalledResource, + ModpackLoaderInfo, + ModpackStep, + PackLoader, ListInstalledResourcesResponse, ListResourceUpdatesResponse, ModpackStatusResponse, @@ -31,10 +35,14 @@ export type ModpackJob = NonNullable; export type ModpackVersions = NonNullable; export type ResourceUpdate = ListResourceUpdatesResponse[number]; export type ResourceUpdateResult = UpdateAllResourcesResponse[number]; +export type ResourceInstallResult = InstallResourceResponse; export type { InstallResourcePayload, InstalledResource, + ModpackLoaderInfo, + ModpackStep, + PackLoader, ResourceKind, ResourceLoader, ResourceLoaderInfo, @@ -78,3 +86,10 @@ export const LOADER_LABELS: Record = { velocity: "Velocity plugins", bungeecord: "BungeeCord plugins", }; + +export const PACK_LOADER_LABELS: Record = { + fabric: "Fabric", + quilt: "Quilt", + neoforge: "NeoForge", + forge: "Forge", +}; diff --git a/frontend/locales/de/servers-management.json b/frontend/locales/de/servers-management.json index 3f8a96b..d7d9ba7 100644 --- a/frontend/locales/de/servers-management.json +++ b/frontend/locales/de/servers-management.json @@ -223,9 +223,8 @@ "description": "Dieses Server-Template bietet keine unterstützte Minecraft-Mod- oder Plugin-Funktion." }, "tabs": { - "modpack": "Modpack", "browse": "Durchsuchen", - "installed": "Installiert" + "content": "Inhalt" }, "versionLabel": "Version", "loadingVersions": "Versionen werden geladen…", @@ -254,33 +253,32 @@ "filesLockedToast": "Ein Modpack wird installiert. Warte, bis es fertig ist, bevor du Dateien änderst.", "toasts": { "installed": "{{title}} installiert.", - "enabled": "{{label}} aktiviert.", - "disabled": "{{label}} deaktiviert.", - "updated": "{{title}} aktualisiert.", "updatedCount_one": "{{count}} Ressource aktualisiert.", "updatedCount_other": "{{count}} Ressourcen aktualisiert.", "updateFailed": "Die Ressourcen konnten nicht aktualisiert werden.", - "removed": "{{title}} entfernt." + "installFailed": "Die Ressource konnte nicht installiert werden.", + "installedWithDependencies_one": "{{title}} und {{count}} Abhängigkeit installiert: {{list}}.", + "installedWithDependencies_other": "{{title}} und {{count}} Abhängigkeiten installiert: {{list}}.", + "dependencyMissing": "Abhängigkeit {{title}} wurde nicht installiert: {{reason}}", + "enabledCount_one": "{{count}} Datei aktiviert.", + "enabledCount_other": "{{count}} Dateien aktiviert.", + "disabledCount_one": "{{count}} Datei deaktiviert.", + "disabledCount_other": "{{count}} Dateien deaktiviert.", + "removedCount_one": "{{count}} Ressource entfernt.", + "removedCount_other": "{{count}} Ressourcen entfernt." }, "browse": { "title": "Mods und Plugins durchsuchen", "description": "Durchsuche Modrinth, CurseForge, Hangar und SpigotMC nach Ressourcen, die zu diesem Server passen. Reine Client-Mods werden ausgelassen.", - "searchPlaceholder": "Mods und Plugins suchen" + "searchPlaceholder": "Mods und Plugins suchen", + "kindResources": "Mods und Plugins", + "kindModpacks": "Modpacks" }, "installed": { - "title": "Installiert", "updatesBadge_one": "{{count}} Update", "updatesBadge_other": "{{count}} Updates", - "empty": "Bisher wurde nichts über den Browser installiert.", - "count_one": "{{count}} Ressource auf diesem Server installiert.", - "count_other": "{{count}} Ressourcen auf diesem Server installiert.", "updateAllButton": "Alle aktualisieren" }, - "unmanaged": { - "title": "Manuell hochgeladen", - "description_one": "{{count}} Datei wurde außerhalb des Browsers hinzugefügt und hat daher keine nachverfolgbare Version.", - "description_other": "{{count}} Dateien wurden außerhalb des Browsers hinzugefügt und haben daher keine nachverfolgbare Version." - }, "unreadable": { "title": "Konnte nicht geprüft werden", "description": "Der Node hat für {{types}} nicht geantwortet, daher konnte nicht überprüft werden, was dort installiert ist. Es wurde nichts geändert." @@ -290,27 +288,31 @@ "replaceTitle": "Modpack ersetzen", "chooseDescription": "Ein Server läuft mit einem Pack. Beim Installieren wird es hier verknüpft, sodass später die Version gewechselt und auf Updates geprüft werden kann. Es werden nur Packs mit Serverseite aufgeführt.", "searchPlaceholder": "Modpacks suchen", - "installedSummary": "Version {{version}} · installiert am {{date}}", "updateAvailable": "Eine neuere Version ist verfügbar: {{version}}", "upToDate": "Dieses Pack ist auf der neuesten Version.", "currentSuffix": "aktuell", "reinstallButton": "Neu installieren", "switchButton": "Version wechseln", "checkUpdatesButton": "Auf Updates prüfen", - "replaceButton": "Pack ersetzen", "stopRequired": "Der Server muss gestoppt sein, bevor ein Pack installiert oder gewechselt werden kann.", "unlinkedLoader": "Dieses Pack wurde installiert, bevor Packs mit einem Loader verknüpft wurden, daher lassen sich seine Versionen nicht auflisten. Installiere es erneut, um es neu zu verknüpfen.", - "contents": "Pack-Inhalt — {{mods}} Mods, {{files}} Konfigurationsdateien", "noModsOnDisk": "Keine der Mods des Packs liegt noch auf der Festplatte.", "dialog": { "title": "{{title}} installieren", - "description": "Das Pack wird heruntergeladen und entpackt, bevor etwas geschrieben wird — der Server wird also erst angefasst, wenn bekannt ist, dass das Pack nutzbar ist.", + "description": "Alles wird heruntergeladen und geprüft, bevor der Server verändert wird. Schlägt etwas fehl, bleibt der Server genau so, wie er war.", "backupLabel": "Zuerst sichern", "backupHint": "Es wird ein Backup erstellt, bevor etwas geschrieben wird. Schlägt dies fehl, startet die Installation nicht.", "backupUnavailable": "Nicht verfügbar: Für diesen Node ist kein Backup-Speicher für den Server konfiguriert.", - "wipeLabel": "Vorhandene Mods ersetzen", - "wipeHint": "Löscht die aktuellen Mod-JARs, damit das Ergebnis dem Pack entspricht. Deaktiviere dies, um das Pack mit dem bereits Installierten zusammenzuführen.", - "wipeWarning": "Vorhandene Mods werden gelöscht und es gibt kein Backup zum Wiederherstellen. Die im Pack enthaltene Mod-Konfiguration überschreibt außerdem, was auf dem Server vorhanden ist." + "changeTitle": "Version von {{title}} ändern", + "resetLabel": "Auf den Originalinhalt des Packs zurücksetzen", + "resetHint": "Entfernt alle Mods, die nicht zum Pack gehören, auch selbst hinzugefügte.", + "keepHint": "Selbst hinzugefügte Mods bleiben erhalten. Mods, die die neue Version nicht mehr enthält, werden entfernt.", + "platformLabel": "Loader und Minecraft-Version des Packs übernehmen", + "platformHint": "Stellt die Server-Software auf das um, wofür das Pack gebaut wurde, damit der Server starten kann.", + "platformChange": "Diese Version ist für {{loader}} auf Minecraft {{gameVersion}} gebaut. Der Server wird entsprechend umgestellt.", + "platformBlocked": "Dieses Pack braucht einen anderen Loader oder eine andere Minecraft-Version. Aktiviere das Übernehmen, um es zu installieren.", + "platformNoPermission": "Dieses Pack braucht einen anderen Loader oder eine andere Minecraft-Version, und du darfst die Build-Einstellungen des Servers nicht ändern.", + "resetWarning": "Selbst hinzugefügte Mods werden gelöscht und es gibt kein Backup zum Wiederherstellen." }, "toasts": { "started": "{{title}} wird im Hintergrund installiert.", @@ -320,6 +322,46 @@ "unlinkConfirm": { "title": "{{title}} entkoppeln?", "description": "Nur die Verknüpfung wird gelöst. Die Mods und die Konfiguration des Packs bleiben auf dem Server und er läuft weiter wie bisher — aber die Version lässt sich von hier aus nicht mehr wechseln oder auf Updates prüfen, und die JAR-Dateien erscheinen als manuell hochgeladene Dateien." + }, + "linkedLabel": "Verknüpftes Modpack", + "minecraftBadge": "Minecraft {{version}}", + "installedOn": "Installiert am {{date}}", + "viewContents_one": "{{count}} Mod", + "viewContents_other": "{{count}} Mods", + "moreActions": "Weitere Modpack-Aktionen", + "reviewUpdate": "Update ansehen", + "disabledCount_one": "{{count}} Pack-Mod deaktiviert", + "disabledCount_other": "{{count}} Pack-Mods deaktiviert", + "missingFiles_one": "{{count}} Datei des Packs fehlt auf dem Server. Installiere das Pack neu, um sie wiederherzustellen.", + "missingFiles_other": "{{count}} Dateien des Packs fehlen auf dem Server. Installiere das Pack neu, um sie wiederherzustellen.", + "contentsTitle": "Inhalt von {{title}}", + "contentsDescription": "{{mods}} Mods und {{files}} weitere Dateien stammen aus dem Pack. Deaktivierte Mods bleiben bei einem Update deaktiviert.", + "emptyTitle": "Mit einem Modpack starten", + "emptyDescription": "Installiere ein Modpack und der Server bekommt Mods, Konfiguration, Loader und Minecraft-Version in einem Schritt. Es bleibt verknüpft, Updates sind also nur einen Klick entfernt.", + "browseButton": "Modpacks durchsuchen", + "switchesPlatform": "Wird eines dieser Packs installiert, wechselt der Server zu {{loader}} und zur Minecraft-Version des Packs." + }, + "dependenciesHint": "Benötigte Abhängigkeiten werden mitinstalliert.", + "content": { + "title": "Inhalt", + "addedTitle": "Hinzugefügte Inhalte", + "empty": "Noch nichts installiert.", + "emptyWithPack": "Noch nichts zusätzlich zum Modpack hinzugefügt.", + "count_one": "{{count}} Datei auf diesem Server.", + "count_other": "{{count}} Dateien auf diesem Server.", + "addButton": "Inhalte hinzufügen", + "filterPlaceholder": "Nach Namen filtern", + "sortName": "Name", + "sortAdded": "Zuletzt hinzugefügt", + "selected_one": "{{count}} ausgewählt", + "selected_other": "{{count}} ausgewählt", + "selectAll": "Alle auswählen", + "clearSelection": "Auswahl aufheben", + "noMatches": "Nichts passt zu diesem Filter.", + "uploadedBadge": "Hochgeladen", + "removeConfirm": { + "title_one": "{{count}} Ressource entfernen?", + "title_other": "{{count}} Ressourcen entfernen?" } } }, @@ -328,13 +370,31 @@ "runningTitle": "{{pack}} wird installiert", "failedTitle": "{{pack}} konnte nicht installiert werden", "doneTitle": "{{pack}} installiert", - "runningFiles": "{{done}} von {{total}} Dateien geschrieben", + "runningFiles": "{{done}} von {{total}} Dateien heruntergeladen", "runningPreparing": "Pack wird vorbereitet…", - "failedDefault": "Die Installation ist fehlgeschlagen. Wenn sie vor dem Installationsschritt fehlschlug, wurde nichts geändert.", - "doneFiles_one": "{{count}} Datei geschrieben.", - "doneFiles_other": "{{count}} Dateien geschrieben.", + "failedDefault": "Die Installation ist fehlgeschlagen.", + "doneFiles_one": "{{count}} Datei installiert.", + "doneFiles_other": "{{count}} Dateien installiert.", "filesLocked": "Das Bearbeiten von Dateien ist deaktiviert, bis dies abgeschlossen ist.", - "failures_one": "{{count}} Datei konnte nicht geladen werden (meist blockiert ein CurseForge-Autor Drittanbieter-Downloads): {{list}}", - "failures_other": "{{count}} Dateien konnten nicht geladen werden (meist blockiert ein CurseForge-Autor Drittanbieter-Downloads): {{list}}" + "failures_one": "{{count}} Datei fehlgeschlagen: {{list}}", + "failures_other": "{{count}} Dateien fehlgeschlagen: {{list}}", + "steps": { + "queued": "In der Warteschlange", + "downloading": "Pack wird heruntergeladen", + "unpacking": "Wird entpackt", + "reading": "Manifest wird gelesen", + "resolving": "Plattform wird geprüft", + "fetching": "Dateien werden heruntergeladen", + "backing_up": "Backup wird erstellt", + "platform": "Plattform wird umgestellt", + "applying": "Dateien werden installiert", + "configuring": "Konfiguration wird angewendet", + "finishing": "Wird abgeschlossen", + "done": "Fertig" + }, + "untouched": "Auf dem Server wurde nichts verändert.", + "platform": "Server auf {{loader}} {{loaderVersion}} mit Minecraft {{gameVersion}} umgestellt.", + "skipped_one": "{{count}} Datei muss von Hand heruntergeladen werden, weil ihr Autor Downloads durch Dritte blockiert: {{list}}", + "skipped_other": "{{count}} Dateien müssen von Hand heruntergeladen werden, weil ihre Autoren Downloads durch Dritte blockieren: {{list}}" } } diff --git a/frontend/locales/en/servers-management.json b/frontend/locales/en/servers-management.json index 7243be0..59bbbbe 100644 --- a/frontend/locales/en/servers-management.json +++ b/frontend/locales/en/servers-management.json @@ -223,9 +223,8 @@ "description": "This server template does not expose a supported Minecraft mod or plugin feature." }, "tabs": { - "modpack": "Modpack", "browse": "Browse", - "installed": "Installed" + "content": "Content" }, "versionLabel": "Version", "loadingVersions": "Loading versions…", @@ -254,33 +253,32 @@ "filesLockedToast": "A modpack is being installed. Wait for it to finish before changing files.", "toasts": { "installed": "Installed {{title}}.", - "enabled": "Enabled {{label}}.", - "disabled": "Disabled {{label}}.", - "updated": "Updated {{title}}.", "updatedCount_one": "Updated {{count}} resource.", "updatedCount_other": "Updated {{count}} resources.", "updateFailed": "The resources could not be updated.", - "removed": "Removed {{title}}." + "installFailed": "The resource could not be installed.", + "installedWithDependencies_one": "Installed {{title}} and {{count}} dependency: {{list}}.", + "installedWithDependencies_other": "Installed {{title}} and {{count}} dependencies: {{list}}.", + "dependencyMissing": "Dependency {{title}} was not installed: {{reason}}", + "enabledCount_one": "Enabled {{count}} file.", + "enabledCount_other": "Enabled {{count}} files.", + "disabledCount_one": "Disabled {{count}} file.", + "disabledCount_other": "Disabled {{count}} files.", + "removedCount_one": "Removed {{count}} resource.", + "removedCount_other": "Removed {{count}} resources." }, "browse": { "title": "Browse mods and plugins", "description": "Search Modrinth, CurseForge, Hangar and SpigotMC for resources that fit this server. Client-only mods are left out.", - "searchPlaceholder": "Search mods and plugins" + "searchPlaceholder": "Search mods and plugins", + "kindResources": "Mods and plugins", + "kindModpacks": "Modpacks" }, "installed": { - "title": "Installed", "updatesBadge_one": "{{count}} update", "updatesBadge_other": "{{count}} updates", - "empty": "Nothing installed through the browser yet.", - "count_one": "{{count}} resource installed on this server.", - "count_other": "{{count}} resources installed on this server.", "updateAllButton": "Update all" }, - "unmanaged": { - "title": "Uploaded by hand", - "description_one": "{{count}} file was added outside the browser, so it has no version to track.", - "description_other": "{{count}} files were added outside the browser, so they have no version to track." - }, "unreadable": { "title": "Could not be checked", "description": "The node did not answer for {{types}}, so what is installed there could not be verified. Nothing was changed." @@ -290,27 +288,31 @@ "replaceTitle": "Replace the modpack", "chooseDescription": "A server runs one pack. Installing one links it here, so its version can be switched and checked for updates later. Only packs with a server side are listed.", "searchPlaceholder": "Search modpacks", - "installedSummary": "Version {{version}} · installed on {{date}}", "updateAvailable": "A newer version is available: {{version}}", "upToDate": "This pack is on its newest version.", "currentSuffix": "current", "reinstallButton": "Reinstall", "switchButton": "Switch version", "checkUpdatesButton": "Check for updates", - "replaceButton": "Replace pack", "stopRequired": "The server has to be stopped before a pack can be installed or switched.", "unlinkedLoader": "This pack was installed before packs were linked to a loader, so its versions cannot be listed. Install it again to relink it.", - "contents": "Pack contents — {{mods}} mods, {{files}} configuration files", "noModsOnDisk": "None of the pack's mods are on disk any more.", "dialog": { "title": "Install {{title}}", - "description": "The pack is downloaded and unpacked before anything is written, so the server is only touched once the pack is known to be usable.", + "description": "Everything is downloaded and checked before the server is touched. If anything fails, the server stays exactly as it was.", "backupLabel": "Back up first", "backupHint": "A backup is taken before anything is written. If it fails, the install does not start.", "backupUnavailable": "Unavailable: this node has no backup storage configured for the server.", - "wipeLabel": "Replace existing mods", - "wipeHint": "Clears the current mod jars so the result matches the pack. Turn this off to merge the pack into what is already installed.", - "wipeWarning": "Existing mods will be deleted and there is no backup to restore from. Mod configuration shipped with the pack will also overwrite what is on the server." + "changeTitle": "Change the version of {{title}}", + "resetLabel": "Reset to the pack's original content", + "resetHint": "Removes every mod that is not part of the pack, including mods you added yourself.", + "keepHint": "Mods you added on top of the pack are kept. Mods the new version no longer ships are removed.", + "platformLabel": "Match the pack's loader and Minecraft version", + "platformHint": "Switches the server's software to what the pack was built for, so it can start.", + "platformChange": "This version is built for {{loader}} on Minecraft {{gameVersion}}. The server will be switched to match.", + "platformBlocked": "This pack needs a different loader or Minecraft version. Turn on matching to install it.", + "platformNoPermission": "This pack needs a different loader or Minecraft version, and you are not allowed to change the server's build settings.", + "resetWarning": "Mods you added will be deleted and there is no backup to restore from." }, "toasts": { "started": "Installing {{title}} in the background.", @@ -320,6 +322,46 @@ "unlinkConfirm": { "title": "Unlink {{title}}?", "description": "Only the link is dropped. The pack's mods and configuration stay on the server and it keeps running as it does now — but its version can no longer be switched or checked for updates from here, and its jars are listed as hand-uploaded files." + }, + "linkedLabel": "Linked modpack", + "minecraftBadge": "Minecraft {{version}}", + "installedOn": "Installed on {{date}}", + "viewContents_one": "{{count}} mod", + "viewContents_other": "{{count}} mods", + "moreActions": "More modpack actions", + "reviewUpdate": "Review update", + "disabledCount_one": "{{count}} pack mod disabled", + "disabledCount_other": "{{count}} pack mods disabled", + "missingFiles_one": "{{count}} file of the pack is missing from the server. Reinstall the pack to restore it.", + "missingFiles_other": "{{count}} files of the pack are missing from the server. Reinstall the pack to restore them.", + "contentsTitle": "{{title}} contents", + "contentsDescription": "{{mods}} mods and {{files}} other files come from the pack. Disabled mods stay disabled when the pack is updated.", + "emptyTitle": "Start from a modpack", + "emptyDescription": "Install a modpack and the server gets its mods, configuration, loader and Minecraft version in one step. It stays linked, so updates are one click away.", + "browseButton": "Browse modpacks", + "switchesPlatform": "Installing one of these packs switches the server to {{loader}} and the pack's Minecraft version." + }, + "dependenciesHint": "Required dependencies are installed along with it.", + "content": { + "title": "Content", + "addedTitle": "Added content", + "empty": "Nothing installed yet.", + "emptyWithPack": "Nothing added on top of the modpack yet.", + "count_one": "{{count}} file on this server.", + "count_other": "{{count}} files on this server.", + "addButton": "Add content", + "filterPlaceholder": "Filter by name", + "sortName": "Name", + "sortAdded": "Recently added", + "selected_one": "{{count}} selected", + "selected_other": "{{count}} selected", + "selectAll": "Select all", + "clearSelection": "Clear selection", + "noMatches": "Nothing matches this filter.", + "uploadedBadge": "Uploaded", + "removeConfirm": { + "title_one": "Remove {{count}} resource?", + "title_other": "Remove {{count}} resources?" } } }, @@ -328,13 +370,31 @@ "runningTitle": "Installing {{pack}}", "failedTitle": "{{pack}} could not be installed", "doneTitle": "{{pack}} installed", - "runningFiles": "{{done}} of {{total}} files written", + "runningFiles": "{{done}} of {{total}} files downloaded", "runningPreparing": "Preparing the pack…", - "failedDefault": "The installation failed. Nothing was changed if it failed before the install step.", - "doneFiles_one": "{{count}} file written.", - "doneFiles_other": "{{count}} files written.", + "failedDefault": "The installation failed.", + "doneFiles_one": "{{count}} file installed.", + "doneFiles_other": "{{count}} files installed.", "filesLocked": "File editing is disabled until this finishes.", - "failures_one": "{{count}} file could not be fetched (most often a CurseForge author blocking third-party downloads): {{list}}", - "failures_other": "{{count}} files could not be fetched (most often a CurseForge author blocking third-party downloads): {{list}}" + "failures_one": "{{count}} file failed: {{list}}", + "failures_other": "{{count}} files failed: {{list}}", + "steps": { + "queued": "Queued", + "downloading": "Downloading the pack", + "unpacking": "Unpacking", + "reading": "Reading the manifest", + "resolving": "Checking the platform", + "fetching": "Downloading files", + "backing_up": "Creating a backup", + "platform": "Switching the platform", + "applying": "Installing files", + "configuring": "Applying configuration", + "finishing": "Finishing", + "done": "Done" + }, + "untouched": "Nothing on the server was changed.", + "platform": "Server switched to {{loader}} {{loaderVersion}} on Minecraft {{gameVersion}}.", + "skipped_one": "{{count}} file has to be downloaded by hand because its author blocks third-party downloads: {{list}}", + "skipped_other": "{{count}} files have to be downloaded by hand because their authors block third-party downloads: {{list}}" } } From d5a762412b02eb13d63497f33ff19455a28c362d Mon Sep 17 00:00:00 2001 From: InvalidJoker <0limitdev@gmail.com> Date: Fri, 16 Oct 2026 13:56:41 +0200 Subject: [PATCH 2/4] feat: forge (no agent this time) --- backend/src/service/modpack.rs | 28 +++--- backend/src/service/resource.rs | 6 +- backend/src/templates/games/minecraft.rs | 70 +++++++++++++-- .../games/minecraft/forge.Dockerfile | 46 ++++++++++ .../src/templates/games/minecraft/forge.rs | 88 +++++++++++++++++++ 5 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 backend/src/templates/games/minecraft/forge.Dockerfile create mode 100644 backend/src/templates/games/minecraft/forge.rs diff --git a/backend/src/service/modpack.rs b/backend/src/service/modpack.rs index 5ab9ea4..5545c6b 100644 --- a/backend/src/service/modpack.rs +++ b/backend/src/service/modpack.rs @@ -209,12 +209,12 @@ struct PlatformPlan { new_extra: Option, } -const fn software_for(loader: PackLoader) -> Option { +const fn software_for(loader: PackLoader) -> MinecraftServerModification { match loader { - PackLoader::Fabric => Some(MinecraftServerModification::Fabric), - PackLoader::Quilt => Some(MinecraftServerModification::Quilt), - PackLoader::Neoforge => Some(MinecraftServerModification::NeoForge), - PackLoader::Forge => None, + PackLoader::Fabric => MinecraftServerModification::Fabric, + PackLoader::Quilt => MinecraftServerModification::Quilt, + PackLoader::Neoforge => MinecraftServerModification::NeoForge, + PackLoader::Forge => MinecraftServerModification::Forge, } } @@ -503,7 +503,7 @@ impl Backend { async fn plan_platform( &self, server: &Server, - target: PackPlatform, + mut target: PackPlatform, sync: bool, ) -> Result { let current: MinecraftServerExtra = serde_json::from_value(server.template_extra.clone()) @@ -518,12 +518,7 @@ impl Backend { new_extra: None, }); }; - let Some(software) = software_for(loader) else { - return Err(InstallFailure::from(format!( - "This pack needs {}, which this server cannot run", - loader.label() - ))); - }; + let software = software_for(loader); let current_software = current.modification(); let current_game = current.minecraft_version(); @@ -533,6 +528,15 @@ impl Backend { .clone() .unwrap_or_else(|| current_game.clone()); + if let Some(build) = target.loader_version.as_mut() + && let Some(short) = build + .strip_prefix(game.as_str()) + .and_then(|rest| rest.strip_prefix('-')) + .map(str::to_owned) + { + *build = short; + } + let software_differs = current_software != software || current_game != game; let build_differs = target .loader_version diff --git a/backend/src/service/resource.rs b/backend/src/service/resource.rs index 8590716..493427f 100644 --- a/backend/src/service/resource.rs +++ b/backend/src/service/resource.rs @@ -148,7 +148,11 @@ struct InstallRequest<'a> { page_url: Option, } -const MODPACK_LOADERS: [ResourceLoader; 2] = [ResourceLoader::Fabric, ResourceLoader::Neoforge]; +const MODPACK_LOADERS: [ResourceLoader; 3] = [ + ResourceLoader::Fabric, + ResourceLoader::Forge, + ResourceLoader::Neoforge, +]; const MAX_DEPENDENCIES: usize = 64; diff --git a/backend/src/templates/games/minecraft.rs b/backend/src/templates/games/minecraft.rs index 99eb1a2..c36bd8c 100644 --- a/backend/src/templates/games/minecraft.rs +++ b/backend/src/templates/games/minecraft.rs @@ -1,4 +1,5 @@ mod fabric; +mod forge; pub(crate) mod maven; mod neoforge; mod paper; @@ -38,6 +39,7 @@ pub(crate) const PURPUR_URL: &str = "https://api.purpurmc.org/"; pub(crate) const FABRIC_URL: &str = "https://meta.fabricmc.net/v2"; pub(crate) const QUILT_URL: &str = "https://meta.quiltmc.org/v3"; pub(crate) const NEOFORGE_URL: &str = "https://maven.neoforged.net"; +pub(crate) const FORGE_URL: &str = "https://maven.minecraftforge.net"; #[derive(Debug, Readable, Writable, Hash, Ord, PartialOrd, Eq, PartialEq, Copy, Clone)] pub(crate) enum MinecraftServerModification { @@ -47,6 +49,7 @@ pub(crate) enum MinecraftServerModification { Fabric, Quilt, NeoForge, + Forge, } impl Display for MinecraftServerModification { @@ -61,6 +64,7 @@ pub(crate) enum MinecraftDownloadBackend { Purpur(&'static str, &'static str), Fabric(&'static str), NeoForge(&'static str), + Forge(&'static str), } #[derive(Serialize, Deserialize)] @@ -119,12 +123,23 @@ pub(crate) enum MinecraftServerExtra { loader_version: String, data_pack: bool, }, + #[serde(rename = "forge")] + Forge { + java: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + java_pinned: Option, + minecraft_version: String, + loader_version: String, + data_pack: bool, + }, } impl MinecraftServerModification { fn backup_exclude(self) -> HashSet { match self { - Self::Vanilla | Self::Fabric | Self::Quilt | Self::NeoForge => HashSet::new(), + Self::Vanilla | Self::Fabric | Self::Quilt | Self::NeoForge | Self::Forge => { + HashSet::new() + } Self::Paper | Self::Purpur => HashSet::from([ "cache/".to_owned(), "libraries/".to_owned(), @@ -141,6 +156,7 @@ impl MinecraftServerModification { Self::Fabric => MinecraftDownloadBackend::Fabric(FABRIC_URL), Self::Quilt => MinecraftDownloadBackend::Fabric(QUILT_URL), Self::NeoForge => MinecraftDownloadBackend::NeoForge(NEOFORGE_URL), + Self::Forge => MinecraftDownloadBackend::Forge(FORGE_URL), } } @@ -155,6 +171,10 @@ impl MinecraftServerModification { const fn is_neoforge(self) -> bool { matches!(self, Self::NeoForge) } + + const fn is_forge(self) -> bool { + matches!(self, Self::Forge) + } } impl MinecraftServerExtra { @@ -168,6 +188,7 @@ impl MinecraftServerExtra { Self::Fabric { .. } => MinecraftServerModification::Fabric, Self::Quilt { .. } => MinecraftServerModification::Quilt, Self::NeoForge { .. } => MinecraftServerModification::NeoForge, + Self::Forge { .. } => MinecraftServerModification::Forge, } } @@ -199,6 +220,7 @@ impl MinecraftServerExtra { MinecraftServerModification::Fabric => include_str!("minecraft/fabric.Dockerfile"), MinecraftServerModification::Quilt => include_str!("minecraft/quilt.Dockerfile"), MinecraftServerModification::NeoForge => include_str!("minecraft/neoforge.Dockerfile"), + MinecraftServerModification::Forge => include_str!("minecraft/forge.Dockerfile"), }; let mut parents = vec![java]; if self.modification().is_paper() { @@ -243,7 +265,8 @@ impl MinecraftServerExtra { | Self::Purpur { data_pack, .. } | Self::Fabric { data_pack, .. } | Self::Quilt { data_pack, .. } - | Self::NeoForge { data_pack, .. } => *data_pack, + | Self::NeoForge { data_pack, .. } + | Self::Forge { data_pack, .. } => *data_pack, } } @@ -308,6 +331,9 @@ impl MinecraftServerExtra { } | Self::NeoForge { minecraft_version, .. + } + | Self::Forge { + minecraft_version, .. } => minecraft_version.clone(), } } @@ -319,7 +345,8 @@ impl MinecraftServerExtra { | Self::Purpur { java, .. } | Self::Fabric { java, .. } | Self::Quilt { java, .. } - | Self::NeoForge { java, .. } => *java, + | Self::NeoForge { java, .. } + | Self::Forge { java, .. } => *java, } } @@ -330,7 +357,8 @@ impl MinecraftServerExtra { | Self::Purpur { java_pinned, .. } | Self::Fabric { java_pinned, .. } | Self::Quilt { java_pinned, .. } - | Self::NeoForge { java_pinned, .. } => *java_pinned, + | Self::NeoForge { java_pinned, .. } + | Self::Forge { java_pinned, .. } => *java_pinned, } } @@ -341,7 +369,8 @@ impl MinecraftServerExtra { Self::Purpur { purpur_build, .. } => Some(purpur_build.to_string()), Self::Fabric { loader_version, .. } | Self::Quilt { loader_version, .. } - | Self::NeoForge { loader_version, .. } => Some(loader_version.clone()), + | Self::NeoForge { loader_version, .. } + | Self::Forge { loader_version, .. } => Some(loader_version.clone()), } } @@ -417,17 +446,30 @@ impl MinecraftServerExtra { java, Self::REL ), + Self::Forge { + java, + minecraft_version, + loader_version, + .. + } => format!( + "minecraft_forge-{}-{}-jre{}-r{}", + minecraft_version, + loader_version, + java, + Self::REL + ), } } } -const MODS: [MinecraftServerModification; 6] = [ +const MODS: [MinecraftServerModification; 7] = [ MinecraftServerModification::Vanilla, MinecraftServerModification::Paper, MinecraftServerModification::Purpur, MinecraftServerModification::Fabric, MinecraftServerModification::Quilt, MinecraftServerModification::NeoForge, + MinecraftServerModification::Forge, ]; pub(crate) struct MinecraftTemplate; @@ -728,7 +770,8 @@ impl Template for MinecraftTemplate { }) } MinecraftServerModification::Fabric - | MinecraftServerModification::Quilt => None, + | MinecraftServerModification::Quilt + | MinecraftServerModification::Forge => None, }) } VersionBuild::Set(versions) => { @@ -750,6 +793,13 @@ impl Template for MinecraftTemplate { loader_version: mod_build.to_owned(), data_pack: *data_pack, }), + MinecraftServerModification::Forge => Some(MinecraftServerExtra::Forge { + java, + java_pinned, + minecraft_version: minecraft_version.clone(), + loader_version: mod_build.to_owned(), + data_pack: *data_pack, + }), _ => None, }) } @@ -779,6 +829,9 @@ impl Template for MinecraftTemplate { if modification.is_neoforge() { features.insert("minecraft_mods_neoforge"); } + if modification.is_forge() { + features.insert("minecraft_mods_forge"); + } Ok(features) } @@ -924,6 +977,9 @@ pub(crate) async fn resolve_cache() -> Result { MinecraftDownloadBackend::NeoForge(url) => { neoforge::resolve(&client, modification, &mut cache, url).await?; } + MinecraftDownloadBackend::Forge(url) => { + forge::resolve(&client, modification, &mut cache, url).await?; + } } } Ok(cache) diff --git a/backend/src/templates/games/minecraft/forge.Dockerfile b/backend/src/templates/games/minecraft/forge.Dockerfile new file mode 100644 index 0000000..2ea4943 --- /dev/null +++ b/backend/src/templates/games/minecraft/forge.Dockerfile @@ -0,0 +1,46 @@ +ARG MINECRAFT_VERSION +ARG LOADER_VERSION + +# install +RUN mkdir /work/.tmp -p +RUN mkdir /forge + +# Older versions carry a Minecraft version suffix in their maven name +RUN set -eu; \ + wget "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml" --output-document /forge-metadata.xml; \ + escaped="$(printf '%s' "$MINECRAFT_VERSION-$LOADER_VERSION" | sed 's/\./\\./g')"; \ + full="$(grep -oE "$escaped(-1\.[0-9.]+)?" /forge-metadata.xml | sed -E 's###g' | awk '{ print length, $0 }' | sort -n | head -n1 | cut -d' ' -f2)"; \ + [ -n "$full" ] || { echo "Forge $LOADER_VERSION for Minecraft $MINECRAFT_VERSION not found" >&2; exit 1; }; \ + wget "https://maven.minecraftforge.net/net/minecraftforge/forge/$full/forge-$full-installer.jar" --output-document /installer.jar; \ + echo "$full" > /forge/version + +RUN cd /forge && java -jar /installer.jar --installServer /forge + +# Newer versions start through a shim jar that only finds its libraries relative to the +# working directory, so its classpath is built here instead +RUN set -eu; \ + full="$(cat /forge/version)"; \ + java_args="-Dterminal.ansi=true -Djava.net.preferIPv6Addresses=system -XX:MaxRAMPercentage=95.0"; \ + args="/forge/libraries/net/minecraftforge/forge/$full/unix_args.txt"; \ + shim="$(ls /forge/forge-*-shim.jar 2>/dev/null | head -n1)"; \ + if [ -n "$shim" ]; then \ + props="$(unzip -p "$shim" bootstrap-shim.properties | tr -d '\r')"; \ + main="$(printf '%s\n' "$props" | sed -n 's/^Main-Class=//p')"; \ + extra="$(printf '%s\n' "$props" | sed -n 's/^Arguments=//p')"; \ + manifest_cp="$(unzip -p "$shim" META-INF/MANIFEST.MF | tr -d '\r' | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n //g' | sed -n 's/^Class-Path: //p')"; \ + cp="$( { unzip -p "$shim" bootstrap-shim.list | tr -d '\r' | cut -f3 | sed 's#^#/forge/libraries/#'; printf '%s\n' $manifest_cp | sed 's#^#/forge/#'; } | awk 'NF && !seen[$0]++' | paste -sd: -)"; \ + [ -n "$main" ] && [ -n "$cp" ] || { echo "Unable to read the Forge shim" >&2; exit 1; }; \ + echo "exec java $java_args -cp $cp $main $extra nogui" > /run.sh; \ + elif [ -f "$args" ]; then \ + { sed 's#libraries#/forge/libraries#g' "$args" | xargs echo exec java $java_args; echo " nogui"; } | tr -d "\n" > /run.sh; \ + else \ + jar="$(ls /forge/forge-*.jar | grep -v installer | head -n1)"; \ + [ -n "$jar" ] || { echo "No Forge server jar found" >&2; exit 1; }; \ + echo "exec java $java_args -jar $jar nogui" > /run.sh; \ + fi; \ + cat /run.sh + +# Clean up +RUN rm -f /installer.jar /forge-metadata.xml /forge/run.sh /forge/run.bat /forge/user_jvm_args.txt /forge/installer.jar.log + +ENTRYPOINT ["/bin/sh", "/run.sh"] diff --git a/backend/src/templates/games/minecraft/forge.rs b/backend/src/templates/games/minecraft/forge.rs new file mode 100644 index 0000000..0560114 --- /dev/null +++ b/backend/src/templates/games/minecraft/forge.rs @@ -0,0 +1,88 @@ +use reqwest::Client; +use std::collections::{HashMap, HashSet}; +use tracing::error; + +use crate::templates::games::minecraft::{ + MinecraftServerModification, VersionBuild, VersionCache, maven::MavenMetadata, +}; + +fn split_version(raw: &str) -> Option<(&str, &str)> { + let (minecraft_version, rest) = raw.split_once('-')?; + let loader_version = match rest.split_once('-') { + Some((loader_version, suffix)) => { + let version_like = suffix.starts_with("1.") + && suffix.chars().all(|ch| ch.is_ascii_digit() || ch == '.'); + if !version_like { + return None; + } + loader_version + } + None => rest, + }; + let numeric = !loader_version.is_empty() + && loader_version + .split('.') + .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())); + numeric.then_some((minecraft_version, loader_version)) +} + +fn version_key(version: &str) -> Vec { + version + .split('.') + .map(|part| part.parse().unwrap_or_default()) + .collect() +} + +pub(crate) async fn resolve( + client: &Client, + modification: &MinecraftServerModification, + cache: &mut VersionCache, + url: &str, +) -> Result<(), ()> { + let response = client + .get(format!("{url}/net/minecraftforge/forge/maven-metadata.xml")) + .send() + .await + .inspect_err(|err| { + error!("Unable to send maven {url:?} metadata (forge) request: {err:?}"); + }) + .map_err(|_err| ())? + .error_for_status() + .inspect_err(|err| error!("Unable to get maven {url:?} metadata (forge): {err:?}")) + .map_err(|_err| ())? + .text() + .await + .inspect_err(|err| { + error!("Unable to receive maven {url:?} metadata (forge) response: {err:?}"); + }) + .map_err(|_err| ())?; + + let metadata: MavenMetadata = serde_xml_rs::from_str(&response) + .inspect_err(|err| { + error!("Unable to parse maven {url:?} metadata (forge) response: {err:?}"); + }) + .map_err(|_err| ())?; + let mut versions_map: HashMap> = HashMap::new(); + for raw in &metadata.versioning.versions.version { + let Some((minecraft_version, loader_version)) = split_version(raw) else { + continue; + }; + versions_map + .entry(minecraft_version.to_owned()) + .or_default() + .insert(loader_version.to_owned()); + } + + for (minecraft_version, loader_versions) in versions_map { + let Some(cache_entry) = cache.0.get_mut(&minecraft_version) else { + continue; + }; + let mut loader_versions: Vec = loader_versions.into_iter().collect(); + loader_versions.sort_by_cached_key(|version| core::cmp::Reverse(version_key(version))); + cache_entry + .3 + .insert(*modification, VersionBuild::Set(loader_versions)); + } + + Ok(()) +} From bd7623261bfac970edc2998f5ca7dabed854fbe7 Mon Sep 17 00:00:00 2001 From: InvalidJoker <0limitdev@gmail.com> Date: Fri, 16 Oct 2026 14:28:02 +0200 Subject: [PATCH 3/4] fix: modrinth hash --- backend/src/resources/modpack.rs | 16 ++++--- backend/src/service/modpack.rs | 45 +++++++++++++++++++ .../server/modpack-install-banner.tsx | 2 +- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/backend/src/resources/modpack.rs b/backend/src/resources/modpack.rs index 73fc12e..9f6fa34 100644 --- a/backend/src/resources/modpack.rs +++ b/backend/src/resources/modpack.rs @@ -68,6 +68,7 @@ pub(crate) struct ModpackFile { pub(crate) source: PackSource, pub(crate) size: Option, pub(crate) sha1: Option, + pub(crate) sha512: Option, } #[derive(Debug, Clone)] @@ -222,6 +223,13 @@ fn modrinth_platform(dependencies: &HashMap) -> PackPlatform { } } +fn hex_hash(hashes: &HashMap, kind: &str, len: usize) -> Option { + hashes + .get(kind) + .filter(|hash| hash.len() == len && hash.chars().all(|ch| ch.is_ascii_hexdigit())) + .map(|hash| hash.to_lowercase()) +} + pub(crate) fn parse_modrinth_index(raw: &[u8]) -> Result { let index: ModrinthIndex = serde_json::from_slice(raw).map_err(|_err| ResourceError::ProviderError)?; @@ -249,11 +257,8 @@ pub(crate) fn parse_modrinth_index(raw: &[u8]) -> Result path, source: PackSource::Url(url), size: file.file_size, - sha1: file - .hashes - .get("sha1") - .filter(|hash| hash.len() == 40 && hash.chars().all(|ch| ch.is_ascii_hexdigit())) - .map(|hash| hash.to_lowercase()), + sha1: hex_hash(&file.hashes, "sha1", 40), + sha512: hex_hash(&file.hashes, "sha512", 128), }); } @@ -304,6 +309,7 @@ pub(crate) fn parse_curseforge_manifest(raw: &[u8]) -> Result bool { + let stream = match server + .node + .server_download_file(server.node_server_id, path.to_owned(), None) + .await + { + Ok(response) => response.into_data_stream(), + Err(err) => { + warn!("Failed to read {path} to check its hash: {err:?}"); + return false; + } + }; + let Some(stream) = stream else { + return false; + }; + + let mut reader = stream.reader(); + let mut hasher = Sha512::new(); + let mut buffer = vec![0u8; HASH_CHUNK_BYTES]; + loop { + match reader.read(&mut buffer).await { + Ok(0) => break, + Ok(read) => hasher.update(buffer.get(..read).unwrap_or_default()), + Err(err) => { + warn!("Failed to read {path} to check its hash: {err:?}"); + return false; + } + } + } + hex::encode(hasher.finalize()) == expected + } + async fn ensure_dir(&self, server: &Server, path: &str) -> Result<(), InstallFailure> { match server .node @@ -455,6 +490,7 @@ impl Backend { source: PackSource::Url(entry.url), size: None, sha1: None, + sha512: None, }); } Err(ResourceError::DownloadUnavailable) => { @@ -661,6 +697,15 @@ impl Backend { { Ok(entry) if entry.size == expected => return Ok(()), Ok(entry) => { + if let Some(sha512) = &file.sha512 + && self.matches_sha512(server, &target, sha512).await + { + warn!( + "{} has {} bytes instead of the declared {expected}, but its hash matches", + file.path, entry.size + ); + return Ok(()); + } warn!( "Attempt {attempt}: {} has {} bytes, expected {expected}", file.path, entry.size diff --git a/frontend/components/server/modpack-install-banner.tsx b/frontend/components/server/modpack-install-banner.tsx index 38c5f5e..b1c8343 100644 --- a/frontend/components/server/modpack-install-banner.tsx +++ b/frontend/components/server/modpack-install-banner.tsx @@ -117,7 +117,7 @@ export function ModpackInstallBanner({

) : null} - {platform?.loader && platform.game_version ? ( + {!running && !failed && platform?.loader && platform.game_version ? (

{t("servers-management:modpackInstall.platform", { loader: PACK_LOADER_LABELS[platform.loader], From 264b535b85a24d0772b0a96a0a9d59caac2e0d48 Mon Sep 17 00:00:00 2001 From: InvalidJoker <0limitdev@gmail.com> Date: Thu, 22 Oct 2026 16:03:25 +0200 Subject: [PATCH 4/4] chore: bump deps --- Cargo.lock | 291 +++++++++--------- backend/src/templates/games/minecraft.rs | 2 +- .../src/templates/games/minecraft_proxy.rs | 2 +- deny.toml | 3 - 4 files changed, 148 insertions(+), 150 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3e16bc..ec53880 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,9 +121,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.2" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" dependencies = [ "rustversion", ] @@ -181,9 +181,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.50" +version = "0.4.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" dependencies = [ "compression-codecs", "compression-core", @@ -210,7 +210,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -236,9 +236,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -247,9 +247,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -411,7 +411,7 @@ dependencies = [ "serde-xml-rs", "serde_json", "serde_yaml_ng 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "sha2 0.11.0", + "sha2 0.11.1", "speedy", "sqlx", "ssh-key", @@ -661,9 +661,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -779,9 +779,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "futures-core", @@ -793,9 +793,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" dependencies = [ "compression-core", "zstd", @@ -804,9 +804,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" [[package]] name = "concat-idents" @@ -946,9 +946,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -1089,7 +1089,7 @@ dependencies = [ [[package]] name = "daemon_common" version = "0.1.0" -source = "git+https://github.com/novium-dev/daemon.git#696e6470a9949159d52f9126cc45281424eb5f70" +source = "git+https://github.com/novium-dev/daemon.git#bd153b0450f5973aecc33ef72402bfcdcbfac713" dependencies = [ "axum", "base64 0.23.1", @@ -1109,7 +1109,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng 0.10.0 (git+https://github.com/acatton/serde-yaml-ng?rev=3628102977f3ec9e02b95ef32fcec30b3df91390)", - "sha2 0.11.0", + "sha2 0.11.1", "tokio", "tokio-util", "toml", @@ -1143,7 +1143,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1154,7 +1154,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1193,7 +1193,7 @@ dependencies = [ "serde", "serde_json", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "socket2", "sqlx", "subtle", @@ -1226,7 +1226,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "url", "utoipa", "utoipa-axum", @@ -1247,9 +1247,9 @@ dependencies = [ [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -1257,21 +1257,21 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ "thiserror 2.0.21", ] @@ -1313,9 +1313,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "derive-syn-parse" @@ -1336,7 +1336,7 @@ checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1347,7 +1347,7 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1415,7 +1415,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1474,7 +1474,7 @@ dependencies = [ "ed25519", "rand_core 0.10.1", "serde", - "sha2 0.11.0", + "sha2 0.11.1", "signature 3.0.0", "subtle", "zeroize", @@ -1524,9 +1524,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1598,9 +1598,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "findshlibs" @@ -1746,7 +1746,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2218,9 +2218,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -2232,9 +2232,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -2245,9 +2245,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2259,15 +2259,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -2280,15 +2280,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -2560,7 +2560,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2574,9 +2574,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libm" @@ -2606,9 +2606,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.29" +version = "1.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" dependencies = [ "cc", "pkg-config", @@ -2708,7 +2708,7 @@ dependencies = [ "manyhow-macros", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2862,7 +2862,7 @@ dependencies = [ "serde_bytes", "serde_with", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "socket2", "stringprep", "strsim", @@ -3612,7 +3612,7 @@ dependencies = [ "md-5", "memchr", "rand 0.10.3", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", ] @@ -4116,7 +4116,7 @@ version = "8.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" dependencies = [ - "sha2 0.11.0", + "sha2 0.11.1", "walkdir", ] @@ -4172,9 +4172,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -4188,9 +4188,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4237,9 +4237,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -4262,7 +4262,7 @@ dependencies = [ "hmac 0.13.0", "jiff", "percent-encoding", - "sha2 0.11.0", + "sha2 0.11.1", "url", "zeroize", ] @@ -4290,9 +4290,9 @@ checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys 0.61.2", ] @@ -4531,15 +4531,16 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash", "indexmap", "itoa", "memchr", @@ -4567,14 +4568,14 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -4610,7 +4611,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4689,9 +4690,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -4778,9 +4779,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -4957,7 +4958,7 @@ dependencies = [ "percent-encoding", "serde", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "sqlx-core", "thiserror 2.0.21", "tracing", @@ -4991,7 +4992,7 @@ dependencies = [ "rand 0.10.3", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "smallvec", "sqlx-core", "stringprep", @@ -5110,9 +5111,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -5147,7 +5148,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5226,7 +5227,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5279,9 +5280,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "zerovec", @@ -5295,9 +5296,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -5318,7 +5319,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5382,9 +5383,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -5398,9 +5399,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -5413,27 +5414,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -5640,15 +5641,15 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -5691,9 +5692,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64 0.23.1", "log", @@ -5707,9 +5708,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -5812,9 +5813,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5935,7 +5936,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -5996,18 +5997,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -6301,9 +6302,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6318,7 +6319,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -6391,23 +6392,23 @@ dependencies = [ "manyhow", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "zerocopy" -version = "0.8.61" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.61" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -6431,7 +6432,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -6454,9 +6455,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "yoke", "zerofrom", @@ -6465,13 +6466,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6502,9 +6503,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -6514,27 +6515,27 @@ dependencies = [ [[package]] name = "zstd" -version = "0.14.0" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "8.0.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.1.0+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", diff --git a/backend/src/templates/games/minecraft.rs b/backend/src/templates/games/minecraft.rs index c36bd8c..999e10f 100644 --- a/backend/src/templates/games/minecraft.rs +++ b/backend/src/templates/games/minecraft.rs @@ -178,7 +178,7 @@ impl MinecraftServerModification { } impl MinecraftServerExtra { - const REL: usize = 8; + const REL: usize = 9; pub(crate) const fn modification(&self) -> MinecraftServerModification { match self { diff --git a/backend/src/templates/games/minecraft_proxy.rs b/backend/src/templates/games/minecraft_proxy.rs index 869de29..e591324 100644 --- a/backend/src/templates/games/minecraft_proxy.rs +++ b/backend/src/templates/games/minecraft_proxy.rs @@ -71,7 +71,7 @@ pub(crate) enum MinecraftProxyExtra { } impl MinecraftProxyExtra { - const REL: usize = 6; + const REL: usize = 7; pub(crate) const fn software(&self) -> MinecraftProxySoftware { match self { diff --git a/deny.toml b/deny.toml index b3fee72..b5b1b8d 100644 --- a/deny.toml +++ b/deny.toml @@ -24,10 +24,7 @@ allow-git = [ "https://github.com/novium-dev/zelus_keys.git", "https://github.com/0xIO32/zelus.git", "https://github.com/0xIO32/reqwest.git", - "https://github.com/fussybeaver/bollard.git?rev=dca659509a12544cabe418c7832f1ac741c88d2c", - "https://github.com/rustls/hyper-rustls.git?rev=5e9b69b4128abfc147e8b6fa55977eac4c3f575c", "https://github.com/acatton/serde-yaml-ng?rev=3628102977f3ec9e02b95ef32fcec30b3df91390", - "https://github.com/qu0b/json-patch.git?rev=4eca239" ] [licenses]