diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dbe0e5b..de992bda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,10 @@ jobs: echo "VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" >> "$env:GITHUB_ENV" echo "$env:VCPKG_INSTALLATION_ROOT\installed\x64-windows\lib" >> "$env:GITHUB_PATH" + - name: Install NASM (Windows) + if: matrix.platform.name == 'Windows' + uses: ilammy/setup-nasm@v1 + - name: Install Native Dependencies (Linux) if: matrix.platform.name == 'Linux' run: | diff --git a/contrib/dyn_templates/README.md b/contrib/dyn_templates/README.md index 317ce0e6..29143724 100644 --- a/contrib/dyn_templates/README.md +++ b/contrib/dyn_templates/README.md @@ -9,11 +9,11 @@ This crate adds support for dynamic template rendering to Rocket. It automatically discovers templates, provides a `Responder` to render templates, -and automatically reloads templates when compiled in debug mode. At present, it -supports [Handlebars] and [Tera]. +and automatically reloads templates when compiled in debug mode. It supports [Handlebars], [Tera] and [MiniJinja]. [Tera]: https://docs.rs/crate/tera/1 [Handlebars]: https://docs.rs/crate/handlebars/5 +[MiniJinja]: https://docs.rs/crate/minijinja/2.0.1 # Usage @@ -23,7 +23,7 @@ supports [Handlebars] and [Tera]. ```toml [dependencies.rocket_dyn_templates] version = "0.1.0" - features = ["handlebars", "tera"] + features = ["handlebars", "tera", "minijinja"] ``` 1. Write your template files in Handlebars (`.hbs`) and/or Tera (`.tera`) in diff --git a/core/codegen/src/attribute/route/mod.rs b/core/codegen/src/attribute/route/mod.rs index de05fa0d..cb501e43 100644 --- a/core/codegen/src/attribute/route/mod.rs +++ b/core/codegen/src/attribute/route/mod.rs @@ -105,9 +105,13 @@ fn query_decls(route: &Route) -> Option { )* if !__e.is_empty() { - ::rocket::trace::span_info!("codegen", + ::rocket::trace::span_info!( + "codegen", "query string failed to match route declaration" => - { for _err in __e { ::rocket::trace::info!("{_err}"); } } + { for _err in __e { ::rocket::trace::info!( + target: concat!("rocket::codegen::route::", module_path!()), + "{_err}" + ); } } ); return #Outcome::Forward((#__data, #Status::UnprocessableEntity)); @@ -128,17 +132,27 @@ fn request_guard_decl(guard: &Guard) -> TokenStream { let #ident: #ty = match <#ty as #FromRequest>::from_request(#__req).await { #Outcome::Success(__v) => __v, #Outcome::Forward(__e) => { - ::rocket::trace::info!(name: "forward", parameter = stringify!(#ident), - type_name = stringify!(#ty), status = __e.code, - "request guard forwarding"); + ::rocket::trace::info!( + name: "forward", + target: concat!("rocket::codegen::route::", module_path!()), + parameter = stringify!(#ident), + type_name = stringify!(#ty), + status = __e.code, + "request guard forwarding" + ); return #Outcome::Forward((#__data, __e)); }, #[allow(unreachable_code)] #Outcome::Error((__c, __e)) => { - ::rocket::trace::info!(name: "failure", parameter = stringify!(#ident), - type_name = stringify!(#ty), reason = %#display_hack!(__e), - "request guard failed"); + ::rocket::trace::info!( + name: "failure", + target: concat!("rocket::codegen::route::", module_path!()), + parameter = stringify!(#ident), + type_name = stringify!(#ty), + reason = %#display_hack!(__e), + "request guard failed" + ); return #Outcome::Error(__c); } @@ -155,9 +169,14 @@ fn param_guard_decl(guard: &Guard) -> TokenStream { // Returned when a dynamic parameter fails to parse. let parse_error = quote!({ - ::rocket::trace::info!(name: "forward", parameter = #name, - type_name = stringify!(#ty), reason = %#display_hack!(__error), - "path guard forwarding"); + ::rocket::trace::info!( + name: "forward", + target: concat!("rocket::codegen::route::", module_path!()), + parameter = #name, + type_name = stringify!(#ty), + reason = %#display_hack!(__error), + "path guard forwarding" + ); #Outcome::Forward((#__data, #Status::UnprocessableEntity)) }); @@ -174,9 +193,12 @@ fn param_guard_decl(guard: &Guard) -> TokenStream { }, #_None => { ::rocket::trace::error!( + target: concat!("rocket::codegen::route::", module_path!()), "Internal invariant broken: dyn param {} not found.\n\ Please report this to the Rocket issue tracker.\n\ - https://github.com/rwf2/Rocket/issues", #i); + https://github.com/rwf2/Rocket/issues", + #i + ); return #Outcome::Forward((#__data, #Status::InternalServerError)); } @@ -203,17 +225,27 @@ fn data_guard_decl(guard: &Guard) -> TokenStream { let #ident: #ty = match <#ty as #FromData>::from_data(#__req, #__data).await { #Outcome::Success(__d) => __d, #Outcome::Forward((__d, __e)) => { - ::rocket::trace::info!(name: "forward", parameter = stringify!(#ident), - type_name = stringify!(#ty), status = __e.code, - "data guard forwarding"); + ::rocket::trace::info!( + name: "forward", + target: concat!("rocket::codegen::route::", module_path!()), + parameter = stringify!(#ident), + type_name = stringify!(#ty), + status = __e.code, + "data guard forwarding" + ); return #Outcome::Forward((__d, __e)); } #[allow(unreachable_code)] #Outcome::Error((__c, __e)) => { - ::rocket::trace::info!(name: "failure", parameter = stringify!(#ident), - type_name = stringify!(#ty), reason = %#display_hack!(__e), - "data guard failed"); + ::rocket::trace::info!( + name: "failure", + target: concat!("rocket::codegen::route::", module_path!()), + parameter = stringify!(#ident), + type_name = stringify!(#ty), + reason = %#display_hack!(__e), + "data guard failed" + ); return #Outcome::Error(__c); } diff --git a/core/http/src/uri/segments.rs b/core/http/src/uri/segments.rs index 6760c1dc..c6c6b52f 100644 --- a/core/http/src/uri/segments.rs +++ b/core/http/src/uri/segments.rs @@ -178,8 +178,9 @@ impl<'a> Segments<'a, Path> { } /// Creates a `PathBuf` from `self`. The returned `PathBuf` is - /// percent-decoded. If a segment is equal to `..`, the previous segment (if - /// any) is skipped. + /// percent-decoded and guaranteed to be relative. If a segment is equal to + /// `.`, it is skipped. If a segment is equal to `..`, the previous segment + /// (if any) is skipped. /// /// For security purposes, if a segment meets any of the following /// conditions, an `Err` is returned indicating the condition met: @@ -193,7 +194,7 @@ impl<'a> Segments<'a, Path> { /// Additionally, if `allow_dotfiles` is `false`, an `Err` is returned if /// the following condition is met: /// - /// * Decoded segment starts with any of: `.` (except `..`) + /// * Decoded segment starts with any of: `.` (except `..` and `.`) /// /// As a result of these conditions, a `PathBuf` derived via `FromSegments` /// is safe to interpolate within, or use as a suffix of, a path without @@ -216,7 +217,9 @@ impl<'a> Segments<'a, Path> { pub fn to_path_buf(&self, allow_dotfiles: bool) -> Result { let mut buf = PathBuf::new(); for segment in self.clone() { - if segment == ".." { + if segment == "." { + continue; + } else if segment == ".." { buf.pop(); } else if !allow_dotfiles && segment.starts_with('.') { return Err(PathError::BadStart('.')) diff --git a/core/lib/src/fs/mod.rs b/core/lib/src/fs/mod.rs index dd00f179..94a0145a 100644 --- a/core/lib/src/fs/mod.rs +++ b/core/lib/src/fs/mod.rs @@ -5,8 +5,59 @@ mod named_file; mod temp_file; mod file_name; +pub mod rewrite; + pub use server::*; pub use named_file::*; pub use temp_file::*; pub use file_name::*; -pub use server::relative; + +crate::export! { + /// Generates a crate-relative version of a path. + /// + /// This macro is primarily intended for use with [`FileServer`] to serve + /// files from a path relative to the crate root. + /// + /// The macro accepts one parameter, `$path`, an absolute or (preferably) + /// relative path. It returns a path as an `&'static str` prefixed with the + /// path to the crate root. Use `Path::new(relative!($path))` to retrieve an + /// `&'static Path`. + /// + /// # Example + /// + /// Serve files from the crate-relative `static/` directory: + /// + /// ```rust + /// # #[macro_use] extern crate rocket; + /// use rocket::fs::{FileServer, relative}; + /// + /// #[launch] + /// fn rocket() -> _ { + /// rocket::build().mount("/", FileServer::new(relative!("static"))) + /// } + /// ``` + /// + /// Path equivalences: + /// + /// ```rust + /// use std::path::Path; + /// + /// use rocket::fs::relative; + /// + /// let manual = Path::new(env!("CARGO_MANIFEST_DIR")).join("static"); + /// let automatic_1 = Path::new(relative!("static")); + /// let automatic_2 = Path::new(relative!("/static")); + /// assert_eq!(manual, automatic_1); + /// assert_eq!(automatic_1, automatic_2); + /// ``` + /// + macro_rules! relative { + ($path:expr) => { + if cfg!(windows) { + concat!(env!("CARGO_MANIFEST_DIR"), "\\", $path) + } else { + concat!(env!("CARGO_MANIFEST_DIR"), "/", $path) + } + }; + } +} diff --git a/core/lib/src/fs/rewrite.rs b/core/lib/src/fs/rewrite.rs new file mode 100644 index 00000000..00b57357 --- /dev/null +++ b/core/lib/src/fs/rewrite.rs @@ -0,0 +1,261 @@ +use std::borrow::Cow; +use std::path::{Path, PathBuf}; + +use crate::Request; +use crate::http::{ext::IntoOwned, HeaderMap}; +use crate::response::Redirect; + +/// A file server [`Rewrite`] rewriter. +/// +/// A [`FileServer`] is a sequence of [`Rewriter`]s which transform the incoming +/// request path into a [`Rewrite`] or `None`. The first rewriter is called with +/// the request path as a [`Rewrite::File`]. Each `Rewriter` thereafter is +/// called in-turn with the previously returned [`Rewrite`], and the value +/// returned from the last `Rewriter` is used to respond to the request. If the +/// final rewrite is `None` or a nonexistent path or a directory, [`FileServer`] +/// responds with [`Status::NotFound`]. Otherwise it responds with the file +/// contents, if [`Rewrite::File`] is specified, or a redirect, if +/// [`Rewrite::Redirect`] is specified. +/// +/// [`FileServer`]: super::FileServer +/// [`Status::NotFound`]: crate::http::Status::NotFound +pub trait Rewriter: Send + Sync + 'static { + /// Alter the [`Rewrite`] as needed. + fn rewrite<'r>(&self, opt: Option>, req: &'r Request<'_>) -> Option>; +} + +/// A Response from a [`FileServer`](super::FileServer) +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum Rewrite<'r> { + /// Return the contents of the specified file. + File(File<'r>), + /// Returns a Redirect. + Redirect(Redirect), +} + +/// A File response from a [`FileServer`](super::FileServer) and a rewriter. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct File<'r> { + /// The path to the file that [`FileServer`](super::FileServer) will respond with. + pub path: Cow<'r, Path>, + /// A list of headers to be added to the generated response. + pub headers: HeaderMap<'r>, +} + +impl<'r> File<'r> { + /// A new `File`, with not additional headers. + pub fn new(path: impl Into>) -> Self { + Self { path: path.into(), headers: HeaderMap::new() } + } + + /// A new `File`, with not additional headers. + /// + /// # Panics + /// + /// Panics if the `path` does not exist. + pub fn checked>(path: P) -> Self { + let path = path.as_ref(); + if !path.exists() { + let path = path.display(); + error!(%path, "FileServer path does not exist.\n\ + Panicking to prevent inevitable handler error."); + panic!("missing file {}: refusing to continue", path); + } + + Self::new(path.to_path_buf()) + } + + /// Replace the path in `self` with the result of applying `f` to the path. + pub fn map_path(self, f: F) -> Self + where F: FnOnce(Cow<'r, Path>) -> P, P: Into>, + { + Self { + path: f(self.path).into(), + headers: self.headers, + } + } + + /// Returns `true` if the file is a dotfile. A dotfile is a file whose + /// name or any directory in it's path start with a period (`.`) and is + /// considered hidden. + /// + /// # Windows Note + /// + /// This does *not* check the file metadata on any platform, so hidden files + /// on Windows will not be detected by this method. + pub fn is_hidden(&self) -> bool { + self.path.iter().any(|s| s.as_encoded_bytes().starts_with(b".")) + } + + /// Returns `true` if the file is not hidden. This is the inverse of + /// [`File::is_hidden()`]. + pub fn is_visible(&self) -> bool { + !self.is_hidden() + } +} + +/// Prefixes all paths with a given path. +/// +/// # Example +/// +/// ```rust,no_run +/// use rocket::fs::FileServer; +/// use rocket::fs::rewrite::Prefix; +/// +/// FileServer::identity() +/// .filter(|f, _| f.is_visible()) +/// .rewrite(Prefix::checked("static")); +/// ``` +pub struct Prefix(PathBuf); + +impl Prefix { + /// Panics if `path` does not exist. + pub fn checked>(path: P) -> Self { + let path = path.as_ref(); + if !path.is_dir() { + let path = path.display(); + error!(%path, "FileServer path is not a directory."); + warn!("Aborting early to prevent inevitable handler error."); + panic!("invalid directory: refusing to continue"); + } + + Self(path.to_path_buf()) + } + + /// Creates a new `Prefix` from a path. + pub fn unchecked>(path: P) -> Self { + Self(path.as_ref().to_path_buf()) + } +} + +impl Rewriter for Prefix { + fn rewrite<'r>(&self, opt: Option>, _: &Request<'_>) -> Option> { + opt.map(|r| match r { + Rewrite::File(f) => Rewrite::File(f.map_path(|p| self.0.join(p))), + Rewrite::Redirect(r) => Rewrite::Redirect(r), + }) + } +} + +impl Rewriter for PathBuf { + fn rewrite<'r>(&self, _: Option>, _: &Request<'_>) -> Option> { + Some(Rewrite::File(File::new(self.clone()))) + } +} + +/// Normalize directories to always include a trailing slash by redirecting +/// (with a 302 temporary redirect) requests for directories without a trailing +/// slash to the same path with a trailing slash. +/// +/// # Example +/// +/// ```rust,no_run +/// use rocket::fs::FileServer; +/// use rocket::fs::rewrite::{Prefix, TrailingDirs}; +/// +/// FileServer::identity() +/// .filter(|f, _| f.is_visible()) +/// .rewrite(TrailingDirs); +/// ``` +pub struct TrailingDirs; + +impl Rewriter for TrailingDirs { + fn rewrite<'r>(&self, opt: Option>, req: &Request<'_>) -> Option> { + if let Some(Rewrite::File(f)) = &opt { + if !req.uri().path().ends_with('/') && f.path.is_dir() { + let uri = req.uri().clone().into_owned(); + let uri = uri.map_path(|p| format!("{p}/")).unwrap(); + return Some(Rewrite::Redirect(Redirect::temporary(uri))); + } + } + + opt + } +} + +/// Rewrite a directory to a file inside of that directory. +/// +/// # Example +/// +/// Rewrites all directory requests to `directory/index.html`. +/// +/// ```rust,no_run +/// use rocket::fs::FileServer; +/// use rocket::fs::rewrite::DirIndex; +/// +/// FileServer::without_index("static") +/// .rewrite(DirIndex::if_exists("index.htm")) +/// .rewrite(DirIndex::unconditional("index.html")); +/// ``` +pub struct DirIndex { + path: PathBuf, + check: bool, +} + +impl DirIndex { + /// Appends `path` to every request for a directory. + pub fn unconditional(path: impl AsRef) -> Self { + Self { path: path.as_ref().to_path_buf(), check: false } + } + + /// Only appends `path` to a request for a directory if the file exists. + pub fn if_exists(path: impl AsRef) -> Self { + Self { path: path.as_ref().to_path_buf(), check: true } + } +} + +impl Rewriter for DirIndex { + fn rewrite<'r>(&self, opt: Option>, _: &Request<'_>) -> Option> { + match opt? { + Rewrite::File(f) if f.path.is_dir() => { + let candidate = f.path.join(&self.path); + if self.check && !candidate.is_file() { + return Some(Rewrite::File(f)); + } + + Some(Rewrite::File(f.map_path(|_| candidate))) + } + r => Some(r), + } + } +} + +impl<'r> From> for Rewrite<'r> { + fn from(value: File<'r>) -> Self { + Self::File(value) + } +} + +impl<'r> From for Rewrite<'r> { + fn from(value: Redirect) -> Self { + Self::Redirect(value) + } +} + +impl Rewriter for F + where F: for<'r> Fn(Option>, &Request<'_>) -> Option> +{ + fn rewrite<'r>(&self, f: Option>, r: &Request<'_>) -> Option> { + self(f, r) + } +} + +impl Rewriter for Rewrite<'static> { + fn rewrite<'r>(&self, _: Option>, _: &Request<'_>) -> Option> { + Some(self.clone()) + } +} + +impl Rewriter for File<'static> { + fn rewrite<'r>(&self, _: Option>, _: &Request<'_>) -> Option> { + Some(Rewrite::File(self.clone())) + } +} + +impl Rewriter for Redirect { + fn rewrite<'r>(&self, _: Option>, _: &Request<'_>) -> Option> { + Some(Rewrite::Redirect(self.clone())) + } +} diff --git a/core/lib/src/fs/server.rs b/core/lib/src/fs/server.rs index faa95f11..2f6efe7a 100644 --- a/core/lib/src/fs/server.rs +++ b/core/lib/src/fs/server.rs @@ -1,32 +1,21 @@ -use std::path::{PathBuf, Path}; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::borrow::Cow; -use crate::{Request, Data}; -use crate::http::{Method, Status, uri::Segments, ext::IntoOwned}; -use crate::route::{Route, Handler, Outcome}; -use crate::response::{Redirect, Responder}; +use crate::{response, Data, Request, Response}; use crate::outcome::IntoOutcome; -use crate::fs::NamedFile; +use crate::http::{uri::Segments, HeaderMap, Method, ContentType, Status}; +use crate::route::{Route, Handler, Outcome}; +use crate::response::Responder; +use crate::util::Formatter; +use crate::fs::rewrite::*; /// Custom handler for serving static files. /// -/// This handler makes it simple to serve static files from a directory on the -/// local file system. To use it, construct a `FileServer` using either -/// [`FileServer::from()`] or [`FileServer::new()`] then simply `mount` the -/// handler at a desired path. When mounted, the handler will generate route(s) -/// that serve the desired static files. If a requested file is not found, the -/// routes _forward_ the incoming request. The default rank of the generated -/// routes is `10`. To customize route ranking, use the [`FileServer::rank()`] -/// method. -/// -/// # Options -/// -/// The handler's functionality can be customized by passing an [`Options`] to -/// [`FileServer::new()`]. -/// -/// # Example -/// -/// Serve files from the `/static` directory on the local file system at the -/// `/public` path with the [default options](#impl-Default): +/// This handler makes is simple to serve static files from a directory on the +/// local file system. To use it, construct a `FileServer` using +/// [`FileServer::new()`], then `mount` the handler. /// /// ```rust,no_run /// # #[macro_use] extern crate rocket; @@ -34,14 +23,44 @@ use crate::fs::NamedFile; /// /// #[launch] /// fn rocket() -> _ { -/// rocket::build().mount("/public", FileServer::from("/static")) +/// rocket::build() +/// .mount("/", FileServer::new("/www/static")) +/// } +/// ``` +/// +/// When mounted, the handler serves files from the specified path. If a +/// requested file does not exist, the handler _forwards_ the request with a +/// `404` status. +/// +/// By default, the route has a rank of `10` which can be changed with +/// [`FileServer::rank()`]. +/// +/// # Customization +/// +/// `FileServer` works through a pipeline of _rewrites_ in which a requested +/// path is transformed into a `PathBuf` via [`Segments::to_path_buf()`] and +/// piped through a series of [`Rewriter`]s to obtain a final [`Rewrite`] which +/// is then used to generate a final response. See [`Rewriter`] for complete +/// details on implementing your own `Rewriter`s. +/// +/// # Example +/// +/// Serve files from the `/static` directory on the local file system at the +/// `/public` path: +/// +/// ```rust,no_run +/// # #[macro_use] extern crate rocket; +/// use rocket::fs::FileServer; +/// +/// #[launch] +/// fn rocket() -> _ { +/// rocket::build().mount("/public", FileServer::new("/static")) /// } /// ``` /// /// Requests for files at `/public/` will be handled by returning the -/// contents of `/static/`. Requests for _directories_ at -/// `/public/` will be handled by returning the contents of -/// `/static//index.html`. +/// contents of `/static/`. Requests for directories will return the +/// contents of `index.html`. /// /// ## Relative Paths /// @@ -57,13 +76,14 @@ use crate::fs::NamedFile; /// /// #[launch] /// fn rocket() -> _ { -/// rocket::build().mount("/", FileServer::from(relative!("static"))) +/// rocket::build().mount("/", FileServer::new(relative!("static"))) /// } /// ``` -#[derive(Debug, Clone)] +/// +/// [`relative!`]: crate::fs::relative! +#[derive(Clone)] pub struct FileServer { - root: PathBuf, - options: Options, + rewrites: Vec>, rank: isize, } @@ -72,120 +92,243 @@ impl FileServer { const DEFAULT_RANK: isize = 10; /// Constructs a new `FileServer` that serves files from the file system - /// `path`. By default, [`Options::Index`] is set, and the generated routes - /// have a rank of `10`. To serve static files with other options, use - /// [`FileServer::new()`]. To choose a different rank for generated routes, - /// use [`FileServer::rank()`]. + /// `path` with the following rewrites: /// - /// # Panics + /// - `|f, _| f.is_visible()`: Serve only visible files (hide dotfiles). + /// - [`Prefix::checked(path)`]: Prefix requests with `path`. + /// - [`TrailingDirs`]: Ensure directory have a trailing slash. + /// - [`DirIndex::unconditional("index.html")`]: Serve `$dir/index.html` for + /// requests to directory `$dir`. /// - /// Panics if `path` does not exist or is not a directory. + /// If you don't want to serve index files or want a different index file, + /// use [`Self::without_index`]. To customize the entire request to file + /// path rewrite pipeline, use [`Self::identity`]. + /// + /// [`Prefix::checked(path)`]: crate::fs::rewrite::Prefix::checked + /// [`TrailingDirs`]: crate::fs::rewrite::TrailingDirs + /// [`DirIndex::unconditional("index.html")`]: DirIndex::unconditional() /// /// # Example /// - /// Serve the static files in the `/www/public` local directory on path - /// `/static`. - /// /// ```rust,no_run /// # #[macro_use] extern crate rocket; /// use rocket::fs::FileServer; /// /// #[launch] /// fn rocket() -> _ { - /// rocket::build().mount("/static", FileServer::from("/www/public")) - /// } - /// ``` - /// - /// Exactly as before, but set the rank for generated routes to `30`. - /// - /// ```rust,no_run - /// # #[macro_use] extern crate rocket; - /// use rocket::fs::FileServer; - /// - /// #[launch] - /// fn rocket() -> _ { - /// rocket::build().mount("/static", FileServer::from("/www/public").rank(30)) - /// } - /// ``` - #[track_caller] - pub fn from>(path: P) -> Self { - FileServer::new(path, Options::default()) - } - - /// Constructs a new `FileServer` that serves files from the file system - /// `path` with `options` enabled. By default, the handler's routes have a - /// rank of `10`. To choose a different rank, use [`FileServer::rank()`]. - /// - /// # Panics - /// - /// If [`Options::Missing`] is not set, panics if `path` does not exist or - /// is not a directory. Otherwise does not panic. - /// - /// # Example - /// - /// Serve the static files in the `/www/public` local directory on path - /// `/static` without serving index files or dot files. Additionally, serve - /// the same files on `/pub` with a route rank of -1 while also serving - /// index files and dot files. - /// - /// ```rust,no_run - /// # #[macro_use] extern crate rocket; - /// use rocket::fs::{FileServer, Options}; - /// - /// #[launch] - /// fn rocket() -> _ { - /// let options = Options::Index | Options::DotFiles; /// rocket::build() - /// .mount("/static", FileServer::from("/www/public")) - /// .mount("/pub", FileServer::new("/www/public", options).rank(-1)) + /// .mount("/", FileServer::new("/www/static")) /// } /// ``` - #[track_caller] - pub fn new>(path: P, options: Options) -> Self { - let path = path.as_ref(); - if !options.contains(Options::Missing) { - if !options.contains(Options::IndexFile) && !path.is_dir() { - error!(path = %path.display(), - "FileServer path does not point to a directory.\n\ - Aborting early to prevent inevitable handler runtime errors."); - - panic!("invalid directory path: refusing to continue"); - } else if !path.exists() { - error!(path = %path.display(), - "FileServer path does not point to a file.\n\ - Aborting early to prevent inevitable handler runtime errors."); - - panic!("invalid file path: refusing to continue"); - } - } - - FileServer { root: path.into(), options, rank: Self::DEFAULT_RANK } + pub fn new>(path: P) -> Self { + Self::identity() + .filter(|f, _| f.is_visible()) + .rewrite(Prefix::checked(path)) + .rewrite(TrailingDirs) + .rewrite(DirIndex::unconditional("index.html")) } - /// Sets the rank for generated routes to `rank`. + /// Exactly like [`FileServer::new()`] except it _does not_ serve directory + /// index files via [`DirIndex`]. It rewrites with the following: + /// + /// - `|f, _| f.is_visible()`: Serve only visible files (hide dotfiles). + /// - [`Prefix::checked(path)`]: Prefix requests with `path`. + /// - [`TrailingDirs`]: Ensure directory have a trailing slash. + /// + /// # Example + /// + /// Constructs a default file server to serve files from `./static` using + /// `index.txt` as the index file if `index.html` doesn't exist. + /// + /// ```rust,no_run + /// # #[macro_use] extern crate rocket; + /// use rocket::fs::{FileServer, rewrite::DirIndex}; + /// + /// #[launch] + /// fn rocket() -> _ { + /// let server = FileServer::new("static") + /// .rewrite(DirIndex::if_exists("index.html")) + /// .rewrite(DirIndex::unconditional("index.txt")); + /// + /// rocket::build() + /// .mount("/", server) + /// } + /// ``` + /// + /// [`Prefix::checked(path)`]: crate::fs::rewrite::Prefix::checked + /// [`TrailingDirs`]: crate::fs::rewrite::TrailingDirs + pub fn without_index>(path: P) -> Self { + Self::identity() + .filter(|f, _| f.is_visible()) + .rewrite(Prefix::checked(path)) + .rewrite(TrailingDirs) + } + + /// Constructs a new `FileServer` with no rewrites. + /// + /// Without any rewrites, a `FileServer` will try to serve the requested + /// file from the current working directory. In other words, it represents + /// the identity rewrite. For example, a request `GET /foo/bar` will be + /// passed through unmodified and thus `./foo/bar` will be served. This is + /// very unlikely to be what you want. + /// + /// Prefer to use [`FileServer::new()`] or [`FileServer::without_index()`] + /// whenever possible and otherwise use one or more of the rewrites in + /// [`rocket::fs::rewrite`] or your own custom rewrites. /// /// # Example /// /// ```rust,no_run - /// use rocket::fs::{FileServer, Options}; + /// # #[macro_use] extern crate rocket; + /// use rocket::fs::{FileServer, rewrite}; /// - /// // A `FileServer` created with `from()` with routes of rank `3`. - /// FileServer::from("/public").rank(3); + /// #[launch] + /// fn rocket() -> _ { + /// // A file server that serves exactly one file: /www/foo.html. The + /// // file is served irrespective of what's requested. + /// let server = FileServer::identity() + /// .rewrite(rewrite::File::checked("/www/foo.html")); /// - /// // A `FileServer` created with `new()` with routes of rank `-15`. - /// FileServer::new("/public", Options::Index).rank(-15); + /// rocket::build() + /// .mount("/", server) + /// } /// ``` + pub fn identity() -> Self { + Self { + rewrites: vec![], + rank: Self::DEFAULT_RANK + } + } + + /// Sets the rank of the route emitted by the `FileServer` to `rank`. + /// + /// # Example + /// + /// ```rust,no_run + /// # use rocket::fs::FileServer; + /// # fn make_server() -> FileServer { + /// FileServer::identity() + /// .rank(5) + /// # } pub fn rank(mut self, rank: isize) -> Self { self.rank = rank; self } + + /// Add `rewriter` to the rewrite pipeline. + /// + /// # Example + /// + /// Redirect filtered requests (`None`) to `/`. + /// + /// ```rust,no_run + /// # #[macro_use] extern crate rocket; + /// use rocket::fs::{FileServer, rewrite::Rewrite}; + /// use rocket::{request::Request, response::Redirect}; + /// + /// fn redir_missing<'r>(p: Option>, _req: &Request<'_>) -> Option> { + /// Some(p.unwrap_or_else(|| Redirect::temporary(uri!("/")).into())) + /// } + /// + /// #[launch] + /// fn rocket() -> _ { + /// rocket::build() + /// .mount("/", FileServer::new("static").rewrite(redir_missing)) + /// } + /// ``` + /// + /// Note that `redir_missing` is not a closure in this example. Making it a closure + /// causes compilation to fail with a lifetime error. It really shouldn't but it does. + pub fn rewrite(mut self, rewriter: R) -> Self { + self.rewrites.push(Arc::new(rewriter)); + self + } + + /// Adds a rewriter to the pipeline that returns `Some` only when the + /// function `f` returns `true`, filtering out all other files. + /// + /// # Example + /// + /// Allow all files that don't have a file name or have a file name other + /// than "hidden". + /// + /// ```rust,no_run + /// # #[macro_use] extern crate rocket; + /// use rocket::fs::FileServer; + /// + /// #[launch] + /// fn rocket() -> _ { + /// let server = FileServer::new("static") + /// .filter(|f, _| f.path.file_name() != Some("hidden".as_ref())); + /// + /// rocket::build() + /// .mount("/", server) + /// } + /// ``` + pub fn filter(self, f: F) -> Self + where F: Fn(&File<'_>, &Request<'_>) -> bool + { + struct Filter(F); + + impl Rewriter for Filter + where F: Fn(&File<'_>, &Request<'_>) -> bool + Send + Sync + 'static + { + fn rewrite<'r>(&self, f: Option>, r: &Request<'_>) -> Option> { + f.and_then(|f| match f { + Rewrite::File(f) if self.0(&f, r) => Some(Rewrite::File(f)), + _ => None, + }) + } + } + + self.rewrite(Filter(f)) + } + + /// Adds a rewriter to the pipeline that maps the current `File` to another + /// `Rewrite` using `f`. If the current `Rewrite` is a `Redirect`, it is + /// passed through without calling `f`. + /// + /// # Example + /// + /// Append `index.txt` to every path. + /// + /// ```rust,no_run + /// # #[macro_use] extern crate rocket; + /// use rocket::fs::FileServer; + /// + /// #[launch] + /// fn rocket() -> _ { + /// let server = FileServer::new("static") + /// .map(|f, _| f.map_path(|p| p.join("index.txt")).into()); + /// + /// rocket::build() + /// .mount("/", server) + /// } + /// ``` + pub fn map(self, f: F) -> Self + where F: for<'r> Fn(File<'r>, &Request<'_>) -> Rewrite<'r> + { + struct Map(F); + + impl Rewriter for Map + where F: for<'r> Fn(File<'r>, &Request<'_>) -> Rewrite<'r> + Send + Sync + 'static + { + fn rewrite<'r>(&self, f: Option>, r: &Request<'_>) -> Option> { + f.map(|f| match f { + Rewrite::File(f) => self.0(f, r), + Rewrite::Redirect(r) => Rewrite::Redirect(r), + }) + } + } + + self.rewrite(Map(f)) + } } impl From for Vec { fn from(server: FileServer) -> Self { - let source = figment::Source::File(server.root.clone()); let mut route = Route::ranked(server.rank, Method::Get, "/", server); - route.name = Some(format!("FileServer: {}", source).into()); + route.name = Some("FileServer".into()); vec![route] } } @@ -193,267 +336,71 @@ impl From for Vec { #[crate::async_trait] impl Handler for FileServer { async fn handle<'r>(&self, req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r> { - use crate::http::uri::fmt::Path; + use crate::http::uri::fmt::Path as UriPath; + let path: Option = req.segments::>(0..).ok() + .and_then(|segments| segments.to_path_buf(true).ok()); - // TODO: Should we reject dotfiles for `self.root` if !DotFiles? - let options = self.options; - if options.contains(Options::IndexFile) && self.root.is_file() { - let segments = match req.segments::>(0..) { - Ok(segments) => segments, - Err(never) => match never {}, - }; - - if segments.is_empty() { - let file = NamedFile::open(&self.root).await; - return file.respond_to(req).or_forward((data, Status::NotFound)); - } else { - return Outcome::forward(data, Status::NotFound); - } + let mut response = path.map(|p| Rewrite::File(File::new(p))); + for rewrite in &self.rewrites { + response = rewrite.rewrite(response, req); } - // Get the segments as a `PathBuf`, allowing dotfiles requested. - let allow_dotfiles = options.contains(Options::DotFiles); - let path = req.segments::>(0..).ok() - .and_then(|segments| segments.to_path_buf(allow_dotfiles).ok()) - .map(|path| self.root.join(path)); - - match path { - Some(p) if p.is_dir() => { - // Normalize '/a/b/foo' to '/a/b/foo/'. - if options.contains(Options::NormalizeDirs) && !req.uri().path().ends_with('/') { - let normal = req.uri().map_path(|p| format!("{}/", p)) - .expect("adding a trailing slash to a known good path => valid path") - .into_owned(); - - return Redirect::permanent(normal) - .respond_to(req) - .or_forward((data, Status::InternalServerError)); - } - - if !options.contains(Options::Index) { - return Outcome::forward(data, Status::NotFound); - } - - let index = NamedFile::open(p.join("index.html")).await; - index.respond_to(req).or_forward((data, Status::NotFound)) - }, - Some(p) => { - let file = NamedFile::open(p).await; - file.respond_to(req).or_forward((data, Status::NotFound)) - } - None => Outcome::forward(data, Status::NotFound), - } - } -} - -/// A bitset representing configurable options for [`FileServer`]. -/// -/// The valid options are: -/// -/// * [`Options::None`] - Return only present, visible files. -/// * [`Options::DotFiles`] - In addition to visible files, return dotfiles. -/// * [`Options::Index`] - Render `index.html` pages for directory requests. -/// * [`Options::IndexFile`] - Allow serving a single file as the index. -/// * [`Options::Missing`] - Don't fail if the path to serve is missing. -/// * [`Options::NormalizeDirs`] - Redirect directories without a trailing -/// slash to ones with a trailing slash. -/// -/// `Options` structures can be `or`d together to select two or more options. -/// For instance, to request that both dot files and index pages be returned, -/// use `Options::DotFiles | Options::Index`. -#[derive(Debug, Clone, Copy)] -pub struct Options(u8); - -#[allow(non_upper_case_globals, non_snake_case)] -impl Options { - /// All options disabled. - /// - /// Note that this is different than [`Options::default()`](#impl-Default), - /// which enables options. - pub const None: Options = Options(0); - - /// Respond to requests for a directory with the `index.html` file in that - /// directory, if it exists. - /// - /// When enabled, [`FileServer`] will respond to requests for a directory - /// `/foo` or `/foo/` with the file at `${root}/foo/index.html` if it - /// exists. When disabled, requests to directories will always forward. - /// - /// **Enabled by default.** - pub const Index: Options = Options(1 << 0); - - /// Allow serving dotfiles. - /// - /// When enabled, [`FileServer`] will respond to requests for files or - /// directories beginning with `.`. When disabled, any dotfiles will be - /// treated as missing. - /// - /// **Disabled by default.** - pub const DotFiles: Options = Options(1 << 1); - - /// Normalizes directory requests by redirecting requests to directory paths - /// without a trailing slash to ones with a trailing slash. - /// - /// **Enabled by default.** - /// - /// When enabled, the [`FileServer`] handler will respond to requests for a - /// directory without a trailing `/` with a permanent redirect (308) to the - /// same path with a trailing `/`. This ensures relative URLs within any - /// document served from that directory will be interpreted relative to that - /// directory rather than its parent. - /// - /// # Example - /// - /// Given the following directory structure... - /// - /// ```text - /// static/ - /// └── foo/ - /// ├── cat.jpeg - /// └── index.html - /// ``` - /// - /// And the following server: - /// - /// ```text - /// rocket.mount("/", FileServer::from("static")) - /// ``` - /// - /// ...requests to `example.com/foo` will be redirected to - /// `example.com/foo/`. If `index.html` references `cat.jpeg` as a relative - /// URL, the browser will resolve the URL to `example.com/foo/cat.jpeg`, - /// which in-turn Rocket will match to `/static/foo/cat.jpg`. - /// - /// Without this option, requests to `example.com/foo` would not be - /// redirected. `index.html` would be rendered, and the relative link to - /// `cat.jpeg` would be resolved by the browser as `example.com/cat.jpeg`. - /// Rocket would thus try to find `/static/cat.jpeg`, which does not exist. - pub const NormalizeDirs: Options = Options(1 << 2); - - /// Allow serving a file instead of a directory. - /// - /// By default, `FileServer` will error on construction if the path to serve - /// does not point to a directory. When this option is enabled, if a path to - /// a file is provided, `FileServer` will serve the file as the root of the - /// mount path. - /// - /// # Example - /// - /// If the file tree looks like: - /// - /// ```text - /// static/ - /// └── cat.jpeg - /// ``` - /// - /// Then `cat.jpeg` can be served at `/cat` with: - /// - /// ```rust,no_run - /// # #[macro_use] extern crate rocket; - /// use rocket::fs::{FileServer, Options}; - /// - /// #[launch] - /// fn rocket() -> _ { - /// rocket::build() - /// .mount("/cat", FileServer::new("static/cat.jpeg", Options::IndexFile)) - /// } - /// ``` - pub const IndexFile: Options = Options(1 << 3); - - /// Don't fail if the file or directory to serve is missing. - /// - /// By default, `FileServer` will error if the path to serve is missing to - /// prevent inevitable 404 errors. This option overrides that. - pub const Missing: Options = Options(1 << 4); - - /// Returns `true` if `self` is a superset of `other`. In other words, - /// returns `true` if all of the options in `other` are also in `self`. - /// - /// # Example - /// - /// ```rust - /// use rocket::fs::Options; - /// - /// let index_request = Options::Index | Options::DotFiles; - /// assert!(index_request.contains(Options::Index)); - /// assert!(index_request.contains(Options::DotFiles)); - /// - /// let index_only = Options::Index; - /// assert!(index_only.contains(Options::Index)); - /// assert!(!index_only.contains(Options::DotFiles)); - /// - /// let dot_only = Options::DotFiles; - /// assert!(dot_only.contains(Options::DotFiles)); - /// assert!(!dot_only.contains(Options::Index)); - /// ``` - #[inline] - pub fn contains(self, other: Options) -> bool { - (other.0 & self.0) == other.0 - } -} - -/// The default set of options: `Options::Index | Options:NormalizeDirs`. -impl Default for Options { - fn default() -> Self { - Options::Index | Options::NormalizeDirs - } -} - -impl std::ops::BitOr for Options { - type Output = Self; - - #[inline(always)] - fn bitor(self, rhs: Self) -> Self { - Options(self.0 | rhs.0) - } -} - -crate::export! { - /// Generates a crate-relative version of a path. - /// - /// This macro is primarily intended for use with [`FileServer`] to serve - /// files from a path relative to the crate root. - /// - /// The macro accepts one parameter, `$path`, an absolute or (preferably) - /// relative path. It returns a path as an `&'static str` prefixed with the - /// path to the crate root. Use `Path::new(relative!($path))` to retrieve an - /// `&'static Path`. - /// - /// # Example - /// - /// Serve files from the crate-relative `static/` directory: - /// - /// ```rust - /// # #[macro_use] extern crate rocket; - /// use rocket::fs::{FileServer, relative}; - /// - /// #[launch] - /// fn rocket() -> _ { - /// rocket::build().mount("/", FileServer::from(relative!("static"))) - /// } - /// ``` - /// - /// Path equivalences: - /// - /// ```rust - /// use std::path::Path; - /// - /// use rocket::fs::relative; - /// - /// let manual = Path::new(env!("CARGO_MANIFEST_DIR")).join("static"); - /// let automatic_1 = Path::new(relative!("static")); - /// let automatic_2 = Path::new(relative!("/static")); - /// assert_eq!(manual, automatic_1); - /// assert_eq!(automatic_1, automatic_2); - /// ``` - /// - macro_rules! relative { - ($path:expr) => { - if cfg!(windows) { - concat!(env!("CARGO_MANIFEST_DIR"), "\\", $path) - } else { - concat!(env!("CARGO_MANIFEST_DIR"), "/", $path) - } + let (outcome, status) = match response { + Some(Rewrite::File(f)) => (f.open().await.respond_to(req), Status::NotFound), + Some(Rewrite::Redirect(r)) => (r.respond_to(req), Status::InternalServerError), + None => return Outcome::forward(data, Status::NotFound), }; + + outcome.or_forward((data, status)) + } +} + +impl fmt::Debug for FileServer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FileServer") + .field("rewrites", &Formatter(|f| write!(f, "<{} rewrites>", self.rewrites.len()))) + .field("rank", &self.rank) + .finish() + } +} + +impl<'r> File<'r> { + async fn open(self) -> std::io::Result> { + let file = tokio::fs::File::open(&self.path).await?; + let metadata = file.metadata().await?; + if metadata.is_dir() { + return Err(std::io::Error::other("is a directory")); + } + + Ok(NamedFile { + file, + len: metadata.len(), + path: self.path, + headers: self.headers, + }) + } +} + +struct NamedFile<'r> { + file: tokio::fs::File, + len: u64, + path: Cow<'r, Path>, + headers: HeaderMap<'r>, +} + +// Do we want to allow the user to rewrite the Content-Type? +impl<'r> Responder<'r, 'r> for NamedFile<'r> { + fn respond_to(self, _: &'r Request<'_>) -> response::Result<'r> { + let mut response = Response::new(); + response.set_header_map(self.headers); + if !response.headers().contains("Content-Type") { + self.path.extension() + .and_then(|ext| ext.to_str()) + .and_then(ContentType::from_extension) + .map(|content_type| response.set_header(content_type)); + } + + response.set_sized_body(self.len as usize, self.file); + Ok(response) } } diff --git a/core/lib/src/response/redirect.rs b/core/lib/src/response/redirect.rs index f685fe1b..7b5cf5d2 100644 --- a/core/lib/src/response/redirect.rs +++ b/core/lib/src/response/redirect.rs @@ -45,7 +45,7 @@ use crate::http::Status; /// /// [`Origin`]: crate::http::uri::Origin /// [`uri!`]: ../macro.uri.html -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Redirect(Status, Option>); impl Redirect { @@ -144,6 +144,12 @@ impl Redirect { pub fn moved>>(uri: U) -> Redirect { Redirect(Status::MovedPermanently, uri.try_into().ok()) } + + pub fn map_uri>>(self, f: impl FnOnce(Reference<'static>) -> U) + -> Redirect + { + Redirect(self.0, self.1.and_then(|p| f(p).try_into().ok())) + } } /// Constructs a response with the appropriate status code and the given URL in diff --git a/core/lib/src/response/response.rs b/core/lib/src/response/response.rs index 7abdaeca..a3b49cec 100644 --- a/core/lib/src/response/response.rs +++ b/core/lib/src/response/response.rs @@ -637,6 +637,10 @@ impl<'r> Response<'r> { &self.headers } + pub fn set_header_map<'h: 'r>(&mut self, headers: HeaderMap<'h>) { + self.headers = headers; + } + /// Sets the header `header` in `self`. Any existing headers with the name /// `header.name` will be lost, and only `header` will remain. The type of /// `header` can be any type that implements `Into
`. See [trait diff --git a/core/lib/src/trace/subscriber/dynamic.rs b/core/lib/src/trace/subscriber/dynamic.rs index ed9b09e3..84eb2d03 100644 --- a/core/lib/src/trace/subscriber/dynamic.rs +++ b/core/lib/src/trace/subscriber/dynamic.rs @@ -39,10 +39,10 @@ impl RocketDynFmt { return; } - let workers = config.map(|c| c.workers).unwrap_or(num_cpus::get()); - let colors = config.map(|c| c.cli_colors).unwrap_or(CliColors::Auto); - let level = config.map(|c| c.log_level).unwrap_or(Some(Level::INFO)); - let format = config.map(|c| c.log_format).unwrap_or(TraceFormat::Pretty); + let workers = config.map_or(num_cpus::get(), |c| c.workers); + let colors = config.map_or(CliColors::Auto, |c| c.cli_colors); + let level = config.map_or(Some(Level::INFO), |c| c.log_level); + let format = config.map_or(TraceFormat::Pretty, |c| c.log_format); let formatter = |format| match format { TraceFormat::Pretty => Self::from(RocketFmt::::new(workers, colors, level)), @@ -57,7 +57,7 @@ impl RocketDynFmt { if result.is_ok() { assert!(HANDLE.set(reload_handle).is_ok()); - } if let Some(handle) = HANDLE.get() { + } else if let Some(handle) = HANDLE.get() { assert!(handle.modify(|layer| *layer = formatter(format)).is_ok()); } } diff --git a/core/lib/tests/file_server.rs b/core/lib/tests/file_server.rs index c0b33e53..5de10fb5 100644 --- a/core/lib/tests/file_server.rs +++ b/core/lib/tests/file_server.rs @@ -1,10 +1,10 @@ -use std::{io::Read, fs::File}; +use std::{io::Read, fs}; use std::path::Path; use rocket::{Rocket, Route, Build}; use rocket::http::Status; use rocket::local::blocking::Client; -use rocket::fs::{FileServer, Options, relative}; +use rocket::fs::{FileServer, relative, rewrite::*}; fn static_root() -> &'static Path { Path::new(relative!("/tests/static")) @@ -13,13 +13,66 @@ fn static_root() -> &'static Path { fn rocket() -> Rocket { let root = static_root(); rocket::build() - .mount("/default", FileServer::from(&root)) - .mount("/no_index", FileServer::new(&root, Options::None)) - .mount("/dots", FileServer::new(&root, Options::DotFiles)) - .mount("/index", FileServer::new(&root, Options::Index)) - .mount("/both", FileServer::new(&root, Options::DotFiles | Options::Index)) - .mount("/redir", FileServer::new(&root, Options::NormalizeDirs)) - .mount("/redir_index", FileServer::new(&root, Options::NormalizeDirs | Options::Index)) + .mount("/default", FileServer::new(&root)) + .mount( + "/no_index", + FileServer::identity() + .filter(|f, _| f.is_visible()) + .rewrite(Prefix::checked(&root)) + ) + .mount( + "/dots", + FileServer::identity() + .rewrite(Prefix::checked(&root)) + ) + .mount( + "/index", + FileServer::identity() + .filter(|f, _| f.is_visible()) + .rewrite(Prefix::checked(&root)) + .rewrite(DirIndex::unconditional("index.html")) + ) + .mount( + "/try_index", + FileServer::identity() + .filter(|f, _| f.is_visible()) + .rewrite(Prefix::checked(&root)) + .rewrite(DirIndex::if_exists("index.html")) + .rewrite(DirIndex::if_exists("index.htm")) + ) + .mount( + "/both", + FileServer::identity() + .rewrite(Prefix::checked(&root)) + .rewrite(DirIndex::unconditional("index.html")) + ) + .mount( + "/redir", + FileServer::identity() + .filter(|f, _| f.is_visible()) + .rewrite(Prefix::checked(&root)) + .rewrite(TrailingDirs) + ) + .mount( + "/redir_index", + FileServer::identity() + .filter(|f, _| f.is_visible()) + .rewrite(Prefix::checked(&root)) + .rewrite(TrailingDirs) + .rewrite(DirIndex::unconditional("index.html")) + ) + .mount( + "/index_file", + FileServer::identity() + .filter(|f, _| f.is_visible()) + .rewrite(File::checked(root.join("other/hello.txt"))) + ) + .mount( + "/missing_root", + FileServer::identity() + .filter(|f, _| f.is_visible()) + .rewrite(File::new(root.join("no_file"))) + ) } static REGULAR_FILES: &[&str] = &[ @@ -27,6 +80,7 @@ static REGULAR_FILES: &[&str] = &[ "inner/goodbye", "inner/index.html", "other/hello.txt", + "other/index.htm", ]; static HIDDEN_FILES: &[&str] = &[ @@ -39,18 +93,18 @@ static INDEXED_DIRECTORIES: &[&str] = &[ "inner/", ]; -fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool) { +fn assert_file_matches(client: &Client, prefix: &str, path: &str, disk_path: Option<&str>) { let full_path = format!("/{}/{}", prefix, path); let response = client.get(full_path).dispatch(); - if exists { + if let Some(disk_path) = disk_path { assert_eq!(response.status(), Status::Ok); - let mut path = static_root().join(path); + let mut path = static_root().join(disk_path); if path.is_dir() { path = path.join("index.html"); } - let mut file = File::open(path).expect("open file"); + let mut file = fs::File::open(path).expect("open file"); let mut expected_contents = String::new(); file.read_to_string(&mut expected_contents).expect("read file"); assert_eq!(response.into_string(), Some(expected_contents)); @@ -59,6 +113,14 @@ fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool) { } } +fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool) { + if exists { + assert_file_matches(client, prefix, path, Some(path)) + } else { + assert_file_matches(client, prefix, path, None) + } +} + fn assert_all(client: &Client, prefix: &str, paths: &[&str], exist: bool) { for path in paths.iter() { assert_file(client, prefix, path, exist); @@ -101,12 +163,35 @@ fn test_static_all() { assert_all(&client, "both", INDEXED_DIRECTORIES, true); } +#[test] +fn test_alt_roots() { + let client = Client::debug(rocket()).expect("valid rocket"); + assert_file(&client, "missing_root", "", false); + assert_file_matches(&client, "index_file", "", Some("other/hello.txt")); +} + +#[test] +fn test_allow_special_dotpaths() { + let client = Client::debug(rocket()).expect("valid rocket"); + assert_file_matches(&client, "no_index", "./index.html", Some("index.html")); + assert_file_matches(&client, "no_index", "foo/../index.html", Some("index.html")); + assert_file_matches(&client, "no_index", "inner/./index.html", Some("inner/index.html")); + assert_file_matches(&client, "no_index", "../index.html", Some("index.html")); +} + +#[test] +fn test_try_index() { + let client = Client::debug(rocket()).expect("valid rocket"); + assert_file_matches(&client, "try_index", "inner", Some("inner/index.html")); + assert_file_matches(&client, "try_index", "other", Some("other/index.htm")); +} + #[test] fn test_ranking() { let root = static_root(); for rank in -128..128 { - let a = FileServer::new(&root, Options::None).rank(rank); - let b = FileServer::from(&root).rank(rank); + let a = FileServer::new(&root).rank(rank); + let b = FileServer::new(&root).rank(rank); for handler in vec![a, b] { let routes: Vec = handler.into(); @@ -153,15 +238,15 @@ fn test_redirection() { assert_eq!(response.status(), Status::Ok); let response = client.get("/redir/inner").dispatch(); - assert_eq!(response.status(), Status::PermanentRedirect); + assert_eq!(response.status(), Status::TemporaryRedirect); assert_eq!(response.headers().get("Location").next(), Some("/redir/inner/")); let response = client.get("/redir/inner?foo=bar").dispatch(); - assert_eq!(response.status(), Status::PermanentRedirect); + assert_eq!(response.status(), Status::TemporaryRedirect); assert_eq!(response.headers().get("Location").next(), Some("/redir/inner/?foo=bar")); let response = client.get("/redir_index/inner").dispatch(); - assert_eq!(response.status(), Status::PermanentRedirect); + assert_eq!(response.status(), Status::TemporaryRedirect); assert_eq!(response.headers().get("Location").next(), Some("/redir_index/inner/")); // Paths with trailing slash are unaffected. @@ -179,14 +264,32 @@ fn test_redirection() { assert_eq!(response.status(), Status::Ok); let response = client.get("/redir/inner").dispatch(); - assert_eq!(response.status(), Status::PermanentRedirect); + assert_eq!(response.status(), Status::TemporaryRedirect); assert_eq!(response.headers().get("Location").next(), Some("/redir/inner/")); let response = client.get("/redir/other").dispatch(); - assert_eq!(response.status(), Status::PermanentRedirect); + assert_eq!(response.status(), Status::TemporaryRedirect); assert_eq!(response.headers().get("Location").next(), Some("/redir/other/")); let response = client.get("/redir_index/other").dispatch(); - assert_eq!(response.status(), Status::PermanentRedirect); + assert_eq!(response.status(), Status::TemporaryRedirect); assert_eq!(response.headers().get("Location").next(), Some("/redir_index/other/")); } + +#[test] +#[should_panic] +fn test_panic_on_missing_file() { + let _ = File::checked(static_root().join("missing_file")); +} + +#[test] +#[should_panic] +fn test_panic_on_missing_dir() { + let _ = Prefix::checked(static_root().join("missing_dir")); +} + +#[test] +#[should_panic] +fn test_panic_on_file_not_dir() { + let _ = Prefix::checked(static_root().join("index.html")); +} diff --git a/core/lib/tests/static/inner/index.html b/core/lib/tests/static/inner/index.html index 1810989f..faf61358 100644 --- a/core/lib/tests/static/inner/index.html +++ b/core/lib/tests/static/inner/index.html @@ -1 +1 @@ -Inner index. +Inner index.html diff --git a/core/lib/tests/static/other/index.htm b/core/lib/tests/static/other/index.htm new file mode 100644 index 00000000..6048ea0a --- /dev/null +++ b/core/lib/tests/static/other/index.htm @@ -0,0 +1 @@ +Inner index.htm diff --git a/docs/guide/05-requests.md b/docs/guide/05-requests.md index 838cbb12..08a0329d 100644 --- a/docs/guide/05-requests.md +++ b/docs/guide/05-requests.md @@ -169,7 +169,7 @@ async fn files(file: PathBuf) -> Option { fn rocket() -> _ { rocket::build() // serve files from `/www/static` at path `/public` - .mount("/public", FileServer::from("/www/static")) + .mount("/public", FileServer::new("/www/static")) } ``` diff --git a/docs/guide/06-responses.md b/docs/guide/06-responses.md index 9343b596..31a10d32 100644 --- a/docs/guide/06-responses.md +++ b/docs/guide/06-responses.md @@ -354,7 +354,7 @@ are: * [`Flash`] - Sets a "flash" cookie that is removed when accessed. * [`Json`] - Automatically serializes values into JSON. * [`MsgPack`] - Automatically serializes values into MessagePack. - * [`Template`] - Renders a dynamic template using handlebars or Tera. + * [`Template`] - Renders a dynamic template using Handlebars, Tera or MiniJinja. [`status`]: @api/master/rocket/response/status/ [`content`]: @api/master/rocket/response/content/ @@ -589,7 +589,7 @@ reloading is disabled. The [`Template`] API documentation contains more information about templates, including how to customize a template engine to add custom helpers and filters. -The [templating example](@git/master/examples/templating) uses both Tera and Handlebars +The [templating example](@git/master/examples/templating) uses Tera, Handlebars and MiniJinja templating to implement the same application. [configurable]: ../configuration/ diff --git a/docs/guide/11-deploying.md b/docs/guide/11-deploying.md index 5afd1898..245683cc 100644 --- a/docs/guide/11-deploying.md +++ b/docs/guide/11-deploying.md @@ -49,7 +49,7 @@ For any deployment, it's important to keep in mind: #[launch] fn rocket() -> _ { rocket::build() - .mount("/", FileServer::from("./static")) + .mount("/", FileServer::new("./static")) .attach(Template::fairing()) } ``` diff --git a/examples/chat/src/main.rs b/examples/chat/src/main.rs index 0a2f68d5..eab8abe1 100644 --- a/examples/chat/src/main.rs +++ b/examples/chat/src/main.rs @@ -54,5 +54,5 @@ fn rocket() -> _ { rocket::build() .manage(channel::(1024).0) .mount("/", routes![post, events]) - .mount("/", FileServer::from(relative!("static"))) + .mount("/", FileServer::new(relative!("static"))) } diff --git a/examples/forms/src/main.rs b/examples/forms/src/main.rs index a908eb47..5953403a 100644 --- a/examples/forms/src/main.rs +++ b/examples/forms/src/main.rs @@ -93,5 +93,5 @@ fn rocket() -> _ { rocket::build() .mount("/", routes![index, submit]) .attach(Template::fairing()) - .mount("/", FileServer::from(relative!("/static"))) + .mount("/", FileServer::new(relative!("/static"))) } diff --git a/examples/static-files/src/main.rs b/examples/static-files/src/main.rs index 53c42708..7b94e7df 100644 --- a/examples/static-files/src/main.rs +++ b/examples/static-files/src/main.rs @@ -23,5 +23,5 @@ mod manual { fn rocket() -> _ { rocket::build() .mount("/", rocket::routes![manual::second]) - .mount("/", FileServer::from(relative!("static"))) + .mount("/", FileServer::new(relative!("static"))) } diff --git a/examples/static-files/src/tests.rs b/examples/static-files/src/tests.rs index 59c0ceca..824e157f 100644 --- a/examples/static-files/src/tests.rs +++ b/examples/static-files/src/tests.rs @@ -63,7 +63,7 @@ fn test_icon_file() { #[test] fn test_invalid_path() { - test_query_file("/hidden", None, Status::PermanentRedirect); + test_query_file("/hidden", None, Status::TemporaryRedirect); test_query_file("/thou_shalt_not_exist", None, Status::NotFound); test_query_file("/thou/shalt/not/exist", None, Status::NotFound); test_query_file("/thou/shalt/not/exist?a=b&c=d", None, Status::NotFound); diff --git a/examples/templating/Cargo.toml b/examples/templating/Cargo.toml index 1fcee3c3..e2379c2a 100644 --- a/examples/templating/Cargo.toml +++ b/examples/templating/Cargo.toml @@ -11,4 +11,4 @@ rocket = { path = "../../core/lib" } # in your application, you should enable only the template engine(s) used [dependencies.rocket_dyn_templates] path = "../../contrib/dyn_templates" -features = ["tera", "handlebars"] +features = ["tera", "handlebars", "minijinja"] diff --git a/examples/templating/src/main.rs b/examples/templating/src/main.rs index e849f427..4eae3181 100644 --- a/examples/templating/src/main.rs +++ b/examples/templating/src/main.rs @@ -1,16 +1,23 @@ -#[macro_use] extern crate rocket; +#[macro_use] +extern crate rocket; mod hbs; +mod minijinja; mod tera; -#[cfg(test)] mod tests; +#[cfg(test)] +mod tests; use rocket::response::content::RawHtml; use rocket_dyn_templates::Template; #[get("/")] fn index() -> RawHtml<&'static str> { - RawHtml(r#"See Tera or Handlebars."#) + RawHtml( + r#"See Tera, + Handlebars, + or MiniJinja."#, + ) } #[launch] @@ -19,10 +26,16 @@ fn rocket() -> _ { .mount("/", routes![index]) .mount("/tera", routes![tera::index, tera::hello, tera::about]) .mount("/hbs", routes![hbs::index, hbs::hello, hbs::about]) + .mount( + "/minijinja", + routes![minijinja::index, minijinja::hello, minijinja::about], + ) .register("/hbs", catchers![hbs::not_found]) .register("/tera", catchers![tera::not_found]) + .register("/minijinja", catchers![minijinja::not_found]) .attach(Template::custom(|engines| { hbs::customize(&mut engines.handlebars); tera::customize(&mut engines.tera); + minijinja::customize(&mut engines.minijinja); })) } diff --git a/examples/templating/src/minijinja.rs b/examples/templating/src/minijinja.rs new file mode 100644 index 00000000..799a1a9c --- /dev/null +++ b/examples/templating/src/minijinja.rs @@ -0,0 +1,61 @@ +use rocket::response::Redirect; +use rocket::Request; + +use rocket_dyn_templates::{context, minijinja::Environment, Template}; + +// use self::minijinja::; + +#[get("/")] +pub fn index() -> Redirect { + Redirect::to(uri!("/minijinja", hello(name = "Your Name"))) +} + +#[get("/hello/")] +pub fn hello(name: &str) -> Template { + Template::render( + "minijinja/index", + context! { + title: "Hello", + name: Some(name), + items: vec!["One", "Two", "Three"], + }, + ) +} + +#[get("/about")] +pub fn about() -> Template { + Template::render( + "minijinja/about.html", + context! { + title: "About", + }, + ) +} + +#[catch(404)] +pub fn not_found(req: &Request<'_>) -> Template { + println!("Handling 404 for URI: {}", req.uri()); + + Template::render( + "minijinja/error/404", + context! { + uri: req.uri() + }, + ) +} + +pub fn customize(env: &mut Environment) { + env.add_template( + "minijinja/about.html", + r#" + {% extends "minijinja/layout" %} + + {% block page %} +
+

About - Here's another page!

+
+ {% endblock %} + "#, + ) + .expect("valid Jinja2 template"); +} diff --git a/examples/templating/src/tests.rs b/examples/templating/src/tests.rs index afdbc045..44984038 100644 --- a/examples/templating/src/tests.rs +++ b/examples/templating/src/tests.rs @@ -40,11 +40,11 @@ fn test_404(base: &str) { let client = Client::tracked(rocket()).unwrap(); for bad_path in &["/hello", "/foo/bar", "/404"] { let path = format!("/{}{}", base, bad_path); - let escaped_path = RawStr::new(&path).html_escape(); + let escaped_path = RawStr::new(&path).html_escape().to_lowercase(); let response = client.get(&path).dispatch(); assert_eq!(response.status(), Status::NotFound); - let response = response.into_string().unwrap(); + let response = response.into_string().unwrap().to_lowercase(); assert!(response.contains(base)); assert! { @@ -66,6 +66,7 @@ fn test_index() { let response = client.get("/").dispatch().into_string().unwrap(); assert!(response.contains("Tera")); assert!(response.contains("Handlebars")); + assert!(response.contains("MiniJinja")); } #[test] @@ -83,3 +84,11 @@ fn tera() { test_404("tera"); test_about("tera"); } + +#[test] +fn minijinja() { + test_root("minijinja"); + test_name("minijinja"); + test_404("minijinja"); + test_about("minijinja"); +} \ No newline at end of file diff --git a/examples/templating/templates/minijinja/error/404.html.j2 b/examples/templating/templates/minijinja/error/404.html.j2 new file mode 100644 index 00000000..b04eb553 --- /dev/null +++ b/examples/templating/templates/minijinja/error/404.html.j2 @@ -0,0 +1,11 @@ + + + + + 404 - minijinja + + +

404: Hey! There's nothing here.

+ The page at {{ uri }} does not exist! + + diff --git a/examples/templating/templates/minijinja/footer.html.j2 b/examples/templating/templates/minijinja/footer.html.j2 new file mode 100644 index 00000000..98266dbe --- /dev/null +++ b/examples/templating/templates/minijinja/footer.html.j2 @@ -0,0 +1,3 @@ + diff --git a/examples/templating/templates/minijinja/index.html.j2 b/examples/templating/templates/minijinja/index.html.j2 new file mode 100644 index 00000000..55b6ba7e --- /dev/null +++ b/examples/templating/templates/minijinja/index.html.j2 @@ -0,0 +1,17 @@ +{% extends "minijinja/layout" %} + +{% block page %} +
+

Hi {{ name }}!

+

Here are your items:

+
    + {% for item in items %} +
  • {{ item }}
  • + {% endfor %} +
+
+ +
+

Try going to /minijinja/hello/Your Name.

+
+{% endblock %} diff --git a/examples/templating/templates/minijinja/layout.html.j2 b/examples/templating/templates/minijinja/layout.html.j2 new file mode 100644 index 00000000..b3c2ff84 --- /dev/null +++ b/examples/templating/templates/minijinja/layout.html.j2 @@ -0,0 +1,11 @@ + + + + Rocket Example - {{ title }} + + + {% include "minijinja/nav" %} + {% block page %}{% endblock %} + {% include "minijinja/footer" %} + + diff --git a/examples/templating/templates/minijinja/nav.html.j2 b/examples/templating/templates/minijinja/nav.html.j2 new file mode 100644 index 00000000..67dcde43 --- /dev/null +++ b/examples/templating/templates/minijinja/nav.html.j2 @@ -0,0 +1 @@ +Hello | About diff --git a/examples/todo/src/main.rs b/examples/todo/src/main.rs index a12f7ab4..b6411624 100644 --- a/examples/todo/src/main.rs +++ b/examples/todo/src/main.rs @@ -110,7 +110,7 @@ fn rocket() -> _ { .attach(DbConn::fairing()) .attach(Template::fairing()) .attach(AdHoc::on_ignite("Run Migrations", run_migrations)) - .mount("/", FileServer::from(relative!("static"))) + .mount("/", FileServer::new(relative!("static"))) .mount("/", routes![index]) .mount("/todo", routes![new, toggle, delete]) } diff --git a/examples/upgrade/src/main.rs b/examples/upgrade/src/main.rs index 7526b5b1..c9a69bd3 100644 --- a/examples/upgrade/src/main.rs +++ b/examples/upgrade/src/main.rs @@ -39,5 +39,5 @@ fn echo_raw(ws: ws::WebSocket) -> ws::Stream!['static] { fn rocket() -> _ { rocket::build() .mount("/", routes![echo_channel, echo_stream, echo_raw]) - .mount("/", FileServer::from(fs::relative!("static"))) + .mount("/", FileServer::new(fs::relative!("static"))) }