diff --git a/src/controllers/version/metadata.rs b/src/controllers/version/metadata.rs index 1588648b978..dee6b77ff42 100644 --- a/src/controllers/version/metadata.rs +++ b/src/controllers/version/metadata.rs @@ -6,17 +6,36 @@ use axum::extract::Path; use axum::Json; +use crates_io_worker::BackgroundJob; +use diesel::{ExpressionMethods, RunQueryDsl}; use diesel_async::async_connection_wrapper::AsyncConnectionWrapper; +use http::request::Parts; +use http::StatusCode; +use serde::Deserialize; use serde_json::Value; +use tokio::runtime::Handle; use crate::app::AppState; -use crate::models::VersionOwnerAction; +use crate::auth::AuthCheck; +use crate::models::token::EndpointScope; +use crate::models::{ + insert_version_owner_action, Crate, Rights, Version, VersionAction, VersionOwnerAction, +}; +use crate::rate_limiter::LimitedAction; use crate::tasks::spawn_blocking; -use crate::util::errors::{version_not_found, AppResult}; +use crate::util::diesel::Conn; +use crate::util::errors::{bad_request, custom, version_not_found, AppResult}; use crate::views::{EncodableDependency, EncodableVersion}; +use crate::worker::jobs::{self, UpdateDefaultVersion}; use super::version_and_crate; +#[derive(Deserialize)] +pub struct VersionUpdate { + yanked: Option, + yank_message: Option, +} + /// Handles the `GET /crates/:crate_id/:version/dependencies` route. /// /// This information can be obtained directly from the index. @@ -84,3 +103,123 @@ pub async fn show( }) .await } + +/// Handles the `PATCH /crates/:crate/:version` route. +/// +/// This endpoint allows updating the yanked state of a version, including a yank message. +pub async fn update( + state: AppState, + Path((crate_name, version)): Path<(String, String)>, + req: Parts, + Json(update_data): Json, +) -> AppResult> { + if semver::Version::parse(&version).is_err() { + return Err(version_not_found(&crate_name, &version)); + } + + let conn = state.db_write().await?; + spawn_blocking(move || { + let conn: &mut AsyncConnectionWrapper<_> = &mut conn.into(); + let (mut version, krate) = version_and_crate(conn, &crate_name, &version)?; + + apply_yank_update(&state, &req, conn, &mut version, &krate, &update_data)?; + + let published_by = version.published_by(conn); + let actions = VersionOwnerAction::by_version(conn, &version)?; + let updated_version = EncodableVersion::from(version, &krate.name, published_by, actions); + Ok(Json(json!({ "version": updated_version }))) + }) + .await +} + +fn apply_yank_update( + state: &AppState, + req: &Parts, + conn: &mut impl Conn, + version: &mut Version, + krate: &Crate, + update_data: &VersionUpdate, +) -> AppResult<()> { + // Try to update the yank state first, to avoid unnecessary checks. + update_version_yank_state(version, update_data)?; + + // Add authentication check + let auth = AuthCheck::default() + .with_endpoint_scope(EndpointScope::Yank) + .for_crate(&krate.name) + .check(req, conn)?; + + // Add rate limiting check + state + .rate_limiter + .check_rate_limit(auth.user_id(), LimitedAction::YankUnyank, conn)?; + + let api_token_id = auth.api_token_id(); + let user = auth.user(); + let owners = krate.owners(conn)?; + + // Check user rights + if Handle::current().block_on(user.rights(state, &owners))? < Rights::Publish { + if user.is_admin { + warn!( + "Admin {} is updating {}@{}", + user.gh_login, krate.name, version.num + ); + } else { + return Err(custom( + StatusCode::FORBIDDEN, + "must already be an owner to update version", + )); + } + } + + diesel::update(&*version) + .set(( + crate::schema::versions::yanked.eq(version.yanked), + crate::schema::versions::yank_message.eq(&version.yank_message), + )) + .execute(conn)?; + + // Add version owner action + let action = if version.yanked { + VersionAction::Yank + } else { + VersionAction::Unyank + }; + insert_version_owner_action(conn, version.id, user.id, api_token_id, action)?; + + // Enqueue jobs + jobs::enqueue_sync_to_index(&krate.name, conn)?; + UpdateDefaultVersion::new(krate.id).enqueue(conn)?; + + Ok(()) +} + +fn update_version_yank_state(version: &mut Version, update_data: &VersionUpdate) -> AppResult<()> { + match (update_data.yanked, &update_data.yank_message) { + (Some(true), Some(message)) => { + version.yanked = true; + version.yank_message = Some(message.clone()); + } + (Some(yanked), None) => { + version.yanked = yanked; + version.yank_message = None; + } + (Some(false), Some(_)) => { + return Err(bad_request("Cannot set yank message when unyanking")); + } + (None, Some(message)) => { + if version.yanked { + version.yank_message = Some(message.clone()); + } else { + return Err(bad_request( + "Cannot update yank message for a version that is not yanked", + )); + } + } + // If both yanked and yank_message are None, do nothing. + // This function only cares about updating the yanked state and yank message. + (None, None) => {} + } + Ok(()) +} diff --git a/src/router.rs b/src/router.rs index 397e68f8e22..b74cce82a69 100644 --- a/src/router.rs +++ b/src/router.rs @@ -45,7 +45,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .route("/api/v1/crates/:crate_id", get(krate::metadata::show)) .route( "/api/v1/crates/:crate_id/:version", - get(version::metadata::show), + get(version::metadata::show).patch(version::metadata::update), ) .route( "/api/v1/crates/:crate_id/:version/readme",