diff --git a/.sqlx/query-4fdaf48f6563fb3e2ceb6f0f14dceac6934ec8f6b63a3e17aff00b1ec37aa7a6.json b/.sqlx/query-4fdaf48f6563fb3e2ceb6f0f14dceac6934ec8f6b63a3e17aff00b1ec37aa7a6.json new file mode 100644 index 0000000..33fafe2 --- /dev/null +++ b/.sqlx/query-4fdaf48f6563fb3e2ceb6f0f14dceac6934ec8f6b63a3e17aff00b1ec37aa7a6.json @@ -0,0 +1,88 @@ +{ + "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, id DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "developer_id", + "type_info": "Int4", + "origin": { + "Table": { + "table": "bans", + "name": "developer_id" + } + } + }, + { + "ordinal": 1, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "bans", + "name": "reason" + } + } + }, + { + "ordinal": 2, + "name": "admin_id", + "type_info": "Int4", + "origin": { + "Table": { + "table": "bans", + "name": "admin_id" + } + } + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "bans", + "name": "created_at" + } + } + }, + { + "ordinal": 4, + "name": "id", + "type_info": "Int4", + "origin": { + "Table": { + "table": "bans", + "name": "id" + } + } + }, + { + "ordinal": 5, + "name": "revoked_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "bans", + "name": "revoked_at" + } + } + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + true + ] + }, + "hash": "4fdaf48f6563fb3e2ceb6f0f14dceac6934ec8f6b63a3e17aff00b1ec37aa7a6" +} diff --git a/.sqlx/query-83d6e2d9f2e9cedbc89048baae3f58e210ef0b240b6791e4bf31b2e591bff95c.json b/.sqlx/query-83d6e2d9f2e9cedbc89048baae3f58e210ef0b240b6791e4bf31b2e591bff95c.json new file mode 100644 index 0000000..893447f --- /dev/null +++ b/.sqlx/query-83d6e2d9f2e9cedbc89048baae3f58e210ef0b240b6791e4bf31b2e591bff95c.json @@ -0,0 +1,91 @@ +{ + "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", + "origin": { + "Table": { + "table": "bans", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "developer_id", + "type_info": "Int4", + "origin": { + "Table": { + "table": "bans", + "name": "developer_id" + } + } + }, + { + "ordinal": 2, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "bans", + "name": "reason" + } + } + }, + { + "ordinal": 3, + "name": "admin_id", + "type_info": "Int4", + "origin": { + "Table": { + "table": "bans", + "name": "admin_id" + } + } + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "bans", + "name": "created_at" + } + } + }, + { + "ordinal": 5, + "name": "revoked_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "bans", + "name": "revoked_at" + } + } + } + ], + "parameters": { + "Left": [ + "Int4", + "Text", + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true + ] + }, + "hash": "83d6e2d9f2e9cedbc89048baae3f58e210ef0b240b6791e4bf31b2e591bff95c" +} diff --git a/.sqlx/query-9c66c5da81ded049266cc05ceaac2cc2a6b6b02ca6e89c171e971b49efed1baf.json b/.sqlx/query-9c66c5da81ded049266cc05ceaac2cc2a6b6b02ca6e89c171e971b49efed1baf.json new file mode 100644 index 0000000..5495109 --- /dev/null +++ b/.sqlx/query-9c66c5da81ded049266cc05ceaac2cc2a6b6b02ca6e89c171e971b49efed1baf.json @@ -0,0 +1,91 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE bans\n SET revoked_at=$2, reason=$3, admin_id=$4 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", + "origin": { + "Table": { + "table": "bans", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "developer_id", + "type_info": "Int4", + "origin": { + "Table": { + "table": "bans", + "name": "developer_id" + } + } + }, + { + "ordinal": 2, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "bans", + "name": "reason" + } + } + }, + { + "ordinal": 3, + "name": "admin_id", + "type_info": "Int4", + "origin": { + "Table": { + "table": "bans", + "name": "admin_id" + } + } + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "bans", + "name": "created_at" + } + } + }, + { + "ordinal": 5, + "name": "revoked_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "bans", + "name": "revoked_at" + } + } + } + ], + "parameters": { + "Left": [ + "Int4", + "Timestamptz", + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true + ] + }, + "hash": "9c66c5da81ded049266cc05ceaac2cc2a6b6b02ca6e89c171e971b49efed1baf" +} diff --git a/.sqlx/query-d170ffeaf2ea1d0deaef4510d63e24026a7d06761212823a08cfd2450136c916.json b/.sqlx/query-d170ffeaf2ea1d0deaef4510d63e24026a7d06761212823a08cfd2450136c916.json new file mode 100644 index 0000000..c121ee6 --- /dev/null +++ b/.sqlx/query-d170ffeaf2ea1d0deaef4510d63e24026a7d06761212823a08cfd2450136c916.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT developer_id, reason, admin_id, created_at, id, revoked_at FROM bans WHERE developer_id=$1 ORDER BY revoked_at DESC NULLS FIRST, id DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "developer_id", + "type_info": "Int4", + "origin": { + "Table": { + "table": "bans", + "name": "developer_id" + } + } + }, + { + "ordinal": 1, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "bans", + "name": "reason" + } + } + }, + { + "ordinal": 2, + "name": "admin_id", + "type_info": "Int4", + "origin": { + "Table": { + "table": "bans", + "name": "admin_id" + } + } + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "bans", + "name": "created_at" + } + } + }, + { + "ordinal": 4, + "name": "id", + "type_info": "Int4", + "origin": { + "Table": { + "table": "bans", + "name": "id" + } + } + }, + { + "ordinal": 5, + "name": "revoked_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "bans", + "name": "revoked_at" + } + } + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + true + ] + }, + "hash": "d170ffeaf2ea1d0deaef4510d63e24026a7d06761212823a08cfd2450136c916" +} diff --git a/.sqlx/query-f6e4bad631ddc06e2b5a3f951e668c05ceda6bf3c590b473a0e1a021905ba569.json b/.sqlx/query-f6e4bad631ddc06e2b5a3f951e668c05ceda6bf3c590b473a0e1a021905ba569.json new file mode 100644 index 0000000..2be4d74 --- /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/20260731085957_add_index_ban.down.sql b/migrations/20260731085957_add_index_ban.down.sql new file mode 100644 index 0000000..72623cb --- /dev/null +++ b/migrations/20260731085957_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/20260731085957_add_index_ban.up.sql b/migrations/20260731085957_add_index_ban.up.sql new file mode 100644 index 0000000..bbe0a4e --- /dev/null +++ b/migrations/20260731085957_add_index_ban.up.sql @@ -0,0 +1,12 @@ +-- 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 +); + +CREATE INDEX bans_revoked_at_idx on bans(revoked_at); +CREATE INDEX bans_developer_id_idx on bans(developer_id); diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 70baaf8..7d66627 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; @@ -493,3 +494,79 @@ pub async fn has_accepted_mod(id: i32, conn: &mut PgConnection) -> Result, + 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| tracing::error!("{:?}", 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, id DESC LIMIT 1", dev_id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +pub async fn get_bans( + 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 ORDER BY revoked_at DESC NULLS FIRST, id DESC", dev_id + ) + .fetch_all(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +pub async fn update_ban( + ban_id: i32, + revoked_at: Option>, + reason: Option<&str>, + developer_id: i32, + conn: &mut PgConnection +) -> Result { + sqlx::query_as!(DeveloperBan, + "UPDATE bans + SET revoked_at=$2, reason=$3, admin_id=$4 WHERE id=$1 + RETURNING + id, developer_id, reason, admin_id, created_at, revoked_at", + ban_id, revoked_at, reason, developer_id) + .fetch_one(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", 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| tracing::error!("{:?}", e))?; + } + + Ok(()) +} diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 670fc8b..24bf738 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -1,6 +1,8 @@ use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; use serde::{Deserialize, Serialize}; +use sqlx::Acquire; use utoipa::{IntoParams, ToSchema}; +use chrono::{DateTime, Utc}; use super::ApiError; use crate::config::AppData; @@ -10,7 +12,7 @@ use crate::types::models::developer::SelfDeveloper; use crate::{ extractors::auth::Auth, types::models::{ - developer::{Developer, ModDeveloper}, + developer::{Developer, ModDeveloper, DeveloperBan}, mod_entity::Mod, mod_version_status::ModVersionStatusEnum, }, @@ -69,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, @@ -127,6 +135,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))); } @@ -141,6 +153,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()) @@ -483,3 +499,166 @@ pub async fn update_developer( payload: result, })) } + + +#[derive(Deserialize, IntoParams)] +struct CreateDeveloperBanPath { + id: i32, +} + +/// Ban a developer from mod submissions (admin only) +/// +/// 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, + 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/{id}/bans")] +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()))?; + + let mut tx = pool.begin().await?; + + // check ban exists + if let Some(ban) = developers::check_ban(path.id, &mut tx).await? { + let result = developers::update_ban( + ban.id, + payload.revoked_at, + payload.reason.as_deref(), + dev.id, + &mut tx + ).await?; + + tx.commit().await?; + + 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 tx, + ) + .await?; + + tx.commit().await?; + + Ok(web::Json(ApiResponse { + error: "".to_string(), + payload: result, + })) +} + +#[derive(Deserialize, IntoParams)] +struct DeleteDeveloperBanPath { + id: i32, +} + +/// Revoke a developer's current ban (admin only) +#[utoipa::path( + delete, + path = "/v1/developers/{id}/bans", + 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}/bans")] +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, +} + +/// Fetch a list of a developer's bans (admin only) +#[utoipa::path( + get, + path = "/v1/developers/{id}/bans", + 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}/bans")] +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::get_bans( + path.id, + &mut pool, + ).await?; + + Ok(web::Json(ApiResponse { + error: "".to_string(), + payload: result, + })) +} diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index f020bc0..15e566f 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -49,6 +49,8 @@ pub enum ApiError { Reqwest(#[from] reqwest::Error), #[error("I/O error: {0}")] IO(#[from] std::io::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_version_submissions.rs b/src/endpoints/mod_version_submissions.rs index 9b1954e..d9fb700 100644 --- a/src/endpoints/mod_version_submissions.rs +++ b/src/endpoints/mod_version_submissions.rs @@ -80,6 +80,10 @@ async fn check_submission_lock( return Ok(true); } + if developers::check_ban(dev.id, &mut *conn).await?.is_some() { + return Ok(false); + } + let access_to_mod = developers::has_access_to_mod(dev.id, mod_id, &mut *conn).await?; let active_developer = developers::has_active_mod(dev.id, &mut *conn).await?; diff --git a/src/endpoints/mod_versions.rs b/src/endpoints/mod_versions.rs index 3d48b99..36423f5 100644 --- a/src/endpoints/mod_versions.rs +++ b/src/endpoints/mod_versions.rs @@ -313,6 +313,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 mut the_mod = mods::get_one(&id, false, &mut pool) diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index b6c622b..a232f75 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -217,6 +217,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( data.check_dns_http_client(), &payload.download_link, diff --git a/src/main.rs b/src/main.rs index 96d8d64..d3047ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -125,6 +125,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 4691b49..18adb92 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -40,6 +40,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 4190e4f..2b34593 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; @@ -43,3 +44,13 @@ impl Developer { } } } + +#[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>, +}