diff --git a/.sqlx/query-648ce6ae0bfbdc28ad7f4099f8141380bd83a93829e8a89d083c263bf621ed5f.json b/.sqlx/query-648ce6ae0bfbdc28ad7f4099f8141380bd83a93829e8a89d083c263bf621ed5f.json new file mode 100644 index 000000000..53cf259af --- /dev/null +++ b/.sqlx/query-648ce6ae0bfbdc28ad7f4099f8141380bd83a93829e8a89d083c263bf621ed5f.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COUNT(*) as \"count!\"\n FROM releases\n INNER JOIN crates ON crates.id = releases.crate_id\n WHERE crates.name = $1 AND releases.version = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "648ce6ae0bfbdc28ad7f4099f8141380bd83a93829e8a89d083c263bf621ed5f" +} diff --git a/.sqlx/query-d5353f99e2f09d4264d925f26b70198ec623b7fc7a9f386dd108f2d0fcdab50c.json b/.sqlx/query-d5353f99e2f09d4264d925f26b70198ec623b7fc7a9f386dd108f2d0fcdab50c.json new file mode 100644 index 000000000..f6a32d74b --- /dev/null +++ b/.sqlx/query-d5353f99e2f09d4264d925f26b70198ec623b7fc7a9f386dd108f2d0fcdab50c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT 1 as \"dummy\"\n FROM releases\n INNER JOIN crates ON crates.id = releases.crate_id\n WHERE crates.name = $1 AND releases.version = $2\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "dummy", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d5353f99e2f09d4264d925f26b70198ec623b7fc7a9f386dd108f2d0fcdab50c" +} diff --git a/Cargo.lock b/Cargo.lock index 55dc53d45..84e49cf69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1097,6 +1097,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" + [[package]] name = "convert_case" version = "0.4.0" @@ -1586,6 +1592,7 @@ dependencies = [ "chrono", "clap", "comrak", + "constant_time_eq", "crates-index", "crates-index-diff", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 11e3ccc79..8b2e168be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,7 @@ chrono = { version = "0.4.11", default-features = false, features = ["clock", "s # Transitive dependencies we don't use directly but need to have specific versions of thread_local = "1.1.3" humantime = "2.1.0" +constant_time_eq = "0.3.0" [dependencies.postgres] version = "0.19" diff --git a/src/build_queue.rs b/src/build_queue.rs index 5491b0951..506acee9b 100644 --- a/src/build_queue.rs +++ b/src/build_queue.rs @@ -151,7 +151,7 @@ impl BuildQueue { .collect()) } - fn has_build_queued(&self, name: &str, version: &str) -> Result { + pub(crate) fn has_build_queued(&self, name: &str, version: &str) -> Result { Ok(self .db .get()? diff --git a/src/config.rs b/src/config.rs index a4acf0aff..5a257534f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,6 +41,10 @@ pub struct Config { // Gitlab authentication pub(crate) gitlab_accesstoken: Option, + // Access token for APIs for crates.io (careful: use + // constant_time_eq for comparisons!) + pub(crate) cratesio_token: Option, + // amount of retries for external API calls, mostly crates.io pub crates_io_api_call_retries: u32, @@ -176,6 +180,8 @@ impl Config { gitlab_accesstoken: maybe_env("DOCSRS_GITLAB_ACCESSTOKEN")?, + cratesio_token: maybe_env("DOCSRS_CRATESIO_TOKEN")?, + max_file_size: env("DOCSRS_MAX_FILE_SIZE", 50 * 1024 * 1024)?, max_file_size_html: env("DOCSRS_MAX_FILE_SIZE_HTML", 50 * 1024 * 1024)?, // LOL HTML only uses as much memory as the size of the start tag! diff --git a/src/test/mod.rs b/src/test/mod.rs index 55447c69e..a633c6ba7 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -777,6 +777,12 @@ impl TestFrontend { self.client.request(Method::GET, url) } + pub(crate) fn post(&self, url: &str) -> RequestBuilder { + let url = self.build_url(url); + debug!("posting {url}"); + self.client.request(Method::POST, url) + } + pub(crate) fn get_no_redirect(&self, url: &str) -> RequestBuilder { let url = self.build_url(url); debug!("getting {url} (no redirects)"); diff --git a/src/web/builds.rs b/src/web/builds.rs index d79b7bbc4..e6113c552 100644 --- a/src/web/builds.rs +++ b/src/web/builds.rs @@ -1,22 +1,34 @@ -use super::{cache::CachePolicy, error::AxumNope, headers::CanonicalUrl}; +use super::{ + cache::CachePolicy, + error::{AxumNope, JsonAxumNope, JsonAxumResult}, + headers::CanonicalUrl, +}; use crate::{ db::types::BuildStatus, docbuilder::Limits, impl_axum_webpage, + utils::spawn_blocking, web::{ error::AxumResult, extractors::{DbConnection, Path}, match_version, MetaData, ReqVersion, }, - Config, + BuildQueue, Config, }; -use anyhow::Result; +use anyhow::{anyhow, Result}; use axum::{ extract::Extension, http::header::ACCESS_CONTROL_ALLOW_ORIGIN, response::IntoResponse, Json, }; +use axum_extra::{ + headers::{authorization::Bearer, Authorization}, + TypedHeader, +}; use chrono::{DateTime, Utc}; +use constant_time_eq::constant_time_eq; +use http::StatusCode; use semver::Version; use serde::Serialize; +use serde_json::json; use std::sync::Arc; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -111,6 +123,103 @@ pub(crate) async fn build_list_json_handler( .into_response()) } +async fn crate_version_exists( + mut conn: DbConnection, + name: &String, + version: &Version, +) -> Result { + let row = sqlx::query!( + r#" + SELECT 1 as "dummy" + FROM releases + INNER JOIN crates ON crates.id = releases.crate_id + WHERE crates.name = $1 AND releases.version = $2 + LIMIT 1"#, + name, + version.to_string(), + ) + .fetch_optional(&mut *conn) + .await?; + Ok(row.is_some()) +} + +async fn build_trigger_check( + conn: DbConnection, + name: &String, + version: &Version, + build_queue: &Arc, +) -> AxumResult { + if !crate_version_exists(conn, name, version).await? { + return Err(AxumNope::VersionNotFound); + } + + let crate_version_is_in_queue = spawn_blocking({ + let name = name.clone(); + let version_string = version.to_string(); + let build_queue = build_queue.clone(); + move || build_queue.has_build_queued(&name, &version_string) + }) + .await?; + if crate_version_is_in_queue { + return Err(AxumNope::BadRequest(anyhow!( + "crate {name} {version} already queued for rebuild" + ))); + } + + Ok(()) +} + +// Priority according to issue #2442; positive here as it's inverted. +// FUTURE: move to a crate-global enum with all special priorities? +const TRIGGERED_REBUILD_PRIORITY: i32 = 5; + +pub(crate) async fn build_trigger_rebuild_handler( + Path((name, version)): Path<(String, Version)>, + conn: DbConnection, + Extension(build_queue): Extension>, + Extension(config): Extension>, + opt_auth_header: Option>>, +) -> JsonAxumResult { + let expected_token = + config + .cratesio_token + .as_ref() + .ok_or(JsonAxumNope(AxumNope::Unauthorized( + "Endpoint is not configured", + )))?; + + // (Future: would it be better to have standard middleware handle auth?) + let TypedHeader(auth_header) = opt_auth_header.ok_or(JsonAxumNope(AxumNope::Unauthorized( + "Missing authentication token", + )))?; + if !constant_time_eq(auth_header.token().as_bytes(), expected_token.as_bytes()) { + return Err(JsonAxumNope(AxumNope::Unauthorized( + "The token used for authentication is not valid", + ))); + } + + build_trigger_check(conn, &name, &version, &build_queue) + .await + .map_err(JsonAxumNope)?; + + spawn_blocking({ + let name = name.clone(); + let version_string = version.to_string(); + move || { + build_queue.add_crate( + &name, + &version_string, + TRIGGERED_REBUILD_PRIORITY, + None, /* because crates.io is the only service that calls this endpoint */ + ) + } + }) + .await + .map_err(|e| JsonAxumNope(e.into()))?; + + Ok((StatusCode::CREATED, Json(json!({})))) +} + async fn get_builds( conn: &mut sqlx::PgConnection, name: &str, @@ -276,6 +385,113 @@ mod tests { }); } + #[test] + fn build_trigger_rebuild_missing_config() { + wrapper(|env| { + env.fake_release().name("foo").version("0.1.0").create()?; + + { + let response = env.frontend().get("/crate/regex/1.3.1/rebuild").send()?; + // Needs POST + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + } + + { + let response = env.frontend().post("/crate/regex/1.3.1/rebuild").send()?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let json: serde_json::Value = response.json()?; + assert_eq!( + json, + serde_json::json!({ + "title": "Unauthorized", + "message": "Endpoint is not configured" + }) + ); + } + + Ok(()) + }) + } + + #[test] + fn build_trigger_rebuild_with_config() { + wrapper(|env| { + let correct_token = "foo137"; + env.override_config(|config| config.cratesio_token = Some(correct_token.into())); + + env.fake_release().name("foo").version("0.1.0").create()?; + + { + let response = env.frontend().post("/crate/regex/1.3.1/rebuild").send()?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let json: serde_json::Value = response.json()?; + assert_eq!( + json, + serde_json::json!({ + "title": "Unauthorized", + "message": "Missing authentication token" + }) + ); + } + + { + let response = env + .frontend() + .post("/crate/regex/1.3.1/rebuild") + .bearer_auth("someinvalidtoken") + .send()?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let json: serde_json::Value = response.json()?; + assert_eq!( + json, + serde_json::json!({ + "title": "Unauthorized", + "message": "The token used for authentication is not valid" + }) + ); + } + + assert_eq!(env.build_queue().pending_count()?, 0); + assert!(!env.build_queue().has_build_queued("foo", "0.1.0")?); + + { + let response = env + .frontend() + .post("/crate/foo/0.1.0/rebuild") + .bearer_auth(correct_token) + .send()?; + assert_eq!(response.status(), StatusCode::CREATED); + let json: serde_json::Value = response.json()?; + assert_eq!(json, serde_json::json!({})); + } + + assert_eq!(env.build_queue().pending_count()?, 1); + assert!(env.build_queue().has_build_queued("foo", "0.1.0")?); + + { + let response = env + .frontend() + .post("/crate/foo/0.1.0/rebuild") + .bearer_auth(correct_token) + .send()?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let json: serde_json::Value = response.json()?; + assert_eq!( + json, + serde_json::json!({ + "title": "Bad request", + "message": "crate foo 0.1.0 already queued for rebuild" + }) + ); + } + + assert_eq!(env.build_queue().pending_count()?, 1); + assert!(env.build_queue().has_build_queued("foo", "0.1.0")?); + + Ok(()) + }); + } + #[test] fn build_empty_list() { wrapper(|env| { diff --git a/src/web/error.rs b/src/web/error.rs index 63d5ebdc4..75bb51407 100644 --- a/src/web/error.rs +++ b/src/web/error.rs @@ -1,14 +1,18 @@ use crate::{ db::PoolError, storage::PathNotFoundError, - web::{cache::CachePolicy, encode_url_path, releases::Search, AxumErrorPage}, + web::{cache::CachePolicy, encode_url_path, releases::Search}, }; use anyhow::anyhow; use axum::{ http::StatusCode, response::{IntoResponse, Response as AxumResponse}, + Json, }; use std::borrow::Cow; +use tracing::error; + +use super::AxumErrorPage; #[derive(Debug, thiserror::Error)] pub enum AxumNope { @@ -24,6 +28,8 @@ pub enum AxumNope { VersionNotFound, #[error("Search yielded no results")] NoResults, + #[error("Unauthorized: {0}")] + Unauthorized(&'static str), #[error("internal error")] InternalError(anyhow::Error), #[error("bad request")] @@ -32,85 +38,149 @@ pub enum AxumNope { Redirect(String, CachePolicy), } -impl IntoResponse for AxumNope { - fn into_response(self) -> AxumResponse { +// FUTURE: Ideally, the split between the 3 kinds of responses would +// be done by having multiple nested enums in the first place instead +// of just `AxumNope`, to keep everything statically type-checked +// throughout instead of having the potential for a runtime error. + +impl AxumNope { + fn into_error_info(self) -> ErrorInfo { match self { AxumNope::ResourceNotFound => { // user tried to navigate to a resource (doc page/file) that doesn't exist - AxumErrorPage { + ErrorInfo { title: "The requested resource does not exist", message: "no such resource".into(), status: StatusCode::NOT_FOUND, } - .into_response() } - - AxumNope::BuildNotFound => AxumErrorPage { + AxumNope::BuildNotFound => ErrorInfo { title: "The requested build does not exist", message: "no such build".into(), status: StatusCode::NOT_FOUND, - } - .into_response(), - + }, AxumNope::CrateNotFound => { // user tried to navigate to a crate that doesn't exist // TODO: Display the attempted crate and a link to a search for said crate - AxumErrorPage { + ErrorInfo { title: "The requested crate does not exist", message: "no such crate".into(), status: StatusCode::NOT_FOUND, } - .into_response() } - - AxumNope::OwnerNotFound => AxumErrorPage { + AxumNope::OwnerNotFound => ErrorInfo { title: "The requested owner does not exist", message: "no such owner".into(), status: StatusCode::NOT_FOUND, - } - .into_response(), - + }, AxumNope::VersionNotFound => { // user tried to navigate to a crate with a version that does not exist // TODO: Display the attempted crate and version - AxumErrorPage { + ErrorInfo { title: "The requested version does not exist", message: "no such version for this crate".into(), status: StatusCode::NOT_FOUND, } - .into_response() } AxumNope::NoResults => { // user did a search with no search terms - Search { - title: "No results given for empty search query".to_owned(), - status: StatusCode::NOT_FOUND, - ..Default::default() - } - .into_response() + unreachable!() } - AxumNope::BadRequest(source) => AxumErrorPage { + AxumNope::BadRequest(source) => ErrorInfo { title: "Bad request", message: Cow::Owned(source.to_string()), status: StatusCode::BAD_REQUEST, - } - .into_response(), + }, + AxumNope::Unauthorized(what) => ErrorInfo { + title: "Unauthorized", + message: what.into(), + status: StatusCode::UNAUTHORIZED, + }, AxumNope::InternalError(source) => { - let web_error = crate::web::AxumErrorPage { + crate::utils::report_error(&source); + ErrorInfo { title: "Internal Server Error", message: Cow::Owned(source.to_string()), status: StatusCode::INTERNAL_SERVER_ERROR, - }; + } + } + AxumNope::Redirect(_target, _cache_policy) => unreachable!(), + } + } +} - crate::utils::report_error(&source); +struct ErrorInfo { + // For the title of the page + pub title: &'static str, + // The error message, displayed as a description + pub message: Cow<'static, str>, + // The status code of the response + pub status: StatusCode, +} + +fn redirect_with_policy(target: String, cache_policy: CachePolicy) -> AxumResponse { + match super::axum_cached_redirect(encode_url_path(&target), cache_policy) { + Ok(response) => response.into_response(), + Err(err) => AxumNope::InternalError(err).into_response(), + } +} - web_error.into_response() +impl IntoResponse for AxumNope { + fn into_response(self) -> AxumResponse { + match self { + AxumNope::NoResults => { + // user did a search with no search terms + Search { + title: "No results given for empty search query".to_owned(), + status: StatusCode::NOT_FOUND, + ..Default::default() + } + .into_response() } - AxumNope::Redirect(target, cache_policy) => { - match super::axum_cached_redirect(&encode_url_path(&target), cache_policy) { - Ok(response) => response.into_response(), - Err(err) => AxumNope::InternalError(err).into_response(), + AxumNope::Redirect(target, cache_policy) => redirect_with_policy(target, cache_policy), + _ => { + let ErrorInfo { + title, + message, + status, + } = self.into_error_info(); + AxumErrorPage { + title, + message, + status, } + .into_response() + } + } + } +} + +/// `AxumNope` but generating error responses in JSON (for API). +pub(crate) struct JsonAxumNope(pub AxumNope); + +impl IntoResponse for JsonAxumNope { + fn into_response(self) -> AxumResponse { + match self.0 { + AxumNope::NoResults => { + // user did a search with no search terms; invalid, + // return 404 + StatusCode::NOT_FOUND.into_response() + } + AxumNope::Redirect(target, cache_policy) => redirect_with_policy(target, cache_policy), + _ => { + let ErrorInfo { + title, + message, + status, + } = self.0.into_error_info(); + ( + status, + Json(serde_json::json!({ + "title": title, + "message": message, + })), + ) + .into_response() } } } @@ -141,6 +211,7 @@ impl From for AxumNope { } pub(crate) type AxumResult = Result; +pub(crate) type JsonAxumResult = Result; #[cfg(test)] mod tests { diff --git a/src/web/mod.rs b/src/web/mod.rs index 2db225a02..1599e4483 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -572,7 +572,7 @@ where fn axum_cached_redirect( uri: U, cache_policy: cache::CachePolicy, -) -> Result +) -> Result where U: TryInto + std::fmt::Debug, >::Error: std::fmt::Debug, diff --git a/src/web/routes.rs b/src/web/routes.rs index ced014a42..00d80b846 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -6,7 +6,7 @@ use axum::{ handler::Handler as AxumHandler, middleware::{self, Next}, response::{IntoResponse, Redirect}, - routing::{get, MethodRouter}, + routing::{get, post, MethodRouter}, Router as AxumRouter, }; use axum_extra::routing::RouterExt; @@ -39,6 +39,18 @@ where })) } +#[instrument(skip_all)] +fn post_internal(handler: H) -> MethodRouter +where + H: AxumHandler, + T: 'static, + S: Clone + Send + Sync + 'static, +{ + post(handler).route_layer(middleware::from_fn(|request, next| async { + request_recorder(request, next, None).await + })) +} + #[instrument(skip_all)] fn get_rustdoc(handler: H) -> MethodRouter where @@ -212,6 +224,10 @@ pub(super) fn build_axum_routes() -> AxumRouter { "/crate/:name/:version/builds.json", get_internal(super::builds::build_list_json_handler), ) + .route( + "/crate/:name/:version/rebuild", + post_internal(super::builds::build_trigger_rebuild_handler), + ) .route( "/crate/:name/:version/status.json", get_internal(super::status::status_handler),