diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9c61627 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/src/api/download.rs b/src/api/download.rs index 178d594..bab4510 100644 --- a/src/api/download.rs +++ b/src/api/download.rs @@ -1,65 +1,56 @@ use std::collections::HashSet; -use std::io; -use std::io::Cursor; -use std::io::Write; +use std::io::{Cursor, Write}; +use std::ops::Deref; use std::path::Path; -use anyhow::anyhow; -use anyhow::Context; -use anyhow::Result; +use anyhow::{Context, Result}; use axum::body::Bytes; use axum::extract::Query; use axum::http::{header, StatusCode}; use axum::response::IntoResponse; -use serde::Deserialize; use cached::proc_macro::cached; use reqwest::Client; -use tokio::fs::File; -use tokio::fs::OpenOptions; -use tokio::io::AsyncReadExt; -use tokio::io::AsyncWriteExt; -use zip::write::SimpleFileOptions; +use serde::Deserialize; use zip::CompressionMethod; +use zip::write::SimpleFileOptions; use zip::ZipArchive; use zip::ZipWriter; -use crate::manifest::v2::Module; -use crate::manifest::v2::ModuleKind; - +use crate::manifest::ManifestKind; +use crate::manifest::v2::{Module, ModuleKind}; +use crate::utils::{read_from_file, write_to_file}; use super::manifest::fetch_manifest; -type VersionedModules = Vec<(Module, String)>; - - #[derive(Deserialize)] pub struct QueryParams { version: String, modules: String, } -pub async fn download(Query(params): Query) -> impl IntoResponse { - let manifest = match fetch_manifest(params.version.clone()).await { - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), - Ok(None) => return (StatusCode::UNPROCESSABLE_ENTITY).into_response(), - Ok(Some(manifest)) => manifest, - }; +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +struct VersionedModule { + module: Module, + version: String, +} - let mut modules = Vec::new(); - let ids: Vec<&str> = params.modules.split(',').collect(); +impl Deref for VersionedModule { + type Target = Module; - for module in manifest.into_latest().modules { - if let Some(id) = ids.iter().find(|&id| id.starts_with(&module.id)) { - modules.push( - if let Some(version) = id.split(':').nth(1) { - (module, version.to_string()) - } else { - (module, params.version.to_string()) - } - ); - } + fn deref(&self) -> &Self::Target { + &self.module } +} + +pub async fn download(Query(params): Query) -> impl IntoResponse { + let manifest = match fetch_manifest(params.version.to_owned()).await { + Ok(Some(manifest)) => manifest, + Ok(None) => return (StatusCode::UNPROCESSABLE_ENTITY).into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + let modules = get_versioned_modules(manifest, params).await; if modules.is_empty() { return (StatusCode::UNPROCESSABLE_ENTITY).into_response(); } @@ -71,7 +62,7 @@ pub async fn download(Query(params): Query) -> impl IntoResponse { (header::CONTENT_TYPE, "application/zip"), ( header::CONTENT_DISPOSITION, - &format!("attachment; filename=\"bookshelf-{}.zip\"", params.version), + "attachment; filename=\"bookshelf.zip\"", ), ]; (StatusCode::OK, headers, Bytes::from(data)).into_response() @@ -80,79 +71,85 @@ pub async fn download(Query(params): Query) -> impl IntoResponse { } #[cached(time = 60, result = true)] -async fn create_bundle(modules: VersionedModules) -> Result> { - if modules.is_empty() { - return Err(anyhow!("Cannot create an empty bundle")); - } - - let (data_packs, resource_packs): (VersionedModules, VersionedModules) = modules - .clone() +async fn create_bundle(modules: Vec) -> Result> { + let (datapacks, resourcepacks): (Vec, Vec) = modules .into_iter() - .partition(|(module, _)| matches!(module.kind, ModuleKind::DataPack)); + .partition(|module| matches!(module.kind, ModuleKind::DataPack)); - if data_packs.is_empty() || resource_packs.is_empty() { - create_specialized_bundle(modules).await + if datapacks.is_empty() || resourcepacks.is_empty() { + create_pack([datapacks, resourcepacks].concat()).await } else { - let mut buffer = Vec::new(); - let mut zip_writer = ZipWriter::new(Cursor::new(&mut buffer)); - let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); - - zip_writer.start_file("resource_packs.zip", options)?; - zip_writer.write_all(&create_specialized_bundle(data_packs).await?)?; - zip_writer.start_file("data_packs.zip", options)?; - zip_writer.write_all(&create_specialized_bundle(resource_packs).await?)?; - zip_writer.finish()?; - - Ok(buffer) + create_packs(datapacks, resourcepacks).await } } -async fn create_specialized_bundle(modules: VersionedModules) -> Result> { +async fn create_packs( + datapacks: Vec, + resourcepacks: Vec, +) -> Result> { let mut buffer = Vec::new(); let mut zip_writer = ZipWriter::new(Cursor::new(&mut buffer)); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + + zip_writer.start_file("resource_packs.zip", options)?; + zip_writer.write_all(&create_pack(datapacks).await?)?; + zip_writer.start_file("data_packs.zip", options)?; + zip_writer.write_all(&create_pack(resourcepacks).await?)?; + zip_writer.finish()?; + + Ok(buffer) +} + +async fn create_pack(modules: Vec) -> Result> { + let mut buffer = Vec::new(); + let mut writer = ZipWriter::new(Cursor::new(&mut buffer)); + + let mut duplicates = HashSet::new(); let options = SimpleFileOptions::default() .compression_method(CompressionMethod::Deflated) .compression_level(Some(9)); - let mut duplicates = HashSet::new(); for module in modules { - let mut archive = fetch_module(module.0, module.1).await?; + let data = fetch_module(module.module, module.version).await?; + let mut archive = ZipArchive::new(Cursor::new(data))?; for i in 0..archive.len() { let mut file = archive.by_index(i)?; let name = file.name().to_string(); - if !duplicates.contains(&name) { - zip_writer.start_file(&name, options)?; - std::io::copy(&mut file, &mut zip_writer)?; - duplicates.insert(name); + if duplicates.insert(name.clone()) { + writer.start_file(&name, options)?; + std::io::copy(&mut file, &mut writer)?; } } } - zip_writer.finish()?; + writer.finish()?; Ok(buffer) } #[cached(time = 60, result = true)] -async fn fetch_module(module: Module, version: String) -> Result>>> { - if let Ok(bytes) = fetch_module_from_modrinth(&module, &version).await { - Ok(ZipArchive::new(Cursor::new(bytes))?) - } else if let Ok(bytes) = fetch_module_from_disk(&module, &version).await { - Ok(ZipArchive::new(Cursor::new(bytes))?) - } else { - Err(anyhow!("Failed to fetch module from all sources")) +async fn fetch_module( + module: Module, + version: String, +) -> Result> { + let disk_path = format!("data/{}/{}.zip", version, module.id); + match fetch_module_from_modrinth(&module, &version).await { + Ok(bytes) => { + if !Path::new(&disk_path).exists() { + write_to_file(&disk_path, &bytes).await?; + } + Ok(bytes) + }, + Err(_) => read_from_file(&disk_path).await + .context("Failed to fetch module from all sources"), } } -async fn fetch_module_from_disk(module: &Module, version: &str) -> Result> { - let mut buffer = Vec::new(); - let path = format!("data/{}/{}.zip", version, module.id); - File::open(path).await?.read_to_end(&mut buffer).await?; - Ok(buffer) -} - -async fn fetch_module_from_modrinth(module: &Module, version: &str) -> Result> { +async fn fetch_module_from_modrinth( + module: &Module, + version: &str, +) -> Result> { let client = Client::new(); let url = format!("https://api.modrinth.com/v3/project/{}/version/{}", module.slug, version); @@ -161,28 +158,19 @@ async fn fetch_module_from_modrinth(module: &Module, version: &str) -> Result io::Result<()> { - let path = format!("data/{}/{}.zip", version, module.id); - if !Path::new(&path).exists() { - return Ok(()) - } - - let mut file = OpenOptions::new() - .write(true) - .truncate(true) - .create(true) - .open(path) - .await?; +async fn get_versioned_modules( + manifest: ManifestKind, + params: QueryParams, +) -> Vec { + let ids: Vec<&str> = params.modules.split(',').collect(); - file.write_all(bytes).await + manifest.into_latest().modules.into_iter().filter_map(|module| { + ids.iter().find(|&id| id.starts_with(&module.id)).map(|id| { + let version = id.split(':').nth(1).unwrap_or(¶ms.version).to_owned(); + VersionedModule {module, version} + }) + }).collect() } diff --git a/src/api/manifest.rs b/src/api/manifest.rs index a6c198e..911392b 100644 --- a/src/api/manifest.rs +++ b/src/api/manifest.rs @@ -1,75 +1,51 @@ -use std::io; +use std::path::Path as StdPath; use anyhow::{Context, Result}; -use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json}; +use axum::extract::Path; +use axum::http::StatusCode; +use axum::Json; +use axum::response::IntoResponse; use cached::proc_macro::cached; use reqwest::Client; -use tokio::{fs::{File, OpenOptions}, io::{AsyncReadExt, AsyncWriteExt}}; -use super::versions::{fetch_versions, Version}; use crate::manifest::ManifestKind; +use crate::utils::{read_from_json_file, write_to_json_file}; +use super::versions::{fetch_versions, Version}; -pub async fn manifest(Path(id): Path) -> impl IntoResponse { - match fetch_manifest(id).await { +pub async fn manifest(Path(version): Path) -> impl IntoResponse { + match fetch_manifest(version).await { + Ok(Some(data)) => Json(data.into_latest()).into_response(), + Ok(None) => (StatusCode::NOT_FOUND).into_response(), Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), - Ok(data) => match data { - Some(data) => Json(data.into_latest()).into_response(), - None => (StatusCode::NOT_FOUND).into_response(), - }, } } -#[cached(time = 600, result = true)] +#[cached(time = 86400, result = true)] pub async fn fetch_manifest(version: String) -> Result> { let versions = fetch_versions().await.context("Failed to fetch versions")?; - match versions.into_iter().find(|entry| entry.version == version) { - None => Ok(None), - Some(version) => { - if let Ok(manifest) = fetch_manifest_from_github(&version).await { - Ok(Some(manifest)) - } else { - fetch_manifest_from_disk(&version).await.map(Some) - } - } - } -} - -async fn fetch_manifest_from_disk(version: &Version) -> Result { - let mut file = File::open(format!("data/{}/manifest.json", version.version)).await - .context("Failed to load manifest.json")?; + if let Some(version) = versions.into_iter().find(|entry| entry.version == version) { + let disk_path = format!("data/{}/manifest.json", version.version); - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer).await - .context("Failed to read manifest.json")?; - let manifest: ManifestKind = serde_json::from_slice(&buffer) - .context("Failed to parse manifest.json")?; + return match fetch_manifest_from_github(&version).await { + Ok(manifest) => { + if !StdPath::new(&disk_path).exists() { + write_to_json_file(&disk_path, &manifest).await?; + } + Ok(Some(manifest)) + }, + Err(_) => read_from_json_file(&disk_path).await.map(Some), + }; + } - Ok(manifest) + Ok(None) } async fn fetch_manifest_from_github(version: &Version) -> Result { let client = Client::new(); - let response = client.get(&version.manifest).send().await - .context("Failed to send request to Github")?; - - let manifest: ManifestKind = response.json().await - .context("Failed to parse response from Github")?; - - write_manifest_to_disk(&manifest, version).await?; + let response = client.get(&version.manifest).send().await?; + let manifest: ManifestKind = response.json().await?; Ok(manifest) } - -async fn write_manifest_to_disk(manifest: &ManifestKind, version: &Version) -> io::Result<()> { - let data = serde_json::to_string(manifest)?; - let mut file = OpenOptions::new() - .write(true) - .truncate(true) - .create(true) - .open(format!("data/{}/manifest.json", version.version)) - .await?; - - file.write_all(data.as_bytes()).await -} diff --git a/src/api/versions.rs b/src/api/versions.rs index 27ff195..c199080 100644 --- a/src/api/versions.rs +++ b/src/api/versions.rs @@ -1,16 +1,12 @@ -use std::io; - -use anyhow::Context; use anyhow::Result; -use axum::{http::StatusCode, response::IntoResponse, Json}; +use axum::http::StatusCode; +use axum::Json; +use axum::response::IntoResponse; use cached::proc_macro::cached; use reqwest::Client; -use serde::Deserialize; -use serde::Serialize; -use tokio::fs::File; -use tokio::fs::OpenOptions; -use tokio::io::AsyncReadExt; -use tokio::io::AsyncWriteExt; +use serde::{Deserialize, Serialize}; + +use crate::utils::{read_from_json_file, write_to_json_file}; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -27,48 +23,23 @@ pub async fn versions() -> impl IntoResponse { } } -#[cached(time = 600, result = true)] +#[cached(time = 3600, result = true)] pub async fn fetch_versions() -> Result> { + let disk_path = "data/versions.json"; match fetch_versions_from_github().await { - Ok(versions) => Ok(versions), - Err(_) => fetch_versions_from_disk().await, + Ok(versions) => { + write_to_json_file(disk_path, &versions).await?; + Ok(versions) + }, + Err(_) => read_from_json_file(disk_path).await, } } -async fn fetch_versions_from_disk() -> Result> { - let mut file = File::open("data/versions.json").await - .context("Failed to load versions.json")?; - - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer).await - .context("Failed to read versions.json")?; - let versions: Vec = serde_json::from_slice(&buffer) - .context("Failed to parse versions.json")?; - - Ok(versions) -} - async fn fetch_versions_from_github() -> Result> { let url = "https://raw.githubusercontent.com/mcbookshelf/Bookshelf/refs/heads/master/meta/versions.json"; let client = Client::new(); let response = client.get(url).send().await?; let versions: Vec = response.json().await?; - if let Err(err) = write_versions_to_disk(&versions).await { - eprintln!("Failed to overwrite versions file: {}", err); - } - Ok(versions) } - -async fn write_versions_to_disk(versions: &Vec) -> io::Result<()> { - let data = serde_json::to_string(versions)?; - let mut file = OpenOptions::new() - .write(true) - .truncate(true) - .create(true) - .open("data/versions.json") - .await?; - - file.write_all(data.as_bytes()).await -} diff --git a/src/main.rs b/src/main.rs index b553bd8..940f678 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,8 @@ use axum::{routing::get, Router}; mod api; mod manifest; +mod utils; + #[tokio::main] async fn main() { @@ -13,6 +15,12 @@ async fn main() { .route("/version/:id", get(manifest)) .route("/download", get(download)); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") + .await + .unwrap_or_else(|err| { + eprintln!("Failed to bind listener: {}", err); + std::process::exit(1); + }); + axum::serve(listener, app).await.unwrap(); } diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index e03c92e..c05cc3a 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; pub mod v1; pub mod v2; + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum ManifestKind { @@ -10,7 +11,10 @@ pub enum ManifestKind { V2(v2::Manifest), } + impl ManifestKind { + /// Converts the ManifestKind enum into the latest version (V2). + /// This is useful for ensuring compatibility with V2-based logic. pub fn into_latest(self) -> v2::Manifest { match self { ManifestKind::V1(manifest) => manifest.into(), @@ -22,30 +26,26 @@ impl ManifestKind { impl From for v2::Manifest { fn from(manifest: v1::Manifest) -> Self { v2::Manifest { - modules: manifest - .datapacks - .into_iter() - .flat_map(move |datapack| { - datapack.modules.into_iter().map(move |module| v2::Module { - slug: module.id.replace("bs.", "bookshelf-"), - id: module.id, - name: module.name, - icon: None, - banner: None, - readme: None, - documentation: module.documentation, - description: module.description, - kind: v2::ModuleKind::default(), - tags: match datapack.name.as_str() { - "Bookshelf" => vec!["default".to_string()], - "Bookshelf Dev" => vec!["dev".to_string()], - _ => vec![], - }, - dependencies: module.dependencies, - weak_dependencies: module.weak_dependencies, - }) + modules: manifest.datapacks.into_iter().flat_map(move |datapack| { + datapack.modules.into_iter().map(move |module| v2::Module { + id: module.id.to_owned(), + name: module.name, + slug: module.id.replace("bs.", "bookshelf-"), + icon: None, + banner: None, + readme: None, + documentation: module.documentation, + description: module.description, + kind: v2::ModuleKind::default(), + tags: match datapack.name.as_str() { + "Bookshelf" => vec!["default".to_string()], + "Bookshelf Dev" => vec!["dev".to_string()], + _ => vec![], + }, + dependencies: module.dependencies, + weak_dependencies: module.weak_dependencies, }) - .collect(), + }).collect(), } } } diff --git a/src/manifest/v1.rs b/src/manifest/v1.rs index f6a2430..b3acdf6 100644 --- a/src/manifest/v1.rs +++ b/src/manifest/v1.rs @@ -1,6 +1,7 @@ use serde::de; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::Value; + #[derive(Clone, Debug, Serialize)] pub struct Manifest { @@ -29,6 +30,7 @@ pub struct Module { pub weak_dependencies: Vec, } + impl<'de> Deserialize<'de> for Manifest { fn deserialize(deserializer: D) -> Result where @@ -39,11 +41,12 @@ impl<'de> Deserialize<'de> for Manifest { .as_array() .or_else(|| value.get("datapacks").and_then(|v| v.as_array())) { - None => Err(de::Error::custom( - "Expected either an array or an object with a 'datapacks' key", - )), + None => Err(de::Error::custom("Expected a 'datapacks' key or an array of datapacks")), Some(datapacks) => Ok(Manifest { - datapacks: serde_json::from_value(json!(datapacks)).map_err(de::Error::custom)?, + datapacks: datapacks + .iter() + .map(|v| serde_json::from_value(v.clone()).map_err(de::Error::custom)) + .collect::>()?, }), } } diff --git a/src/manifest/v2.rs b/src/manifest/v2.rs index b0e423d..bd3ed4f 100644 --- a/src/manifest/v2.rs +++ b/src/manifest/v2.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Manifest { pub modules: Vec, diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..f3ab8d5 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,39 @@ +use anyhow::{Context, Result}; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + +pub async fn read_from_file(path: &str) -> Result> { + let mut file = File::open(path).await.context("Failed to open file")?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer).await.context("Failed to read file")?; + Ok(buffer) +} + +pub async fn read_from_json_file(path: &str) -> Result +where + T: serde::de::DeserializeOwned, +{ + let buffer = read_from_file(path).await?; + serde_json::from_slice(&buffer).context("Failed to deserialize JSON") +} + +pub async fn write_to_file(path: &str, bytes: &[u8]) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(path) + .await + .context("Failed to open file for writing")?; + + file.write_all(bytes).await.context("Failed to write data to file") +} + +pub async fn write_to_json_file(path: &str, data: &T) -> Result<()> +where + T: serde::Serialize, +{ + let data = serde_json::to_string(data).context("Failed to serialize data")?; + write_to_file(path, data.as_bytes()).await +}