Add connected library for Git modpack manifests
Some checks failed
Build / verify (push) Failing after 18m55s
Some checks failed
Build / verify (push) Failing after 18m55s
This commit is contained in:
518
packages/app-lib/src/api/connected_library.rs
Normal file
518
packages/app-lib/src/api/connected_library.rs
Normal 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())
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user