From e4e12f9988be8b6b49a36eaddeeaa9975ab756a4 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Sun, 21 Jun 2026 19:10:10 +0200 Subject: [PATCH] Backend: proper HTTP cache handling --- server/src/handlers/bundle.rs | 12 +--- server/src/handlers/fs.rs | 102 +++++++++++++++++++++++++++++++++- server/src/handlers/mod.rs | 10 ++++ 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/server/src/handlers/bundle.rs b/server/src/handlers/bundle.rs index 8db053e2..fe00c4ad 100644 --- a/server/src/handlers/bundle.rs +++ b/server/src/handlers/bundle.rs @@ -5,21 +5,11 @@ use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use chrono::{TimeZone, Utc}; - +use crate::handlers::http_date; use crate::router::run_blocking; use crate::ssr::{convert_wiki_links, render_markdown}; use crate::state::ServerState; -/// Format a millisecond Unix timestamp as an HTTP-date (IMF-fixdate, GMT) for -/// `Last-Modified`. -fn http_date(ms: i64) -> String { - Utc.timestamp_millis_opt(ms) - .single() - .map(|dt| dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()) - .unwrap_or_default() -} - /// SPA fallback: serve a bundle asset by request path verbatim, or fall back to /// the templated `index.html` shell for any unknown path (client-side routing). /// For a public, read-only space the shell is filled with server-side-rendered diff --git a/server/src/handlers/fs.rs b/server/src/handlers/fs.rs index d540f084..50e8ac0a 100644 --- a/server/src/handlers/fs.rs +++ b/server/src/handlers/fs.rs @@ -6,7 +6,7 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use silverbullet_server_common::FileMeta; -use crate::handlers::space_error_response; +use crate::handlers::{http_date, space_error_response}; use crate::router::run_blocking; use crate::state::ServerState; @@ -50,6 +50,31 @@ pub async fn handle_fs_get( }; } + // Conditional request: `/.fs` serves mutable files with `Cache-Control: + // no-cache`, so the browser revalidates on every load. We emit a standard + // `Last-Modified` validator (see `set_file_meta_headers`) which the browser + // echoes back verbatim in `If-Modified-Since`; a string match means the file + // is unchanged and we can answer 304 without reading the (potentially large) + // body off disk — we only need a metadata-only `get_file_meta` probe here. + let if_modified_since = headers + .get(axum::http::header::IF_MODIFIED_SINCE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + if let Some(ims) = if_modified_since { + let state_inner = state.clone(); + let path_inner = path.clone(); + if let Ok(meta) = run_blocking(move || state_inner.space.get_file_meta(&path_inner)).await { + let last_modified = http_date(meta.last_modified); + if !last_modified.is_empty() && ims == last_modified { + return Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(axum::http::header::LAST_MODIFIED, &last_modified) + .body(Body::empty()) + .unwrap(); + } + } + } + let force_octet_stream = headers .get("accept") .and_then(|v| v.to_str().ok()) @@ -78,13 +103,20 @@ pub(crate) fn set_file_meta_headers( builder: axum::http::response::Builder, meta: &FileMeta, ) -> axum::http::response::Builder { - builder + let mut builder = builder .header("Content-Type", &meta.content_type) .header("X-Created", meta.created.to_string()) .header("X-Last-Modified", meta.last_modified.to_string()) .header("X-Content-Length", meta.size.to_string()) .header("X-Permission", &meta.perm) - .header("Cache-Control", "no-cache") + .header("Cache-Control", "no-cache"); + // Standard validator so the browser can revalidate `/.fs` files and get a + // 304 (handled in `handle_fs_get`) instead of refetching the full body. + let last_modified = http_date(meta.last_modified); + if !last_modified.is_empty() { + builder = builder.header(axum::http::header::LAST_MODIFIED, &last_modified); + } + builder } pub async fn handle_fs_put( @@ -222,6 +254,70 @@ mod tests { assert_eq!(&bytes[..], b"hello"); } + #[tokio::test] + async fn fs_get_supports_conditional_304() { + let state = Arc::new(test_state()); + state.space.write_file("big.js", b"payload", None).unwrap(); + // First request: 200 with a standard `Last-Modified` header. + let r1 = crate::build_router(state.clone()) + .oneshot( + Request::builder() + .uri("/.fs/big.js") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(r1.status(), StatusCode::OK); + let last_modified = r1 + .headers() + .get("last-modified") + .expect("Last-Modified present") + .to_str() + .unwrap() + .to_string(); + assert!(!last_modified.is_empty()); + + // Re-request echoing that value back: 304 Not Modified, empty body, but + // the `Last-Modified` validator is still present. + let r2 = crate::build_router(state.clone()) + .oneshot( + Request::builder() + .uri("/.fs/big.js") + .header("if-modified-since", &last_modified) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(r2.status(), StatusCode::NOT_MODIFIED); + assert_eq!( + r2.headers().get("last-modified").unwrap().to_str().unwrap(), + last_modified + ); + let body = axum::body::to_bytes(r2.into_body(), usize::MAX) + .await + .unwrap(); + assert!(body.is_empty()); + + // A stale `If-Modified-Since` must still serve the full body with 200. + let r3 = crate::build_router(state) + .oneshot( + Request::builder() + .uri("/.fs/big.js") + .header("if-modified-since", "Tue, 01 Jan 1980 00:00:00 GMT") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(r3.status(), StatusCode::OK); + let body = axum::body::to_bytes(r3.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"payload"); + } + #[tokio::test] async fn get_with_octet_stream_accept_overrides_content_type() { // The subtlest part of the /.fs contract: an `accept: application/octet-stream` diff --git a/server/src/handlers/mod.rs b/server/src/handlers/mod.rs index 5b049dfe..31af07fe 100644 --- a/server/src/handlers/mod.rs +++ b/server/src/handlers/mod.rs @@ -10,8 +10,18 @@ pub mod shell; use axum::body::Body; use axum::http::StatusCode; use axum::response::Response; +use chrono::{TimeZone, Utc}; use silverbullet_server_common::SpaceError; +/// Format a millisecond Unix timestamp as an HTTP-date (IMF-fixdate, GMT) for +/// `Last-Modified`. Returns an empty string for an out-of-range timestamp. +pub(crate) fn http_date(ms: i64) -> String { + Utc.timestamp_millis_opt(ms) + .single() + .map(|dt| dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()) + .unwrap_or_default() +} + /// Map a `SpaceError` to an HTTP response, matching the client's expectations /// (missing files are a plain 404). Client-side faults (paths escaping the /// space root, unauthorized) map to 4xx so callers can tell them apart from