diff --git a/Cargo.lock b/Cargo.lock index 7488ee738..2b327c59c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,7 +384,7 @@ dependencies = [ "notify 4.0.15 (registry+https://github.com/rust-lang/crates.io-index)", "once_cell 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "params 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", - "path-slash 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "path-slash 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", "postgres 0.15.2 (registry+https://github.com/rust-lang/crates.io-index)", "procfs 0.7.9 (registry+https://github.com/rust-lang/crates.io-index)", "prometheus 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1938,7 +1938,7 @@ dependencies = [ [[package]] name = "path-slash" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] @@ -4255,7 +4255,7 @@ dependencies = [ "checksum parking_lot_core 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b876b1b9e7ac6e1a74a6da34d25c42e17e8862aa409cbbbdcfc8d86c6f3bc62b" "checksum parking_lot_core 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d58c7c768d4ba344e3e8d72518ac13e259d7c7ade24167003b8488e10b6740a3" "checksum parse-zoneinfo 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "feece9d0113b400182a7d00adcff81ccf29158c49c5abd11e2eed8589bf6ff07" -"checksum path-slash 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "a0858af4d9136275541f4eac7be1af70add84cf356d901799b065ac1b8ff6e2f" +"checksum path-slash 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ff65715a17cba8979903db6294baef56c5d39e05c8b054cffa31e69e61f24c68" "checksum percent-encoding 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "31010dd2e1ac33d5b46a5b413495239882813e0369f8ed8a5e266f173602f831" "checksum percent-encoding 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" "checksum persistent 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8e8fa0009c4f3d350281309909c618abddf10bb7e3145f28410782f6a5ec74c5" diff --git a/Cargo.toml b/Cargo.toml index d838e602f..ee265de4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,8 @@ mime_guess = "2" dotenv = "0.15" zstd = "0.5" git2 = { version = "0.13.6", default-features = false } -once_cell = "1.2.0" +once_cell = "1.4.0" +path-slash = "0.1.3" # Data serialization and deserialization serde = { version = "1.0", features = ["derive"] } @@ -77,9 +78,6 @@ features = ["with-chrono", "with-serde_json"] # Process information procfs = "0.7" -[target.'cfg(windows)'.dependencies] -path-slash = "0.1.1" - [dev-dependencies] criterion = "0.3" rand = "0.7.3" diff --git a/src/storage/mod.rs b/src/storage/mod.rs index cb42a0450..ebc4a9fb3 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -5,13 +5,15 @@ pub(crate) use self::database::DatabaseBackend; pub(crate) use self::s3::S3Backend; use chrono::{DateTime, Utc}; use failure::{err_msg, Error}; +use path_slash::PathExt; use postgres::{transaction::Transaction, Connection}; -use std::collections::{HashMap, HashSet}; -use std::ffi::OsStr; -use std::fmt; -use std::fs; -use std::io::Read; -use std::path::{Path, PathBuf}; +use std::{ + collections::{HashMap, HashSet}, + ffi::OsStr, + fmt, fs, + io::Read, + path::{Path, PathBuf}, +}; const MAX_CONCURRENT_UPLOADS: usize = 1000; const DEFAULT_COMPRESSION: CompressionAlgorithm = CompressionAlgorithm::Zstd; @@ -171,12 +173,7 @@ impl<'a> Storage<'a> { .map(|(file_path, file)| -> Result<_, Error> { let alg = DEFAULT_COMPRESSION; let content = compress(file, alg)?; - let bucket_path = Path::new(prefix).join(&file_path); - - #[cfg(windows)] // On windows, we need to normalize \\ to / so the route logic works - let bucket_path = path_slash::PathBufExt::to_slash(&bucket_path).unwrap(); - #[cfg(not(windows))] - let bucket_path = bucket_path.into_os_string().into_string().unwrap(); + let bucket_path = Path::new(prefix).join(&file_path).to_slash().unwrap(); let mime = detect_mime(&file_path)?; file_paths_and_mimes.insert(file_path, mime.to_string()); diff --git a/src/test/fakes.rs b/src/test/fakes.rs index 5f1a5f1a9..fa899d75e 100644 --- a/src/test/fakes.rs +++ b/src/test/fakes.rs @@ -92,6 +92,11 @@ impl<'a> FakeRelease<'a> { self } + pub fn author(mut self, author: &str) -> Self { + self.package.authors = vec![author.into()]; + self + } + pub(crate) fn repo(mut self, repo: impl Into) -> Self { self.package.repository = Some(repo.into()); self diff --git a/src/web/crate_details.rs b/src/web/crate_details.rs index c2baa84bf..4e5ff15a9 100644 --- a/src/web/crate_details.rs +++ b/src/web/crate_details.rs @@ -17,7 +17,7 @@ use serde_json::Value; // TODO: Add target name and versions -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] pub struct CrateDetails { name: String, version: String, @@ -106,7 +106,7 @@ impl Serialize for CrateDetails { } } -#[derive(Debug, Eq, PartialEq, Serialize)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] pub struct Release { pub version: String, pub build_status: bool, diff --git a/src/web/error.rs b/src/web/error.rs index d49054ac3..2c9060c36 100644 --- a/src/web/error.rs +++ b/src/web/error.rs @@ -1,11 +1,14 @@ -use crate::db::PoolError; -use crate::web::page::Page; +use crate::{ + db::PoolError, + web::{ + page::{Page, WebPage}, + releases::Search, + }, +}; use failure::Fail; -use iron::prelude::*; -use iron::status; -use iron::Handler; -use std::error::Error; -use std::fmt; +use iron::{status, Handler, IronError, IronResult, Plugin, Request, Response}; +use params::{Params, Value}; +use std::{error::Error, fmt}; #[derive(Debug, Copy, Clone)] pub enum Nope { @@ -38,6 +41,7 @@ impl Handler for Nope { .title("The requested resource does not exist") .to_resp("error") } + Nope::CrateNotFound => { // user tried to navigate to a crate that doesn't exist Page::new("no such crate".to_owned()) @@ -45,24 +49,30 @@ impl Handler for Nope { .title("The requested crate does not exist") .to_resp("error") } + Nope::NoResults => { - use params::{Params, Value}; let params = req.get::().unwrap(); - if let Some(&Value::String(ref query)) = params.find(&["query"]) { + + if let Some(Value::String(ref query)) = params.find(&["query"]) { // this used to be a search - Page::new(Vec::::new()) - .set_status(status::NotFound) - .set("search_query", &query) - .title(&format!("No crates found matching '{}'", query)) - .to_resp("releases") + Search { + title: format!("No crates found matching '{}'", query), + search_query: Some(query.to_owned()), + status: status::NotFound, + ..Default::default() + } + .into_response(req) } else { // user did a search with no search terms - Page::new(Vec::::new()) - .set_status(status::NotFound) - .title("No results given for empty search query") - .to_resp("releases") + Search { + title: "No results given for empty search query".to_owned(), + status: status::NotFound, + ..Default::default() + } + .into_response(req) } } + Nope::InternalServerError => { // something went wrong, details should have been logged Page::new("internal server error".to_owned()) diff --git a/src/web/mod.rs b/src/web/mod.rs index d820a05d5..cc12c5365 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -76,9 +76,7 @@ use postgres::Connection; use router::NoRoute; use semver::{Version, VersionReq}; use staticfile::Static; -use std::net::SocketAddr; -use std::sync::Arc; -use std::{env, fmt, path::PathBuf, time::Duration}; +use std::{env, fmt, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; /// Duration of static files for staticfile and DatabaseFileHandler (in seconds) const STATIC_FILE_CACHE_DURATION: u64 = 60 * 60 * 24 * 30 * 12; // 12 months diff --git a/src/web/page/handlebars.rs b/src/web/page/handlebars.rs index 8755fd089..988f29560 100644 --- a/src/web/page/handlebars.rs +++ b/src/web/page/handlebars.rs @@ -84,12 +84,6 @@ impl Page { self } - /// Sets an integer variable - pub fn set_int(mut self, var: &str, val: i64) -> Page { - self.varsi.insert(var.to_owned(), val); - self - } - /// Sets title of page pub fn title(mut self, title: &str) -> Page { self.title = Some(title.to_owned()); @@ -158,59 +152,8 @@ fn build_version_safe(version: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::web::releases::{self, Release}; - use chrono::Utc; + use crate::web::releases; use iron::Url; - use serde_json::json; - - #[test] - fn serialize_page() { - let time = Utc::now(); - - let mut release = Release::default(); - release.name = "lasso".into(); - release.version = "0.1.0".into(); - release.release_time = time.clone(); - - let mut varss = BTreeMap::new(); - varss.insert("test".into(), "works".into()); - let mut varsb = BTreeMap::new(); - varsb.insert("test2".into(), true); - let mut varsi = BTreeMap::new(); - varsi.insert("test3".into(), 1337); - - let page = Page { - title: None, - content: vec![release.clone()], - status: status::Status::Ok, - varss, - varsb, - varsi, - rustc_resource_suffix: &*RUSTC_RESOURCE_SUFFIX, - }; - - let correct_json = json!({ - "content": [{ - "name": "lasso", - "version": "0.1.0", - "description": null, - "target_name": null, - "rustdoc_status": false, - "release_time": super::super::super::duration_to_str(time), - "release_time_rfc3339": time.format("%+").to_string(), - "stars": 0 - }], - "varss": { "test": "works" }, - "varsb": { "test2": true }, - "varsi": { "test3": 1337 }, - "rustc_resource_suffix": &*RUSTC_RESOURCE_SUFFIX, - "cratesfyi_version": crate::BUILD_VERSION, - "cratesfyi_version_safe": build_version_safe(crate::BUILD_VERSION), - "has_global_alert": crate::GLOBAL_ALERT.is_some() - }); - - assert_eq!(correct_json, serde_json::to_value(&page).unwrap()); - } #[test] fn load_page_from_releases() { diff --git a/src/web/page/templates.rs b/src/web/page/templates.rs index e7744e1a9..f93792144 100644 --- a/src/web/page/templates.rs +++ b/src/web/page/templates.rs @@ -1,16 +1,18 @@ -use crate::db::Pool; -use crate::error::Result; +use crate::{db::Pool, error::Result}; use arc_swap::ArcSwap; use chrono::{DateTime, Utc}; use failure::ResultExt; use notify::{watcher, RecursiveMode, Watcher}; +use path_slash::PathExt; use postgres::Connection; use serde_json::Value; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::{mpsc::channel, Arc}; -use std::thread; -use std::time::Duration; +use std::{ + collections::HashMap, + path::PathBuf, + sync::{mpsc::channel, Arc}, + thread, + time::Duration, +}; use tera::{Result as TeraResult, Tera}; use walkdir::WalkDir; @@ -152,10 +154,8 @@ fn find_templates_in_filesystem(base: &str) -> Result) -> TeraResult { let fmt = if let Some(Value::Bool(true)) = args.get("relative") { - let value = DateTime::parse_from_str(value.as_str().unwrap(), "%Y-%m-%dT%H:%M:%S%z") + let value = DateTime::parse_from_rfc3339(value.as_str().unwrap()) .unwrap() .with_timezone(&Utc); diff --git a/src/web/page/web_page.rs b/src/web/page/web_page.rs index 07656b2d9..5c38b76e5 100644 --- a/src/web/page/web_page.rs +++ b/src/web/page/web_page.rs @@ -3,6 +3,7 @@ use iron::{headers::ContentType, response::Response, status::Status, IronResult, use serde::Serialize; use tera::Context; +/// When making using a custom status, use a closure that coerces to a `fn(&Self) -> Status` #[macro_export] macro_rules! impl_webpage { ($page:ty = $template:expr $(, status = $status:expr)? $(, content_type = $content_type:expr)? $(,)?) => { @@ -11,7 +12,8 @@ macro_rules! impl_webpage { $( fn get_status(&self) -> ::iron::status::Status { - $status + let status: fn(&Self) -> ::iron::status::Status = $status; + (status)(self) } )? diff --git a/src/web/releases.rs b/src/web/releases.rs index e25099f09..c083a1763 100644 --- a/src/web/releases.rs +++ b/src/web/releases.rs @@ -1,16 +1,26 @@ //! Releases web handlers -use super::error::Nope; -use super::page::Page; -use super::{duration_to_str, match_version, redirect_base}; -use crate::db::Pool; -use crate::BuildQueue; +use crate::{ + db::Pool, + impl_webpage, + web::{ + error::Nope, + match_version, + page::{Page, WebPage}, + redirect_base, + }, + BuildQueue, +}; use chrono::{DateTime, NaiveDateTime, Utc}; -use iron::prelude::*; -use iron::status; +use iron::{ + headers::{ContentType, Expires, HttpDate}, + mime::{Mime, SubLevel, TopLevel}, + modifiers::Redirect, + status, IronError, IronResult, Plugin, Request, Response, Url, +}; use postgres::Connection; use router::Router; -use serde::ser::{Serialize, SerializeStruct, Serializer}; +use serde::Serialize; use serde_json::Value; /// Number of release in home page @@ -20,7 +30,7 @@ const RELEASES_IN_RELEASES: i64 = 30; /// Releases in recent releases feed const RELEASES_IN_FEED: i64 = 150; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Release { pub(crate) name: String, pub(crate) version: String, @@ -45,28 +55,6 @@ impl Default for Release { } } -impl Serialize for Release { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let mut state = serializer.serialize_struct("Release", 8)?; - state.serialize_field("name", &self.name)?; - state.serialize_field("version", &self.version)?; - state.serialize_field("description", &self.description)?; - state.serialize_field("target_name", &self.target_name)?; - state.serialize_field("rustdoc_status", &self.rustdoc_status)?; - state.serialize_field("release_time", &duration_to_str(self.release_time))?; - state.serialize_field( - "release_time_rfc3339", - &self.release_time.format("%+").to_string(), - )?; - state.serialize_field("stars", &self.stars)?; - - state.end() - } -} - #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(crate) enum Order { ReleaseTime, // this is default order @@ -343,182 +331,210 @@ fn get_search_results( (total_results, packages) } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct HomePage { + recent_releases: Vec, +} + +impl_webpage! { + HomePage = "core/home.html", +} + pub fn home_page(req: &mut Request) -> IronResult { let conn = extension!(req, Pool).get()?; - let packages = get_releases(&conn, 1, RELEASES_IN_HOME, Order::ReleaseTime); - Page::new(packages) - .set_true("show_search_form") - .set_true("hide_package_navigation") - .to_resp("releases") + let recent_releases = get_releases(&conn, 1, RELEASES_IN_HOME, Order::ReleaseTime); + + HomePage { recent_releases }.into_response(req) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ReleaseFeed { + recent_releases: Vec, +} + +impl_webpage! { + ReleaseFeed = "releases/feed.xml", + content_type = ContentType(Mime(TopLevel::Application, SubLevel::Xml, vec![])), } pub fn releases_feed_handler(req: &mut Request) -> IronResult { let conn = extension!(req, Pool).get()?; - let packages = get_releases(&conn, 1, RELEASES_IN_FEED, Order::ReleaseTime); - let mut resp = ctry!(Page::new(packages).to_resp("releases_feed")); - resp.headers.set(::iron::headers::ContentType( - "application/atom+xml".parse().unwrap(), - )); - Ok(resp) + let recent_releases = get_releases(&conn, 1, RELEASES_IN_FEED, Order::ReleaseTime); + + ReleaseFeed { recent_releases }.into_response(req) } -fn releases_handler( - packages: Vec, +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ViewReleases { + releases: Vec, + description: String, + release_type: ReleaseType, + show_next_page: bool, + show_previous_page: bool, page_number: i64, - release_type: &str, - tab: &str, - title: &str, -) -> IronResult { - if packages.is_empty() { - return Err(IronError::new(Nope::CrateNotFound, status::NotFound)); - } + author: Option, +} + +impl_webpage! { + ViewReleases = "releases/releases.html", +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum ReleaseType { + Recent, + Stars, + RecentFailures, + Failures, + Author, + Search, +} + +fn releases_handler(req: &mut Request, release_type: ReleaseType) -> IronResult { + let page_number: i64 = extension!(req, Router) + .find("page") + .and_then(|page_num| page_num.parse().ok()) + .unwrap_or(1); + + let (description, release_order) = match release_type { + ReleaseType::Recent => ("Recently uploaded crates", Order::ReleaseTime), + ReleaseType::Stars => ("Crates with most stars", Order::GithubStars), + ReleaseType::RecentFailures => ("Recent crates failed to build", Order::RecentFailures), + ReleaseType::Failures => ( + "Crates with most stars failed to build", + Order::FailuresByGithubStars, + ), + + ReleaseType::Author | ReleaseType::Search => panic!( + "The authors and search page have special requirements and cannot use this handler", + ), + }; + + let releases = { + let conn = extension!(req, Pool).get()?; + get_releases(&conn, page_number, RELEASES_IN_RELEASES, release_order) + }; // Show next and previous page buttons - // This is a temporary solution to avoid expensive COUNT(*) let (show_next_page, show_previous_page) = ( - packages.len() == RELEASES_IN_RELEASES as usize, + releases.len() == RELEASES_IN_RELEASES as usize, page_number != 1, ); - Page::new(packages) - .title("Releases") - .set("description", title) - .set("release_type", release_type) - .set_true("show_releases_navigation") - .set_true(tab) - .set_bool("show_next_page_button", show_next_page) - .set_int("next_page", page_number + 1) - .set_bool("show_previous_page_button", show_previous_page) - .set_int("previous_page", page_number - 1) - .to_resp("releases") + ViewReleases { + releases, + description: description.into(), + release_type, + show_next_page, + show_previous_page, + page_number, + author: None, + } + .into_response(req) } -// Following functions caused a code repeat due to design of our /releases/ URL routes pub fn recent_releases_handler(req: &mut Request) -> IronResult { - let page_number: i64 = extension!(req, Router) - .find("page") - .unwrap_or("1") - .parse() - .unwrap_or(1); - let conn = extension!(req, Pool).get()?; - let packages = get_releases(&conn, page_number, RELEASES_IN_RELEASES, Order::ReleaseTime); - releases_handler( - packages, - page_number, - "recent", - "releases_navigation_recent_tab", - "Recently uploaded crates", - ) + releases_handler(req, ReleaseType::Recent) } pub fn releases_by_stars_handler(req: &mut Request) -> IronResult { - let page_number: i64 = extension!(req, Router) - .find("page") - .unwrap_or("1") - .parse() - .unwrap_or(1); - let conn = extension!(req, Pool).get()?; - let packages = get_releases(&conn, page_number, RELEASES_IN_RELEASES, Order::GithubStars); - releases_handler( - packages, - page_number, - "stars", - "releases_navigation_stars_tab", - "Crates with most stars", - ) + releases_handler(req, ReleaseType::Stars) } pub fn releases_recent_failures_handler(req: &mut Request) -> IronResult { - let page_number: i64 = extension!(req, Router) - .find("page") - .unwrap_or("1") - .parse() - .unwrap_or(1); - let conn = extension!(req, Pool).get()?; - let packages = get_releases( - &conn, - page_number, - RELEASES_IN_RELEASES, - Order::RecentFailures, - ); - releases_handler( - packages, - page_number, - "recent-failures", - "releases_navigation_recent_failures_tab", - "Recent crates failed to build", - ) + releases_handler(req, ReleaseType::RecentFailures) } pub fn releases_failures_by_stars_handler(req: &mut Request) -> IronResult { - let page_number: i64 = extension!(req, Router) - .find("page") - .unwrap_or("1") - .parse() - .unwrap_or(1); - let conn = extension!(req, Pool).get()?; - let packages = get_releases( - &conn, - page_number, - RELEASES_IN_RELEASES, - Order::FailuresByGithubStars, - ); - releases_handler( - packages, - page_number, - "failures", - "releases_navigation_failures_by_stars_tab", - "Crates with most stars failed to build", - ) + releases_handler(req, ReleaseType::Failures) } pub fn author_handler(req: &mut Request) -> IronResult { let router = extension!(req, Router); // page number of releases - let page_number: i64 = router.find("page").unwrap_or("1").parse().unwrap_or(1); - - let conn = extension!(req, Pool).get()?; - - #[allow(clippy::or_fun_call)] + let page_number: i64 = router + .find("page") + .and_then(|page_num| page_num.parse().ok()) + .unwrap_or(1); let author = ctry!(router .find("author") - .ok_or(IronError::new(Nope::CrateNotFound, status::NotFound))); - - let (author_name, packages) = if author.starts_with('@') { - let mut author = author.split('@'); - - get_releases_by_owner( - &conn, - page_number, - RELEASES_IN_RELEASES, - cexpect!(author.nth(1)), - ) - } else { - get_releases_by_author(&conn, page_number, RELEASES_IN_RELEASES, author) + // TODO: Accurate error here, the author wasn't provided + .ok_or_else(|| IronError::new(Nope::CrateNotFound, status::NotFound))); + + let (author_name, releases) = { + let conn = extension!(req, Pool).get()?; + + if author.starts_with('@') { + let mut author = author.split('@'); + + get_releases_by_owner( + &conn, + page_number, + RELEASES_IN_RELEASES, + // TODO: Is this fallible? + cexpect!(author.nth(1)), + ) + } else { + get_releases_by_author(&conn, page_number, RELEASES_IN_RELEASES, author) + } }; - if packages.is_empty() { + if releases.is_empty() { + // TODO: Accurate error here, the author wasn't found return Err(IronError::new(Nope::CrateNotFound, status::NotFound)); } // Show next and previous page buttons - // This is a temporary solution to avoid expensive COUNT(*) let (show_next_page, show_previous_page) = ( - packages.len() == RELEASES_IN_RELEASES as usize, + releases.len() == RELEASES_IN_RELEASES as usize, page_number != 1, ); - Page::new(packages) - .title("Releases") - .set("description", &format!("Crates from {}", author_name)) - .set("author", &author_name) - .set("release_type", author) - .set_true("show_releases_navigation") - .set_true("show_stars") - .set_bool("show_next_page_button", show_next_page) - .set_int("next_page", page_number + 1) - .set_bool("show_previous_page_button", show_previous_page) - .set_int("previous_page", page_number - 1) - .to_resp("releases") + + ViewReleases { + releases, + description: format!("Crates from {}", author_name), + release_type: ReleaseType::Author, + show_next_page, + show_previous_page, + page_number, + author: Some(author_name), + } + .into_response(req) +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(super) struct Search { + pub(super) title: String, + #[serde(rename = "releases")] + pub(super) results: Vec, + pub(super) search_query: Option, + pub(super) previous_page_button: bool, + pub(super) next_page_button: bool, + pub(super) current_page: i64, + /// This should always be `ReleaseType::Search` + pub(super) release_type: ReleaseType, + #[serde(skip)] + pub(super) status: iron::status::Status, +} + +impl Default for Search { + fn default() -> Self { + Self { + title: String::default(), + results: Vec::default(), + search_query: None, + previous_page_button: false, + next_page_button: false, + current_page: 0, + release_type: ReleaseType::Search, + status: iron::status::Ok, + } + } +} + +impl_webpage! { + Search = "releases/releases.html", + status = |search| search.status, } pub fn search_handler(req: &mut Request) -> IronResult { @@ -526,18 +542,18 @@ pub fn search_handler(req: &mut Request) -> IronResult { let params = ctry!(req.get::()); let query = params.find(&["query"]); - let conn = extension!(req, Pool).get()?; - if let Some(&Value::String(ref query)) = query { + + if let Some(Value::String(query)) = query { // check if I am feeling lucky button pressed and redirect user to crate page // if there is a match // TODO: Redirecting to latest doc might be more useful if params.find(&["i-am-feeling-lucky"]).is_some() { - use iron::modifiers::Redirect; - use iron::Url; - // redirect to a random crate if query is empty if query.is_empty() { + // FIXME: This is a fast query but using a constant + // There are currently 280 crates with docs and 100+ + // starts. This should be fine for a while. let rows = ctry!(conn.query( "SELECT crates.name, releases.version, @@ -549,13 +565,11 @@ pub fn search_handler(req: &mut Request) -> IronResult { OFFSET FLOOR(RANDOM() * 280) LIMIT 1", &[] )); - // ~~~~~~^ - // FIXME: This is a fast query but using a constant - // There are currently 280 crates with docs and 100+ - // starts. This should be fine for a while. - let name: String = rows.get(0).get(0); - let version: String = rows.get(0).get(1); - let target_name: String = rows.get(0).get(2); + let row = rows.into_iter().next().unwrap(); + + let name: String = row.get("name"); + let version: String = row.get("version"); + let target_name: String = row.get("target_name"); let url = ctry!(Url::parse(&format!( "{}/{}/{}/{}", redirect_base(req), @@ -565,8 +579,8 @@ pub fn search_handler(req: &mut Request) -> IronResult { ))); let mut resp = Response::with((status::Found, Redirect(url))); - use iron::headers::{Expires, HttpDate}; resp.headers.set(Expires(HttpDate(time::now()))); + return Ok(resp); } @@ -576,35 +590,43 @@ pub fn search_handler(req: &mut Request) -> IronResult { if let Some(matchver) = match_version(&conn, &query, None) { let (version, id) = matchver.version.into_parts(); let query = matchver.corrected_name.unwrap_or_else(|| query.to_string()); + // FIXME: This is a super dirty way to check if crate have rustdocs generated. // match_version should handle this instead of this code block. // This block is introduced to fix #163 let rustdoc_status = { let rows = ctry!(conn.query( "SELECT rustdoc_status - FROM releases - WHERE releases.id = $1", + FROM releases + WHERE releases.id = $1", &[&id] )); - if rows.is_empty() { - false - } else { - rows.get(0).get(0) - } + + rows.into_iter() + .next() + .map(|r| r.get("rustdoc_status")) + .unwrap_or_default() }; + let url = if rustdoc_status { - ctry!(Url::parse( - &format!("{}/{}/{}", redirect_base(req), query, version)[..] - )) + ctry!(Url::parse(&format!( + "{}/{}/{}", + redirect_base(req), + query, + version, + ))) } else { - ctry!(Url::parse( - &format!("{}/crate/{}/{}", redirect_base(req), query, version)[..] - )) + ctry!(Url::parse(&format!( + "{}/crate/{}/{}", + redirect_base(req), + query, + version, + ))) }; - let mut resp = Response::with((status::Found, Redirect(url))); - use iron::headers::{Expires, HttpDate}; + let mut resp = Response::with((status::Found, Redirect(url))); resp.headers.set(Expires(HttpDate(time::now()))); + return Ok(resp); } } @@ -617,10 +639,13 @@ pub fn search_handler(req: &mut Request) -> IronResult { }; // FIXME: There is no pagination - Page::new(results) - .set("search_query", &query) - .title(&title) - .to_resp("releases") + Search { + title, + results, + search_query: Some(query.to_owned()), + ..Default::default() + } + .into_response(req) } else { Err(IronError::new(Nope::NoResults, status::NotFound)) } @@ -632,8 +657,10 @@ pub fn activity_handler(req: &mut Request) -> IronResult { "SELECT value FROM config WHERE name = 'release_activity'", &[] )) - .get(0) - .get(0); + .iter() + .next() + .map_or(Value::Null, |row| row.get("value")); + Page::new(release_activity_data) .title("Releases") .set("description", "Monthly release activity") @@ -670,7 +697,6 @@ mod tests { use crate::test::{assert_success, wrapper}; use chrono::TimeZone; use kuchiki::traits::TendrilSink; - use serde_json::json; #[test] fn database_search() { @@ -1004,65 +1030,122 @@ mod tests { } #[test] - fn serialize_releases() { - let now = Utc::now(); - - let mut release = Release { - name: "serde".to_string(), - version: "0.0.0".to_string(), - description: Some("serde makes things other things".to_string()), - target_name: Some("x86_64-pc-windows-msvc".to_string()), - rustdoc_status: true, - release_time: now, - stars: 100, - }; + fn home_page() { + wrapper(|env| { + let web = env.frontend(); + assert_success("/", web)?; - let correct_json = json!({ - "name": "serde", - "version": "0.0.0", - "description": "serde makes things other things", - "target_name": "x86_64-pc-windows-msvc", - "rustdoc_status": true, - "release_time": duration_to_str(now), - "release_time_rfc3339": now.format("%+").to_string(), - "stars": 100 - }); + env.db().fake_release().name("some_random_crate").create()?; + env.db() + .fake_release() + .name("some_random_crate_that_failed") + .build_result_successful(false) + .create()?; + assert_success("/", web) + }) + } - assert_eq!(correct_json, serde_json::to_value(&release).unwrap()); - - release.target_name = None; - let correct_json = json!({ - "name": "serde", - "version": "0.0.0", - "description": "serde makes things other things", - "target_name": null, - "rustdoc_status": true, - "release_time": duration_to_str(now), - "release_time_rfc3339": now.format("%+").to_string(), - "stars": 100 - }); + #[test] + fn search() { + wrapper(|env| { + let web = env.frontend(); + env.db().fake_release().name("some_random_crate").create()?; + assert_success("/releases/search?query=some_random_crate", web) + }) + } - assert_eq!(correct_json, serde_json::to_value(&release).unwrap()); - - release.description = None; - let correct_json = json!({ - "name": "serde", - "version": "0.0.0", - "description": null, - "target_name": null, - "rustdoc_status": true, - "release_time": duration_to_str(now), - "release_time_rfc3339": now.format("%+").to_string(), - "stars": 100 - }); + #[test] + fn recent_releases() { + wrapper(|env| { + let web = env.frontend(); + assert_success("/releases", web)?; - assert_eq!(correct_json, serde_json::to_value(&release).unwrap()); + env.db().fake_release().name("some_random_crate").create()?; + env.db() + .fake_release() + .name("some_random_crate_that_failed") + .build_result_successful(false) + .create()?; + assert_success("/releases", web) + }) + } + + #[test] + fn recent_releases_by_stars() { + wrapper(|env| { + let web = env.frontend(); + assert_success("/releases/stars", web)?; + + env.db().fake_release().name("some_random_crate").create()?; + env.db() + .fake_release() + .name("some_random_crate_that_failed") + .build_result_successful(false) + .create()?; + assert_success("/releases/stars", web) + }) + } + + #[test] + fn recent_failures() { + wrapper(|env| { + let web = env.frontend(); + assert_success("/releases/recent-failures", web)?; + + env.db().fake_release().name("some_random_crate").create()?; + env.db() + .fake_release() + .name("some_random_crate_that_failed") + .build_result_successful(false) + .create()?; + assert_success("/releases/recent-failures", web) + }) + } + + #[test] + fn failures_by_stars() { + wrapper(|env| { + let web = env.frontend(); + assert_success("/releases/failures", web)?; + + env.db().fake_release().name("some_random_crate").create()?; + env.db() + .fake_release() + .name("some_random_crate_that_failed") + .build_result_successful(false) + .create()?; + assert_success("/releases/failures", web) + }) + } + + #[test] + fn release_activity() { + wrapper(|env| { + let web = env.frontend(); + assert_success("/releases/activity", web)?; + + env.db().fake_release().name("some_random_crate").create()?; + env.db() + .fake_release() + .name("some_random_crate_that_failed") + .build_result_successful(false) + .create()?; + assert_success("/releases/activity", web) + }) } #[test] fn release_feed() { wrapper(|env| { let web = env.frontend(); + assert_success("/releases/feed", web)?; + + env.db().fake_release().name("some_random_crate").create()?; + env.db() + .fake_release() + .name("some_random_crate_that_failed") + .build_result_successful(false) + .create()?; assert_success("/releases/feed", web) }) } @@ -1110,4 +1193,17 @@ mod tests { Ok(()) }); } + + #[test] + fn authors_page() { + wrapper(|env| { + let web = env.frontend(); + env.db() + .fake_release() + .name("some_random_crate") + .author("frankenstein ") + .create()?; + assert_success("/releases/frankenstein", web) + }) + } } diff --git a/src/web/sitemap.rs b/src/web/sitemap.rs index 73068c318..ad0a6f1f7 100644 --- a/src/web/sitemap.rs +++ b/src/web/sitemap.rs @@ -1,19 +1,18 @@ -use crate::db::Pool; -use crate::{docbuilder::Limits, impl_webpage, web::page::WebPage}; +use crate::{db::Pool, docbuilder::Limits, impl_webpage, web::page::WebPage}; use chrono::{DateTime, NaiveDateTime, Utc}; use iron::{ headers::ContentType, mime::{Mime, SubLevel, TopLevel}, - IronResult, Request, Response, + status, IronResult, Request, Response, }; use serde::Serialize; use serde_json::Value; /// The sitemap #[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub(crate) struct SitemapXml { +struct SitemapXml { /// The release's names and RFC 3339 timestamp to be displayed on the sitemap - pub releases: Vec<(String, String)>, + releases: Vec<(String, String)>, } impl_webpage! { @@ -50,18 +49,18 @@ pub fn sitemap_handler(req: &mut Request) -> IronResult { } pub fn robots_txt_handler(_: &mut Request) -> IronResult { - let mut resp = Response::with("Sitemap: https://docs.rs/sitemap.xml"); + let mut resp = Response::with((status::Ok, "Sitemap: https://docs.rs/sitemap.xml")); resp.headers.set(ContentType::plaintext()); Ok(resp) } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub(crate) struct About { +struct About { /// The current version of rustc that docs.rs is using to build crates - pub rustc_version: Option, + rustc_version: Option, /// The default crate build limits - pub limits: Limits, + limits: Limits, } impl_webpage!(About = "core/about.html"); @@ -70,11 +69,13 @@ pub fn about_handler(req: &mut Request) -> IronResult { let conn = extension!(req, Pool).get()?; let res = ctry!(conn.query("SELECT value FROM config WHERE name = 'rustc_version'", &[])); - let mut rustc_version = None; - - if let Some(Ok(Value::String(version))) = res.iter().next().and_then(|row| row.get_opt(0)) { - rustc_version = Some(version); - } + let rustc_version = res.iter().next().and_then(|row| { + if let Some(Ok(Value::String(version))) = row.get_opt(0) { + Some(version) + } else { + None + } + }); About { rustc_version, @@ -82,3 +83,40 @@ pub fn about_handler(req: &mut Request) -> IronResult { } .into_response(req) } + +#[cfg(test)] +mod tests { + use crate::test::{assert_success, wrapper}; + + #[test] + fn sitemap() { + wrapper(|env| { + let web = env.frontend(); + assert_success("/sitemap.xml", web)?; + + env.db().fake_release().name("some_random_crate").create()?; + env.db() + .fake_release() + .name("some_random_crate_that_failed") + .build_result_successful(false) + .create()?; + assert_success("/sitemap.xml", web) + }) + } + + #[test] + fn about_page() { + wrapper(|env| { + let web = env.frontend(); + assert_success("/about", web) + }) + } + + #[test] + fn robots_txt() { + wrapper(|env| { + let web = env.frontend(); + assert_success("/robots.txt", web) + }) + } +} diff --git a/templates/releases.hbs b/templates/releases.hbs deleted file mode 100644 index 30bf47568..000000000 --- a/templates/releases.hbs +++ /dev/null @@ -1,144 +0,0 @@ -{{> header}} - - - -{{#if varsb.show_search_form}} -
-

Docs.rs

- -
-
-
- - -
-
- -
-{{/if}} - -
-
- {{#if varsb.show_search_form}} - - {{else}} - - {{/if}} - - - {{#unless varsb.show_search_form}} - - {{/unless}} -
-
- -{{> footer}} diff --git a/templates/releases_feed.hbs b/templates/releases_feed.hbs deleted file mode 100644 index e60c4e1c1..000000000 --- a/templates/releases_feed.hbs +++ /dev/null @@ -1,21 +0,0 @@ - - -Docs.rs -Recent Rust crates - - - - -urn:docs-rs:{{cratesfyi_version_safe}} -{{content.[0].release_time_rfc3339}} -{{#each content}} - -{{name}}-{{version}} - -urn:docs-rs:{{name}}:{{version}} -{{release_time_rfc3339}} -{{#if description}}{{description}}{{else}}-{{/if}} -docs.rs - -{{/each}} - diff --git a/tera-templates/core/Cargo.toml.example b/tera-templates/core/Cargo.toml.example index b55157f81..22bf5c74d 100644 --- a/tera-templates/core/Cargo.toml.example +++ b/tera-templates/core/Cargo.toml.example @@ -5,7 +5,7 @@ name = "test" [package.metadata.docs.rs] # Features to pass to Cargo (default: []) -features = [ "feature1", "feature2" ] +features = ["feature1", "feature2"] # Whether to pass `--all-features` to Cargo (default: false) all-features = true @@ -35,10 +35,10 @@ default-target = "x86_64-unknown-linux-gnu" # Otherwise, these `targets` are built in addition to the default target. # If both `default-target` and `targets` are unset, # all tier-one targets will be built and `x86_64-unknown-linux-gnu` will be used as the default target. -targets = [ "x86_64-apple-darwin", "x86_64-pc-windows-msvc" ] +targets = ["x86_64-apple-darwin", "x86_64-pc-windows-msvc"] # Additional `RUSTFLAGS` to set (default: []) -rustc-args = [ "--example-rustc-arg" ] +rustc-args = ["--example-rustc-arg"] # Additional `RUSTDOCFLAGS` to set (default: []) -rustdoc-args = [ "--example-rustdoc-arg" ] +rustdoc-args = ["--example-rustdoc-arg"] diff --git a/tera-templates/core/home.html b/tera-templates/core/home.html new file mode 100644 index 000000000..ab2e5dc57 --- /dev/null +++ b/tera-templates/core/home.html @@ -0,0 +1,109 @@ +{%- extends "base.html" -%} + +{%- block title -%}Docs.rs{%- endblock title -%} + +{%- block body -%} +
+

Docs.rs

+ +
+
+ +
+ +
+ + +
+
+
+ +
+
+ + + +
+
+{%- endblock body -%} + +{%- block javascript -%} + +{%- endblock javascript -%} diff --git a/tera-templates/releases/feed.xml b/tera-templates/releases/feed.xml new file mode 100644 index 000000000..88e0ed255 --- /dev/null +++ b/tera-templates/releases/feed.xml @@ -0,0 +1,39 @@ + + + Docs.rs + Recent Rust crates + + + + + + + urn:docs-rs:{{ docsrs_version() }} + {{ recent_releases[0].release_time | default(value=now()) | date(format="%+") }} + + {%- for release in recent_releases -%} + {%- set name = release.name | escape_xml -%} + {%- set version = release.version | escape_xml -%} + {%- if rustdoc_status -%} + {%- set link = "/" ~ release.name ~ "/" ~ release.version ~ "/" ~ release.target_name -%} + {%- else -%} + {%- set link = "/crate/" ~ release.name ~ "/" ~ version -%} + {%- endif %} + + + {{ name }}-{{ version }} + + + urn:docs-rs:{{ name }}:{{ version }} + {{ release.release_time | date(format="%+") }} + + + {{ release.description | default(value="-") | escape_xml }} + + + + docs.rs + + + {%- endfor %} + diff --git a/tera-templates/releases/header.html b/tera-templates/releases/header.html new file mode 100644 index 000000000..f13b86b33 --- /dev/null +++ b/tera-templates/releases/header.html @@ -0,0 +1,83 @@ +{# + Builds the header for the release dashboard + * `title` A string + * `description` A string + * `tab` A string with one of the following values + * `recent` + * `stars` + * `recent-failures` + * `failures` + * `activity` + * `queue` + * `author` + * `author` A string, used for the authors page +#} +{% macro header(title, description, tab, author=false) %} +
+
+

{{ title }}

+
{{ description | default(value="") }}
+ + {# This does double-duty as the search, so hide all tabs when we're searching something #} + {%- if tab != "search" -%} +
+ +
+ {%- endif -%} +
+
+{% endmacro header %} diff --git a/tera-templates/releases/releases.html b/tera-templates/releases/releases.html new file mode 100644 index 000000000..ed3b55ad9 --- /dev/null +++ b/tera-templates/releases/releases.html @@ -0,0 +1,116 @@ +{%- extends "base.html" -%} +{%- import "releases/header.html" as release_macros -%} + +{%- block title -%}Releases - Docs.rs{%- endblock title -%} + +{%- block header -%} + {# These all have defaults so searches work #} + {{ + release_macros::header( + title=title | default(value="Releases"), + description=description | default(value=""), + tab=release_type, + author=author | default(value=false) + ) + }} +{%- endblock header -%} + +{%- block body -%} +
+
+ + + +
+
+{%- endblock body -%} + +{%- block javascript -%} + +{%- endblock javascript -%}