Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions examples/static_files/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@ use ohkami::prelude::*;
struct Options {
omit_dot_html: bool,
serve_dotfiles: bool,
etag: Option<fn(&std::fs::File) -> String>,
}
impl Default for Options {
fn default() -> Self {
Self {
omit_dot_html: false,
serve_dotfiles: false,
etag: None,
}
}
}

fn ohkami(Options { omit_dot_html, serve_dotfiles }: Options) -> Ohkami {
fn ohkami(Options { omit_dot_html, serve_dotfiles, etag }: Options) -> Ohkami {
Ohkami::new((
"/".Dir("./public")
.omit_extensions(if omit_dot_html {&["html"]} else {&[]})
.serve_dotfiles(serve_dotfiles),
.serve_dotfiles(serve_dotfiles)
.etag(etag),
))
}

Expand Down
126 changes: 126 additions & 0 deletions ohkami/src/header/etag.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
use std::borrow::Cow;

#[derive(Clone, Debug, PartialEq)]
pub enum ETag<'header> {
Any,
Strong(Cow<'header, str>),
Weak(Cow<'header, str>),
}

pub enum ETagError {
InvalidFormat,
InvalidCharactor,
}
impl std::fmt::Debug for ETagError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ETagError::InvalidFormat => "InvalidFormat",
ETagError::InvalidCharactor => "InvalidCharactor",
})
}
}
impl std::fmt::Display for ETagError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ETagError::InvalidFormat => "InvalidFormat(Etag must be * or a strong/weak tag)",
ETagError::InvalidCharactor => "InvalidCharactor(Etag can only contain ASCII characters)",
})
}
}
impl std::error::Error for ETagError {}

impl<'header> ETag<'header> {
pub fn serialize(&self) -> Cow<'static, str> {
match self {
ETag::Any => Cow::Borrowed("*"),
ETag::Strong(value) => Cow::Owned(format!("\"{value}\"")),
ETag::Weak(value) => Cow::Owned(format!("W/\"{value}\"")),
}
}

/// Parse a single ETag.
pub fn parse(mut raw: &'header str) -> Result<Self, ETagError> {
if raw == "*" {
Ok(ETag::Any)
} else {
let is_weak = raw.starts_with("W/");
if is_weak {
raw = &raw[2..];
}

raw = (raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"'))
.then(|| &raw[1..raw.len() - 1])
.ok_or(ETagError::InvalidFormat)?;

let _ = raw.is_ascii()
.then_some(())
.ok_or(ETagError::InvalidCharactor)?;

Ok(if is_weak {
ETag::Weak(Cow::Borrowed(raw))
} else {
ETag::Strong(Cow::Borrowed(raw))
})
}
}

/// Parse comma-separated ETags into an iterator of `Result<ETag, ETagError>`.
/// Invalid ETag is returned as `Err`.
pub fn try_iter_from(raw: &'header str) -> impl Iterator<Item = Result<Self, ETagError>> + 'header {
raw.split(", ").map(ETag::parse)
}

/// Parse comma-separated ETags into an iterator of `ETag`.
/// Invalid ETag is just ignored.
///
/// ## Example
///
/// ```
/// use ohkami::header::ETag;
///
/// # fn main() {
/// let mut etags = ETag::iter_from(
/// r#""abc123", W/"def456", "ghi789""#
/// );
///
/// assert_eq!(etags.next(), Some(ETag::Strong("abc123".into())));
/// assert_eq!(etags.next(), Some(ETag::Weak("def456".into())));
/// assert_eq!(etags.next(), Some(ETag::Strong("ghi789".into())));
/// assert_eq!(etags.next(), None);
///
/// let mut etags = ETag::iter_from("*");
/// assert_eq!(etags.next(), Some(ETag::Any));
/// assert_eq!(etags.next(), None);
/// # }
/// ```
pub fn iter_from(raw: &'header str) -> impl Iterator<Item = Self> + 'header {
raw.split(", ").filter_map(|it| ETag::parse(it).ok())
}

pub fn matches(&self, other: &ETag<'_>) -> bool {
match (self, other) {
(ETag::Any, _) | (_, ETag::Any) => true,
| (ETag::Strong(a), ETag::Strong(b))
| (ETag::Strong(a), ETag::Weak(b))
| (ETag::Weak(a), ETag::Strong(b))
| (ETag::Weak(a), ETag::Weak(b))
=> a == b,
}
}

pub fn into_owned(self) -> ETag<'static> {
match self {
ETag::Any => ETag::Any,
ETag::Strong(cow) => ETag::Strong(Cow::Owned(cow.into_owned())),
ETag::Weak(cow) => ETag::Weak(Cow::Owned(cow.into_owned())),
}
}
}

impl ETag<'static> {
pub fn new(value: String) -> Result<Self, ETagError> {
value.is_ascii()
.then_some(Self::Strong(value.into()))
.ok_or(ETagError::InvalidCharactor)
}
}
3 changes: 3 additions & 0 deletions ohkami/src/header/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ mod append;
pub use append::append;
pub(crate) use append::Append;

mod etag;
pub use etag::ETag;

mod setcookie;
pub(crate) use setcookie::*;

Expand Down
115 changes: 86 additions & 29 deletions ohkami/src/ohkami/dir.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#![cfg(feature="__rt_native__")]

use crate::handler::{Handler, IntoHandler};
use crate::response::Content;
use std::fs::File;
use crate::header::ETag;
use ohkami_lib::time::ImfFixdate;
use std::{io, fs::File};
use std::path::{PathBuf, Path};

pub struct Dir {
Expand All @@ -12,23 +13,24 @@ pub struct Dir {
/*=== config ===*/
pub(crate) serve_dotfiles: bool,
pub(crate) omit_extensions: &'static [&'static str],
pub(crate) etag: Option<fn(&File) -> String>,
}

impl Dir {
pub(super) fn new(route: &'static str, dir_path: std::path::PathBuf) -> std::io::Result<Self> {
pub(super) fn new(route: &'static str, dir_path: PathBuf) -> io::Result<Self> {
let dir_path = dir_path.canonicalize()?;

if !dir_path.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} is not directory", dir_path.display()))
)
}

let mut files = Vec::<(PathBuf, File)>::new(); {
fn fetch_entries(
dir: std::path::PathBuf
) -> std::io::Result<Vec<std::path::PathBuf>> {
dir: PathBuf
) -> io::Result<Vec<PathBuf>> {
dir.read_dir()?
.map(|de| de.map(|de| de.path()))
.collect()
Expand All @@ -39,7 +41,7 @@ impl Dir {
if entry.is_file() {
files.push((
entry.iter().skip(dir_path.iter().count()).collect(),
std::fs::File::open(entry)?
File::open(entry)?
));

} else if entry.is_dir() {
Expand All @@ -56,6 +58,7 @@ impl Dir {
files,
serve_dotfiles: false,
omit_extensions: &[],
etag: None,
})
}

Expand All @@ -75,55 +78,109 @@ impl Dir {
self.omit_extensions = extensions_to_omit;
self
}

/// Set a function to generate ETag for each file.
pub fn etag(mut self, etag: impl Into<Option<fn(&File) -> String>>) -> Self {
self.etag = etag.into();
self
}
}

#[derive(Clone)]
pub(super) struct StaticFileHandler {
mime: &'static str,
last_modified: ImfFixdate,
last_modified_str: String,
etag: Option<ETag<'static>>,
mime: &'static str,
content: std::sync::Arc<Vec<u8>>,
}

impl StaticFileHandler {
pub(super) fn new(path: &Path, file: std::fs::File) -> std::io::Result<Self> {
pub(super) fn new(
path: &Path,
file: File,
get_etag: Option<fn(&File) -> String>,
) -> io::Result<Self> {
let last_modified_str = ohkami_lib::time::UTCDateTime::from_unix_timestamp(
file
.metadata()?
.modified()?
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
).into_imf_fixdate();

let last_modified = ImfFixdate::parse(&last_modified_str)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

let etag = get_etag
.map(|f| ETag::new(f(&file)))
.transpose()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

let mime = ::mime_guess::from_path(path)
.first_raw()
.unwrap_or("application/octet-stream");

let mut content = vec![
u8::default();
file.metadata().unwrap().len() as usize
]; {use std::io::Read;
]; {use io::Read;
let mut file = file;
file.read_exact(&mut content)?;
}

Ok(Self { mime, content:std::sync::Arc::new(content) })
Ok(Self {
last_modified,
last_modified_str,
etag,
mime,
content: std::sync::Arc::new(content)
})
}
}

impl IntoHandler<File> for StaticFileHandler {
fn n_params(&self) -> usize {0}

fn into_handler(self) -> Handler {
let this: &'static StaticFileHandler
= Box::leak(Box::new(self));

Handler::new(|_| Box::pin(async {
let mut res = crate::Response::OK();
{
let content: &'static [u8] = &this.content;
res.headers.set()
.ContentType(this.mime)
.ContentLength(ohkami_lib::num::itoa(content.len()));
res.content = Content::Payload(content.into());
let this: &'static StaticFileHandler = Box::leak(Box::new(self));

Handler::new(|req| Box::pin(async {
use crate::{Response, header::ETag};

if let (Some(if_none_match), Some(etag)) = (req.headers.IfNoneMatch(), &this.etag) {
if ETag::iter_from(if_none_match).any(|it| it.matches(etag)) {
return Response::NotModified();
}
}
res
if let Some(if_modified_since) = req.headers.IfModifiedSince() {
let Ok(if_modified_since) = ImfFixdate::parse(if_modified_since) else {
return Response::BadRequest();
};
if if_modified_since >= this.last_modified {
return Response::NotModified();
}
}

Response::OK()
.with_payload(this.mime, &*this.content)
.with_headers(|h| h
.LastModified(&*this.last_modified_str)
.ETag(this.etag.as_ref().map(|etag| etag.serialize()))
)
}), #[cfg(feature="openapi")] {use crate::openapi;
openapi::Operation::with(openapi::Responses::new([(
200,
openapi::Response::when("OK")
.content(this.mime, openapi::string().format("binary"))
)]))
openapi::Operation::with(openapi::Responses::new([
(
200,
openapi::Response::when("OK")
.content(this.mime, openapi::string().format("binary"))
),
(
304,
openapi::Response::when("Not Modified")
)
]))
})
}
}
2 changes: 1 addition & 1 deletion ohkami/src/ohkami/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ const _: () = {
};

for (mut path, file) in self.files {
let handler = StaticFileHandler::new(&path, file)
let handler = StaticFileHandler::new(&path, file, self.etag)
.expect(&format!("can't serve file: `{}`", path.display()));

let file_name = path.file_name().unwrap().to_str()
Expand Down
3 changes: 2 additions & 1 deletion ohkami/src/response/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ macro_rules! Header {
}
}
};
} Header! {47;
} Header! {48;
AcceptRanges: b"Accept-Ranges",
AccessControlAllowCredentials: b"Access-Control-Allow-Credentials",
AccessControlAllowHeaders: b"Access-Control-Allow-Headers",
Expand Down Expand Up @@ -219,6 +219,7 @@ macro_rules! Header {
Expires: b"Expires",
Link: b"Link",
Location: b"Location",
LastModified: b"Last-Modified",
ProxyAuthenticate: b"Proxy-Authenticate",
ReferrerPolicy: b"Referrer-Policy",
Refresh: b"Refresh",
Expand Down
Loading