feat(statics): assets versionnés par contenu (?v=hash), cache immuable, HTML no-storeUnverified
4328d27 parent: 23ab955 modified
src/routes/mod.rs +11 -0 | @@ -69,7 +69,18 @@ pub fn router(state: AppState) -> Router { | ||
| 69 | 69 | /// dans une iframe et de lire ses ressources depuis un autre site. |
| 70 | 70 | async fn security_headers(req: Request, next: Next) -> Response { |
| 71 | 71 | let mut res = next.run(req).await; |
| 72 | + // Les pages (dynamiques, jetons CSRF) ne doivent jamais être mises en | |
| 73 | + // cache — c'est aussi ce qui garantit qu'une PWA recharge un HTML frais | |
| 74 | + // pointant vers les assets versionnés après un déploiement. | |
| 75 | + let html = res | |
| 76 | + .headers() | |
| 77 | + .get("content-type") | |
| 78 | + .and_then(|v| v.to_str().ok()) | |
| 79 | + .is_some_and(|v| v.starts_with("text/html")); | |
| 72 | 80 | let headers = res.headers_mut(); |
| 81 | + if html { | |
| 82 | + headers.insert("cache-control", HeaderValue::from_static("no-store")); | |
| 83 | + } | |
| 73 | 84 | headers.insert("x-frame-options", HeaderValue::from_static("DENY")); |
| 74 | 85 | // frame-ancestors seulement : une CSP complète casserait le <script> |
| 75 | 86 | // inline d'add_step2 et les styles inline. |
| @@ -69,7 +69,18 @@ pub fn router(state: AppState) -> Router { | |||
| 69 | /// dans une iframe et de lire ses ressources depuis un autre site. | 69 | /// dans une iframe et de lire ses ressources depuis un autre site. |
| 70 | async fn security_headers(req: Request, next: Next) -> Response { | 70 | async fn security_headers(req: Request, next: Next) -> Response { |
| 71 | let mut res = next.run(req).await; | 71 | let mut res = next.run(req).await; |
| 72 | + // Les pages (dynamiques, jetons CSRF) ne doivent jamais être mises en | ||
| 73 | + // cache — c'est aussi ce qui garantit qu'une PWA recharge un HTML frais | ||
| 74 | + // pointant vers les assets versionnés après un déploiement. | ||
| 75 | + let html = res | ||
| 76 | + .headers() | ||
| 77 | + .get("content-type") | ||
| 78 | + .and_then(|v| v.to_str().ok()) | ||
| 79 | + .is_some_and(|v| v.starts_with("text/html")); | ||
| 72 | let headers = res.headers_mut(); | 80 | let headers = res.headers_mut(); |
| 81 | + if html { | ||
| 82 | + headers.insert("cache-control", HeaderValue::from_static("no-store")); | ||
| 83 | + } | ||
| 73 | headers.insert("x-frame-options", HeaderValue::from_static("DENY")); | 84 | headers.insert("x-frame-options", HeaderValue::from_static("DENY")); |
| 74 | // frame-ancestors seulement : une CSP complète casserait le <script> | 85 | // frame-ancestors seulement : une CSP complète casserait le <script> |
| 75 | // inline d'add_step2 et les styles inline. | 86 | // inline d'add_step2 et les styles inline. |
modified
src/routes/statics.rs +34 -7 | @@ -1,12 +1,42 @@ | ||
| 1 | +use std::hash::Hasher; | |
| 2 | +use std::sync::LazyLock; | |
| 3 | + | |
| 1 | 4 | use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; |
| 2 | 5 | use axum::response::IntoResponse; |
| 3 | 6 | |
| 4 | -const CACHE: &str = "public, max-age=86400"; | |
| 7 | +const CSS: &[u8] = include_bytes!("../../assets/style.css"); | |
| 8 | +const MANIFEST: &[u8] = include_bytes!("../../assets/manifest.json"); | |
| 9 | +const ICON: &[u8] = include_bytes!("../../assets/icon-512.png"); | |
| 10 | + | |
| 11 | +/// Les URLs sont versionnées par le contenu (`?v=<hash>`) : le cache peut | |
| 12 | +/// être long et immuable, un déploiement qui change l'asset change l'URL. | |
| 13 | +const CACHE: &str = "public, max-age=31536000, immutable"; | |
| 14 | + | |
| 15 | +fn versioned(path: &str, bytes: &[u8]) -> String { | |
| 16 | + let mut h = std::collections::hash_map::DefaultHasher::new(); | |
| 17 | + h.write(bytes); | |
| 18 | + format!("{path}?v={:08x}", h.finish() as u32) | |
| 19 | +} | |
| 20 | + | |
| 21 | +pub fn css_href() -> &'static str { | |
| 22 | + static HREF: LazyLock<String> = LazyLock::new(|| versioned("/static/style.css", CSS)); | |
| 23 | + &HREF | |
| 24 | +} | |
| 25 | + | |
| 26 | +pub fn manifest_href() -> &'static str { | |
| 27 | + static HREF: LazyLock<String> = LazyLock::new(|| versioned("/manifest.json", MANIFEST)); | |
| 28 | + &HREF | |
| 29 | +} | |
| 30 | + | |
| 31 | +pub fn icon_href() -> &'static str { | |
| 32 | + static HREF: LazyLock<String> = LazyLock::new(|| versioned("/icon-512.png", ICON)); | |
| 33 | + &HREF | |
| 34 | +} | |
| 5 | 35 | |
| 6 | 36 | pub async fn css() -> impl IntoResponse { |
| 7 | 37 | ( |
| 8 | 38 | [(CONTENT_TYPE, "text/css; charset=utf-8"), (CACHE_CONTROL, CACHE)], |
| 9 | - include_bytes!("../../assets/style.css").as_slice(), | |
| 39 | + CSS, | |
| 10 | 40 | ) |
| 11 | 41 | } |
| 12 | 42 | |
| @@ -16,13 +46,10 @@ pub async fn manifest() -> impl IntoResponse { | ||
| 16 | 46 | (CONTENT_TYPE, "application/manifest+json"), |
| 17 | 47 | (CACHE_CONTROL, CACHE), |
| 18 | 48 | ], |
| 19 | - include_bytes!("../../assets/manifest.json").as_slice(), | |
| 49 | + MANIFEST, | |
| 20 | 50 | ) |
| 21 | 51 | } |
| 22 | 52 | |
| 23 | 53 | pub async fn icon() -> impl IntoResponse { |
| 24 | - ( | |
| 25 | - [(CONTENT_TYPE, "image/png"), (CACHE_CONTROL, CACHE)], | |
| 26 | - include_bytes!("../../assets/icon-512.png").as_slice(), | |
| 27 | - ) | |
| 54 | + ([(CONTENT_TYPE, "image/png"), (CACHE_CONTROL, CACHE)], ICON) | |
| 28 | 55 | } |
| @@ -1,12 +1,42 @@ | |||
| 1 | +use std::hash::Hasher; | ||
| 2 | +use std::sync::LazyLock; | ||
| 3 | + | ||
| 1 | use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; | 4 | use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; |
| 2 | use axum::response::IntoResponse; | 5 | use axum::response::IntoResponse; |
| 3 | 6 | ||
| 4 | -const CACHE: &str = "public, max-age=86400"; | 7 | +const CSS: &[u8] = include_bytes!("../../assets/style.css"); |
| 8 | +const MANIFEST: &[u8] = include_bytes!("../../assets/manifest.json"); | ||
| 9 | +const ICON: &[u8] = include_bytes!("../../assets/icon-512.png"); | ||
| 10 | + | ||
| 11 | +/// Les URLs sont versionnées par le contenu (`?v=<hash>`) : le cache peut | ||
| 12 | +/// être long et immuable, un déploiement qui change l'asset change l'URL. | ||
| 13 | +const CACHE: &str = "public, max-age=31536000, immutable"; | ||
| 14 | + | ||
| 15 | +fn versioned(path: &str, bytes: &[u8]) -> String { | ||
| 16 | + let mut h = std::collections::hash_map::DefaultHasher::new(); | ||
| 17 | + h.write(bytes); | ||
| 18 | + format!("{path}?v={:08x}", h.finish() as u32) | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +pub fn css_href() -> &'static str { | ||
| 22 | + static HREF: LazyLock<String> = LazyLock::new(|| versioned("/static/style.css", CSS)); | ||
| 23 | + &HREF | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +pub fn manifest_href() -> &'static str { | ||
| 27 | + static HREF: LazyLock<String> = LazyLock::new(|| versioned("/manifest.json", MANIFEST)); | ||
| 28 | + &HREF | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +pub fn icon_href() -> &'static str { | ||
| 32 | + static HREF: LazyLock<String> = LazyLock::new(|| versioned("/icon-512.png", ICON)); | ||
| 33 | + &HREF | ||
| 34 | +} | ||
| 5 | 35 | ||
| 6 | pub async fn css() -> impl IntoResponse { | 36 | pub async fn css() -> impl IntoResponse { |
| 7 | ( | 37 | ( |
| 8 | [(CONTENT_TYPE, "text/css; charset=utf-8"), (CACHE_CONTROL, CACHE)], | 38 | [(CONTENT_TYPE, "text/css; charset=utf-8"), (CACHE_CONTROL, CACHE)], |
| 9 | - include_bytes!("../../assets/style.css").as_slice(), | 39 | + CSS, |
| 10 | ) | 40 | ) |
| 11 | } | 41 | } |
| 12 | 42 | ||
| @@ -16,13 +46,10 @@ pub async fn manifest() -> impl IntoResponse { | |||
| 16 | (CONTENT_TYPE, "application/manifest+json"), | 46 | (CONTENT_TYPE, "application/manifest+json"), |
| 17 | (CACHE_CONTROL, CACHE), | 47 | (CACHE_CONTROL, CACHE), |
| 18 | ], | 48 | ], |
| 19 | - include_bytes!("../../assets/manifest.json").as_slice(), | 49 | + MANIFEST, |
| 20 | ) | 50 | ) |
| 21 | } | 51 | } |
| 22 | 52 | ||
| 23 | pub async fn icon() -> impl IntoResponse { | 53 | pub async fn icon() -> impl IntoResponse { |
| 24 | - ( | 54 | + ([(CONTENT_TYPE, "image/png"), (CACHE_CONTROL, CACHE)], ICON) |
| 25 | - [(CONTENT_TYPE, "image/png"), (CACHE_CONTROL, CACHE)], | ||
| 26 | - include_bytes!("../../assets/icon-512.png").as_slice(), | ||
| 27 | - ) | ||
| 28 | } | 55 | } |
modified
templates/base.html +4 -4 | @@ -6,10 +6,10 @@ | ||
| 6 | 6 | <meta name="theme-color" content="#f5ead8"> |
| 7 | 7 | <meta name="apple-mobile-web-app-title" content="time-time"> |
| 8 | 8 | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
| 9 | -<link rel="manifest" href="/manifest.json"> | |
| 10 | -<link rel="icon" href="/icon-512.png"> | |
| 11 | -<link rel="apple-touch-icon" href="/icon-512.png"> | |
| 12 | -<link rel="stylesheet" href="/static/style.css"> | |
| 9 | +<link rel="manifest" href="{{ crate::routes::statics::manifest_href() }}"> | |
| 10 | +<link rel="icon" href="{{ crate::routes::statics::icon_href() }}"> | |
| 11 | +<link rel="apple-touch-icon" href="{{ crate::routes::statics::icon_href() }}"> | |
| 12 | +<link rel="stylesheet" href="{{ crate::routes::statics::css_href() }}"> | |
| 13 | 13 | <title>{% block title %}time-time{% endblock %}</title> |
| 14 | 14 | </head> |
| 15 | 15 | <body> |
| @@ -6,10 +6,10 @@ | |||
| 6 | <meta name="theme-color" content="#f5ead8"> | 6 | <meta name="theme-color" content="#f5ead8"> |
| 7 | <meta name="apple-mobile-web-app-title" content="time-time"> | 7 | <meta name="apple-mobile-web-app-title" content="time-time"> |
| 8 | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> | 8 | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
| 9 | -<link rel="manifest" href="/manifest.json"> | 9 | +<link rel="manifest" href="{{ crate::routes::statics::manifest_href() }}"> |
| 10 | -<link rel="icon" href="/icon-512.png"> | 10 | +<link rel="icon" href="{{ crate::routes::statics::icon_href() }}"> |
| 11 | -<link rel="apple-touch-icon" href="/icon-512.png"> | 11 | +<link rel="apple-touch-icon" href="{{ crate::routes::statics::icon_href() }}"> |
| 12 | -<link rel="stylesheet" href="/static/style.css"> | 12 | +<link rel="stylesheet" href="{{ crate::routes::statics::css_href() }}"> |
| 13 | <title>{% block title %}time-time{% endblock %}</title> | 13 | <title>{% block title %}time-time{% endblock %}</title> |
| 14 | </head> | 14 | </head> |
| 15 | <body> | 15 | <body> |