diff --git a/src/docbuilder/limits.rs b/src/docbuilder/limits.rs index 028bdf192..b123c5238 100644 --- a/src/docbuilder/limits.rs +++ b/src/docbuilder/limits.rs @@ -18,7 +18,7 @@ impl Default for Limits { Self { memory: 3 * 1024 * 1024 * 1024, // 3 GB timeout: Duration::from_secs(15 * 60), // 15 minutes - targets: 10, + targets: crate::DEFAULT_MAX_TARGETS, networking: false, max_log_size: 100 * 1024, // 100 KB } diff --git a/src/lib.rs b/src/lib.rs index d53fd32b3..c046fdb9f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,3 +62,6 @@ pub const BUILD_VERSION: &str = concat!( /// Example: /// `s3://rust-docs-rs//rustdoc-static/something.css` pub const RUSTDOC_STATIC_STORAGE_PREFIX: &str = "/rustdoc-static/"; + +/// Maximum number of targets allowed for a crate to be documented on. +pub const DEFAULT_MAX_TARGETS: usize = 10; diff --git a/src/web/crate_details.rs b/src/web/crate_details.rs index 13ef493b2..66ed10843 100644 --- a/src/web/crate_details.rs +++ b/src/web/crate_details.rs @@ -1,5 +1,7 @@ use super::{markdown, match_version, MatchSemver, MetaData}; use crate::utils::{get_correct_docsrs_style_file, report_error, spawn_blocking}; +use crate::web::rustdoc::RustdocHtmlParams; +use crate::web::{axum_cached_redirect, match_version_axum}; use crate::{ db::Pool, impl_axum_webpage, @@ -23,6 +25,7 @@ use serde::Deserialize; use serde::{ser::Serializer, Serialize}; use serde_json::Value; use std::sync::Arc; +use tracing::{instrument, trace}; // TODO: Add target name and versions @@ -466,12 +469,194 @@ pub(crate) async fn get_all_releases( Ok(res.into_response()) } +#[derive(Debug, Clone, PartialEq, Serialize)] +struct ShortMetadata { + name: String, + version_or_latest: String, + doc_targets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +struct PlatformList { + metadata: ShortMetadata, + inner_path: String, + use_direct_platform_links: bool, + current_target: String, +} + +impl_axum_webpage! { + PlatformList = "rustdoc/platforms.html", + cpu_intensive_rendering = true, +} + +#[tracing::instrument] +pub(crate) async fn get_all_platforms_inner( + Path(params): Path, + Extension(pool): Extension, + is_crate_root: bool, +) -> AxumResult { + let req_path: String = params.path.unwrap_or_default(); + let req_path: Vec<&str> = req_path.split('/').collect(); + + let release_found = match_version_axum(&pool, ¶ms.name, Some(¶ms.version)).await?; + trace!(?release_found, "found release"); + + // Convenience function to allow for easy redirection + #[instrument] + fn redirect( + name: &str, + vers: &str, + path: &[&str], + cache_policy: CachePolicy, + ) -> AxumResult { + trace!("redirect"); + // Format and parse the redirect url + Ok(axum_cached_redirect( + encode_url_path(&format!("/platforms/{}/{}/{}", name, vers, path.join("/"))), + cache_policy, + )? + .into_response()) + } + + let (version, version_or_latest) = match release_found.version { + MatchSemver::Exact((version, _)) => { + // Redirect when the requested crate name isn't correct + if let Some(name) = release_found.corrected_name { + return redirect(&name, &version, &req_path, CachePolicy::NoCaching); + } + + (version.clone(), version) + } + + MatchSemver::Latest((version, _)) => { + // Redirect when the requested crate name isn't correct + if let Some(name) = release_found.corrected_name { + return redirect(&name, "latest", &req_path, CachePolicy::NoCaching); + } + + (version, "latest".to_string()) + } + + // Redirect when the requested version isn't correct + MatchSemver::Semver((v, _)) => { + // to prevent cloudfront caching the wrong artifacts on URLs with loose semver + // versions, redirect the browser to the returned version instead of loading it + // immediately + return redirect(¶ms.name, &v, &req_path, CachePolicy::ForeverInCdn); + } + }; + + let (name, doc_targets, releases, default_target): (String, Vec, Vec, String) = + spawn_blocking({ + let pool = pool.clone(); + move || { + let mut conn = pool.get()?; + let query = " + SELECT + crates.id, + crates.name, + releases.default_target, + releases.doc_targets + FROM releases + INNER JOIN crates ON releases.crate_id = crates.id + WHERE crates.name = $1 AND releases.version = $2;"; + + let rows = conn.query(query, &[¶ms.name, &version])?; + + let krate = if rows.is_empty() { + return Err(AxumNope::CrateNotFound.into()); + } else { + &rows[0] + }; + + // get releases, sorted by semver + let releases = releases_for_crate(&mut *conn, krate.get("id"))?; + + Ok(( + krate.get("name"), + MetaData::parse_doc_targets(krate.get("doc_targets")), + releases, + krate.get("default_target"), + )) + } + }) + .await?; + + let latest_release = releases + .iter() + .find(|release| release.version.pre.is_empty() && !release.yanked) + .unwrap_or(&releases[0]); + + // The path within this crate version's rustdoc output + let inner; + let (target, inner_path) = { + let mut inner_path = req_path.clone(); + + let target = if inner_path.len() > 1 + && doc_targets + .iter() + .any(|s| Some(s) == params.target.as_ref()) + { + inner_path.remove(0); + params.target.as_ref().unwrap() + } else { + "" + }; + + inner = inner_path.join("/"); + (target, inner.trim_end_matches('/')) + }; + let inner_path = if inner_path.is_empty() { + format!("{name}/index.html") + } else { + format!("{name}/{inner_path}") + }; + + let current_target = if latest_release.build_status { + if target.is_empty() { + default_target + } else { + target.to_owned() + } + } else { + String::new() + }; + + let res = PlatformList { + metadata: ShortMetadata { + name, + version_or_latest: version_or_latest.to_string(), + doc_targets, + }, + inner_path, + use_direct_platform_links: is_crate_root, + current_target, + }; + Ok(res.into_response()) +} + +pub(crate) async fn get_all_platforms_root( + Path(mut params): Path, + pool: Extension, +) -> AxumResult { + params.path = None; + get_all_platforms_inner(Path(params), pool, true).await +} + +pub(crate) async fn get_all_platforms( + params: Path, + pool: Extension, +) -> AxumResult { + get_all_platforms_inner(params, pool, false).await +} + #[cfg(test)] mod tests { use super::*; use crate::index::api::CrateOwner; use crate::test::{ assert_cache_control, assert_redirect, assert_redirect_cached, wrapper, TestDatabase, + TestEnvironment, }; use anyhow::{Context, Error}; use kuchikiki::traits::TendrilSink; @@ -1077,42 +1262,189 @@ mod tests { #[test] fn platform_links_are_direct_and_without_nofollow() { - wrapper(|env| { - env.fake_release() - .name("dummy") - .version("0.4.0") - .rustdoc_file("dummy/index.html") - .rustdoc_file("x86_64-pc-windows-msvc/dummy/index.html") - .default_target("x86_64-unknown-linux-gnu") - .add_target("x86_64-pc-windows-msvc") - .create()?; - - let response = env.frontend().get("/crate/dummy/0.4.0").send()?; - assert!(response.status().is_success()); - - let platform_links: Vec<(String, String)> = kuchikiki::parse_html() - .one(response.text()?) - .select(r#"a[aria-label="Platform"] + ul li a"#) + fn check_links( + response_text: String, + ajax: bool, + should_contain_redirect: bool, + ) -> Vec<(String, String, String)> { + let platform_links: Vec<(String, String, String)> = kuchikiki::parse_html() + .one(response_text) + .select(&format!(r#"{}li a"#, if ajax { "" } else { "#platforms " })) .expect("invalid selector") .map(|el| { let attributes = el.attributes.borrow(); let url = attributes.get("href").expect("href").to_string(); let rel = attributes.get("rel").unwrap_or("").to_string(); - (url, rel) + (el.text_contents(), url, rel) }) .collect(); assert_eq!(platform_links.len(), 2); - for (url, rel) in platform_links { - assert!(!url.contains("/target-redirect/")); - assert_eq!(rel, ""); + for (_, url, rel) in &platform_links { + assert_eq!( + url.contains("/target-redirect/"), + should_contain_redirect, + "ajax: {ajax:?}, should_contain_redirect: {should_contain_redirect:?}", + ); + if !should_contain_redirect { + assert_eq!(rel, ""); + } else { + assert_eq!(rel, "nofollow"); + } } + platform_links + } + + fn run_check_links( + env: &TestEnvironment, + url_start: &str, + url_end: &str, + extra: &str, + should_contain_redirect: bool, + ) { + run_check_links_redir( + env, + url_start, + url_end, + extra, + should_contain_redirect, + should_contain_redirect, + ) + } + + fn run_check_links_redir( + env: &TestEnvironment, + url_start: &str, + url_end: &str, + extra: &str, + should_contain_redirect: bool, + ajax_should_contain_redirect: bool, + ) { + let response = env + .frontend() + .get(&format!("{url_start}{url_end}")) + .send() + .unwrap(); + assert!(response.status().is_success()); + let list1 = check_links(response.text().unwrap(), false, should_contain_redirect); + // Same test with AJAX endpoint. + let (start, extra_name) = if url_start.starts_with("/crate/") { + ("", "/crate") + } else { + ("/crate", "") + }; + let response = env + .frontend() + .get(&format!( + "{start}{url_start}/menus/platforms{extra_name}{url_end}{extra}" + )) + .send() + .unwrap(); + assert!(response.status().is_success()); + let list2 = check_links(response.text().unwrap(), true, ajax_should_contain_redirect); + if should_contain_redirect == ajax_should_contain_redirect { + assert_eq!(list1, list2); + } else { + // If redirects differ, we only check platform names. + assert!( + list1.iter().zip(&list2).all(|(a, b)| a.0 == b.0), + "{:?} != {:?}", + list1, + list2, + ); + } + } + + wrapper(|env| { + env.fake_release() + .name("dummy") + .version("0.4.0") + .rustdoc_file("dummy/index.html") + .rustdoc_file("x86_64-pc-windows-msvc/dummy/index.html") + .rustdoc_file("x86_64-pc-windows-msvc/dummy/struct.A.html") + .default_target("x86_64-unknown-linux-gnu") + .add_target("x86_64-pc-windows-msvc") + .source_file("README.md", b"storage readme") + .create()?; + + // FIXME: For some reason, there are target-redirects on non-AJAX lists on docs.rs + // crate pages other than the "default" one. + run_check_links_redir(env, "/crate/dummy/0.4.0", "/features", "", true, false); + run_check_links_redir(env, "/crate/dummy/0.4.0", "/builds", "", true, false); + run_check_links_redir(env, "/crate/dummy/0.4.0", "/source/", "", true, false); + run_check_links_redir( + env, + "/crate/dummy/0.4.0", + "/source/README.md", + "", + true, + false, + ); + + run_check_links(env, "/crate/dummy/0.4.0", "", "/", false); + run_check_links(env, "/dummy/latest", "/dummy", "/", true); + run_check_links( + env, + "/dummy/0.4.0", + "/x86_64-pc-windows-msvc/dummy", + "/", + true, + ); + run_check_links( + env, + "/dummy/0.4.0", + "/x86_64-pc-windows-msvc/dummy/struct.A.html", + "/", + true, + ); Ok(()) }); } + // Ensure that if there are more than a given number of targets, it will not generate them in + // the HTML directly (they will be loaded by AJAX if the user opens the menu). + #[test] + #[allow(clippy::assertions_on_constants)] + fn platform_menu_ajax() { + assert!(crate::DEFAULT_MAX_TARGETS > 2); + + fn check_count(nb_targets: usize, expected: usize) { + wrapper(|env| { + let mut rel = env + .fake_release() + .name("dummy") + .version("0.4.0") + .rustdoc_file("dummy/index.html") + .rustdoc_file("x86_64-pc-windows-msvc/dummy/index.html") + .default_target("x86_64-unknown-linux-gnu"); + + for nb in 0..nb_targets - 1 { + rel = rel.add_target(&format!("x86_64-pc-windows-msvc{nb}")); + } + rel.create()?; + + let response = env.frontend().get("/crate/dummy/0.4.0").send()?; + assert!(response.status().is_success()); + + let nb_li = kuchikiki::parse_html() + .one(response.text()?) + .select(r#"#platforms li a"#) + .expect("invalid selector") + .count(); + assert_eq!(nb_li, expected); + Ok(()) + }); + } + + // First we check that with 2 releases, the platforms list should be in the HTML. + check_count(2, 2); + // Then we check the same thing but with number of targets equal + // to `DEFAULT_MAX_TARGETS`. + check_count(crate::DEFAULT_MAX_TARGETS, 0); + } + #[test] fn latest_url() { wrapper(|env| { diff --git a/src/web/csp.rs b/src/web/csp.rs index f21eb24c0..edfb19d9d 100644 --- a/src/web/csp.rs +++ b/src/web/csp.rs @@ -72,6 +72,9 @@ impl Csp { // Allow loading any font from the current origin. result.push_str("; font-src 'self'"); + // Allow XHR. + result.push_str("; connect-src 'self'"); + // Only allow scripts with the random nonce attached to them. // // We can't just allow 'self' here, as users can upload arbitrary .js files as part of @@ -190,7 +193,7 @@ mod tests { assert_eq!( Some(format!( "default-src 'none'; base-uri 'none'; img-src 'self' https:; \ - style-src 'self'; font-src 'self'; script-src 'nonce-{}'", + style-src 'self'; font-src 'self'; connect-src 'self'; script-src 'nonce-{}'", csp.nonce() )), csp.render(ContentType::Html) diff --git a/src/web/page/web_page.rs b/src/web/page/web_page.rs index 511a6a4f5..9885d1ead 100644 --- a/src/web/page/web_page.rs +++ b/src/web/page/web_page.rs @@ -93,8 +93,12 @@ macro_rules! impl_axum_webpage { response.extensions_mut().insert($crate::web::page::web_page::DelayedTemplateRender { - context: ::tera::Context::from_serialize(&self) - .expect("could not create tera context from web-page"), + context: { + let mut c = ::tera::Context::from_serialize(&self) + .expect("could not create tera context from web-page"); + c.insert("DEFAULT_MAX_TARGETS", &$crate::DEFAULT_MAX_TARGETS); + c + }, template: { let template: fn(&Self) -> ::std::borrow::Cow<'static, str> = $template; template(&self).to_string() diff --git a/src/web/routes.rs b/src/web/routes.rs index 7577cbf57..a5b4485c1 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -180,10 +180,6 @@ pub(super) fn build_axum_routes() -> AxumRouter { "/crate/:name", get_internal(super::crate_details::crate_details_handler), ) - .route( - "/:name/releases", - get_internal(super::crate_details::get_all_releases), - ) .route_with_tsr( "/crate/:name/:version", get_internal(super::crate_details::crate_details_handler), @@ -240,6 +236,50 @@ pub(super) fn build_axum_routes() -> AxumRouter { "/crate/:name/:version/source/*path", get_internal(super::source::source_browser_handler), ) + .route( + "/crate/:name/:version/menus/platforms/crate/", + get_internal(super::crate_details::get_all_platforms_root), + ) + .route( + "/crate/:name/:version/menus/platforms/crate/features", + get_internal(super::crate_details::get_all_platforms_root), + ) + .route( + "/crate/:name/:version/menus/platforms/crate/builds", + get_internal(super::crate_details::get_all_platforms_root), + ) + .route( + "/crate/:name/:version/menus/platforms/crate/builds/*path", + get_internal(super::crate_details::get_all_platforms_root), + ) + .route( + "/crate/:name/:version/menus/platforms/crate/source/", + get_internal(super::crate_details::get_all_platforms_root), + ) + .route( + "/crate/:name/:version/menus/platforms/crate/source/*path", + get_internal(super::crate_details::get_all_platforms_root), + ) + .route( + "/crate/:name/:version/menus/platforms/:target", + get_internal(super::crate_details::get_all_platforms), + ) + .route( + "/crate/:name/:version/menus/platforms/:target/*path", + get_internal(super::crate_details::get_all_platforms), + ) + .route( + "/crate/:name/:version/menus/platforms/", + get_internal(super::crate_details::get_all_platforms), + ) + .route( + "/crate/:name/:version/menus/platforms/:target/", + get_internal(super::crate_details::get_all_platforms), + ) + .route( + "/crate/:name/:version/menus/releases", + get_internal(super::crate_details::get_all_releases), + ) .route( "/-/rustdoc.static/*path", get_internal(super::rustdoc::static_asset_handler), diff --git a/src/web/rustdoc.rs b/src/web/rustdoc.rs index d3e1d4438..fc0174d64 100644 --- a/src/web/rustdoc.rs +++ b/src/web/rustdoc.rs @@ -317,7 +317,8 @@ impl RustdocPage { let is_latest_url = self.is_latest_url; // Build the page of documentation - let ctx = tera::Context::from_serialize(self).context("error creating tera context")?; + let mut ctx = tera::Context::from_serialize(self).context("error creating tera context")?; + ctx.insert("DEFAULT_MAX_TARGETS", &crate::DEFAULT_MAX_TARGETS); // Extract the head and body of the rustdoc file so that we can insert it into our own html // while logging OOM errors from html rewriting @@ -349,17 +350,15 @@ impl RustdocPage { } } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Debug)] pub(crate) struct RustdocHtmlParams { - name: String, - version: String, + pub(crate) name: String, + pub(crate) version: String, // both target and path are only used for matching the route. // The actual path is read from the request `Uri` because // we have some static filenames directly in the routes. - #[allow(dead_code)] - target: Option, - #[allow(dead_code)] - path: Option, + pub(crate) target: Option, + pub(crate) path: Option, } /// Serves documentation generated by rustdoc. @@ -2258,8 +2257,12 @@ mod test { .create()?; // test rustdoc pages stay on the documentation - let page = kuchikiki::parse_html() - .one(env.frontend().get("/hexponent/releases").send()?.text()?); + let page = kuchikiki::parse_html().one( + env.frontend() + .get("/crate/hexponent/0.3.1/menus/releases") + .send()? + .text()?, + ); let selector = r#"ul > li a[href="/crate/hexponent/0.3.1/target-redirect/hexponent/index.html"]"# .to_string(); diff --git a/static/menu.js b/static/menu.js index 8f0c8dc24..c84e0eb3c 100644 --- a/static/menu.js +++ b/static/menu.js @@ -5,18 +5,29 @@ const updateMenuPositionForSubMenu = (currentMenuSupplier) => { subMenu?.style.setProperty('--menu-x', `${currentMenu.getBoundingClientRect().x}px`); } -function generateReleaseList(data, crateName) { -} +const loadedMenus = new Set(); -let loadReleases = function() { - const releaseListElem = document.getElementById('releases-list'); - // To prevent reloading the list unnecessarily. - loadReleases = function() {}; +function loadAjaxMenu(menu, id, msg, path, extra) { + if (loadedMenus.has(id)) { + return; + } + loadedMenus.add(id); + if (!menu.querySelector(".rotate")) { + return; + } + const releaseListElem = document.getElementById(id); if (!releaseListElem) { // We're not in a documentation page, so no need to do anything. return; } - const crateName = window.location.pathname.split('/')[1]; + const parts = window.location.pathname.split("/"); + let crateName = parts[1]; + let version = parts[2]; + if (crateName === "crate") { + crateName = parts[2]; + version = parts[3]; + path += "/crate"; + } const xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function() { if (xhttp.readyState !== XMLHttpRequest.DONE) { @@ -25,11 +36,12 @@ let loadReleases = function() { if (xhttp.status === 200) { releaseListElem.innerHTML = xhttp.responseText; } else { - console.error(`Failed to load release list: [${xhttp.status}] ${xhttp.responseText}`); - document.getElementById('releases-list').innerHTML = "Failed to load release list"; + console.error(`Failed to load ${msg}: [${xhttp.status}] ${xhttp.responseText}`); + document.getElementById(id).innerHTML = `Failed to load ${msg}`; } }; - xhttp.open("GET", `/${crateName}/releases`, true); + console.log(extra, path); + xhttp.open("GET", `/crate/${crateName}/${version}/menus/${path}${extra}`, true); xhttp.send(); }; @@ -81,7 +93,20 @@ let loadReleases = function() { currentMenu = newMenu; newMenu.className += " pure-menu-active"; backdrop.style.display = "block"; - loadReleases(); + if (newMenu.querySelector("#releases-list")) { + loadAjaxMenu(newMenu, "releases-list", "release list", "releases", ""); + } else if (newMenu.querySelector("#platforms")) { + const parts = window.location.pathname.split("/"); + const startFrom = parts[1] === "crate" ? 4 : 3; + loadAjaxMenu( + newMenu, + "platforms", + "platforms list", + "platforms", + // We get everything except the first crate name and the version. + "/" + parts.slice(startFrom).join("/") + ); + } } function menuOnClick(e) { if (this.getAttribute("href") != "#") { diff --git a/templates/rustdoc/platforms.html b/templates/rustdoc/platforms.html new file mode 100644 index 000000000..67e0cb167 --- /dev/null +++ b/templates/rustdoc/platforms.html @@ -0,0 +1,25 @@ +{%- for target in metadata.doc_targets -%} + {# + The crate-detail page is the only page where we want to allow google to follow + the target-links. On that page we also don't have to use `/target-redirect/` + because the documentation root page is guaranteed to exist for all targets. + #} + {%- if use_direct_platform_links -%} + {%- set target_url = "/" ~ metadata.name ~ "/" ~ metadata.version_or_latest ~ "/" ~ target ~ "/" ~ inner_path -%} + {%- set target_no_follow = "" -%} + {%- else -%} + {%- set target_url = "/crate/" ~ metadata.name ~ "/" ~ metadata.version_or_latest ~ "/target-redirect/" ~ target ~ "/" ~ inner_path -%} + {%- set target_no_follow = "nofollow" -%} + {%- endif -%} + {%- if current_target is defined and current_target == target -%} + {%- set current = " current" -%} + {%- else -%} + {%- set current = "" -%} + {%- endif -%} + +
  • + + {{- target -}} + +
  • +{%- endfor -%} diff --git a/templates/rustdoc/topbar.html b/templates/rustdoc/topbar.html index 004c4c834..10e9fb4da 100644 --- a/templates/rustdoc/topbar.html +++ b/templates/rustdoc/topbar.html @@ -209,31 +209,11 @@ {# Build the dropdown list showing available targets #}
      - {%- for target in metadata.doc_targets -%} - {# - The crate-detail page is the only page where we want to allow google to follow - the target-links. On that page we also don't have to use `/target-redirect/` - because the documentation root page is guaranteed to exist for all targets. - #} - {%- if use_direct_platform_links -%} - {%- set target_url = "/" ~ metadata.name ~ "/" ~ metadata.version_or_latest ~ "/" ~ target ~ "/" ~ inner_path -%} - {%- set target_no_follow = "" -%} - {%- else -%} - {%- set target_url = "/crate/" ~ metadata.name ~ "/" ~ metadata.version_or_latest ~ "/target-redirect/" ~ target ~ "/" ~ inner_path -%} - {%- set target_no_follow = "nofollow" -%} - {%- endif -%} - {%- if current_target is defined and current_target == target -%} - {%- set current = " current" -%} - {%- else -%} - {%- set current = "" -%} - {%- endif -%} - -
    • - - {{- target -}} - -
    • - {%- endfor -%} + {%- if metadata.doc_targets|length < DEFAULT_MAX_TARGETS -%} + {%- include "rustdoc/platforms.html" -%} + {%- else -%} + {{ "spinner" | fas }} + {%- endif -%}
    {# Display the features available in current build