diff --git a/Cargo.lock b/Cargo.lock index 804a3d1..a02d9ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1636,6 +1636,7 @@ dependencies = [ "chrono", "clap", "futures", + "hyper", "libarchive", "sea-orm", "sea-orm-migration", diff --git a/server/Cargo.toml b/server/Cargo.toml index bf4fd7f..0bdfee3 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -8,6 +8,7 @@ authors = ["Jef Roosens"] [dependencies] axum = { version = "0.6.18", features = ["http2"] } +hyper = "*" chrono = { version = "0.4.26", features = ["serde"] } clap = { version = "4.3.12", features = ["env", "derive"] } futures = "0.3.28" diff --git a/server/src/cli.rs b/server/src/cli.rs index 540f457..ec93f8b 100644 --- a/server/src/cli.rs +++ b/server/src/cli.rs @@ -2,12 +2,10 @@ use crate::repo::RepoGroupManager; use crate::{Config, Global}; use axum::extract::FromRef; -use axum::Router; use clap::Parser; use std::io; use std::path::PathBuf; use std::sync::{Arc, RwLock}; -use tower_http::trace::TraceLayer; use tracing::debug; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -95,18 +93,9 @@ impl Cli { }; // build our application with a single route - let app = Router::new() - .nest("/api", crate::api::router()) - .merge(crate::repo::router(&self.api_key)) - .with_state(global) - .layer(TraceLayer::new_for_http()); - - // run it with hyper on localhost:3000 - Ok( - axum::Server::bind(&format!("0.0.0.0:{}", self.port).parse().unwrap()) - .serve(app.into_make_service()) - .await - .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?, - ) + let app = crate::web::app(global, &self.api_key); + Ok(crate::web::serve(app, self.port) + .await + .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?) } } diff --git a/server/src/main.rs b/server/src/main.rs index fc5c110..1d90a24 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,8 +1,8 @@ -mod api; mod cli; pub mod db; mod error; mod repo; +mod web; use clap::Parser; pub use error::{Result, ServerError}; diff --git a/server/src/repo/mod.rs b/server/src/repo/mod.rs index 21acf81..44f0e10 100644 --- a/server/src/repo/mod.rs +++ b/server/src/repo/mod.rs @@ -1,255 +1,4 @@ -mod manager; +pub mod manager; pub mod package; pub use manager::RepoGroupManager; - -use std::path::PathBuf; - -use axum::body::Body; -use axum::extract::{BodyStream, Path, State}; -use axum::http::Request; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{delete, post}; -use axum::Router; -use futures::StreamExt; -use sea_orm::ModelTrait; -use std::sync::Arc; -use tokio::{fs, io::AsyncWriteExt}; -use tower::util::ServiceExt; -use tower_http::services::{ServeDir, ServeFile}; -use tower_http::validate_request::ValidateRequestHeaderLayer; -use uuid::Uuid; - -const DB_FILE_EXTS: [&str; 4] = [".db", ".files", ".db.tar.gz", ".files.tar.gz"]; - -pub fn router(api_key: &str) -> Router { - Router::new() - .route( - "/:repo", - post(post_package_archive) - .delete(delete_repo) - .route_layer(ValidateRequestHeaderLayer::bearer(api_key)), - ) - .route( - "/:repo/:arch", - delete(delete_arch_repo).route_layer(ValidateRequestHeaderLayer::bearer(api_key)), - ) - // Routes added after the layer do not get that layer applied, so the GET requests will not - // be authorized - .route( - "/:repo/:arch/:filename", - delete(delete_package) - .route_layer(ValidateRequestHeaderLayer::bearer(api_key)) - .get(get_file), - ) -} - -/// Serve the package archive files and database archives. If files are requested for an -/// architecture that does not have any explicit packages, a repository containing only "any" files -/// is returned. -async fn get_file( - State(global): State, - Path((repo, arch, mut file_name)): Path<(String, String, String)>, - req: Request, -) -> crate::Result { - let repo_dir = global.config.repo_dir.join(&repo).join(&arch); - let repo_exists = tokio::fs::try_exists(&repo_dir).await?; - - let res = if DB_FILE_EXTS.iter().any(|ext| file_name.ends_with(ext)) { - // Append tar extension to ensure we find the file - if !file_name.ends_with(".tar.gz") { - file_name.push_str(".tar.gz"); - }; - - if repo_exists { - ServeFile::new(repo_dir.join(file_name)).oneshot(req).await - } else { - let path = global - .config - .repo_dir - .join(repo) - .join(manager::ANY_ARCH) - .join(file_name); - - ServeFile::new(path).oneshot(req).await - } - } else { - let any_file = global - .config - .pkg_dir - .join(repo) - .join(manager::ANY_ARCH) - .join(file_name); - - if repo_exists { - ServeDir::new(global.config.pkg_dir) - .fallback(ServeFile::new(any_file)) - .oneshot(req) - .await - } else { - ServeFile::new(any_file).oneshot(req).await - } - }; - - Ok(res) -} - -async fn post_package_archive( - State(global): State, - Path(repo): Path, - mut body: BodyStream, -) -> crate::Result<()> { - // We first stream the uploaded file to disk - let uuid: uuid::fmt::Simple = Uuid::new_v4().into(); - let path = global.config.pkg_dir.join(uuid.to_string()); - let mut f = fs::File::create(&path).await?; - - while let Some(chunk) = body.next().await { - f.write_all(&chunk?).await?; - } - - let clone = Arc::clone(&global.repo_manager); - let path_clone = path.clone(); - let repo_clone = repo.clone(); - let res = tokio::task::spawn_blocking(move || { - clone - .write() - .unwrap() - .add_pkg_from_path(&repo_clone, &path_clone) - }) - .await?; - - match res { - // Insert the newly added package into the database - Ok(pkg) => { - tracing::info!("Added '{}' to repository '{}'", pkg.file_name(), repo); - - // Query the repo for its ID, or create it if it does not already exist - let res = global.db.repo.by_name(&repo).await?; - - let repo_id = if let Some(repo_entity) = res { - repo_entity.id - } else { - global.db.repo.insert(&repo, None).await?.last_insert_id - }; - - // If the package already exists in the database, we remove it first - let res = global - .db - .pkg - .by_fields(repo_id, &pkg.info.name, None, &pkg.info.arch) - .await?; - - if let Some(entry) = res { - entry.delete(&global.db).await?; - } - - global.db.pkg.insert(repo_id, pkg).await?; - - Ok(()) - } - // Remove the uploaded file and return the error - Err(err) => { - tokio::fs::remove_file(path).await?; - - Err(err.into()) - } - } -} - -async fn delete_repo( - State(global): State, - Path(repo): Path, -) -> crate::Result { - let clone = Arc::clone(&global.repo_manager); - - let repo_clone = repo.clone(); - let repo_removed = - tokio::task::spawn_blocking(move || clone.write().unwrap().remove_repo(&repo_clone)) - .await??; - - if repo_removed { - let res = global.db.repo.by_name(&repo).await?; - - if let Some(repo_entry) = res { - repo_entry.delete(&global.db).await?; - } - - tracing::info!("Removed repository '{}'", repo); - - Ok(StatusCode::OK) - } else { - Ok(StatusCode::NOT_FOUND) - } -} - -async fn delete_arch_repo( - State(global): State, - Path((repo, arch)): Path<(String, String)>, -) -> crate::Result { - let clone = Arc::clone(&global.repo_manager); - - let arch_clone = arch.clone(); - let repo_clone = repo.clone(); - let repo_removed = tokio::task::spawn_blocking(move || { - clone - .write() - .unwrap() - .remove_repo_arch(&repo_clone, &arch_clone) - }) - .await??; - - if repo_removed { - let res = global.db.repo.by_name(&repo).await?; - - if let Some(repo_entry) = res { - global.db.pkg.delete_with_arch(repo_entry.id, &arch).await?; - } - tracing::info!("Removed architecture '{}' from repository '{}'", arch, repo); - - Ok(StatusCode::OK) - } else { - Ok(StatusCode::NOT_FOUND) - } -} - -async fn delete_package( - State(global): State, - Path((repo, arch, file_name)): Path<(String, String, String)>, -) -> crate::Result { - let clone = Arc::clone(&global.repo_manager); - let path = PathBuf::from(&repo).join(arch).join(&file_name); - - let res = tokio::task::spawn_blocking(move || { - clone.write().unwrap().remove_pkg_from_path(path, true) - }) - .await??; - - if let Some((name, version, release, arch)) = res { - let res = global.db.repo.by_name(&repo).await?; - - if let Some(repo_entry) = res { - let res = global - .db - .pkg - .by_fields( - repo_entry.id, - &name, - Some(&format!("{}-{}", version, release)), - &arch, - ) - .await?; - - if let Some(entry) = res { - entry.delete(&global.db).await?; - } - } - - tracing::info!("Removed '{}' from repository '{}'", file_name, repo); - - Ok(StatusCode::OK) - } else { - Ok(StatusCode::NOT_FOUND) - } -} diff --git a/server/src/api/mod.rs b/server/src/web/api/mod.rs similarity index 100% rename from server/src/api/mod.rs rename to server/src/web/api/mod.rs diff --git a/server/src/api/pagination.rs b/server/src/web/api/pagination.rs similarity index 100% rename from server/src/api/pagination.rs rename to server/src/web/api/pagination.rs diff --git a/server/src/web/mod.rs b/server/src/web/mod.rs new file mode 100644 index 0000000..ce32a07 --- /dev/null +++ b/server/src/web/mod.rs @@ -0,0 +1,19 @@ +mod api; +mod repo; + +use axum::{Router, Server}; +use tower_http::trace::TraceLayer; + +pub fn app(global: crate::Global, api_key: &str) -> Router { + Router::new() + .nest("/api", api::router()) + .merge(repo::router(api_key)) + .with_state(global) + .layer(TraceLayer::new_for_http()) +} + +pub async fn serve(app: Router, port: u16) -> Result<(), hyper::Error> { + Server::bind(&format!("0.0.0.0:{}", port).parse().unwrap()) + .serve(app.into_make_service()) + .await +} diff --git a/server/src/web/repo.rs b/server/src/web/repo.rs new file mode 100644 index 0000000..f8c3d65 --- /dev/null +++ b/server/src/web/repo.rs @@ -0,0 +1,250 @@ +use std::path::PathBuf; + +use axum::body::Body; +use axum::extract::{BodyStream, Path, State}; +use axum::http::Request; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{delete, post}; +use axum::Router; +use futures::StreamExt; +use sea_orm::ModelTrait; +use std::sync::Arc; +use tokio::{fs, io::AsyncWriteExt}; +use tower::util::ServiceExt; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::validate_request::ValidateRequestHeaderLayer; +use uuid::Uuid; + +const DB_FILE_EXTS: [&str; 4] = [".db", ".files", ".db.tar.gz", ".files.tar.gz"]; + +pub fn router(api_key: &str) -> Router { + Router::new() + .route( + "/:repo", + post(post_package_archive) + .delete(delete_repo) + .route_layer(ValidateRequestHeaderLayer::bearer(api_key)), + ) + .route( + "/:repo/:arch", + delete(delete_arch_repo).route_layer(ValidateRequestHeaderLayer::bearer(api_key)), + ) + // Routes added after the layer do not get that layer applied, so the GET requests will not + // be authorized + .route( + "/:repo/:arch/:filename", + delete(delete_package) + .route_layer(ValidateRequestHeaderLayer::bearer(api_key)) + .get(get_file), + ) +} + +/// Serve the package archive files and database archives. If files are requested for an +/// architecture that does not have any explicit packages, a repository containing only "any" files +/// is returned. +async fn get_file( + State(global): State, + Path((repo, arch, mut file_name)): Path<(String, String, String)>, + req: Request, +) -> crate::Result { + let repo_dir = global.config.repo_dir.join(&repo).join(&arch); + let repo_exists = tokio::fs::try_exists(&repo_dir).await?; + + let res = if DB_FILE_EXTS.iter().any(|ext| file_name.ends_with(ext)) { + // Append tar extension to ensure we find the file + if !file_name.ends_with(".tar.gz") { + file_name.push_str(".tar.gz"); + }; + + if repo_exists { + ServeFile::new(repo_dir.join(file_name)).oneshot(req).await + } else { + let path = global + .config + .repo_dir + .join(repo) + .join(crate::repo::manager::ANY_ARCH) + .join(file_name); + + ServeFile::new(path).oneshot(req).await + } + } else { + let any_file = global + .config + .pkg_dir + .join(repo) + .join(crate::repo::manager::ANY_ARCH) + .join(file_name); + + if repo_exists { + ServeDir::new(global.config.pkg_dir) + .fallback(ServeFile::new(any_file)) + .oneshot(req) + .await + } else { + ServeFile::new(any_file).oneshot(req).await + } + }; + + Ok(res) +} + +async fn post_package_archive( + State(global): State, + Path(repo): Path, + mut body: BodyStream, +) -> crate::Result<()> { + // We first stream the uploaded file to disk + let uuid: uuid::fmt::Simple = Uuid::new_v4().into(); + let path = global.config.pkg_dir.join(uuid.to_string()); + let mut f = fs::File::create(&path).await?; + + while let Some(chunk) = body.next().await { + f.write_all(&chunk?).await?; + } + + let clone = Arc::clone(&global.repo_manager); + let path_clone = path.clone(); + let repo_clone = repo.clone(); + let res = tokio::task::spawn_blocking(move || { + clone + .write() + .unwrap() + .add_pkg_from_path(&repo_clone, &path_clone) + }) + .await?; + + match res { + // Insert the newly added package into the database + Ok(pkg) => { + tracing::info!("Added '{}' to repository '{}'", pkg.file_name(), repo); + + // Query the repo for its ID, or create it if it does not already exist + let res = global.db.repo.by_name(&repo).await?; + + let repo_id = if let Some(repo_entity) = res { + repo_entity.id + } else { + global.db.repo.insert(&repo, None).await?.last_insert_id + }; + + // If the package already exists in the database, we remove it first + let res = global + .db + .pkg + .by_fields(repo_id, &pkg.info.name, None, &pkg.info.arch) + .await?; + + if let Some(entry) = res { + entry.delete(&global.db).await?; + } + + global.db.pkg.insert(repo_id, pkg).await?; + + Ok(()) + } + // Remove the uploaded file and return the error + Err(err) => { + tokio::fs::remove_file(path).await?; + + Err(err.into()) + } + } +} + +async fn delete_repo( + State(global): State, + Path(repo): Path, +) -> crate::Result { + let clone = Arc::clone(&global.repo_manager); + + let repo_clone = repo.clone(); + let repo_removed = + tokio::task::spawn_blocking(move || clone.write().unwrap().remove_repo(&repo_clone)) + .await??; + + if repo_removed { + let res = global.db.repo.by_name(&repo).await?; + + if let Some(repo_entry) = res { + repo_entry.delete(&global.db).await?; + } + + tracing::info!("Removed repository '{}'", repo); + + Ok(StatusCode::OK) + } else { + Ok(StatusCode::NOT_FOUND) + } +} + +async fn delete_arch_repo( + State(global): State, + Path((repo, arch)): Path<(String, String)>, +) -> crate::Result { + let clone = Arc::clone(&global.repo_manager); + + let arch_clone = arch.clone(); + let repo_clone = repo.clone(); + let repo_removed = tokio::task::spawn_blocking(move || { + clone + .write() + .unwrap() + .remove_repo_arch(&repo_clone, &arch_clone) + }) + .await??; + + if repo_removed { + let res = global.db.repo.by_name(&repo).await?; + + if let Some(repo_entry) = res { + global.db.pkg.delete_with_arch(repo_entry.id, &arch).await?; + } + tracing::info!("Removed architecture '{}' from repository '{}'", arch, repo); + + Ok(StatusCode::OK) + } else { + Ok(StatusCode::NOT_FOUND) + } +} + +async fn delete_package( + State(global): State, + Path((repo, arch, file_name)): Path<(String, String, String)>, +) -> crate::Result { + let clone = Arc::clone(&global.repo_manager); + let path = PathBuf::from(&repo).join(arch).join(&file_name); + + let res = tokio::task::spawn_blocking(move || { + clone.write().unwrap().remove_pkg_from_path(path, true) + }) + .await??; + + if let Some((name, version, release, arch)) = res { + let res = global.db.repo.by_name(&repo).await?; + + if let Some(repo_entry) = res { + let res = global + .db + .pkg + .by_fields( + repo_entry.id, + &name, + Some(&format!("{}-{}", version, release)), + &arch, + ) + .await?; + + if let Some(entry) = res { + entry.delete(&global.db).await?; + } + } + + tracing::info!("Removed '{}' from repository '{}'", file_name, repo); + + Ok(StatusCode::OK) + } else { + Ok(StatusCode::NOT_FOUND) + } +}