523 lines
15 KiB
Rust
523 lines
15 KiB
Rust
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())
|
|
}
|