diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 8ab8d0ff4..773db9cbf 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -4,7 +4,7 @@ set -euv export CRATESFYI_PREFIX=/opt/docsrs/prefix export DOCS_RS_DOCKER=true -export RUST_LOG=cratesfyi,rustwide=info +export RUST_LOG=${RUST_LOG-cratesfyi,rustwide=info} export PATH="$PATH:/build/target/release" # Try migrating the database multiple times if it fails diff --git a/src/db/migrate.rs b/src/db/migrate.rs index db38ebea1..d996d99cc 100644 --- a/src/db/migrate.rs +++ b/src/db/migrate.rs @@ -310,6 +310,21 @@ pub fn migrate(version: Option, conn: &Connection) -> CratesfyiResult<( // downgrade query "DROP TABLE crate_priorities;", ), + migration!( + context, + // version + 12, + // description + "Mark doc_targets non-nullable (it has a default of empty array anyway)", + // upgrade query + " + ALTER TABLE releases ALTER COLUMN doc_targets SET NOT NULL; + ", + // downgrade query + " + ALTER TABLE releases ALTER COLUMN doc_targets DROP NOT NULL; + " + ), ]; for migration in migrations { diff --git a/src/test/fakes.rs b/src/test/fakes.rs index aee38c806..a56a4afe6 100644 --- a/src/test/fakes.rs +++ b/src/test/fakes.rs @@ -97,10 +97,16 @@ impl<'a> FakeRelease<'a> { } pub(crate) fn default_target(mut self, target: &'a str) -> Self { + self = self.add_target(target); self.default_target = Some(target); self } + pub(crate) fn add_target(mut self, target: &str) -> Self { + self.doc_targets.push(target.into()); + self + } + pub(crate) fn binary(mut self, bin: bool) -> Self { self.has_docs = !bin; if bin { @@ -112,9 +118,11 @@ impl<'a> FakeRelease<'a> { } pub(crate) fn add_platform>(mut self, platform: S) -> Self { + let platform = platform.into(); let name = self.package.targets[0].name.clone(); - let target = Target::dummy_lib(name, Some(platform.into())); + let target = Target::dummy_lib(name, Some(platform.clone())); self.package.targets.push(target); + self.doc_targets.push(platform); self } diff --git a/src/test/mod.rs b/src/test/mod.rs index fd2ccc8e6..0be67c5f9 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -93,6 +93,8 @@ pub(crate) struct TestEnvironment { impl TestEnvironment { fn new() -> Self { + // If this fails it's probably already initialized + let _ = env_logger::try_init(); Self { db: OnceCell::new(), frontend: OnceCell::new(), diff --git a/src/web/crate_details.rs b/src/web/crate_details.rs index 87fa0737a..853dea705 100644 --- a/src/web/crate_details.rs +++ b/src/web/crate_details.rs @@ -42,7 +42,7 @@ pub struct CrateDetails { github_issues: Option, pub(crate) metadata: MetaData, is_library: bool, - doc_targets: Option, + pub(crate) doc_targets: Vec, license: Option, documentation_url: Option, } @@ -185,6 +185,18 @@ impl CrateDetails { default_target: rows.get(0).get(25), }; + let doc_targets = { + let data: Json = rows.get(0).get(22); + data.as_array() + .map(|array| { + array + .iter() + .filter_map(|item| item.as_string().map(|s| s.to_owned())) + .collect() + }) + .unwrap_or_else(Vec::new) + }; + let mut crate_details = CrateDetails { name: rows.get(0).get(2), version: rows.get(0).get(3), @@ -211,7 +223,7 @@ impl CrateDetails { github_issues: rows.get(0).get(20), metadata, is_library: rows.get(0).get(21), - doc_targets: rows.get(0).get(22), + doc_targets, license: rows.get(0).get(23), documentation_url: rows.get(0).get(24), }; diff --git a/src/web/routes.rs b/src/web/routes.rs index 25727ab2f..7489066d7 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -87,6 +87,10 @@ pub(super) fn build_routes() -> Routes { "/crate/:name/:version/source/*", super::source::source_browser_handler, ); + routes.internal_page( + "/crate/:name/:version/target-redirect/*", + super::rustdoc::target_redirect_handler, + ); routes.rustdoc_page("/:crate", super::rustdoc::rustdoc_redirector_handler); routes.rustdoc_page("/:crate/", super::rustdoc::rustdoc_redirector_handler); diff --git a/src/web/rustdoc.rs b/src/web/rustdoc.rs index 7efbdcd95..b50333bd6 100644 --- a/src/web/rustdoc.rs +++ b/src/web/rustdoc.rs @@ -19,7 +19,7 @@ use rustc_serialize::json::{Json, ToJson}; use std::collections::BTreeMap; use time; -#[derive(Debug)] +#[derive(Debug, Default)] struct RustdocPage { head: String, body: String, @@ -31,21 +31,6 @@ struct RustdocPage { crate_details: Option, } -impl Default for RustdocPage { - fn default() -> RustdocPage { - RustdocPage { - head: String::new(), - body: String::new(), - body_class: String::new(), - name: String::new(), - full: String::new(), - version: String::new(), - description: None, - crate_details: None, - } - } -} - impl ToJson for RustdocPage { fn to_json(&self) -> Json { let mut m: BTreeMap = BTreeMap::new(); @@ -93,9 +78,21 @@ pub fn rustdoc_redirector_handler(req: &mut Request) -> IronResult { req: &Request, name: &str, vers: &str, + target: Option<&str>, target_name: &str, ) -> IronResult { - let mut url_str = format!("{}/{}/{}/{}/", redirect_base(req), name, vers, target_name,); + let mut url_str = if let Some(target) = target { + format!( + "{}/{}/{}/{}/{}/", + redirect_base(req), + name, + vers, + target, + target_name + ) + } else { + format!("{}/{}/{}/{}/", redirect_base(req), name, vers, target_name) + }; if let Some(query) = req.url.query() { url_str.push('?'); url_str.push_str(query); @@ -164,6 +161,7 @@ pub fn rustdoc_redirector_handler(req: &mut Request) -> IronResult { .unwrap_or_else(|_| crate_name.into()) .into_owned(); let req_version = router.find("version"); + let mut target = router.find("target"); let conn = extension!(req, Pool).get(); @@ -188,16 +186,20 @@ pub fn rustdoc_redirector_handler(req: &mut Request) -> IronResult { let (target_name, has_docs): (String, bool) = { let rows = ctry!(conn.query( "SELECT target_name, rustdoc_status - FROM releases - WHERE releases.id = $1", + FROM releases + WHERE releases.id = $1", &[&id] )); (rows.get(0).get(0), rows.get(0).get(1)) }; + if target == Some("index.html") || target == Some(&target_name) { + target = None; + } + if has_docs { - redirect_to_doc(req, &crate_name, &version, &target_name) + redirect_to_doc(req, &crate_name, &version, target, &target_name) } else { redirect_to_crate(req, &crate_name, &version) } @@ -243,9 +245,10 @@ pub fn rustdoc_html_server_handler(req: &mut Request) -> IronResult { req_path.insert(1, &name); req_path.insert(2, &version); + let crate_details = cexpect!(CrateDetails::new(&conn, &name, &version)); + // if visiting the full path to the default target, remove the target from the path // expects a req_path that looks like `/rustdoc/:crate/:version[/:target]/.*` - let crate_details = cexpect!(CrateDetails::new(&conn, &name, &version)); if req_path[3] == crate_details.metadata.default_target { let path = [base, req_path[1..3].join("/"), req_path[4..].join("/")].join("/"); let canonical = Url::parse(&path).expect("got an invalid URL to start"); @@ -275,6 +278,8 @@ pub fn rustdoc_html_server_handler(req: &mut Request) -> IronResult { } }; + let req_path = req_path; + // serve file directly if it's not html if !path.ends_with(".html") { return Ok(file.serve()); @@ -301,13 +306,25 @@ pub fn rustdoc_html_server_handler(req: &mut Request) -> IronResult { let latest_version = crate_details.latest_version().to_owned(); let is_latest_version = latest_version == version; - let path = if !is_latest_version { - req_path[2] = &latest_version; - path_for_version(&req_path, &crate_details.target_name, &conn) + let path_in_latest = if !is_latest_version { + let mut latest_path = req_path.clone(); + latest_path[2] = &latest_version; + path_for_version(&latest_path, &crate_details.doc_targets, &conn) } else { Default::default() }; + // The path within this crate version's rustdoc output + let inner_path = { + let mut inner_path = req_path.clone(); + // Drop the `rustdoc/:crate/:version[/:platform]` prefix + inner_path.drain(..3); + if inner_path.len() > 1 && crate_details.doc_targets.iter().any(|s| s == inner_path[0]) { + inner_path.remove(0); + } + inner_path.join("/") + }; + content.crate_details = Some(crate_details); Page::new(content) @@ -315,8 +332,9 @@ pub fn rustdoc_html_server_handler(req: &mut Request) -> IronResult { .set_true("package_navigation_documentation_tab") .set_true("package_navigation_show_platforms_tab") .set_bool("is_latest_version", is_latest_version) - .set("path_in_latest", &path) + .set("path_in_latest", &path_in_latest) .set("latest_version", &latest_version) + .set("inner_path", &inner_path) .to_resp("rustdoc") } @@ -330,7 +348,7 @@ pub fn rustdoc_html_server_handler(req: &mut Request) -> IronResult { /// `rustdoc/crate/version[/platform]/module/[kind.name.html|index.html]` /// /// Returns a path that can be appended to `/crate/version/` to create a complete URL. -fn path_for_version(req_path: &[&str], target_name: &str, conn: &Connection) -> String { +fn path_for_version(req_path: &[&str], known_platforms: &[String], conn: &Connection) -> String { // Simple case: page exists in the latest version, so just change the version number if File::from_path(&conn, &req_path.join("/")).is_some() { // NOTE: this adds 'index.html' if it wasn't there before @@ -350,18 +368,57 @@ fn path_for_version(req_path: &[&str], target_name: &str, conn: &Connection) -> .expect("paths should be of the form ..html") }; // check if req_path[3] is the platform choice or the name of the crate - // rustdoc generates a ../settings.html page, so if req_path[3] is not - // the target, that doesn't necessarily mean it's a platform. - // we also can't check if it's in TARGETS, since some targets have been - // removed (looking at you, i686-apple-darwin) - let concat_path; - let crate_root = if req_path[3] != target_name && req_path.len() >= 5 { - concat_path = format!("{}/{}", req_path[3], req_path[4]); - &concat_path - } else { + let platform = if known_platforms.iter().any(|s| s == req_path[3]) && req_path.len() >= 5 { req_path[3] + } else { + "" }; - format!("{}?search={}", crate_root, search_item) + format!("{}?search={}", platform, search_item) +} + +pub fn target_redirect_handler(req: &mut Request) -> IronResult { + let router = extension!(req, Router); + let name = cexpect!(router.find("name")); + let version = cexpect!(router.find("version")); + + let conn = extension!(req, Pool).get(); + let base = redirect_base(req); + + let crate_details = cexpect!(CrateDetails::new(&conn, &name, &version)); + + // [crate, :name, :version, target-redirect, :target, *path] + // is transformed to + // [rustdoc, :name, :version, :target?, *path] + // path might be empty, but target is guaranteed to be there because of the route used + let file_path = { + let mut file_path = req.url.path(); + file_path[0] = "rustdoc"; + file_path.remove(3); + if file_path[3] == crate_details.metadata.default_target { + file_path.remove(3); + } + if let Some(last) = file_path.last_mut() { + if *last == "" { + *last = "index.html"; + } + } + file_path + }; + + let path = path_for_version(&file_path, &crate_details.doc_targets, &conn); + let url = format!( + "{base}/{name}/{version}/{path}", + base = base, + name = name, + version = version, + path = path + ); + + let url = ctry!(Url::parse(&url)); + let mut resp = Response::with((status::Found, Redirect(url))); + resp.headers.set(Expires(HttpDate(time::now()))); + + Ok(resp) } pub fn badge_handler(req: &mut Request) -> IronResult { @@ -461,19 +518,21 @@ impl Handler for SharedResourceHandler { mod test { use crate::test::*; use reqwest::StatusCode; + use std::{collections::BTreeMap, iter::FromIterator}; fn latest_version_redirect(path: &str, web: &TestFrontend) -> Result { use html5ever::tendril::TendrilSink; assert_success(path, web)?; let data = web.get(path).send()?.text()?; let dom = kuchiki::parse_html().one(data); - for elems in dom.select("form ul li a.warn") { - for elem in elems { - let warning = elem.as_node().as_element().unwrap(); - let link = warning.attributes.borrow().get("href").unwrap().to_string(); - assert_success(&link, web)?; - return Ok(link); - } + if let Some(elem) = dom + .select("form ul li a.warn") + .expect("invalid selector") + .next() + { + let link = elem.attributes.borrow().get("href").unwrap().to_string(); + assert_success(&link, web)?; + return Ok(link); } panic!("no redirect found for {}", path); } @@ -595,13 +654,14 @@ mod test { // check it searches for removed pages let redirect = latest_version_redirect("/dummy/0.1.0/dummy/struct.will-be-deleted.html", &web)?; - assert_eq!(redirect, "/dummy/0.2.0/dummy?search=will-be-deleted"); + // This must be a double redirect to deal with crates that failed to build in the + // latest version + assert_eq!(redirect, "/dummy/0.2.0/?search=will-be-deleted"); assert_redirect( - "/dummy/0.2.0/dummy?search=will-be-deleted", + &redirect, "/dummy/0.2.0/dummy/?search=will-be-deleted", &web, - ) - .unwrap(); + )?; Ok(()) }) @@ -615,6 +675,7 @@ mod test { .name("dummy") .version("0.1.0") .add_platform("x86_64-pc-windows-msvc") + .rustdoc_file("dummy/struct.Blah.html", b"lah") .create() .unwrap(); db.fake_release() @@ -640,9 +701,22 @@ mod test { "/dummy/0.2.0/x86_64-pc-windows-msvc/dummy/index.html" ); + // With deleted file platform specific redirect also handles search + let redirect = latest_version_redirect( + "/dummy/0.1.0/x86_64-pc-windows-msvc/dummy/struct.Blah.html", + web, + )?; + assert_eq!(redirect, "/dummy/0.2.0/x86_64-pc-windows-msvc?search=Blah"); + assert_redirect( + &redirect, + "/dummy/0.2.0/x86_64-pc-windows-msvc/dummy/?search=Blah", + web, + )?; + Ok(()) }) } + #[test] fn redirect_latest_goes_to_crate_if_build_failed() { wrapper(|env| { @@ -814,4 +888,296 @@ mod test { Ok(()) }) } + + #[test] + fn no_target_target_redirect_404s() { + wrapper(|env| { + assert_eq!( + env.frontend() + .get("/crate/dummy/0.1.0/target-redirect") + .send()? + .status(), + StatusCode::NOT_FOUND + ); + + assert_eq!( + env.frontend() + .get("/crate/dummy/0.1.0/target-redirect/") + .send()? + .status(), + StatusCode::NOT_FOUND + ); + + Ok(()) + }) + } + + #[test] + fn platform_links_go_to_current_path() { + fn get_platform_links( + path: &str, + web: &TestFrontend, + ) -> Result, failure::Error> { + use html5ever::tendril::TendrilSink; + assert_success(path, web)?; + let data = web.get(path).send()?.text()?; + let dom = kuchiki::parse_html().one(data); + Ok(dom + .select(r#"a[aria-label="Platform"] + ul li a"#) + .expect("invalid selector") + .map(|el| { + let url = el + .attributes + .borrow() + .get("href") + .expect("href") + .to_string(); + let name = el.text_contents(); + (name, url) + }) + .collect()) + } + + fn assert_platform_links( + web: &TestFrontend, + path: &str, + links: &[(&str, &str)], + ) -> Result<(), failure::Error> { + let mut links = BTreeMap::from_iter(links.iter().copied()); + + for (platform, link) in get_platform_links(path, web)? { + assert_redirect(&link, links.remove(platform.as_str()).unwrap(), web)?; + } + + assert!(links.is_empty()); + + Ok(()) + } + + wrapper(|env| { + let (db, web) = (env.db(), env.frontend()); + + // no explicit default-target + db.fake_release() + .name("dummy") + .version("0.1.0") + .rustdoc_file("dummy/index.html", b"some content") + .rustdoc_file("dummy/struct.Dummy.html", b"some other content") + .add_target("x86_64-unknown-linux-gnu") + .create()?; + + assert_platform_links( + web, + "/dummy/0.1.0/dummy/", + &[("x86_64-unknown-linux-gnu", "/dummy/0.1.0/dummy/index.html")], + )?; + + assert_platform_links( + web, + "/dummy/0.1.0/dummy/index.html", + &[("x86_64-unknown-linux-gnu", "/dummy/0.1.0/dummy/index.html")], + )?; + + assert_platform_links( + web, + "/dummy/0.1.0/dummy/struct.Dummy.html", + &[( + "x86_64-unknown-linux-gnu", + "/dummy/0.1.0/dummy/struct.Dummy.html", + )], + )?; + + // set an explicit target that requires cross-compile + db.fake_release() + .name("dummy") + .version("0.2.0") + .rustdoc_file("dummy/index.html", b"some content") + .rustdoc_file("dummy/struct.Dummy.html", b"some other content") + .default_target("x86_64-pc-windows-msvc") + .create()?; + + assert_platform_links( + web, + "/dummy/0.2.0/dummy/", + &[("x86_64-pc-windows-msvc", "/dummy/0.2.0/dummy/index.html")], + )?; + + assert_platform_links( + web, + "/dummy/0.2.0/dummy/index.html", + &[("x86_64-pc-windows-msvc", "/dummy/0.2.0/dummy/index.html")], + )?; + + assert_platform_links( + web, + "/dummy/0.2.0/dummy/struct.Dummy.html", + &[( + "x86_64-pc-windows-msvc", + "/dummy/0.2.0/dummy/struct.Dummy.html", + )], + )?; + + // set an explicit target without cross-compile + db.fake_release() + .name("dummy") + .version("0.3.0") + .rustdoc_file("dummy/index.html", b"some content") + .rustdoc_file("dummy/struct.Dummy.html", b"some other content") + .default_target("x86_64-unknown-linux-gnu") + .create()?; + + assert_platform_links( + web, + "/dummy/0.3.0/dummy/", + &[("x86_64-unknown-linux-gnu", "/dummy/0.3.0/dummy/index.html")], + )?; + + assert_platform_links( + web, + "/dummy/0.3.0/dummy/index.html", + &[("x86_64-unknown-linux-gnu", "/dummy/0.3.0/dummy/index.html")], + )?; + + assert_platform_links( + web, + "/dummy/0.3.0/dummy/struct.Dummy.html", + &[( + "x86_64-unknown-linux-gnu", + "/dummy/0.3.0/dummy/struct.Dummy.html", + )], + )?; + + // multiple targets + db.fake_release() + .name("dummy") + .version("0.4.0") + .rustdoc_file("settings.html", b"top-level items") + .rustdoc_file("dummy/index.html", b"some content") + .rustdoc_file("dummy/struct.Dummy.html", b"some other content") + .rustdoc_file("dummy/struct.DefaultOnly.html", b"some otter content") + .rustdoc_file("x86_64-pc-windows-msvc/settings.html", b"top-level items") + .rustdoc_file("x86_64-pc-windows-msvc/dummy/index.html", b"some content") + .rustdoc_file( + "x86_64-pc-windows-msvc/dummy/struct.Dummy.html", + b"some other content", + ) + .rustdoc_file( + "x86_64-pc-windows-msvc/dummy/struct.WindowsOnly.html", + b"some otter content", + ) + .default_target("x86_64-unknown-linux-gnu") + .add_target("x86_64-pc-windows-msvc") + .create()?; + + assert_platform_links( + web, + "/dummy/0.4.0/settings.html", + &[ + ( + "x86_64-pc-windows-msvc", + "/dummy/0.4.0/x86_64-pc-windows-msvc/settings.html", + ), + ("x86_64-unknown-linux-gnu", "/dummy/0.4.0/settings.html"), + ], + )?; + + assert_platform_links( + web, + "/dummy/0.4.0/dummy/", + &[ + ( + "x86_64-pc-windows-msvc", + "/dummy/0.4.0/x86_64-pc-windows-msvc/dummy/index.html", + ), + ("x86_64-unknown-linux-gnu", "/dummy/0.4.0/dummy/index.html"), + ], + )?; + + assert_platform_links( + web, + "/dummy/0.4.0/x86_64-pc-windows-msvc/dummy/index.html", + &[ + ( + "x86_64-pc-windows-msvc", + "/dummy/0.4.0/x86_64-pc-windows-msvc/dummy/index.html", + ), + ("x86_64-unknown-linux-gnu", "/dummy/0.4.0/dummy/index.html"), + ], + )?; + + assert_platform_links( + web, + "/dummy/0.4.0/dummy/index.html", + &[ + ( + "x86_64-pc-windows-msvc", + "/dummy/0.4.0/x86_64-pc-windows-msvc/dummy/index.html", + ), + ("x86_64-unknown-linux-gnu", "/dummy/0.4.0/dummy/index.html"), + ], + )?; + + assert_platform_links( + web, + "/dummy/0.4.0/dummy/struct.DefaultOnly.html", + &[ + ( + "x86_64-pc-windows-msvc", + "/dummy/0.4.0/x86_64-pc-windows-msvc/dummy/?search=DefaultOnly", + ), + ( + "x86_64-unknown-linux-gnu", + "/dummy/0.4.0/dummy/struct.DefaultOnly.html", + ), + ], + )?; + + assert_platform_links( + web, + "/dummy/0.4.0/dummy/struct.Dummy.html", + &[ + ( + "x86_64-pc-windows-msvc", + "/dummy/0.4.0/x86_64-pc-windows-msvc/dummy/struct.Dummy.html", + ), + ( + "x86_64-unknown-linux-gnu", + "/dummy/0.4.0/dummy/struct.Dummy.html", + ), + ], + )?; + + assert_platform_links( + web, + "/dummy/0.4.0/x86_64-pc-windows-msvc/dummy/struct.Dummy.html", + &[ + ( + "x86_64-pc-windows-msvc", + "/dummy/0.4.0/x86_64-pc-windows-msvc/dummy/struct.Dummy.html", + ), + ( + "x86_64-unknown-linux-gnu", + "/dummy/0.4.0/dummy/struct.Dummy.html", + ), + ], + )?; + + assert_platform_links( + web, + "/dummy/0.4.0/x86_64-pc-windows-msvc/dummy/struct.WindowsOnly.html", + &[ + ( + "x86_64-pc-windows-msvc", + "/dummy/0.4.0/x86_64-pc-windows-msvc/dummy/struct.WindowsOnly.html", + ), + ( + "x86_64-unknown-linux-gnu", + "/dummy/0.4.0/dummy/?search=WindowsOnly", + ), + ], + )?; + + Ok(()) + }); + } } diff --git a/templates/navigation_rustdoc.hbs b/templates/navigation_rustdoc.hbs index 63053d261..5f6f45f40 100644 --- a/templates/navigation_rustdoc.hbs +++ b/templates/navigation_rustdoc.hbs @@ -105,7 +105,7 @@ Platform