Backend: proper HTTP cache handling

This commit is contained in:
Zef Hemel
2026-06-21 19:10:10 +02:00
parent 73ba91c61e
commit e4e12f9988
3 changed files with 110 additions and 14 deletions
+1 -11
View File
@@ -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
+99 -3
View File
@@ -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`
+10
View File
@@ -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