diff --git a/src/api/mod.rs b/src/api/mod.rs index 05c4215d..9a79ce95 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -30,7 +30,7 @@ pub use crate::api::{ }, web::catchers as web_catchers, web::routes as web_routes, - web::static_files, + web::{invalidate_css_cache, static_files}, }; use crate::{ CONFIG, diff --git a/src/api/web.rs b/src/api/web.rs index 5bd4c85d..a7eca9fc 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; use rocket::{ Catcher, Route, @@ -13,12 +16,13 @@ use crate::{ CONFIG, api::{ApiResult, EmptyResult, core::now}, auth::decode_file_download, + crypto::sha256_hex, db::{ DbConn, models::{AttachmentId, CipherId}, }, error::Error, - util::Cached, + util::{Cached, EtagCached}, }; pub fn routes() -> Vec { @@ -63,8 +67,27 @@ fn not_found() -> ApiResult> { Ok(Html(text)) } +struct CssCache { + css: String, + etag: String, +} + +static CSS_CACHE: RwLock>> = RwLock::new(None); + +pub fn invalidate_css_cache() { + *CSS_CACHE.write().unwrap() = None; +} + #[get("/css/vaultwarden.css")] -fn vaultwarden_css() -> Cached> { +fn vaultwarden_css() -> EtagCached> { + // If reload_templates is false, and we already have the CSS Cached, return this + if !CONFIG.reload_templates() + && let Some(cached) = CSS_CACHE.read().unwrap().as_ref() + { + return EtagCached::new(Css(cached.css.clone()), &cached.etag); + } + + // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS let css_options = json!({ "emergency_access_allowed": CONFIG.emergency_access_allowed(), "load_user_scss": true, @@ -112,8 +135,18 @@ fn vaultwarden_css() -> Cached> { } }; - // Cache for one day should be enough and not too much - Cached::ttl(Css(css), 86_400, false) + let etag = sha256_hex(css.as_bytes()); + let cached = Arc::new(CssCache { + css, + etag, + }); + + if !CONFIG.reload_templates() { + *CSS_CACHE.write().unwrap() = Some(Arc::clone(&cached)); + } + + // Etag Caching will let the browser send us an etag to verify and send new content if needed + EtagCached::new(Css(cached.css.clone()), &cached.etag) } #[get("/")] diff --git a/src/config.rs b/src/config.rs index 687e2aaf..d5b50146 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1506,6 +1506,9 @@ impl Config { let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; operator.write(&CONFIG_FILENAME, config_str).await?; + // Invalidate CSS Cache because several config items might have impact on the rendered CSS + crate::api::invalidate_css_cache(); + Ok(()) } @@ -1588,6 +1591,9 @@ impl Config { writer._overrides = Vec::new(); } + // Invalidate CSS Cache because several config items might have impact on the rendered CSS + crate::api::invalidate_css_cache(); + Ok(()) } diff --git a/src/util.rs b/src/util.rs index 91f075d1..0e8a93e4 100644 --- a/src/util.rs +++ b/src/util.rs @@ -257,6 +257,44 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache } } +pub struct EtagCached { + response: R, + etag: String, +} + +impl EtagCached { + /// An `etag` response should always be quoted + pub fn new(response: R, etag: &str) -> Self { + Self { + response, + etag: format!("\"{etag}\""), + } + } +} + +impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagCached { + fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { + // Check and validate a `If-None-Match` ETag header + // Multiple tags could be returned for the same URI if the browser has multiple versions cached + // Also, weak tags are prefixed with `W/`, but ETags are always weak, so just strip it too before comparing + let etag_matches = request + .headers() + .get_one("If-None-Match") + .is_some_and(|v| v.split(',').any(|t| t.trim().trim_start_matches("W/") == self.etag)); + + let mut res = if etag_matches { + Response::build().status(Status::NotModified).ok()? + } else { + self.response.respond_to(request)? + }; + + // Both 200 (OK) and 304 (Not Modified) need to return the etag and cache-control + res.set_raw_header("Etag", self.etag); + res.set_raw_header("Cache-Control", "public, no-cache"); + Ok(res) + } +} + // Log all the routes from the main paths list, and the attachments endpoint // Effectively ignores, any static file route, and the alive endpoint const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];