use crate::pack::install_from::{CreatePack, CreatePackDescription}; use crate::pack::install_mrpack::install_zipped_mrpack_files; use crate::profile; use crate::state::ModLoader; use crate::{ErrorKind, State}; use chrono::{TimeZone, Utc}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha512}; use sqlx::{Row, SqlitePool}; use url::Url; use uuid::Uuid; const MANIFEST_FILE_NAME: &str = "modrinth-plus.json"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConnectedManifest { pub schema_version: u32, pub name: String, pub version: String, pub version_id: String, pub mrpack_url: String, pub sha512: String, pub changelog: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConnectedPack { pub id: String, pub source_url: String, pub manifest_url: String, pub name: String, pub version: String, pub version_id: String, pub mrpack_url: String, pub sha512: String, pub changelog: Option, pub profile_path: Option, pub installed_version_id: Option, pub auto_update: bool, pub last_checked: Option>, pub last_error: Option, pub created: chrono::DateTime, pub updated: chrono::DateTime, pub update_available: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConnectedCheckResult { pub pack: ConnectedPack, pub installed: bool, } struct ConnectedPackRow { id: String, source_url: String, manifest_url: String, name: String, version: String, version_id: String, mrpack_url: String, sha512: String, changelog: Option, profile_path: Option, installed_version_id: Option, auto_update: i64, last_checked: Option, last_error: Option, created: i64, updated: i64, } impl ConnectedPackRow { fn from_row(row: sqlx::sqlite::SqliteRow) -> sqlx::Result { Ok(Self { id: row.try_get("id")?, source_url: row.try_get("source_url")?, manifest_url: row.try_get("manifest_url")?, name: row.try_get("name")?, version: row.try_get("version")?, version_id: row.try_get("version_id")?, mrpack_url: row.try_get("mrpack_url")?, sha512: row.try_get("sha512")?, changelog: row.try_get("changelog")?, profile_path: row.try_get("profile_path")?, installed_version_id: row.try_get("installed_version_id")?, auto_update: row.try_get("auto_update")?, last_checked: row.try_get("last_checked")?, last_error: row.try_get("last_error")?, created: row.try_get("created")?, updated: row.try_get("updated")?, }) } fn into_pack(self) -> ConnectedPack { let update_available = match self.installed_version_id.as_ref() { Some(installed) => installed != &self.version_id, None => true, }; ConnectedPack { id: self.id, source_url: self.source_url, manifest_url: self.manifest_url, name: self.name, version: self.version, version_id: self.version_id, mrpack_url: self.mrpack_url, sha512: self.sha512, changelog: self.changelog, profile_path: self.profile_path, installed_version_id: self.installed_version_id, auto_update: self.auto_update != 0, last_checked: self .last_checked .and_then(|ts| Utc.timestamp_opt(ts, 0).single()), last_error: self.last_error, created: Utc .timestamp_opt(self.created, 0) .single() .unwrap_or_else(Utc::now), updated: Utc .timestamp_opt(self.updated, 0) .single() .unwrap_or_else(Utc::now), update_available, } } } pub async fn list() -> crate::Result> { let state = State::get().await?; let rows = sqlx::query( " SELECT id, source_url, manifest_url, name, version, version_id, mrpack_url, sha512, changelog, profile_path, installed_version_id, auto_update, last_checked, last_error, created, updated FROM connected_library_packs ORDER BY name COLLATE NOCASE ASC ", ) .map(ConnectedPackRow::from_row) .fetch_all(&state.pool) .await?; let packs = rows .into_iter() .collect::>>()? .into_iter() .map(ConnectedPackRow::into_pack) .collect(); Ok(packs) } pub async fn connect(source_url: String) -> crate::Result { let state = State::get().await?; let manifest_url = normalize_manifest_url(&source_url)?; let manifest = fetch_manifest(&manifest_url).await?; validate_manifest(&manifest)?; upsert_manifest(&state.pool, None, &source_url, &manifest_url, &manifest) .await?; get_by_source(&state.pool, &source_url).await } pub async fn remove(id: String) -> crate::Result<()> { let state = State::get().await?; sqlx::query("DELETE FROM connected_library_packs WHERE id = ?") .bind(id) .execute(&state.pool) .await?; Ok(()) } pub async fn set_auto_update( id: String, auto_update: bool, ) -> crate::Result { let state = State::get().await?; sqlx::query( " UPDATE connected_library_packs SET auto_update = ?, updated = ? WHERE id = ? ", ) .bind(auto_update) .bind(Utc::now().timestamp()) .bind(&id) .execute(&state.pool) .await?; get_by_id(&state.pool, &id).await } pub async fn check(id: String) -> crate::Result { let state = State::get().await?; let pack = get_by_id(&state.pool, &id).await?; let manifest = fetch_manifest(&pack.manifest_url).await?; validate_manifest(&manifest)?; upsert_manifest( &state.pool, Some(&pack.id), &pack.source_url, &pack.manifest_url, &manifest, ) .await?; let pack = get_by_id(&state.pool, &pack.id).await?; let should_install = pack.auto_update && pack.update_available; if should_install { install(pack.id.clone()).await?; } Ok(ConnectedCheckResult { pack: get_by_id(&state.pool, &pack.id).await?, installed: should_install, }) } pub async fn check_all() -> crate::Result> { let packs = list().await?; let mut results = Vec::with_capacity(packs.len()); for pack in packs { match check(pack.id.clone()).await { Ok(result) => results.push(result), Err(err) => { let state = State::get().await?; sqlx::query( " UPDATE connected_library_packs SET last_checked = ?, last_error = ?, updated = ? WHERE id = ? ", ) .bind(Utc::now().timestamp()) .bind(err.to_string()) .bind(Utc::now().timestamp()) .bind(&pack.id) .execute(&state.pool) .await?; results.push(ConnectedCheckResult { pack: get_by_id(&state.pool, &pack.id).await?, installed: false, }); } } } Ok(results) } pub async fn install(id: String) -> crate::Result { let state = State::get().await?; let pack = get_by_id(&state.pool, &id).await?; let bytes = fetch_mrpack(&pack.mrpack_url, &pack.sha512).await?; let profile_path = if let Some(profile_path) = &pack.profile_path { profile_path.clone() } else { profile::create::profile_create( pack.name.clone(), "1.20.1".to_string(), ModLoader::Vanilla, None, None, None, Some(true), ) .await? }; let create_pack = CreatePack { file: bytes, description: CreatePackDescription { icon: None, override_title: Some(pack.name.clone()), project_id: None, version_id: Some(pack.version_id.clone()), existing_loading_bar: None, profile_path: profile_path.clone(), }, }; install_zipped_mrpack_files(create_pack, true).await?; sqlx::query( " UPDATE connected_library_packs SET profile_path = ?, installed_version_id = ?, last_error = NULL, updated = ? WHERE id = ? ", ) .bind(profile_path) .bind(&pack.version_id) .bind(Utc::now().timestamp()) .bind(&id) .execute(&state.pool) .await?; get_by_id(&state.pool, &id).await } fn normalize_manifest_url(source_url: &str) -> crate::Result { let source = source_url.trim(); let parsed = Url::parse(source)?; if parsed.scheme() != "https" { return Err(ErrorKind::InputError( "Connected Library only supports public HTTPS URLs".to_string(), ) .into()); } if source.ends_with(MANIFEST_FILE_NAME) || source.contains("/raw/") { return Ok(source.to_string()); } let host = parsed.host_str().unwrap_or_default(); let segments = parsed .path_segments() .map(|parts| parts.collect::>()) .unwrap_or_default(); if host == "github.com" && segments.len() >= 2 { let repo = segments[1].trim_end_matches(".git"); return Ok(format!( "https://raw.githubusercontent.com/{}/{}/main/{}", segments[0], repo, MANIFEST_FILE_NAME )); } if host == "gitlab.com" && segments.len() >= 2 { let repo = segments[1].trim_end_matches(".git"); return Ok(format!( "https://gitlab.com/{}/{}/-/raw/main/{}", segments[0], repo, MANIFEST_FILE_NAME )); } if segments.len() >= 2 { let repo = segments[1].trim_end_matches(".git"); return Ok(format!( "{}://{}/{}/{}/raw/branch/main/{}", parsed.scheme(), host, segments[0], repo, MANIFEST_FILE_NAME )); } Err(ErrorKind::InputError( "Enter a raw modrinth-plus.json URL or a GitHub, GitLab, or Gitea repository URL" .to_string(), ) .into()) } async fn fetch_manifest(url: &str) -> crate::Result { let manifest = reqwest::get(url) .await? .error_for_status()? .json::() .await?; validate_manifest(&manifest)?; Ok(manifest) } async fn fetch_mrpack( url: &str, expected_sha512: &str, ) -> crate::Result { let parsed = Url::parse(url)?; if parsed.scheme() != "https" { return Err(ErrorKind::InputError( "Connected Library .mrpack downloads must use HTTPS".to_string(), ) .into()); } let bytes = reqwest::get(url).await?.error_for_status()?.bytes().await?; let hash = format!("{:x}", Sha512::digest(&bytes[..])); if !hash.eq_ignore_ascii_case(expected_sha512) { return Err( ErrorKind::HashError(expected_sha512.to_string(), hash).into() ); } Ok(bytes) } fn validate_manifest(manifest: &ConnectedManifest) -> crate::Result<()> { if manifest.schema_version != 1 { return Err(ErrorKind::InputError( "Unsupported Connected Library schema version".to_string(), ) .into()); } for (field, value) in [ ("name", &manifest.name), ("version", &manifest.version), ("versionId", &manifest.version_id), ("mrpackUrl", &manifest.mrpack_url), ("sha512", &manifest.sha512), ] { if value.trim().is_empty() { return Err(ErrorKind::InputError(format!( "Connected Library manifest field `{field}` is required" )) .into()); } } let mrpack_url = Url::parse(&manifest.mrpack_url)?; if mrpack_url.scheme() != "https" { return Err(ErrorKind::InputError( "Connected Library .mrpack downloads must use HTTPS".to_string(), ) .into()); } Ok(()) } async fn upsert_manifest( pool: &SqlitePool, existing_id: Option<&str>, source_url: &str, manifest_url: &str, manifest: &ConnectedManifest, ) -> crate::Result<()> { let now = Utc::now().timestamp(); let id = existing_id .map(ToString::to_string) .unwrap_or_else(|| Uuid::new_v4().to_string()); sqlx::query( " INSERT INTO connected_library_packs ( id, source_url, manifest_url, name, version, version_id, mrpack_url, sha512, changelog, last_checked, last_error, created, updated ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?) ON CONFLICT(source_url) DO UPDATE SET manifest_url = excluded.manifest_url, name = excluded.name, version = excluded.version, version_id = excluded.version_id, mrpack_url = excluded.mrpack_url, sha512 = excluded.sha512, changelog = excluded.changelog, last_checked = excluded.last_checked, last_error = NULL, updated = excluded.updated ", ) .bind(id) .bind(source_url) .bind(manifest_url) .bind(&manifest.name) .bind(&manifest.version) .bind(&manifest.version_id) .bind(&manifest.mrpack_url) .bind(&manifest.sha512) .bind(&manifest.changelog) .bind(now) .bind(now) .bind(now) .execute(pool) .await?; Ok(()) } async fn get_by_id( pool: &SqlitePool, id: &str, ) -> crate::Result { let row = sqlx::query( " SELECT id, source_url, manifest_url, name, version, version_id, mrpack_url, sha512, changelog, profile_path, installed_version_id, auto_update, last_checked, last_error, created, updated FROM connected_library_packs WHERE id = ? ", ) .bind(id) .map(ConnectedPackRow::from_row) .fetch_one(pool) .await??; Ok(row.into_pack()) } async fn get_by_source( pool: &SqlitePool, source_url: &str, ) -> crate::Result { let row = sqlx::query( " SELECT id, source_url, manifest_url, name, version, version_id, mrpack_url, sha512, changelog, profile_path, installed_version_id, auto_update, last_checked, last_error, created, updated FROM connected_library_packs WHERE source_url = ? ", ) .bind(source_url) .map(ConnectedPackRow::from_row) .fetch_one(pool) .await??; Ok(row.into_pack()) }