Add connected library for Git modpack manifests
Some checks failed
Build / verify (push) Failing after 18m55s

This commit is contained in:
MrSphay
2026-05-03 01:43:34 +02:00
parent 4348664618
commit dd7f9de5ce
14 changed files with 1109 additions and 3 deletions

View File

@@ -0,0 +1,24 @@
CREATE TABLE connected_library_packs (
id TEXT NOT NULL,
source_url TEXT NOT NULL,
manifest_url TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
version_id TEXT NOT NULL,
mrpack_url TEXT NOT NULL,
sha512 TEXT NOT NULL,
changelog TEXT NULL,
profile_path TEXT NULL,
installed_version_id TEXT NULL,
auto_update INTEGER NOT NULL DEFAULT FALSE,
last_checked INTEGER NULL,
last_error TEXT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
UNIQUE (source_url),
PRIMARY KEY (id)
);

View File

@@ -0,0 +1,518 @@
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<String>,
}
#[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<String>,
pub profile_path: Option<String>,
pub installed_version_id: Option<String>,
pub auto_update: bool,
pub last_checked: Option<chrono::DateTime<Utc>>,
pub last_error: Option<String>,
pub created: chrono::DateTime<Utc>,
pub updated: chrono::DateTime<Utc>,
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<String>,
profile_path: Option<String>,
installed_version_id: Option<String>,
auto_update: i64,
last_checked: Option<i64>,
last_error: Option<String>,
created: i64,
updated: i64,
}
impl ConnectedPackRow {
fn from_row(row: sqlx::sqlite::SqliteRow) -> sqlx::Result<Self> {
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<Vec<ConnectedPack>> {
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::<sqlx::Result<Vec<_>>>()?
.into_iter()
.map(ConnectedPackRow::into_pack)
.collect();
Ok(packs)
}
pub async fn connect(source_url: String) -> crate::Result<ConnectedPack> {
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<ConnectedPack> {
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<ConnectedCheckResult> {
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<Vec<ConnectedCheckResult>> {
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<ConnectedPack> {
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<String> {
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::<Vec<_>>())
.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<ConnectedManifest> {
let manifest = reqwest::get(url)
.await?
.error_for_status()?
.json::<ConnectedManifest>()
.await?;
validate_manifest(&manifest)?;
Ok(manifest)
}
async fn fetch_mrpack(url: &str, expected_sha512: &str) -> crate::Result<bytes::Bytes> {
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<ConnectedPack> {
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<ConnectedPack> {
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())
}

View File

@@ -1,5 +1,6 @@
//! API for interacting with Theseus
pub mod cache;
pub mod connected_library;
pub mod friends;
pub mod handler;
pub mod jre;
@@ -34,7 +35,8 @@ pub mod prelude {
State,
data::*,
event::CommandPayload,
jre, metadata, minecraft_auth, mr_auth, pack, process,
connected_library, jre, metadata, minecraft_auth, mr_auth, pack,
process,
profile::{self, Profile, create},
settings,
util::{