diff --git a/.sqlx/query-4ce6aabb164fd260388ee0d4cab6784c9d2182f66d79f7abbd318a7dc75b9476.json b/.sqlx/query-4ce6aabb164fd260388ee0d4cab6784c9d2182f66d79f7abbd318a7dc75b9476.json new file mode 100644 index 00000000..82d20ff2 --- /dev/null +++ b/.sqlx/query-4ce6aabb164fd260388ee0d4cab6784c9d2182f66d79f7abbd318a7dc75b9476.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT developer_id, reason, admin_id, created_at, id, revoked_at FROM bans WHERE developer_id=$1 AND revoked_at > NOW() or revoked_at IS NULL ORDER BY revoked_at DESC NULLS FIRST LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "developer_id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "reason", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "admin_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + true + ] + }, + "hash": "4ce6aabb164fd260388ee0d4cab6784c9d2182f66d79f7abbd318a7dc75b9476" +} diff --git a/.sqlx/query-83d6e2d9f2e9cedbc89048baae3f58e210ef0b240b6791e4bf31b2e591bff95c.json b/.sqlx/query-83d6e2d9f2e9cedbc89048baae3f58e210ef0b240b6791e4bf31b2e591bff95c.json new file mode 100644 index 00000000..51268a56 --- /dev/null +++ b/.sqlx/query-83d6e2d9f2e9cedbc89048baae3f58e210ef0b240b6791e4bf31b2e591bff95c.json @@ -0,0 +1,55 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO bans (developer_id, reason, admin_id, revoked_at)\n VALUES ($1, $2, $3, $4)\n RETURNING\n id, developer_id, reason, admin_id, created_at, revoked_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "developer_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "reason", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "admin_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4", + "Text", + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true + ] + }, + "hash": "83d6e2d9f2e9cedbc89048baae3f58e210ef0b240b6791e4bf31b2e591bff95c" +} diff --git a/.sqlx/query-a2a271aa89f4ddf8d5e2524276956e84e0884307abe6f9e4c6425f6173f93616.json b/.sqlx/query-a2a271aa89f4ddf8d5e2524276956e84e0884307abe6f9e4c6425f6173f93616.json new file mode 100644 index 00000000..a9b89a60 --- /dev/null +++ b/.sqlx/query-a2a271aa89f4ddf8d5e2524276956e84e0884307abe6f9e4c6425f6173f93616.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE bans\n SET revoked_at=$2 WHERE id=$1\n RETURNING\n id, developer_id, reason, admin_id, created_at, revoked_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "developer_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "reason", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "admin_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true + ] + }, + "hash": "a2a271aa89f4ddf8d5e2524276956e84e0884307abe6f9e4c6425f6173f93616" +} diff --git a/.sqlx/query-f6e4bad631ddc06e2b5a3f951e668c05ceda6bf3c590b473a0e1a021905ba569.json b/.sqlx/query-f6e4bad631ddc06e2b5a3f951e668c05ceda6bf3c590b473a0e1a021905ba569.json new file mode 100644 index 00000000..2be4d746 --- /dev/null +++ b/.sqlx/query-f6e4bad631ddc06e2b5a3f951e668c05ceda6bf3c590b473a0e1a021905ba569.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE bans SET revoked_at=NOW() WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "f6e4bad631ddc06e2b5a3f951e668c05ceda6bf3c590b473a0e1a021905ba569" +} diff --git a/migrations/20260418000114_add_index_ban.down.sql b/migrations/20260418000114_add_index_ban.down.sql new file mode 100644 index 00000000..72623cb3 --- /dev/null +++ b/migrations/20260418000114_add_index_ban.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE IF EXISTS bans; \ No newline at end of file diff --git a/migrations/20260418000114_add_index_ban.up.sql b/migrations/20260418000114_add_index_ban.up.sql new file mode 100644 index 00000000..9d5ae071 --- /dev/null +++ b/migrations/20260418000114_add_index_ban.up.sql @@ -0,0 +1,9 @@ +-- Add up migration script here +CREATE TABLE bans ( + id SERIAL PRIMARY KEY NOT NULL, + developer_id INTEGER NOT NULL REFERENCES developers(id) ON DELETE CASCADE, + reason TEXT, + admin_id INTEGER REFERENCES developers(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + revoked_at TIMESTAMPTZ +); diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 18eae63b..5e37677e 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -1,7 +1,8 @@ use crate::database::DatabaseError; use crate::types::api::PaginatedData; -use crate::types::models::developer::{Developer, ModDeveloper}; +use crate::types::models::developer::{Developer, DeveloperBan, ModDeveloper}; use sqlx::PgConnection; +use sqlx::types::chrono::{DateTime, Utc}; use std::collections::HashMap; use uuid::Uuid; @@ -427,3 +428,60 @@ pub async fn find_by_token( .inspect_err(|e| log::error!("{}", e)) .map_err(|e| e.into()) } + +pub async fn create_ban( + dev_id: i32, + admin_id: i32, + reason: Option<&str>, + revoked_at: Option>, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!(DeveloperBan, + "INSERT INTO bans (developer_id, reason, admin_id, revoked_at) + VALUES ($1, $2, $3, $4) + RETURNING + id, developer_id, reason, admin_id, created_at, revoked_at", + dev_id, reason, admin_id, revoked_at + ) + .fetch_one(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to insert create developer ban: {e}")) + .map_err(|e| e.into()) +} + +pub async fn check_ban( + dev_id: i32, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!(DeveloperBan, + "SELECT developer_id, reason, admin_id, created_at, id, revoked_at FROM bans WHERE developer_id=$1 AND revoked_at > NOW() or revoked_at IS NULL ORDER BY revoked_at DESC NULLS FIRST LIMIT 1", dev_id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to get developer ban: {e}")) + .map_err(|e| e.into()) +} + +pub async fn update_ban_revoke_time(ban_id: i32, revoked_at: Option>, conn: &mut PgConnection) -> Result, DatabaseError> { + sqlx::query_as!(DeveloperBan, + "UPDATE bans + SET revoked_at=$2 WHERE id=$1 + RETURNING + id, developer_id, reason, admin_id, created_at, revoked_at", + ban_id, revoked_at) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to update developer ban: {e}")) + .map_err(|e| e.into()) +} + +pub async fn delete_ban(dev_id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError> { + if let Some(current_ban) = check_ban(dev_id, conn).await? { + sqlx::query!("UPDATE bans SET revoked_at=NOW() WHERE id=$1", current_ban.id) + .execute(conn) + .await + .inspect_err(|e| log::error!("Failed to revoke developer ban: {e}"))?; + } + + Ok(()) +} diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index da80cabb..74149ecf 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -1,6 +1,7 @@ use actix_web::{delete, get, post, put, web, HttpResponse, Responder}; use serde::{Deserialize, Serialize}; use utoipa::{ToSchema, IntoParams}; +use chrono::{DateTime, Utc}; use super::ApiError; use crate::config::AppData; @@ -10,7 +11,7 @@ use crate::{ extractors::auth::Auth, types::{ models::{ - developer::{ModDeveloper, Developer}, + developer::{ModDeveloper, Developer, DeveloperBan}, mod_entity::Mod, mod_version_status::ModVersionStatusEnum, }, @@ -70,6 +71,12 @@ struct DeveloperIndexQuery { per_page: Option, } +#[derive(Deserialize, ToSchema)] +struct DeveloperBanPayload { + reason: Option, + revoked_at: Option>, +} + /// List all developers with optional search and pagination #[utoipa::path( get, @@ -126,6 +133,10 @@ pub async fn add_developer_to_mod( let dev = auth.developer()?; 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)); + } + if !mods::exists(&path.id, &mut pool).await? { return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); } @@ -140,6 +151,10 @@ pub async fn add_developer_to_mod( json.username )))?; + if let Some(_) = developers::check_ban(target.id, &mut pool).await? { + return Err(ApiError::Banned(Some("The developer being added is banned".into()))); + } + mods::assign_developer(&path.id, target.id, false, &mut pool).await?; Ok(HttpResponse::NoContent()) @@ -470,3 +485,156 @@ pub async fn update_developer( payload: result, })) } + + +#[derive(Deserialize, IntoParams)] +struct CreateDeveloperBanPath { + id: i32, +} + +/// Ban a developer from mod submissions (admin only) +#[utoipa::path( + post, + path = "/v1/developers/{id}/ban", + tag = "developers", + params(CreateDeveloperBanPath), + request_body = DeveloperBanPayload, + responses( + (status = 200, description = "Developer banned", body = inline(ApiResponse)), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin only"), + (status = 404, description = "Developer not found") + ), + security( + ("bearer_token" = []) + ) +)] +#[post("v1/developers/ban")] +pub async fn ban_developer( + auth: Auth, + data: web::Data, + path: web::Path, + payload: web::Json, +) -> Result { + let dev = auth.developer()?; + auth.check_admin()?; + + let mut pool = data.db().acquire().await?; + + // check dev exists (we don't need the result) + developers::get_one(path.id, &mut pool) + .await? + .ok_or(ApiError::NotFound("Developer not found".into()))?; + + // check ban exists + if let Some(ban) = developers::check_ban(path.id, &mut pool).await? { + let result = developers::update_ban_revoke_time(ban.id, payload.revoked_at, &mut pool) + .await? + .ok_or(ApiError::InternalError("Ban was deleted between asserting its existence and updating it".into()))?; + + return Ok(web::Json(ApiResponse { + error: "".to_string(), + payload: result, + })) + } + + let result = developers::create_ban( + path.id, + dev.id, + payload.reason.as_deref(), + payload.revoked_at, + &mut pool, + ) + .await?; + + Ok(web::Json(ApiResponse { + error: "".to_string(), + payload: result, + })) +} + +#[derive(Deserialize, IntoParams)] +struct DeleteDeveloperBanPath { + id: i32, +} + +/// Remove a developer ban (admin only) +#[utoipa::path( + delete, + path = "/v1/developers/{id}/ban", + tag = "developers", + params(DeleteDeveloperBanPath), + responses( + (status = 204, description = "Ban deleted"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin only"), + ), + security( + ("bearer_token" = []) + ) +)] +#[delete("/v1/developers/{id}/ban")] +pub async fn unban_developer( + auth: Auth, + data: web::Data, + path: web::Path, +) -> Result { + auth.check_admin()?; + + let mut pool = data.db().acquire().await?; + + developers::delete_ban( + path.id, + &mut pool, + ) + .await?; + + Ok(HttpResponse::NoContent()) +} + +#[derive(Deserialize, IntoParams)] +struct GetDeveloperBanPath { + id: i32, +} + +/// Check if a developer is banned (admin only) +#[utoipa::path( + get, + path = "/v1/developers/{id}/ban", + tag = "developers", + params(GetDeveloperBanPath), + responses( + (status = 200, description = "Ban object", body = inline(ApiResponse)), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin only"), + (status = 404, description = "Ban not found") + ), + security( + ("bearer_token" = []) + ) +)] +#[get("/v1/developers/{id}/ban")] +pub async fn get_developer_ban( + auth: Auth, + data: web::Data, + path: web::Path, +) -> Result { + auth.check_admin()?; + + let mut pool = data.db().acquire().await?; + + let result = developers::check_ban( + path.id, + &mut pool, + ) + .await? + .ok_or(ApiError::NotFound("Ban was not found".into()))?; + + Ok(web::Json(ApiResponse { + error: "".to_string(), + payload: result, + })) +} diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index b519f9ed..551f40bf 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -43,6 +43,8 @@ pub enum ApiError { Zip(#[from] zip::result::ZipError), #[error("Failed to contact external resource: {0}")] Reqwest(#[from] reqwest::Error), + #[error("You are banned from accessing this resource: {}", .0.as_deref().unwrap_or("No reason provided"))] + Banned(Option), } impl ApiError { diff --git a/src/endpoints/mod_versions.rs b/src/endpoints/mod_versions.rs index 487fcc4e..8d042188 100644 --- a/src/endpoints/mod_versions.rs +++ b/src/endpoints/mod_versions.rs @@ -300,6 +300,10 @@ pub async fn create_version( let dev = auth.developer()?; 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 id = path.into_inner(); let the_mod = mods::get_one(&id, false, &mut pool) diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index 5e579fd3..c3fd3592 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -208,6 +208,11 @@ pub async fn create( ) -> Result { let dev = auth.developer()?; 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 bytes = mod_zip::download_mod(&payload.download_link, data.max_download_mb()).await?; let json = ModJson::from_zip(bytes, &payload.download_link, false)?; json.validate()?; diff --git a/src/main.rs b/src/main.rs index d1bff29f..81bfda6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -93,6 +93,9 @@ async fn main() -> anyhow::Result<()> { .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) diff --git a/src/openapi.rs b/src/openapi.rs index 11e824f3..7aad85a3 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -31,6 +31,9 @@ use crate::{endpoints, types}; endpoints::developers::get_own_mods, endpoints::developers::get_me, endpoints::developers::update_developer, + endpoints::developers::ban_developer, + endpoints::developers::unban_developer, + endpoints::developers::get_developer_ban, endpoints::tags::index, endpoints::tags::detailed_index, endpoints::stats::get_stats, diff --git a/src/types/models/developer.rs b/src/types/models/developer.rs index 29ac26af..da814d54 100644 --- a/src/types/models/developer.rs +++ b/src/types/models/developer.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -18,3 +19,13 @@ pub struct Developer { pub admin: bool, pub github_id: i64, } + +#[derive(sqlx::FromRow, Serialize, Clone, Debug, ToSchema)] +pub struct DeveloperBan { + pub id: i32, + pub developer_id: i32, + pub reason: Option, + pub admin_id: Option, + pub created_at: DateTime, + pub revoked_at: Option>, +}