diff --git a/Cargo.toml b/Cargo.toml index 0e8d0ee2..74a6736a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ ammonia = "4" anyhow = "1.0" bytes = "1.11" chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4.5", features = ["derive"] } +clap = { version = "4.5", features = ["derive", "env"] } dotenvy = "0.15" futures = "0.3" image = "0.25" diff --git a/src/cli/jobs.rs b/src/cli/jobs.rs new file mode 100644 index 00000000..7b75b0c0 --- /dev/null +++ b/src/cli/jobs.rs @@ -0,0 +1,37 @@ +use crate::{config::AppData, database, jobs}; + +#[derive(Debug, clap::Subcommand)] +pub enum JobCommand { + /// Cleans up mod_downloads from more than 30 days ago + CleanupDownloads, + /// Cleans up auth and refresh tokens that are expired + CleanupTokens, + /// Emergency logout for a developer + LogoutDeveloper { + /// Username of the developer + username: String, + }, + /// Runs migrations + Migrate, +} + +pub async fn run(job: JobCommand, data: AppData) -> anyhow::Result<()> { + let mut conn = data.db().acquire().await?; + + match job { + JobCommand::Migrate => { + database::migrate(&mut conn).await?; + } + JobCommand::CleanupDownloads => { + jobs::cleanup_downloads::cleanup_downloads(&mut conn).await?; + } + JobCommand::LogoutDeveloper { username } => { + jobs::logout_user::logout_user(&username, &mut conn).await?; + } + JobCommand::CleanupTokens => { + jobs::token_cleanup::token_cleanup(&mut conn).await?; + } + } + + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index deaa7c9e..89926e66 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,68 +1,32 @@ -use crate::config::AppData; -use crate::jobs; +use crate::{cli::serve::ServeArgs, config::AppData}; use clap::{Parser, Subcommand}; +mod jobs; +mod serve; + #[derive(Debug, Parser)] #[command(version, about, long_about = None)] -struct Args { +pub struct Args { #[command(subcommand)] command: Option, } #[derive(Subcommand, Debug)] -enum Commands { +pub enum Commands { + /// Run the HTTP server (default) + Serve(serve::ServeArgs), /// Run an internal job #[command(subcommand)] - Job(JobCommand), -} - -#[derive(Debug, Subcommand)] -enum JobCommand { - /// Cleans up mod_downloads from more than 30 days ago - CleanupDownloads, - /// Cleans up auth and refresh tokens that are expired - CleanupTokens, - /// Emergency logout for a developer - LogoutDeveloper { - /// Username of the developer - username: String, - }, - /// Runs migrations - Migrate, + Job(jobs::JobCommand), } -pub async fn maybe_cli(data: &AppData) -> anyhow::Result { - let cli = Args::parse(); - - if let Some(c) = cli.command { - return match c { - Commands::Job(job) => match job { - JobCommand::Migrate => { - let mut conn = data.db().acquire().await?; - jobs::migrate::migrate(&mut conn).await?; - - Ok(true) - } - JobCommand::CleanupDownloads => { - let mut conn = data.db().acquire().await?; - jobs::cleanup_downloads::cleanup_downloads(&mut conn).await?; - - Ok(true) - } - JobCommand::LogoutDeveloper { username } => { - let mut conn = data.db().acquire().await?; - jobs::logout_user::logout_user(&username, &mut conn).await?; - - Ok(true) - } - JobCommand::CleanupTokens => { - let mut conn = data.db().acquire().await?; - jobs::token_cleanup::token_cleanup(&mut conn).await?; - - Ok(true) - } - }, - }; +#[tracing::instrument(skip_all, fields(command = ?args.command))] +pub async fn run(args: Args, data: AppData) -> anyhow::Result<()> { + match args + .command + .unwrap_or_else(|| Commands::Serve(ServeArgs::default())) + { + Commands::Serve(serve_args) => serve::run(serve_args, data).await, + Commands::Job(job) => jobs::run(job, data).await, } - Ok(false) } diff --git a/src/cli/serve.rs b/src/cli/serve.rs new file mode 100644 index 00000000..e585aa8a --- /dev/null +++ b/src/cli/serve.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use tracing::Instrument; + +use crate::{config::AppData, database, http, s3_worker}; + +#[derive(clap::Args, Debug, Default)] +pub struct ServeArgs { + /// Don't run database migrations on startup + #[arg(long, env = "SKIP_MIGRATIONS", value_parser = clap::builder::FalseyValueParser::new())] + skip_migrations: bool, +} + +pub async fn run(args: ServeArgs, data: AppData) -> anyhow::Result<()> { + data.static_storage().init().await?; + data.private_storage().init().await?; + + if let Some(cdn) = data.cdn_storage() { + cdn.init().await?; + } + + if !args.skip_migrations { + database::migrate(data.db()).await?; + } + + let data = Arc::new(data); + tokio::spawn( + s3_worker::run_s3_worker(data.clone()).instrument(tracing::info_span!("s3_worker")), + ); + http::serve_http(data).await +} diff --git a/src/config.rs b/src/config.rs index 83f8af70..1e12d8f8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,12 +9,12 @@ use tokio::sync::mpsc::Sender; use crate::{ auth::github::GithubClient, dns::ValidateDnsResolver, - endpoints::mods::IndexQueryParams, - s3_worker::S3WorkerTask, + http::{self, response::ApiResponse}, + s3_worker::{QueuedS3Task, S3WorkerTask}, storage::{LocalBackend, PrivateDisk, PublicDisk, S3Backend, S3Configuration}, types::{ - api::{ApiResponse, PaginatedData}, - models::mod_entity::Mod, + api::PaginatedData, + models::mod_entity::{IndexQueryParams, Mod}, }, }; @@ -39,7 +39,7 @@ pub struct AppData { http_client: reqwest::Client, check_dns_http_client: reqwest::Client, - s3_sender: OnceLock>, + s3_sender: OnceLock>, } pub async fn build_config() -> anyhow::Result { @@ -198,17 +198,17 @@ impl AppData { &self.check_dns_http_client } - pub fn init_s3_sender(&self, sender: Sender) { + pub fn init_s3_sender(&self, sender: Sender) { self.s3_sender .set(sender) .expect("init_s3_sender must be called only once"); } pub fn send_s3_task(&self, task: S3WorkerTask) -> bool { - if let Some(sender) = self.s3_sender.get() { - sender.try_send(task).is_ok() - } else { - false - } + let Some(sender) = self.s3_sender.get() else { + return false; + }; + let request_id = http::REQUEST_ID.try_with(|id| *id).ok(); + sender.try_send(QueuedS3Task { task, request_id }).is_ok() } } diff --git a/src/database/mod.rs b/src/database/mod.rs index b19a744b..15222f5f 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,3 +1,7 @@ +use std::ops::Deref; + +use sqlx::{Acquire, migrate::Migrate}; + pub mod repository; #[derive(thiserror::Error, Debug)] @@ -7,3 +11,15 @@ pub enum DatabaseError { #[error("Unknown database error")] SqlxError(#[from] sqlx::Error), } + +pub async fn migrate<'a, A>(conn: A) -> anyhow::Result<()> +where + A: Acquire<'a>, + ::Target: Migrate, +{ + sqlx::migrate!() + .run(conn) + .await + .inspect_err(|e| tracing::error!("Failed to run migrations: {e}"))?; + Ok(()) +} diff --git a/src/endpoints/auth/github.rs b/src/http/endpoints/auth/github.rs similarity index 94% rename from src/endpoints/auth/github.rs rename to src/http/endpoints/auth/github.rs index cf27c3cd..f7b6d3b6 100644 --- a/src/endpoints/auth/github.rs +++ b/src/http/endpoints/auth/github.rs @@ -9,9 +9,9 @@ use crate::config::AppData; use crate::database::repository::{ auth_tokens, developers, github_login_attempts, github_web_logins, refresh_tokens, }; -use crate::endpoints::ApiError; -use crate::endpoints::auth::TokensResponse; -use crate::types::api::ApiResponse; +use crate::http::error::ApiError; +use crate::http::endpoints::auth::TokensResponse; +use crate::http::response::ApiResponse; #[derive(Deserialize, ToSchema)] struct PollParams { @@ -30,16 +30,23 @@ struct CallbackParams { state: String, } +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(start_github_web_login) + .service(github_web_callback) + .service(poll_github_login) + .service(github_token_login) + .service(start_github_login); +} + /// Start GitHub device flow authentication #[utoipa::path( - post, - path = "/v1/login/github", tag = "auth", responses( (status = 200, description = "Device flow started", body = inline(ApiResponse)) ) )] -#[post("v1/login/github")] +#[post("/login/github")] #[tracing::instrument(skip_all)] pub async fn start_github_login( data: web::Data, @@ -67,14 +74,12 @@ pub async fn start_github_login( /// Start GitHub web OAuth flow #[utoipa::path( - post, - path = "/v1/login/github/web", tag = "auth", responses( (status = 200, description = "OAuth URL generated", body = inline(ApiResponse)) ) )] -#[post("v1/login/github/web")] +#[post("/login/github/web")] #[tracing::instrument(skip_all)] pub async fn start_github_web_login(data: web::Data) -> Result { let mut pool = data.db().acquire().await?; @@ -95,8 +100,6 @@ pub async fn start_github_web_login(data: web::Data) -> Result) -> Result, @@ -149,8 +152,6 @@ pub async fn github_web_callback( /// Poll GitHub device flow for authentication #[utoipa::path( - post, - path = "/v1/login/github/poll", tag = "auth", request_body = PollParams, responses( @@ -159,7 +160,7 @@ pub async fn github_web_callback( (status = 404, description = "Login attempt not found") ) )] -#[post("v1/login/github/poll")] +#[post("/login/github/poll")] #[tracing::instrument(skip_all)] pub async fn poll_github_login( json: web::Json, @@ -252,8 +253,6 @@ pub async fn poll_github_login( /// Login using a GitHub personal access token #[utoipa::path( - post, - path = "/v1/login/github/token", tag = "auth", request_body = TokenLoginParams, responses( @@ -261,7 +260,7 @@ pub async fn poll_github_login( (status = 400, description = "Invalid access token") ) )] -#[post("v1/login/github/token")] +#[post("/login/github/token")] #[tracing::instrument(skip_all)] pub async fn github_token_login( json: web::Json, diff --git a/src/endpoints/auth/mod.rs b/src/http/endpoints/auth/mod.rs similarity index 87% rename from src/endpoints/auth/mod.rs rename to src/http/endpoints/auth/mod.rs index 286a08c2..688693da 100644 --- a/src/endpoints/auth/mod.rs +++ b/src/http/endpoints/auth/mod.rs @@ -1,8 +1,8 @@ use crate::config::AppData; use crate::database::repository::{auth_tokens, developers, refresh_tokens}; -use crate::endpoints::ApiError; -use crate::extractors::auth::Auth; -use crate::types::api::ApiResponse; +use crate::http::error::ApiError; +use crate::http::extractors::auth::Auth; +use crate::http::response::ApiResponse; use actix_web::{Responder, post, web}; use serde::{Deserialize, Serialize}; use sqlx::Acquire; @@ -22,10 +22,14 @@ struct RefreshBody { refresh_token: String, } +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(refresh_token) + .configure(github::router_config); +} + /// Refresh an access token using a refresh token #[utoipa::path( - post, - path = "/v1/login/refresh", tag = "auth", request_body = RefreshBody, responses( @@ -33,7 +37,7 @@ struct RefreshBody { (status = 400, description = "Invalid or expired refresh token") ) )] -#[post("v1/login/refresh")] +#[post("/login/refresh")] #[tracing::instrument(skip_all)] pub async fn refresh_token( json: web::Json, diff --git a/src/endpoints/deprecations.rs b/src/http/endpoints/deprecations.rs similarity index 93% rename from src/endpoints/deprecations.rs rename to src/http/endpoints/deprecations.rs index 1d06cab6..cb75026b 100644 --- a/src/endpoints/deprecations.rs +++ b/src/http/endpoints/deprecations.rs @@ -1,9 +1,9 @@ use crate::{ config::AppData, database::repository::{deprecations, developers, mods}, - endpoints::ApiError, - extractors::auth::Auth, - types::api::ApiResponse, + http::error::ApiError, + http::extractors::auth::Auth, + http::response::ApiResponse, types::models::deprecations::Deprecation, }; use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; @@ -36,10 +36,17 @@ struct UpdateDeprecationData { const MAX_MODS_PER_DEPRECATION: usize = 20; +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(index) + .service(store) + .service(update) + .service(clear_all) + .service(delete); +} + /// Fetch all deprecations for a mod #[utoipa::path( - get, - path = "/v1/mods/{id}/deprecations", tag = "deprecations", params(ModPath), responses( @@ -47,7 +54,7 @@ const MAX_MODS_PER_DEPRECATION: usize = 20; (status = 404, description = "Mod not found") ) )] -#[get("v1/mods/{id}/deprecations")] +#[get("/mods/{id}/deprecations")] #[tracing::instrument(skip_all, fields(mod_id = %path.id))] pub async fn index( data: web::Data, @@ -68,8 +75,6 @@ pub async fn index( /// Insert one deprecation for a mod #[utoipa::path( - post, - path = "/v1/mods/{id}/deprecations", tag = "deprecations", params(ModPath), request_body = CreateDeprecationData, @@ -84,7 +89,7 @@ pub async fn index( ("bearer_token" = []) ) )] -#[post("v1/mods/{id}/deprecations")] +#[post("/mods/{id}/deprecations")] #[tracing::instrument(skip_all, fields(mod_id = %path.id))] pub async fn store( data: web::Data, @@ -117,8 +122,6 @@ pub async fn store( /// Update a deprecation #[utoipa::path( - put, - path = "/v1/mods/{id}/deprecations/{deprecation_id}", tag = "deprecations", params(ModDeprecationPath), request_body = UpdateDeprecationData, @@ -132,7 +135,7 @@ pub async fn store( ("bearer_token" = []) ) )] -#[put("v1/mods/{id}/deprecations/{deprecation_id}")] +#[put("/mods/{id}/deprecations/{deprecation_id}")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, deprecation_id = %path.deprecation_id))] pub async fn update( data: web::Data, @@ -189,8 +192,6 @@ pub async fn update( /// Delete a deprecation #[utoipa::path( - delete, - path = "/v1/mods/{id}/deprecations/{deprecation_id}", tag = "deprecations", params(ModDeprecationPath), responses( @@ -203,7 +204,7 @@ pub async fn update( ("bearer_token" = []) ) )] -#[delete("v1/mods/{id}/deprecations/{deprecation_id}")] +#[delete("/mods/{id}/deprecations/{deprecation_id}")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, deprecation_id = %path.deprecation_id))] pub async fn delete( data: web::Data, @@ -243,8 +244,6 @@ pub async fn delete( /// Delete all deprecations for a mod #[utoipa::path( - delete, - path = "/v1/mods/{id}/deprecations", tag = "deprecations", params(ModPath), responses( @@ -257,7 +256,7 @@ pub async fn delete( ("bearer_token" = []) ) )] -#[delete("v1/mods/{id}/deprecations")] +#[delete("/mods/{id}/deprecations")] #[tracing::instrument(skip_all, fields(mod_id = %path.id))] pub async fn clear_all( data: web::Data, diff --git a/src/endpoints/developers.rs b/src/http/endpoints/developers.rs similarity index 90% rename from src/endpoints/developers.rs rename to src/http/endpoints/developers.rs index 24bf7389..360be6e3 100644 --- a/src/endpoints/developers.rs +++ b/src/http/endpoints/developers.rs @@ -1,42 +1,24 @@ use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use sqlx::Acquire; use utoipa::{IntoParams, ToSchema}; use chrono::{DateTime, Utc}; -use super::ApiError; +use crate::http::error::ApiError; use crate::config::AppData; use crate::database::repository::{auth_tokens, developers, mods, refresh_tokens}; -use crate::types::api::{ApiResponse, PaginatedData}; +use crate::http::response::ApiResponse; +use crate::types::api::PaginatedData; use crate::types::models::developer::SelfDeveloper; use crate::{ - extractors::auth::Auth, + http::extractors::auth::Auth, types::models::{ - developer::{Developer, ModDeveloper, DeveloperBan}, - mod_entity::Mod, + developer::{Developer, DeveloperBan}, + mod_entity::{Mod, SimpleDevMod}, mod_version_status::ModVersionStatusEnum, }, }; -#[derive(Deserialize, Serialize, Debug, Clone, ToSchema)] -pub struct SimpleDevMod { - pub id: String, - pub featured: bool, - pub download_count: i32, - pub versions: Vec, - pub developers: Vec, -} - -#[derive(Deserialize, Serialize, Debug, Clone, ToSchema)] -pub struct SimpleDevModVersion { - pub name: String, - pub version: String, - pub download_count: i32, - pub validated: bool, - pub info: Option, - pub status: ModVersionStatusEnum, -} - #[derive(Deserialize, IntoParams)] struct AddDevPath { id: String, @@ -77,17 +59,32 @@ struct DeveloperBanPayload { revoked_at: Option>, } +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(developer_index) + .service(get_developer) + .service(add_developer_to_mod) + .service(remove_dev_from_mod) + .service(delete_token) + .service(delete_tokens) + .service(update_profile) + .service(get_own_mods) + .service(get_me) + .service(update_developer) + .service(ban_developer) + .service(unban_developer) + .service(get_developer_ban); +} + /// List all developers with optional search and pagination #[utoipa::path( - get, - path = "/v1/developers", tag = "developers", params(DeveloperIndexQuery), responses( (status = 200, description = "List of developers", body = inline(ApiResponse>)) ) )] -#[get("v1/developers")] +#[get("/developers")] #[tracing::instrument(skip_all)] pub async fn developer_index( data: web::Data, @@ -108,8 +105,6 @@ pub async fn developer_index( /// Add a developer to a mod #[utoipa::path( - post, - path = "/v1/mods/{id}/developers", tag = "developers", params(AddDevPath), request_body = AddDevPayload, @@ -124,7 +119,7 @@ pub async fn developer_index( ("bearer_token" = []) ) )] -#[post("v1/mods/{id}/developers")] +#[post("/mods/{id}/developers")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, username = %json.username))] pub async fn add_developer_to_mod( data: web::Data, @@ -164,8 +159,6 @@ pub async fn add_developer_to_mod( /// Remove a developer from a mod #[utoipa::path( - delete, - path = "/v1/mods/{id}/developers/{username}", tag = "developers", params(RemoveDevPath), responses( @@ -179,7 +172,7 @@ pub async fn add_developer_to_mod( ("bearer_token" = []) ) )] -#[delete("v1/mods/{id}/developers/{username}")] +#[delete("/mods/{id}/developers/{username}")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, username = %path.username))] pub async fn remove_dev_from_mod( data: web::Data, @@ -225,8 +218,6 @@ pub async fn remove_dev_from_mod( /// Delete the current API token #[utoipa::path( - delete, - path = "/v1/me/token", tag = "developers", responses( (status = 204, description = "Token deleted successfully"), @@ -236,7 +227,7 @@ pub async fn remove_dev_from_mod( ("bearer_token" = []) ) )] -#[delete("v1/me/token")] +#[delete("/me/token")] #[tracing::instrument(skip_all)] pub async fn delete_token( data: web::Data, @@ -252,8 +243,6 @@ pub async fn delete_token( /// Delete all API tokens for the current developer #[utoipa::path( - delete, - path = "/v1/me/tokens", tag = "developers", responses( (status = 204, description = "All tokens deleted successfully"), @@ -263,7 +252,7 @@ pub async fn delete_token( ("bearer_token" = []) ) )] -#[delete("v1/me/tokens")] +#[delete("/me/tokens")] #[tracing::instrument(skip_all)] pub async fn delete_tokens( data: web::Data, @@ -285,8 +274,6 @@ struct UploadProfilePayload { /// Update the current developer's profile #[utoipa::path( - put, - path = "/v1/me", tag = "developers", request_body = UploadProfilePayload, responses( @@ -298,7 +285,7 @@ struct UploadProfilePayload { ("bearer_token" = []) ) )] -#[put("v1/me")] +#[put("/me")] #[tracing::instrument(skip_all)] pub async fn update_profile( data: web::Data, @@ -344,8 +331,6 @@ pub fn default_own_mods_status() -> ModVersionStatusEnum { /// Get all mods owned by the current developer #[utoipa::path( - get, - path = "/v1/me/mods", tag = "developers", params(GetOwnModsQuery), responses( @@ -356,7 +341,7 @@ pub fn default_own_mods_status() -> ModVersionStatusEnum { ("bearer_token" = []) ) )] -#[get("v1/me/mods")] +#[get("/me/mods")] #[tracing::instrument(skip_all)] pub async fn get_own_mods( data: web::Data, @@ -375,8 +360,6 @@ pub async fn get_own_mods( /// Get the current developer's profile #[utoipa::path( - get, - path = "/v1/me", tag = "developers", responses( (status = 200, description = "Current developer profile", body = inline(ApiResponse)), @@ -386,7 +369,7 @@ pub async fn get_own_mods( ("bearer_token" = []) ) )] -#[get("v1/me")] +#[get("/me")] #[tracing::instrument(skip_all)] pub async fn get_me(auth: Auth, data: web::Data) -> Result { let mut pool = data.db().acquire().await?; @@ -407,8 +390,6 @@ struct GetDeveloperPath { /// Get a specific developer by ID #[utoipa::path( - get, - path = "/v1/developers/{id}", tag = "developers", params(GetDeveloperPath), responses( @@ -416,7 +397,7 @@ struct GetDeveloperPath { (status = 404, description = "Developer not found") ) )] -#[get("v1/developers/{id}")] +#[get("/developers/{id}")] #[tracing::instrument(skip_all, fields(developer_id = %path.id))] pub async fn get_developer( data: web::Data, @@ -435,8 +416,6 @@ pub async fn get_developer( /// Update a developer's admin/verified status (admin only) #[utoipa::path( - put, - path = "/v1/developers/{id}", tag = "developers", params(UpdateDeveloperPath), request_body = DeveloperUpdatePayload, @@ -451,7 +430,7 @@ pub async fn get_developer( ("bearer_token" = []) ) )] -#[put("v1/developers/{id}")] +#[put("/developers/{id}")] #[tracing::instrument(skip_all, fields(developer_id = %path.id))] pub async fn update_developer( auth: Auth, @@ -510,8 +489,6 @@ struct CreateDeveloperBanPath { /// /// If the developer is already banned, this will overwrite that ban. #[utoipa::path( - post, - path = "/v1/developers/{id}/bans", tag = "developers", params(CreateDeveloperBanPath), request_body = DeveloperBanPayload, @@ -526,7 +503,7 @@ struct CreateDeveloperBanPath { ("bearer_token" = []) ) )] -#[post("/v1/developers/{id}/bans")] +#[post("/developers/{id}/bans")] pub async fn ban_developer( auth: Auth, data: web::Data, @@ -587,8 +564,6 @@ struct DeleteDeveloperBanPath { /// Revoke a developer's current ban (admin only) #[utoipa::path( - delete, - path = "/v1/developers/{id}/bans", tag = "developers", params(DeleteDeveloperBanPath), responses( @@ -601,7 +576,7 @@ struct DeleteDeveloperBanPath { ("bearer_token" = []) ) )] -#[delete("/v1/developers/{id}/bans")] +#[delete("/developers/{id}/bans")] pub async fn unban_developer( auth: Auth, data: web::Data, @@ -627,8 +602,6 @@ struct GetDeveloperBanPath { /// Fetch a list of a developer's bans (admin only) #[utoipa::path( - get, - path = "/v1/developers/{id}/bans", tag = "developers", params(GetDeveloperBanPath), responses( @@ -642,7 +615,7 @@ struct GetDeveloperBanPath { ("bearer_token" = []) ) )] -#[get("/v1/developers/{id}/bans")] +#[get("/developers/{id}/bans")] pub async fn get_developer_ban( auth: Auth, data: web::Data, diff --git a/src/endpoints/health.rs b/src/http/endpoints/health.rs similarity index 93% rename from src/endpoints/health.rs rename to src/http/endpoints/health.rs index b97aec9f..4e80ca3f 100644 --- a/src/endpoints/health.rs +++ b/src/http/endpoints/health.rs @@ -1,35 +1,38 @@ -use actix_web::route; - -/// Health check endpoint -#[utoipa::path( - get, - path = "/", - tag = "health", - responses( - (status = 200, description = "Service is healthy", body = String, content_type = "text/plain") - ) -)] -#[route("/", method = "GET", method = "HEAD")] -#[tracing::instrument(skip_all)] -pub async fn health() -> &'static str { - r#" - _____ _____ -( ___ )----------------------------------------------------------------------( ___ ) - | | | | - | | _ _ _ _ _ | | - | | | | | (_) ___| |_ _ __ __ _ _ __ __ _ ___ _ __| | | | - | | | |_| | | / __| __| '__/ _` | '_ \ / _` |/ _ \ '__| | | | - | | | _ | |_ \__ \ |_| | | (_| | | | | (_| | __/ | |_| | | - | | |_| |_|_( )_|___/\__|_| \__,_|_| |_|\__, |\___|_| (_)_ _ | | - | | \ \ |/ /__| | ___ ___ _ __ ___ |___/ | |_ ___ | |_| |__ ___ | | - | | \ \ /\ / / _ \ |/ __/ _ \| '_ ` _ \ / _ \ | __/ _ \ | __| '_ \ / _ \ | | - | | \ V V / __/ | (_| (_) | | | | | | __/ | || (_) | | |_| | | | __/ | | - | | _\_/\_/ \___|_|\___\___/|_| |_| |_|\___| _\__\___/ \__|_| |_|\___| | | - | | / ___| ___ ___ __| | ___ (_)_ __ __| | _____ _| | | | - | | | | _ / _ \/ _ \ / _` |/ _ \ | | '_ \ / _` |/ _ \ \/ / | | | - | | | |_| | __/ (_) | (_| | __/ | | | | | (_| | __/> <|_| | | - | | \____|\___|\___/ \__,_|\___| |_|_| |_|\__,_|\___/_/\_(_) | | - |___| |___| -(_____)----------------------------------------------------------------------(_____) -"# -} +use actix_web::{route, web}; + +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(health); +} + +/// Health check endpoint +#[utoipa::path( + tag = "health", + responses( + (status = 200, description = "Service is healthy", body = String, content_type = "text/plain") + ) +)] +#[route("/", method = "GET", method = "HEAD")] +#[tracing::instrument(skip_all)] +pub async fn health() -> &'static str { + r#" + _____ _____ +( ___ )----------------------------------------------------------------------( ___ ) + | | | | + | | _ _ _ _ _ | | + | | | | | (_) ___| |_ _ __ __ _ _ __ __ _ ___ _ __| | | | + | | | |_| | | / __| __| '__/ _` | '_ \ / _` |/ _ \ '__| | | | + | | | _ | |_ \__ \ |_| | | (_| | | | | (_| | __/ | |_| | | + | | |_| |_|_( )_|___/\__|_| \__,_|_| |_|\__, |\___|_| (_)_ _ | | + | | \ \ |/ /__| | ___ ___ _ __ ___ |___/ | |_ ___ | |_| |__ ___ | | + | | \ \ /\ / / _ \ |/ __/ _ \| '_ ` _ \ / _ \ | __/ _ \ | __| '_ \ / _ \ | | + | | \ V V / __/ | (_| (_) | | | | | | __/ | || (_) | | |_| | | | __/ | | + | | _\_/\_/ \___|_|\___\___/|_| |_| |_|\___| _\__\___/ \__|_| |_|\___| | | + | | / ___| ___ ___ __| | ___ (_)_ __ __| | _____ _| | | | + | | | | _ / _ \/ _ \ / _` |/ _ \ | | '_ \ / _` |/ _ \ \/ / | | | + | | | |_| | __/ (_) | (_| | __/ | | | | | (_| | __/> <|_| | | + | | \____|\___|\___/ \__,_|\___| |_|_| |_|\__,_|\___/_/\_(_) | | + |___| |___| +(_____)----------------------------------------------------------------------(_____) +"# +} diff --git a/src/endpoints/loader.rs b/src/http/endpoints/loader.rs similarity index 93% rename from src/endpoints/loader.rs rename to src/http/endpoints/loader.rs index 56cd664c..9d1a9d9f 100644 --- a/src/endpoints/loader.rs +++ b/src/http/endpoints/loader.rs @@ -5,13 +5,13 @@ use utoipa::{IntoParams, ToSchema}; use sqlx::Acquire; -use crate::endpoints::ApiError; +use crate::http::error::ApiError; use crate::s3_worker::S3WorkerTask; use crate::{ config::AppData, - extractors::auth::Auth, + http::{extractors::auth::Auth, response::ApiResponse}, types::{ - api::{ApiResponse, PaginatedData}, + api::PaginatedData, models::{ gd_version_alias::GDVersionAlias, loader_version::{GetVersionsQuery, LoaderVersion, LoaderVersionCreate}, @@ -33,10 +33,15 @@ struct GetOnePath { version: String, } +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(get_one) + .service(create_version) + .service(get_many); +} + /// Get a specific loader version (or latest) #[utoipa::path( - get, - path = "/v1/loader/versions/{version}", tag = "loader", params(GetOnePath, GetOneQuery), responses( @@ -44,7 +49,7 @@ struct GetOnePath { (status = 404, description = "Version not found") ) )] -#[get("v1/loader/versions/{version}")] +#[get("/loader/versions/{version}")] #[tracing::instrument(skip_all, fields(version = %path.version))] pub async fn get_one( path: web::Path, @@ -95,8 +100,6 @@ struct CreateVersionBody { /// Create a new loader version (admin only) #[utoipa::path( - post, - path = "/v1/loader/versions", tag = "loader", request_body = CreateVersionBody, responses( @@ -109,7 +112,7 @@ struct CreateVersionBody { ("bearer_token" = []) ) )] -#[post("v1/loader/versions")] +#[post("/loader/versions")] #[tracing::instrument(skip_all, fields(tag = %payload.tag))] pub async fn create_version( data: web::Data, @@ -158,15 +161,13 @@ struct GetManyQuery { /// Get all loader versions with optional filtering #[utoipa::path( - get, - path = "/v1/loader/versions", tag = "loader", params(GetManyQuery), responses( (status = 200, description = "List of loader versions", body = inline(ApiResponse>)) ) )] -#[get("v1/loader/versions")] +#[get("/loader/versions")] #[tracing::instrument(skip_all)] pub async fn get_many( data: web::Data, diff --git a/src/http/endpoints/mod.rs b/src/http/endpoints/mod.rs new file mode 100644 index 00000000..df58b82a --- /dev/null +++ b/src/http/endpoints/mod.rs @@ -0,0 +1,11 @@ +pub mod auth; +pub mod deprecations; +pub mod developers; +pub mod health; +pub mod loader; +pub mod mod_status_badge; +pub mod mod_version_submissions; +pub mod mod_versions; +pub mod mods; +pub mod stats; +pub mod tags; diff --git a/src/endpoints/mod_status_badge.rs b/src/http/endpoints/mod_status_badge.rs similarity index 94% rename from src/endpoints/mod_status_badge.rs rename to src/http/endpoints/mod_status_badge.rs index 1112aad8..54e008ec 100644 --- a/src/endpoints/mod_status_badge.rs +++ b/src/http/endpoints/mod_status_badge.rs @@ -1,5 +1,5 @@ use crate::config::AppData; -use crate::endpoints::ApiError; +use crate::http::error::ApiError; use actix_web::{HttpResponse, Responder, get, web}; use serde::Deserialize; use utoipa::{IntoParams, ToSchema}; @@ -23,9 +23,12 @@ pub struct StatusBadgeQuery { pub stat: StatusBadgeStat, } +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(status_badge); +} + #[utoipa::path( - get, - path = "/v1/mods/{id}/status_badge", tag = "mods", params( ("id" = String, Path, description = "Mod ID"), @@ -37,7 +40,7 @@ pub struct StatusBadgeQuery { (status = 404, description = "Mod not found") ) )] -#[get("/v1/mods/{id}/status_badge")] +#[get("/mods/{id}/status_badge")] #[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn status_badge( data: web::Data, diff --git a/src/endpoints/mod_version_submissions.rs b/src/http/endpoints/mod_version_submissions.rs similarity index 94% rename from src/endpoints/mod_version_submissions.rs rename to src/http/endpoints/mod_version_submissions.rs index 23126149..73f3e3f1 100644 --- a/src/endpoints/mod_version_submissions.rs +++ b/src/http/endpoints/mod_version_submissions.rs @@ -1,10 +1,11 @@ -use super::ApiError; +use crate::http::error::ApiError; use crate::config::AppData; use crate::database::DatabaseError; use crate::database::repository::{developers, mod_version_submissions, mod_versions, mods}; use crate::events::thread_comment::NewThreadComment; -use crate::extractors::auth::Auth; -use crate::types::api::{ApiResponse, PaginatedData}; +use crate::http::extractors::auth::Auth; +use crate::http::response::ApiResponse; +use crate::types::api::PaginatedData; use crate::types::models::audit_actions::{AuditAction, AuditActionRow}; use crate::types::models::developer::Developer; use crate::types::models::mod_version_status::ModVersionStatusEnum; @@ -95,10 +96,23 @@ async fn check_submission_lock( }) } +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(get_submission) + .service(get_submission_audit) + .service(update_submission) + .service(get_comments) + .service(get_comment_audit) + .service(create_comment) + .service(update_comment) + .service(delete_comment) + .service(get_attachments) + .service(upload_attachments) + .service(delete_attachment); +} + /// Get the submission for a mod version, or create one if the mod version is pending and has no submission #[utoipa::path( - get, - path = "/v1/mods/{id}/versions/{version}/submission", tag = "mod_version_submissions", params(SubmissionPath), responses( @@ -108,7 +122,7 @@ async fn check_submission_lock( ), security(("bearer_token" = [])) )] -#[get("v1/mods/{id}/versions/{version}/submission")] +#[get("/mods/{id}/versions/{version}/submission")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn get_submission( path: web::Path, @@ -154,7 +168,6 @@ pub async fn get_submission( } #[utoipa::path( - path = "/v1/mods/{id}/versions/{version}/submission/audit", tag = "mod_version_submissions", params(SubmissionPath), responses( @@ -165,7 +178,7 @@ pub async fn get_submission( ), security(("bearer_token" = [])) )] -#[get("v1/mods/{id}/versions/{version}/submission/audit")] +#[get("/mods/{id}/versions/{version}/submission/audit")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn get_submission_audit( path: web::Path, @@ -197,8 +210,6 @@ pub async fn get_submission_audit( /// Update (lock / unlock) a submission (admin only) #[utoipa::path( - put, - path = "/v1/mods/{id}/versions/{version}/submission", tag = "mod_version_submissions", params(SubmissionPath), request_body = UpdateSubmissionPayload, @@ -210,7 +221,7 @@ pub async fn get_submission_audit( ), security(("bearer_token" = [])) )] -#[put("v1/mods/{id}/versions/{version}/submission")] +#[put("/mods/{id}/versions/{version}/submission")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn update_submission( path: web::Path, @@ -258,8 +269,6 @@ pub async fn update_submission( /// List comments for a mod version submission #[utoipa::path( - get, - path = "/v1/mods/{id}/versions/{version}/submission/comments", tag = "mod_version_submissions", params(SubmissionPath, CommentsQuery), responses( @@ -269,7 +278,7 @@ pub async fn update_submission( ), security(("bearer_token" = [])) )] -#[get("v1/mods/{id}/versions/{version}/submission/comments")] +#[get("/mods/{id}/versions/{version}/submission/comments")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn get_comments( path: web::Path, @@ -350,7 +359,6 @@ pub async fn get_comments( } #[utoipa::path( - path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/audit", tag = "mod_version_submissions", params(CommentPath), responses( @@ -361,7 +369,7 @@ pub async fn get_comments( ), security(("bearer_token" = [])) )] -#[get("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/audit")] +#[get("/mods/{id}/versions/{version}/submission/comments/{comment_id}/audit")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id))] pub async fn get_comment_audit( path: web::Path, @@ -400,8 +408,6 @@ pub async fn get_comment_audit( /// Add a comment to a mod version submission #[utoipa::path( - post, - path = "/v1/mods/{id}/versions/{version}/submission/comments", tag = "mod_version_submissions", params(SubmissionPath), request_body = CreateCommentPayload, @@ -413,7 +419,7 @@ pub async fn get_comment_audit( ), security(("bearer_token" = [])) )] -#[post("v1/mods/{id}/versions/{version}/submission/comments")] +#[post("/mods/{id}/versions/{version}/submission/comments")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn create_comment( path: web::Path, @@ -482,8 +488,6 @@ pub async fn create_comment( /// Update a comment on a mod version submission #[utoipa::path( - put, - path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}", tag = "mod_version_submissions", params(CommentPath), request_body = UpdateCommentPayload, @@ -496,7 +500,7 @@ pub async fn create_comment( ), security(("bearer_token" = [])) )] -#[put("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}")] +#[put("/mods/{id}/versions/{version}/submission/comments/{comment_id}")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id))] pub async fn update_comment( path: web::Path, @@ -584,8 +588,6 @@ pub async fn update_comment( /// Delete a comment on a mod version submission #[utoipa::path( - delete, - path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}", tag = "mod_version_submissions", params(CommentPath), responses( @@ -596,7 +598,7 @@ pub async fn update_comment( ), security(("bearer_token" = [])) )] -#[delete("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}")] +#[delete("/mods/{id}/versions/{version}/submission/comments/{comment_id}")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id))] pub async fn delete_comment( path: web::Path, @@ -672,8 +674,6 @@ pub async fn delete_comment( /// List attachments for a submission comment #[utoipa::path( - get, - path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments", tag = "mod_version_submissions", params(CommentPath), responses( @@ -683,7 +683,7 @@ pub async fn delete_comment( ), security(("bearer_token" = [])) )] -#[get("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments")] +#[get("/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id))] pub async fn get_attachments( path: web::Path, @@ -737,8 +737,6 @@ pub struct UploadAttachmentsForm { /// Upload attachments to a submission comment #[utoipa::path( - post, - path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments", tag = "mod_version_submissions", params(CommentPath), request_body( @@ -754,7 +752,7 @@ pub struct UploadAttachmentsForm { ), security(("bearer_token" = [])) )] -#[post("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments")] +#[post("/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id))] pub async fn upload_attachments( path: web::Path, @@ -916,8 +914,6 @@ pub async fn upload_attachments( /// Delete an attachment from a submission comment #[utoipa::path( - delete, - path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments/{attachment_id}", tag = "mod_version_submissions", params(AttachmentPath), responses( @@ -929,7 +925,7 @@ pub async fn upload_attachments( security(("bearer_token" = [])) )] #[delete( - "v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments/{attachment_id}" + "/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments/{attachment_id}" )] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id, attachment_id = %path.attachment_id))] pub async fn delete_attachment( diff --git a/src/endpoints/mod_versions.rs b/src/http/endpoints/mod_versions.rs similarity index 97% rename from src/endpoints/mod_versions.rs rename to src/http/endpoints/mod_versions.rs index 70c807d5..f2b67fce 100644 --- a/src/endpoints/mod_versions.rs +++ b/src/http/endpoints/mod_versions.rs @@ -11,7 +11,7 @@ use crate::database::repository::{ dependencies, developers, incompatibilities, mod_downloads, mod_gd_versions, mod_links, mod_tags, mod_version_submissions, mod_versions, mods, }; -use crate::endpoints::ApiError; +use crate::http::error::ApiError; use crate::events::mod_created::{ NewModAcceptedEvent, NewModVersionAcceptedEvent, NewModVersionVerification, NewUnverifiedModVersionCreated, @@ -22,9 +22,8 @@ use crate::types::models; use crate::types::models::mod_version_submission::ModVersionSubmissionLock; use crate::webhook::discord::DiscordWebhook; use crate::{ - extractors::auth::Auth, + http::{extractors::auth::Auth, response::ApiResponse}, types::{ - api::ApiResponse, mod_json::{ModJson, split_version_and_compare}, models::{ mod_gd_version::{GDVersionEnum, VerPlatform}, @@ -81,10 +80,17 @@ struct IndexQuery { compare: Option, } +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(get_version_index) + .service(get_one) + .service(download_version) + .service(create_version) + .service(update_version); +} + /// List all versions for a mod #[utoipa::path( - get, - path = "/v1/mods/{id}/versions", tag = "mod_versions", params(IndexPath, IndexQuery), responses( @@ -92,7 +98,7 @@ struct IndexQuery { (status = 404, description = "Mod not found") ) )] -#[get("v1/mods/{id}/versions")] +#[get("/mods/{id}/versions")] #[tracing::instrument(skip_all, fields(mod_id = %path.id))] pub async fn get_version_index( path: web::Path, @@ -144,8 +150,6 @@ pub async fn get_version_index( /// Get a specific version of a mod #[utoipa::path( - get, - path = "/v1/mods/{id}/versions/{version}", tag = "mod_versions", params(GetOnePath, GetOneQuery), responses( @@ -153,7 +157,7 @@ pub async fn get_version_index( (status = 404, description = "Mod or version not found") ) )] -#[get("v1/mods/{id}/versions/{version}")] +#[get("/mods/{id}/versions/{version}")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn get_one( path: web::Path, @@ -207,8 +211,6 @@ struct DownloadQuery { /// Download a specific version of a mod (redirects to download URL) #[utoipa::path( - get, - path = "/v1/mods/{id}/versions/{version}/download", tag = "mod_versions", params(GetOnePath, DownloadQuery), responses( @@ -216,7 +218,7 @@ struct DownloadQuery { (status = 404, description = "Mod or version not found") ) )] -#[get("v1/mods/{id}/versions/{version}/download")] +#[get("/mods/{id}/versions/{version}/download")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn download_version( path: web::Path, @@ -286,8 +288,6 @@ pub async fn download_version( /// Create a new version for a mod #[utoipa::path( - post, - path = "/v1/mods/{id}/versions", tag = "mod_versions", params( ("id" = String, Path, description = "Mod ID") @@ -304,7 +304,7 @@ pub async fn download_version( ("bearer_token" = []) ) )] -#[post("v1/mods/{id}/versions")] +#[post("/mods/{id}/versions")] #[tracing::instrument(skip_all, fields(mod_id = %path))] pub async fn create_version( path: web::Path, @@ -515,8 +515,6 @@ pub async fn create_version( /// Update a mod version status (admin only) #[utoipa::path( - put, - path = "/v1/mods/{id}/versions/{version}", tag = "mod_versions", params(UpdateVersionPath), request_body = UpdatePayload, @@ -531,7 +529,7 @@ pub async fn create_version( ("bearer_token" = []) ) )] -#[put("v1/mods/{id}/versions/{version}")] +#[put("/mods/{id}/versions/{version}")] #[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn update_version( path: web::Path, diff --git a/src/endpoints/mods.rs b/src/http/endpoints/mods.rs similarity index 91% rename from src/endpoints/mods.rs rename to src/http/endpoints/mods.rs index b6ef0f13..127ebce2 100644 --- a/src/endpoints/mods.rs +++ b/src/http/endpoints/mods.rs @@ -12,16 +12,17 @@ use crate::database::repository::mod_versions; use crate::database::repository::mods; use crate::database::repository::mods::ModLogo; use crate::database::repository::{dependencies, deprecations, mod_version_submissions}; -use crate::endpoints::ApiError; +use crate::http::error::ApiError; use crate::events::mod_created::NewUnverifiedModVersionCreated; use crate::events::mod_feature::ModFeaturedEvent; -use crate::extractors::auth::Auth; +use crate::http::extractors::auth::Auth; use crate::mod_zip; -use crate::types::api::{ApiResponse, PaginatedData}; +use crate::http::response::ApiResponse; +use crate::types::api::PaginatedData; use crate::types::mod_json::ModJson; use crate::types::models; use crate::types::models::deprecations::Deprecation; -use crate::types::models::mod_entity::{Mod, ModUpdate}; +use crate::types::models::mod_entity::{IndexQueryParams, Mod, ModUpdate}; use crate::types::models::mod_gd_version::{GDVersionEnum, VerPlatform}; use crate::types::models::mod_link::ModLinks; use crate::types::models::mod_version_status::ModVersionStatusEnum; @@ -35,48 +36,25 @@ use validator::Validate; const MAX_UPDATE_BATCH_SIZE: usize = 200; -#[derive(Deserialize, Default, Hash, Eq, PartialEq, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum IndexSortType { - #[default] - Downloads, - RecentlyUpdated, - RecentlyPublished, - Oldest, - Name, - NameReverse, - Random, -} - -#[derive(Deserialize, Hash, Eq, PartialEq, IntoParams)] -pub struct IndexQueryParams { - pub page: Option, - pub per_page: Option, - pub query: Option, - #[serde(default)] - pub gd: Option, - #[serde(default)] - pub platforms: Option, - #[serde(default)] - pub sort: IndexSortType, - pub geode: Option, - pub developer: Option, - pub tags: Option, - pub featured: Option, - pub jitless: Option, - pub status: Option, -} - #[derive(Deserialize, ToSchema, Validate)] pub struct CreateQueryParams { #[validate(length(max = 1024))] download_link: String, } +// `get_mod_updates` must be registered before `get`, or "updates" is captured as `{id}` +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(index) + .service(get_mod_updates) + .service(get) + .service(create) + .service(update_mod) + .service(get_logo); +} + /// List all mods with optional filtering and pagination #[utoipa::path( - get, - path = "/v1/mods", tag = "mods", params(IndexQueryParams), responses( @@ -85,7 +63,7 @@ pub struct CreateQueryParams { (status = 403, description = "Forbidden") ) )] -#[get("/v1/mods")] +#[get("/mods")] #[tracing::instrument(skip_all)] pub async fn index( data: web::Data, @@ -127,8 +105,6 @@ pub async fn index( /// Get a specific mod by ID #[utoipa::path( - get, - path = "/v1/mods/{id}", tag = "mods", params( ("id" = String, Path, description = "Mod ID") @@ -138,7 +114,7 @@ pub async fn index( (status = 404, description = "Mod not found") ) )] -#[get("/v1/mods/{id}")] +#[get("/mods/{id}")] #[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn get( data: web::Data, @@ -199,8 +175,6 @@ pub async fn get( /// Create a new mod #[utoipa::path( - post, - path = "/v1/mods", tag = "mods", request_body = CreateQueryParams, responses( @@ -213,7 +187,7 @@ pub async fn get( ("bearer_token" = []) ) )] -#[post("/v1/mods")] +#[post("/mods")] #[tracing::instrument(skip_all)] pub async fn create( data: web::Data, @@ -234,11 +208,11 @@ pub async fn create( json.validate()?; let mut pool = data.db().acquire().await?; - + if let Some(ban) = developers::check_ban(dev.id, &mut pool).await? { return Err(ApiError::Banned(ban.reason)); } - + let existing: Option = mods::get_one(&json.id, false, &mut pool).await?; if json.id.starts_with("geode.") && !dev.admin { @@ -351,8 +325,6 @@ enum UpdateQueryResponse { /// Get available mod updates for a list of installed mods #[utoipa::path( - get, - path = "/v1/mods/updates", tag = "mods", params(UpdateQueryParams), responses( @@ -360,7 +332,7 @@ enum UpdateQueryResponse { (status = 400, description = "Bad request") ) )] -#[get("/v1/mods/updates")] +#[get("/mods/updates")] #[tracing::instrument(skip_all, fields(ids = %query.ids, geode = %query.geode))] pub async fn get_mod_updates( data: web::Data, @@ -413,8 +385,6 @@ pub async fn get_mod_updates( /// Get the logo image for a mod #[utoipa::path( - get, - path = "/v1/mods/{id}/logo", tag = "mods", params( ("id" = String, Path, description = "Mod ID") @@ -424,7 +394,7 @@ pub async fn get_mod_updates( (status = 404, description = "Logo not found") ) )] -#[get("/v1/mods/{id}/logo")] +#[get("/mods/{id}/logo")] #[tracing::instrument(skip_all, fields(mod_id = %path))] pub async fn get_logo( data: web::Data, @@ -450,8 +420,6 @@ struct UpdateModPayload { /// Update a mod (admin only) #[utoipa::path( - put, - path = "/v1/mods/{id}", tag = "mods", params( ("id" = String, Path, description = "Mod ID") @@ -467,7 +435,7 @@ struct UpdateModPayload { ("bearer_token" = []) ) )] -#[put("/v1/mods/{id}")] +#[put("/mods/{id}")] #[tracing::instrument(skip_all, fields(mod_id = %path))] pub async fn update_mod( data: web::Data, diff --git a/src/endpoints/stats.rs b/src/http/endpoints/stats.rs similarity index 71% rename from src/endpoints/stats.rs rename to src/http/endpoints/stats.rs index 8ca6dea0..726320d5 100644 --- a/src/endpoints/stats.rs +++ b/src/http/endpoints/stats.rs @@ -1,19 +1,23 @@ use actix_web::{Responder, get, web}; -use super::ApiError; +use crate::http::error::ApiError; use crate::config::AppData; -use crate::types::{api::ApiResponse, models::stats::Stats}; +use crate::http::response::ApiResponse; +use crate::types::models::stats::Stats; + +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(get_stats); +} /// Get global index statistics #[utoipa::path( - get, - path = "/v1/stats", tag = "stats", responses( (status = 200, description = "Index statistics", body = inline(ApiResponse)) ) )] -#[get("/v1/stats")] +#[get("/stats")] #[tracing::instrument(skip_all)] pub async fn get_stats(data: web::Data) -> Result { let mut pool = data.db().acquire().await?; diff --git a/src/endpoints/tags.rs b/src/http/endpoints/tags.rs similarity index 84% rename from src/endpoints/tags.rs rename to src/http/endpoints/tags.rs index 234c1e0d..ae527d33 100644 --- a/src/endpoints/tags.rs +++ b/src/http/endpoints/tags.rs @@ -2,20 +2,24 @@ use actix_web::{Responder, get, web}; use crate::config::AppData; use crate::database::repository::mod_tags; -use crate::endpoints::ApiError; -use crate::types::api::ApiResponse; +use crate::http::error::ApiError; +use crate::http::response::ApiResponse; use crate::types::models::tag::Tag; +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg + .service(index) + .service(detailed_index); +} + /// Get all available tags #[utoipa::path( - get, - path = "/v1/tags", tag = "tags", responses( (status = 200, description = "List of tag names", body = inline(ApiResponse>)) ) )] -#[get("/v1/tags")] +#[get("/tags")] #[tracing::instrument(skip_all)] pub async fn index(data: web::Data) -> Result { let mut pool = data.db().acquire().await?; @@ -33,14 +37,12 @@ pub async fn index(data: web::Data) -> Result /// Get all available tags with detailed information #[utoipa::path( - get, - path = "/v1/detailed-tags", tag = "tags", responses( (status = 200, description = "List of tags with details", body = inline(ApiResponse>)) ) )] -#[get("/v1/detailed-tags")] +#[get("/detailed-tags")] #[tracing::instrument(skip_all)] pub async fn detailed_index(data: web::Data) -> Result { let mut pool = data.db().acquire().await?; diff --git a/src/endpoints/mod.rs b/src/http/error.rs similarity index 82% rename from src/endpoints/mod.rs rename to src/http/error.rs index a671df77..3413cb2e 100644 --- a/src/endpoints/mod.rs +++ b/src/http/error.rs @@ -1,21 +1,11 @@ use crate::{ - mod_zip::ModZipError, - types::{api::ApiResponse, models::mod_gd_version::PlatformParseError}, + http::response::ApiResponse, mod_zip::ModZipError, + types::models::mod_gd_version::PlatformParseError, }; -use actix_web::{HttpResponse, http::StatusCode}; +use actix_web::{HttpRequest, HttpResponse, error::QueryPayloadError, http::StatusCode}; +use image::ImageError; use validator::{ValidationError, ValidationErrors}; - -pub mod auth; -pub mod deprecations; -pub mod developers; -pub mod health; -pub mod loader; -pub mod mod_status_badge; -pub mod mod_version_submissions; -pub mod mod_versions; -pub mod mods; -pub mod stats; -pub mod tags; +use zip::result::ZipError; #[derive(thiserror::Error, Debug)] pub enum ApiError { @@ -72,7 +62,7 @@ impl actix_web::ResponseError for ApiError { ApiError::TooManyRequests(..) => StatusCode::TOO_MANY_REQUESTS, ApiError::NotFound(_) => StatusCode::NOT_FOUND, ApiError::BadRequest(..) => StatusCode::BAD_REQUEST, - ApiError::ModZip(err) => err.status_code(), + ApiError::ModZip(err) => mod_zip_status_code(err), ApiError::PlatformParseError(_) => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, } @@ -83,6 +73,25 @@ impl actix_web::ResponseError for ApiError { } } +fn mod_zip_status_code(err: &ModZipError) -> StatusCode { + match err { + ModZipError::IoError(_) => StatusCode::INTERNAL_SERVER_ERROR, + ModZipError::ImageError(e) => match e { + ImageError::Limits(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, + ModZipError::ZipError(e) => match e { + ZipError::InvalidArchive(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, + _ => StatusCode::BAD_REQUEST, + } +} + +pub fn query_error_handler(err: QueryPayloadError, _req: &HttpRequest) -> actix_web::Error { + ApiError::BadRequest(err.to_string()).into() +} + // validator errors impl From for ApiError { diff --git a/src/extractors/auth.rs b/src/http/extractors/auth.rs similarity index 98% rename from src/extractors/auth.rs rename to src/http/extractors/auth.rs index 959142a0..55223d17 100644 --- a/src/extractors/auth.rs +++ b/src/http/extractors/auth.rs @@ -3,7 +3,7 @@ use std::pin::Pin; use crate::auth::AuthenticationError; use crate::config::AppData; use crate::database::repository::developers; -use crate::endpoints::ApiError; +use crate::http::error::ApiError; use crate::types::models::developer::Developer; use actix_web::http::header::HeaderMap; use actix_web::{FromRequest, HttpRequest, web}; diff --git a/src/extractors/mod.rs b/src/http/extractors/mod.rs similarity index 100% rename from src/extractors/mod.rs rename to src/http/extractors/mod.rs diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 00000000..0383d4df --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,115 @@ +use actix_cors::Cors; +use actix_web::{ + App, HttpMessage, HttpServer, + body::MessageBody, + dev::{Service, ServiceFactory, ServiceRequest, ServiceResponse}, + web::{self, QueryConfig}, +}; +use std::sync::Arc; +use tracing_actix_web::RequestId; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; +use uuid::Uuid; + +use crate::{ + config::AppData, + http::endpoints::{ + auth, deprecations, developers, health, loader, mod_status_badge, mod_version_submissions, + mod_versions, mods, stats, tags, + }, + http::{error::query_error_handler, openapi::ApiDoc}, +}; + +pub mod endpoints; +pub mod error; +pub mod extractors; +pub mod response; + +mod openapi; + +tokio::task_local! { + pub static REQUEST_ID: Uuid; +} + +fn router_config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/v1") + .configure(mods::router_config) + .configure(mod_status_badge::router_config) + .configure(mod_versions::router_config) + .configure(mod_version_submissions::router_config) + .configure(deprecations::router_config) + .configure(auth::router_config) + .configure(developers::router_config) + .configure(tags::router_config) + .configure(stats::router_config) + .configure(loader::router_config), + ) + .configure(health::router_config); +} + +pub fn cors_settings() -> Cors { + Cors::default() + .allow_any_origin() + .allowed_methods(vec!["GET", "HEAD"]) + .allowed_headers(vec![actix_web::http::header::ACCEPT]) + .max_age(3600) +} + +pub fn create_app( + data: web::Data, + openapi: utoipa::openapi::OpenApi, +) -> App< + impl ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = actix_web::Error, + InitError = (), + >, +> { + let app = App::new() + .app_data(data.clone()) + .app_data(QueryConfig::default().error_handler(query_error_handler)) + .service(SwaggerUi::new("/swagger/{_:.*}").url("/swagger/openapi.json", openapi.clone())) + .wrap(cors_settings()) + .wrap_fn(|req, srv| { + let request_id = req.extensions().get::().map(|id| **id); + let fut = srv.call(req); + async move { + match request_id { + Some(id) => REQUEST_ID.scope(id, fut).await, + None => fut.await, + } + } + }) + .wrap(tracing_actix_web::TracingLogger::default()); + + #[cfg(feature = "serve-static")] + let app = app + .service(actix_files::Files::new("/static", "static")) + .service(actix_files::Files::new("/storage", "storage/public")); + + app.configure(router_config) +} + +pub async fn serve_http(data: Arc) -> anyhow::Result<()> { + let data = web::Data::from(data); + let port = data.port(); + let debug = data.debug(); + + tracing::info!("Starting server on 0.0.0.0:{}", port); + + let openapi = ApiDoc::openapi(); + let server = HttpServer::new(move || create_app(data.clone(), openapi.clone())) + .bind(("0.0.0.0", port))?; + + if debug { + tracing::info!("Running in debug mode, using 1 thread."); + server.workers(1).run().await?; + } else { + server.run().await?; + } + + Ok(()) +} diff --git a/src/openapi.rs b/src/http/openapi.rs similarity index 91% rename from src/openapi.rs rename to src/http/openapi.rs index 18adb922..6420fb30 100644 --- a/src/openapi.rs +++ b/src/http/openapi.rs @@ -1,7 +1,8 @@ use utoipa::OpenApi; -use crate::{endpoints, types}; +use crate::{http, http::endpoints, types}; +/// Routes served under the `/v1` scope. Their paths are relative to it, like the actix macros. #[derive(OpenApi)] #[openapi( paths( @@ -49,17 +50,23 @@ use crate::{endpoints, types}; endpoints::loader::get_one, endpoints::loader::create_version, endpoints::loader::get_many, - endpoints::health::health, endpoints::auth::refresh_token, endpoints::auth::github::start_github_login, endpoints::auth::github::start_github_web_login, endpoints::auth::github::github_web_callback, endpoints::auth::github::poll_github_login, endpoints::auth::github::github_token_login, - ), + ) +)] +struct V1Api; + +#[derive(OpenApi)] +#[openapi( + paths(endpoints::health::health), + nest((path = "/v1", api = V1Api)), components( schemas( - types::api::ApiResponse, + http::response::ApiResponse, types::api::PaginatedData, types::api::PaginatedData, types::api::PaginatedData, @@ -90,9 +97,9 @@ use crate::{endpoints, types}; types::models::mod_version_submission::CreateCommentPayload, types::models::mod_version_submission::UpdateCommentPayload, types::models::mod_version_submission::ModVersionSubmissionAttachment, - endpoints::mods::IndexSortType, - endpoints::developers::SimpleDevMod, - endpoints::developers::SimpleDevModVersion, + types::models::mod_entity::IndexSortType, + types::models::mod_entity::SimpleDevMod, + types::models::mod_entity::SimpleDevModVersion, ) ), tags( @@ -109,7 +116,6 @@ use crate::{endpoints, types}; ), info( title = "Geode Index API", - version = "0.53.2", description = "API for the Geode mod index", contact( name = "Geode Team", diff --git a/src/http/response.rs b/src/http/response.rs new file mode 100644 index 00000000..4578d81e --- /dev/null +++ b/src/http/response.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct ApiResponse { + pub error: String, + pub payload: T, +} diff --git a/src/jobs/cleanup_downloads.rs b/src/jobs/cleanup_downloads.rs index c209c36a..e7fdbd79 100644 --- a/src/jobs/cleanup_downloads.rs +++ b/src/jobs/cleanup_downloads.rs @@ -1,8 +1,7 @@ use crate::database::repository::mod_downloads; -use crate::endpoints::ApiError; use sqlx::PgConnection; -pub async fn cleanup_downloads(conn: &mut PgConnection) -> Result<(), ApiError> { +pub async fn cleanup_downloads(conn: &mut PgConnection) -> anyhow::Result<()> { mod_downloads::cleanup(conn).await?; Ok(()) diff --git a/src/jobs/logout_user.rs b/src/jobs/logout_user.rs index c9b17d9e..47c528a4 100644 --- a/src/jobs/logout_user.rs +++ b/src/jobs/logout_user.rs @@ -1,11 +1,10 @@ use crate::database::repository::{auth_tokens, developers, refresh_tokens}; -use crate::endpoints::ApiError; use sqlx::PgConnection; -pub async fn logout_user(username: &str, conn: &mut PgConnection) -> Result<(), ApiError> { +pub async fn logout_user(username: &str, conn: &mut PgConnection) -> anyhow::Result<()> { let dev = developers::get_one_by_username(username, conn) .await? - .ok_or(ApiError::NotFound("Developer not found".into()))?; + .ok_or_else(|| anyhow::anyhow!("Developer {username} not found"))?; auth_tokens::remove_developer_tokens(dev.id, conn).await?; refresh_tokens::remove_developer_tokens(dev.id, conn).await?; diff --git a/src/jobs/migrate.rs b/src/jobs/migrate.rs deleted file mode 100644 index b65dd793..00000000 --- a/src/jobs/migrate.rs +++ /dev/null @@ -1,9 +0,0 @@ -use sqlx::PgConnection; - -pub async fn migrate(pool: &mut PgConnection) -> anyhow::Result<()> { - if let Err(e) = sqlx::migrate!("./migrations").run(&mut *pool).await { - tracing::error!("Error encountered while running migrations: {}", e); - } - - Ok(()) -} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index f359b066..1e95982d 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -1,4 +1,3 @@ pub mod cleanup_downloads; pub mod logout_user; -pub mod migrate; pub mod token_cleanup; diff --git a/src/jobs/token_cleanup.rs b/src/jobs/token_cleanup.rs index 00ef8b61..a8f7ff36 100644 --- a/src/jobs/token_cleanup.rs +++ b/src/jobs/token_cleanup.rs @@ -1,8 +1,7 @@ use crate::database::repository::{auth_tokens, refresh_tokens}; -use crate::endpoints::ApiError; use sqlx::PgConnection; -pub async fn token_cleanup(conn: &mut PgConnection) -> Result<(), ApiError> { +pub async fn token_cleanup(conn: &mut PgConnection) -> anyhow::Result<()> { auth_tokens::cleanup(conn).await?; refresh_tokens::cleanup(conn).await?; diff --git a/src/main.rs b/src/main.rs index 6dd6cb2e..7344a586 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,4 @@ -use crate::openapi::ApiDoc; -use crate::types::api; -use actix_cors::Cors; -use actix_web::{ - App, HttpServer, - web::{self, QueryConfig}, -}; -use utoipa::OpenApi; -use utoipa_swagger_ui::SwaggerUi; +use clap::Parser; mod abbreviate; mod auth; @@ -14,13 +6,11 @@ mod cli; mod config; mod database; mod dns; -mod endpoints; mod events; -mod extractors; +mod http; mod jobs; mod logging; mod mod_zip; -mod openapi; mod s3_worker; mod storage; mod types; @@ -34,115 +24,8 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; #[tokio::main] async fn main() -> anyhow::Result<()> { + let args = cli::Args::parse(); logging::init(); - let app_data = config::build_config().await?; - app_data.static_storage().init().await?; - app_data.private_storage().init().await?; - if let Some(cdn_storage) = app_data.cdn_storage() { - cdn_storage.init().await?; - } - - if cli::maybe_cli(&app_data).await? { - return Ok(()); - } - - tracing::info!("Running migrations"); - sqlx::migrate!("./migrations").run(app_data.db()).await?; - - let port = app_data.port(); - let debug = app_data.debug(); - - let data = web::Data::new(app_data.clone()); - tokio::spawn(s3_worker::run_s3_worker(data.clone())); - - tracing::info!("Starting server on 0.0.0.0:{}", port); - let server = HttpServer::new(move || { - let openapi = ApiDoc::openapi(); - - let app = App::new() - .app_data(data.clone()) - .app_data(QueryConfig::default().error_handler(api::query_error_handler)) - .service( - SwaggerUi::new("/swagger/{_:.*}").url("/swagger/openapi.json", openapi.clone()), - ) - .wrap( - Cors::default() - .allow_any_origin() - .allowed_methods(vec!["GET", "HEAD"]) - .allowed_headers(vec![actix_web::http::header::ACCEPT]) - .max_age(3600), - ) - .wrap(tracing_actix_web::TracingLogger::default()); - - #[cfg(feature = "serve-static")] - let app = app - .service(actix_files::Files::new("/static", "static")) - .service(actix_files::Files::new("/storage", "storage/public")); - - app.service(endpoints::mods::index) - .service(endpoints::mods::get_mod_updates) - .service(endpoints::mods::get) - .service(endpoints::mods::create) - .service(endpoints::mods::update_mod) - .service(endpoints::mods::get_logo) - .service(endpoints::mod_status_badge::status_badge) - .service(endpoints::mod_versions::get_version_index) - .service(endpoints::mod_versions::get_one) - .service(endpoints::mod_versions::download_version) - .service(endpoints::mod_versions::create_version) - .service(endpoints::mod_versions::update_version) - .service(endpoints::mod_version_submissions::get_submission) - .service(endpoints::mod_version_submissions::get_submission_audit) - .service(endpoints::mod_version_submissions::update_submission) - .service(endpoints::mod_version_submissions::get_comments) - .service(endpoints::mod_version_submissions::get_comment_audit) - .service(endpoints::mod_version_submissions::create_comment) - .service(endpoints::mod_version_submissions::update_comment) - .service(endpoints::mod_version_submissions::delete_comment) - .service(endpoints::mod_version_submissions::get_attachments) - .service(endpoints::mod_version_submissions::upload_attachments) - .service(endpoints::mod_version_submissions::delete_attachment) - .service(endpoints::deprecations::index) - .service(endpoints::deprecations::store) - .service(endpoints::deprecations::update) - .service(endpoints::deprecations::clear_all) - .service(endpoints::deprecations::delete) - .service(endpoints::auth::github::start_github_web_login) - .service(endpoints::auth::refresh_token) - .service(endpoints::auth::github::github_web_callback) - .service(endpoints::auth::github::poll_github_login) - .service(endpoints::auth::github::github_token_login) - .service(endpoints::auth::github::start_github_login) - .service(endpoints::developers::developer_index) - .service(endpoints::developers::get_developer) - .service(endpoints::developers::add_developer_to_mod) - .service(endpoints::developers::remove_dev_from_mod) - .service(endpoints::developers::delete_token) - .service(endpoints::developers::delete_tokens) - .service(endpoints::developers::update_profile) - .service(endpoints::developers::get_own_mods) - .service(endpoints::developers::get_me) - .service(endpoints::developers::update_developer) - .service(endpoints::developers::ban_developer) - .service(endpoints::developers::unban_developer) - .service(endpoints::developers::get_developer_ban) - .service(endpoints::tags::index) - .service(endpoints::tags::detailed_index) - .service(endpoints::stats::get_stats) - .service(endpoints::loader::get_one) - .service(endpoints::loader::create_version) - .service(endpoints::loader::get_many) - .service(endpoints::health::health) - }) - .bind(("0.0.0.0", port))?; - - if debug { - tracing::info!("Running in debug mode, using 1 thread."); - server.workers(1).run().await?; - } else { - server.run().await?; - } - - anyhow::Ok(()) + cli::run(args, app_data).await } diff --git a/src/mod_zip.rs b/src/mod_zip.rs index 16dedad6..cc2185ce 100644 --- a/src/mod_zip.rs +++ b/src/mod_zip.rs @@ -1,8 +1,7 @@ use std::io::Seek; use std::io::{BufReader, Cursor, Read}; -use actix_web::http::StatusCode; -use actix_web::web::Bytes; +use bytes::Bytes; use image::codecs::png::PngDecoder; use image::codecs::png::PngEncoder; use image::{DynamicImage, GenericImageView}; @@ -40,23 +39,6 @@ pub enum ModZipError { InvalidBinaries(String), } -impl ModZipError { - pub fn status_code(&self) -> StatusCode { - match self { - ModZipError::IoError(_) => StatusCode::INTERNAL_SERVER_ERROR, - ModZipError::ImageError(e) => match e { - ImageError::Limits(_) => StatusCode::BAD_REQUEST, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }, - ModZipError::ZipError(e) => match e { - ZipError::InvalidArchive(_) => StatusCode::BAD_REQUEST, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }, - _ => StatusCode::BAD_REQUEST, - } - } -} - pub fn extract_mod_logo(file: &mut ZipFile) -> Result, ModZipError> { const FIVE_MEGABYTES: u64 = 5 * 1000 * 1000; if file.size() > FIVE_MEGABYTES { diff --git a/src/s3_worker.rs b/src/s3_worker.rs index e39a1c3d..45faca4f 100644 --- a/src/s3_worker.rs +++ b/src/s3_worker.rs @@ -1,9 +1,10 @@ +use std::sync::Arc; use std::time::Duration; -use actix_web::web; use bytes::Bytes; use reqwest::StatusCode; use sqlx::PgConnection; +use uuid::Uuid; use crate::{ config::AppData, @@ -19,6 +20,11 @@ use crate::{ }, }; +pub(crate) struct QueuedS3Task { + pub task: S3WorkerTask, + pub request_id: Option, +} + pub enum S3WorkerTask { UploadMod { data: Bytes, @@ -219,19 +225,34 @@ async fn upload_mod_logo( update_mod_logo_url(mod_id, &logo_public_url, db).await?; - tracing::info!("Uploaded logo for {} to S3 at {}", mod_id, logo_public_url); + tracing::info!(url = %logo_public_url, "uploaded mod logo"); } Ok(()) } +#[tracing::instrument( + name = "s3_task", + skip_all, + fields( + request_id = request_id.map(tracing::field::display), + is_migration = is_migration, + kind = tracing::field::Empty, + mod_id = tracing::field::Empty, + version = tracing::field::Empty, + version_id = tracing::field::Empty, + size = tracing::field::Empty, + tag = tracing::field::Empty, + ), + err(Debug) +)] async fn process_task( data: &AppData, task: S3WorkerTask, is_migration: bool, + request_id: Option, ) -> anyhow::Result<()> { - let storage = data.cdn_storage().expect("mod storage must be set by now"); - let mut db = data.db().acquire().await?; + let span = tracing::Span::current(); match task { S3WorkerTask::UploadMod { @@ -240,57 +261,83 @@ async fn process_task( version, version_id, } => { - let path = path_for_mod(&mod_id, &version); - let public_url = storage.asset_url(&path); - - storage - .store(&path, &bytes, "application/octet-stream") - .await?; - - update_managed_download_link(version_id, Some(&public_url), &mut db).await?; - - // upload logo if not migrating mods - if !is_migration { - upload_mod_logo(data, &mod_id, None, &mut db).await?; - } + span.record("kind", "upload_mod") + .record("mod_id", mod_id.as_str()) + .record("version", version.as_str()) + .record("version_id", version_id) + .record("size", bytes.len()); - tracing::info!( - "Uploaded mod {} {} to S3 at {}", - mod_id, - version, - public_url - ); + upload_mod(data, bytes, &mod_id, &version, version_id, is_migration).await } S3WorkerTask::UploadLoader { tag } => { - tracing::info!("Preparing to upload Geode v{tag} to S3"); + span.record("kind", "upload_loader") + .record("tag", tag.as_str()); - let mut tx = data.db().begin().await?; + upload_loader(data, &tag).await + } + } +} + +async fn upload_mod( + data: &AppData, + bytes: Bytes, + mod_id: &str, + version: &str, + version_id: i32, + is_migration: bool, +) -> anyhow::Result<()> { + let storage = data.cdn_storage().expect("mod storage must be set by now"); + let mut db = data.db().acquire().await?; - let downloads = LoaderDownloads { - win: migrate_geode_version(data, &mut tx, &tag, "win").await?, - mac: migrate_geode_version(data, &mut tx, &tag, "mac").await?, - android32: migrate_geode_version(data, &mut tx, &tag, "android32").await?, - android64: migrate_geode_version(data, &mut tx, &tag, "android64").await?, - ios: migrate_artifact(data, &mut tx, &LoaderMigration::new(&tag, "ios")) - .await? - .unwrap_or_default(), - resources: migrate_resources(data, &mut tx, &tag).await?, + let path = path_for_mod(mod_id, version); + let public_url = storage.asset_url(&path); - win_installer: migrate_geode_installer(data, &mut tx, &tag, "win").await?, - mac_installer: migrate_geode_installer(data, &mut tx, &tag, "mac").await?, - linux_installer: migrate_geode_installer(data, &mut tx, &tag, "linux").await?, - }; + storage + .store(&path, &bytes, "application/octet-stream") + .await?; - tx.commit().await?; + update_managed_download_link(version_id, Some(&public_url), &mut db).await?; - tracing::info!("Uploaded new loader release to S3: {downloads:?}"); - } + // upload logo if not migrating mods + if !is_migration { + upload_mod_logo(data, mod_id, None, &mut db).await?; } + tracing::info!(url = %public_url, "uploaded mod"); + + Ok(()) +} + +async fn upload_loader(data: &AppData, tag: &str) -> anyhow::Result<()> { + tracing::info!("uploading loader release"); + + let mut tx = data.db().begin().await?; + + let downloads = LoaderDownloads { + win: migrate_geode_version(data, &mut tx, tag, "win").await?, + mac: migrate_geode_version(data, &mut tx, tag, "mac").await?, + android32: migrate_geode_version(data, &mut tx, tag, "android32").await?, + android64: migrate_geode_version(data, &mut tx, tag, "android64").await?, + ios: migrate_artifact(data, &mut tx, &LoaderMigration::new(tag, "ios")) + .await? + .unwrap_or_default(), + resources: migrate_resources(data, &mut tx, tag).await?, + + win_installer: migrate_geode_installer(data, &mut tx, tag, "win").await?, + mac_installer: migrate_geode_installer(data, &mut tx, tag, "mac").await?, + linux_installer: migrate_geode_installer(data, &mut tx, tag, "linux").await?, + }; + + tx.commit().await?; + + tracing::info!("uploaded loader release"); + tracing::debug!(?downloads, "loader release downloads"); + Ok(()) } +#[tracing::instrument(name = "s3_cleanup", skip_all, err(Debug))] async fn cleanup_old_s3_files(data: &AppData) -> anyhow::Result<()> { let supported_gd = GDVersionEnum::supported_for_storage(); let storage = data.cdn_storage().expect("mod storage must be set by now"); @@ -316,21 +363,20 @@ async fn cleanup_old_s3_files(data: &AppData) -> anyhow::Result<()> { .fetch_all(&mut *data.db().acquire().await?) .await?; - tracing::info!("Cleaning up {} old S3 files", versions.len()); + if versions.is_empty() { + return Ok(()); + } + + tracing::info!(total = versions.len(), "cleaning up old S3 files"); for record in versions { let path = path_for_mod(&record.mod_id, &record.version); if let Err(e) = storage.delete(&path).await { - tracing::error!( - "error deleting old S3 file for mod {} {} at {:?}: {e:?}", - record.mod_id, - record.version, - record.managed_download_link - ); + tracing::error!(error = ?e, mod_id = %record.mod_id, version = %record.version, %path, "failed to delete old S3 file"); continue; } - tracing::debug!("Deleted S3 file at path {}", path); + tracing::debug!(%path, "deleted old S3 file"); let mut tx = data.db().begin().await?; update_managed_download_link(record.id, None, &mut tx).await?; @@ -340,6 +386,12 @@ async fn cleanup_old_s3_files(data: &AppData) -> anyhow::Result<()> { Ok(()) } +#[tracing::instrument( + name = "s3_migrate_mod", + skip_all, + fields(mod_id = %mod_id, version = %version, version_id = version_id), + err(Debug) +)] async fn migrate_one( data: &AppData, original_url: &str, @@ -354,19 +406,20 @@ async fn migrate_one( ) .await?; - process_task( - data, - S3WorkerTask::UploadMod { - data: bytes, - mod_id: mod_id.to_owned(), - version: version.to_owned(), - version_id, - }, - true, - ) - .await + upload_mod(data, bytes, mod_id, version, version_id, true).await } +#[tracing::instrument(name = "s3_migrate_logo", skip_all, fields(mod_id = %mod_id), err(Debug))] +async fn migrate_mod_logo( + data: &AppData, + mod_id: &str, + image: Option>, + db: &mut PgConnection, +) -> anyhow::Result<()> { + upload_mod_logo(data, mod_id, image, db).await +} + +#[tracing::instrument(name = "s3_migrate_mods", skip_all, err(Debug))] async fn migrate_existing_mods_to_s3(data: &AppData) -> anyhow::Result<()> { let supported_gd = GDVersionEnum::supported_for_storage(); @@ -390,24 +443,28 @@ async fn migrate_existing_mods_to_s3(data: &AppData) -> anyhow::Result<()> { .fetch_all(&mut *data.db().acquire().await?) .await?; - tracing::info!("Migrating {} existing mods to S3", versions.len()); + if !versions.is_empty() { + let total = versions.len(); + let mut failed = 0; - for record in versions { - if let Err(e) = migrate_one( - data, - &record.download_link, - &record.id, - &record.version, - record.mv_id, - ) - .await - { - tracing::error!( - "error migrating mod {} {} to S3: {e:?}", - record.id, - record.version - ); + tracing::info!(total = versions.len(), "migrating existing mods to S3"); + + for record in versions { + if migrate_one( + data, + &record.download_link, + &record.id, + &record.version, + record.mv_id, + ) + .await + .is_err() + { + failed += 1; + } } + + tracing::info!(total, failed, "finished migrating mods"); } // independently migrate mod logos @@ -418,17 +475,28 @@ async fn migrate_existing_mods_to_s3(data: &AppData) -> anyhow::Result<()> { .fetch_all(&mut *db) .await?; - tracing::info!("Migrating {} existing mod logos to S3", mods.len()); + if !mods.is_empty() { + let total = mods.len(); + let mut failed = 0; + + tracing::info!(total = mods.len(), "migrating mod logos to S3"); - for record in mods { - if let Err(e) = upload_mod_logo(data, &record.id, record.image, &mut db).await { - tracing::error!("error migrating mod logo for {} to S3: {e:?}", record.id); + for record in mods { + if migrate_mod_logo(data, &record.id, record.image, &mut db) + .await + .is_err() + { + failed += 1; + } } + + tracing::info!(total, failed, "finished migrating mod logos"); } Ok(()) } +#[tracing::instrument(name = "s3_migrate_loader", skip_all, err(Debug))] async fn migrate_loader_versions_to_s3(data: &AppData) -> anyhow::Result<()> { let versions: Vec = sqlx::query_scalar( "SELECT gv.tag FROM geode_versions gv WHERE NOT EXISTS ( @@ -439,20 +507,28 @@ async fn migrate_loader_versions_to_s3(data: &AppData) -> anyhow::Result<()> { .await .map_err(|e| anyhow::anyhow!("{e}"))?; - tracing::info!("Migrating {} Geode releases to S3", versions.len()); + if !versions.is_empty() { + let total = versions.len(); + let mut failed = 0; - for tag in versions { - if let Err(e) = - process_task(data, S3WorkerTask::UploadLoader { tag: tag.clone() }, true).await - { - tracing::error!("error migrating Geode release {} to S3: {e:?}", tag); + tracing::info!(total = versions.len(), "migrating Geode releases"); + + for tag in versions { + if process_task(data, S3WorkerTask::UploadLoader { tag }, true, None) + .await + .is_err() + { + failed += 1; + } } + + tracing::info!(total, failed, "finished migrating Geode releases"); } Ok(()) } -pub async fn run_s3_worker(data: web::Data) { +pub async fn run_s3_worker(data: Arc) { if data.cdn_storage().is_none() { return; } @@ -462,32 +538,26 @@ pub async fn run_s3_worker(data: web::Data) { let s_data = data.clone(); tokio::spawn(async move { - if let Err(e) = migrate_existing_mods_to_s3(&s_data).await { - tracing::error!("Error migrating existing mods to S3: {:?}", e); - } + let _ = migrate_existing_mods_to_s3(&s_data).await; }); let s_data2 = data.clone(); tokio::spawn(async move { - if let Err(e) = migrate_loader_versions_to_s3(&s_data2).await { - tracing::error!("Error migrating loader versions to S3: {:?}", e); - } + let _ = migrate_loader_versions_to_s3(&s_data2).await; }); let mut interval = tokio::time::interval(Duration::from_mins(30)); loop { - let result = tokio::select! { - task = rx.recv() => match task { - Some(task) => process_task(&data, task, false).await, - None => break, - }, + tokio::select! { + queued = rx.recv() => { + let Some(queued) = queued else { break }; + let _ = process_task(&data, queued.task, false, queued.request_id).await; + } - _ = interval.tick() => cleanup_old_s3_files(&data).await, + _ = interval.tick() => { + let _ = cleanup_old_s3_files(&data).await; + }, }; - - if let Err(e) = result { - tracing::error!("Error processing S3 worker task: {:?}", e); - } } } diff --git a/src/types/api.rs b/src/types/api.rs index 275f9e00..ec02a538 100644 --- a/src/types/api.rs +++ b/src/types/api.rs @@ -1,25 +1,12 @@ -use actix_web::{HttpRequest, error::QueryPayloadError}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::endpoints::ApiError; - #[derive(Serialize, Deserialize, Clone, ToSchema)] pub struct PaginatedData { pub data: Vec, pub count: i64, } -#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] -pub struct ApiResponse { - pub error: String, - pub payload: T, -} - -pub fn query_error_handler(err: QueryPayloadError, _req: &HttpRequest) -> actix_web::Error { - ApiError::BadRequest(err.to_string()).into() -} - pub fn create_download_link(app_url: &str, mod_id: &str, version: &str) -> String { format!( "{}/v1/mods/{}/versions/{}/download", diff --git a/src/types/mod_json.rs b/src/types/mod_json.rs index 234eeec1..05c7dc63 100644 --- a/src/types/mod_json.rs +++ b/src/types/mod_json.rs @@ -8,7 +8,7 @@ use serde::Deserialize; use validator::{Validate, ValidationError}; use zip::read::ZipFile; -use crate::endpoints::format_validation_errors; +use crate::http::error::format_validation_errors; use crate::mod_zip::{self, ModZipError}; use super::models::{ diff --git a/src/types/models/mod_entity.rs b/src/types/models/mod_entity.rs index 55421edb..449cbb50 100644 --- a/src/types/models/mod_entity.rs +++ b/src/types/models/mod_entity.rs @@ -9,13 +9,9 @@ use super::{ }; use crate::{ database::{DatabaseError, repository::developers}, - endpoints::ApiError, + http::error::ApiError, }; use crate::{ - endpoints::{ - developers::{SimpleDevMod, SimpleDevModVersion}, - mods::{IndexQueryParams, IndexSortType}, - }, types::{ api::PaginatedData, models::{mod_version::ModVersion, mod_version_status::ModVersionStatusEnum}, @@ -23,13 +19,64 @@ use crate::{ }, }; use semver::Version; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::{ PgConnection, types::chrono::{DateTime, Utc}, }; use std::collections::HashMap; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; + +#[derive(Deserialize, Default, Hash, Eq, PartialEq, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum IndexSortType { + #[default] + Downloads, + RecentlyUpdated, + RecentlyPublished, + Oldest, + Name, + NameReverse, + Random, +} + +#[derive(Deserialize, Hash, Eq, PartialEq, IntoParams)] +pub struct IndexQueryParams { + pub page: Option, + pub per_page: Option, + pub query: Option, + #[serde(default)] + pub gd: Option, + #[serde(default)] + pub platforms: Option, + #[serde(default)] + pub sort: IndexSortType, + pub geode: Option, + pub developer: Option, + pub tags: Option, + pub featured: Option, + pub jitless: Option, + pub status: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, ToSchema)] +pub struct SimpleDevMod { + pub id: String, + pub featured: bool, + pub download_count: i32, + pub versions: Vec, + pub developers: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone, ToSchema)] +pub struct SimpleDevModVersion { + pub name: String, + pub version: String, + pub download_count: i32, + pub validated: bool, + pub info: Option, + pub status: ModVersionStatusEnum, +} #[derive(Serialize, Debug, Clone, sqlx::FromRow, ToSchema)] pub struct Mod { diff --git a/src/types/models/stats.rs b/src/types/models/stats.rs index eef5793d..bd1a5e82 100644 --- a/src/types/models/stats.rs +++ b/src/types/models/stats.rs @@ -1,4 +1,4 @@ -use crate::{database::repository::developers, endpoints::ApiError}; +use crate::{database::repository::developers, http::error::ApiError}; use chrono::Utc; use reqwest::{Client, header::HeaderValue}; use serde::{Deserialize, Serialize}; diff --git a/src/types/models/tag.rs b/src/types/models/tag.rs index 025ad2cc..27f8ea8b 100644 --- a/src/types/models/tag.rs +++ b/src/types/models/tag.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use crate::database::{DatabaseError, repository::mod_tags}; -use crate::endpoints::ApiError; +use crate::http::error::ApiError; use sqlx::PgConnection; use utoipa::ToSchema;