sova-static 0.1.1

Static file plugin for Sova
Documentation
//! Conditional static file serving (ETag / 304 / Range / Cache-Control).

use http::{header, HeaderValue};
use sova_core::{Request, Response};
use std::path::{Component, Path, PathBuf};
use std::time::{Duration, SystemTime};
use tokio::io::{AsyncReadExt, AsyncSeekExt};

pub(crate) struct StaticOpts {
    pub max_age: Duration,
    pub immutable: bool,
    pub allow_dotfiles: bool,
}

pub(crate) struct FileOptions<'a> {
    pub range: Option<&'a str>,
    pub if_none_match: Option<&'a str>,
    pub if_modified_since: Option<&'a str>,
}

impl<'a> FileOptions<'a> {
    pub(crate) fn from_request(req: &'a Request) -> Self {
        Self {
            range: req.header("range"),
            if_none_match: req.header("if-none-match"),
            if_modified_since: req.header("if-modified-since"),
        }
    }
}

pub(crate) async fn serve_in(
    dir: &Path,
    relative: &Path,
    opts: FileOptions<'_>,
    static_opts: &StaticOpts,
) -> Response {
    if !is_safe_relative(relative) {
        return Response::text("Forbidden").status(403);
    }
    if !static_opts.allow_dotfiles && has_dotfile_segment(relative) {
        return Response::text("Forbidden").status(403);
    }

    let full = match resolve_safe(dir, relative).await {
        Ok(p) => p,
        Err(status) => {
            return Response::text(if status == 403 {
                "Forbidden"
            } else {
                "File Not Found"
            })
            .status(status);
        }
    };

    let meta = match tokio::fs::metadata(&full).await {
        Ok(m) if m.is_file() => m,
        Ok(_) => return Response::text("Forbidden").status(403),
        Err(_) => return Response::text("File Not Found").status(404),
    };

    let len = meta.len();
    let modified = meta.modified().ok();
    let etag = format_etag(len, modified);
    let mime = mime_guess::from_path(&full)
        .first_or_octet_stream()
        .essence_str()
        .to_string();

    if not_modified(&etag, modified, opts.if_none_match, opts.if_modified_since) {
        let mut res = Response::empty().status(304);
        set_file_headers(&mut res, &mime, &etag, modified, None, static_opts);
        return res;
    }

    if let Some(range) = opts.range.and_then(parse_byte_range) {
        if let Some((start, end)) = range.resolve(len) {
            return serve_range(
                &full,
                &mime,
                &etag,
                modified,
                start,
                end,
                len,
                static_opts,
            )
            .await;
        }
        return Response::text("Range Not Satisfiable")
            .status(416)
            .header("content-range", format!("bytes */{len}"));
    }

    let mut res = Response::file_in(dir, relative).await;
    if res.status_code().as_u16() != 200 {
        return res;
    }
    set_file_headers(&mut res, &mime, &etag, modified, Some(len), static_opts);
    res
}

fn has_dotfile_segment(path: &Path) -> bool {
    path.components().any(|c| match c {
        Component::Normal(s) => s.to_string_lossy().starts_with('.'),
        _ => false,
    })
}

fn is_safe_relative(path: &Path) -> bool {
    path.components()
        .all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
}

fn not_modified(
    etag: &str,
    modified: Option<SystemTime>,
    if_none_match: Option<&str>,
    if_modified_since: Option<&str>,
) -> bool {
    if let Some(inm) = if_none_match {
        let inm = inm.trim();
        if inm == "*" || inm.split(',').any(|t| t.trim() == etag) {
            return true;
        }
    }
    if let (Some(ims), Some(m)) = (if_modified_since, modified) {
        if httpdate::fmt_http_date(m) == ims.trim() {
            return true;
        }
    }
    false
}

#[allow(clippy::too_many_arguments)]
async fn serve_range(
    full: &Path,
    mime: &str,
    etag: &str,
    modified: Option<SystemTime>,
    start: u64,
    end: u64,
    total: u64,
    static_opts: &StaticOpts,
) -> Response {
    let mut file = match tokio::fs::File::open(full).await {
        Ok(f) => f,
        Err(_) => return Response::text("File Not Found").status(404),
    };
    if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
        return Response::text("Range Not Satisfiable").status(416);
    }
    let to_read = (end - start + 1) as usize;
    let mut buf = vec![0u8; to_read];
    if file.read_exact(&mut buf).await.is_err() {
        return Response::text("Range Not Satisfiable").status(416);
    }

    let mut res = Response::empty().status(206);
    res.set_body(bytes::Bytes::from(buf));
    set_file_headers(
        &mut res,
        mime,
        etag,
        modified,
        Some(end - start + 1),
        static_opts,
    );
    res.headers_mut().insert(
        header::CONTENT_RANGE,
        HeaderValue::from_str(&format!("bytes {start}-{end}/{total}")).expect("content-range"),
    );
    res
}

fn set_file_headers(
    res: &mut Response,
    mime: &str,
    etag: &str,
    modified: Option<SystemTime>,
    len: Option<u64>,
    static_opts: &StaticOpts,
) {
    if let Ok(v) = HeaderValue::from_str(mime) {
        res.headers_mut().insert(header::CONTENT_TYPE, v);
    }
    if let Ok(v) = HeaderValue::from_str(etag) {
        res.headers_mut().insert(header::ETAG, v);
    }
    if let Some(m) = modified {
        if let Ok(v) = HeaderValue::from_str(&httpdate::fmt_http_date(m)) {
            res.headers_mut().insert(header::LAST_MODIFIED, v);
        }
    }
    res.headers_mut()
        .insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));

    let secs = static_opts.max_age.as_secs();
    let cc = if static_opts.immutable {
        format!("public, max-age={secs}, immutable")
    } else {
        format!("public, max-age={secs}")
    };
    if let Ok(v) = HeaderValue::from_str(&cc) {
        res.headers_mut().insert(header::CACHE_CONTROL, v);
    }

    if let Some(len) = len {
        if let Ok(v) = HeaderValue::from_str(&len.to_string()) {
            res.headers_mut().insert(header::CONTENT_LENGTH, v);
        }
    }
}

async fn resolve_safe(dir: &Path, relative: &Path) -> Result<PathBuf, u16> {
    let base = tokio::fs::canonicalize(dir).await.map_err(|_| 404u16)?;
    let candidate = dir.join(relative);
    let canon = tokio::fs::canonicalize(&candidate)
        .await
        .map_err(|_| 404u16)?;
    if !canon.starts_with(&base) {
        return Err(403);
    }
    Ok(canon)
}

struct ByteRange {
    start: Option<u64>,
    end: Option<u64>,
}

impl ByteRange {
    fn resolve(&self, len: u64) -> Option<(u64, u64)> {
        if len == 0 {
            return None;
        }
        let start = self.start.unwrap_or(0);
        let end = self.end.unwrap_or(len.saturating_sub(1)).min(len - 1);
        if start > end || start >= len {
            return None;
        }
        Some((start, end))
    }
}

fn parse_byte_range(header: &str) -> Option<ByteRange> {
    let header = header.strip_prefix("bytes=")?;
    let (start, end) = header.split_once('-')?;
    let start = if start.is_empty() {
        None
    } else {
        Some(start.parse().ok()?)
    };
    let end = if end.is_empty() {
        None
    } else {
        Some(end.parse().ok()?)
    };
    Some(ByteRange { start, end })
}

fn format_etag(len: u64, modified: Option<SystemTime>) -> String {
    modified
        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| format!("\"{:x}-{:x}\"", d.as_secs(), len))
        .unwrap_or_else(|| format!("\"{len:x}\""))
}