Files
Loki/rust/loki-rs/src/ui.rs
T
MichaelandClaude Opus 5.5 d4fa4488cc Façade Rust, lot 2 : interface, authentification et /api/ping
L'interface (index, marked, service worker, manifeste, polices) est
désormais servie par loki-rs, embarquée par build.rs depuis la même source
que le go:embed — les deux binaires d'un build servent la même UI. Mêmes
en-têtes de cache que le Go, gzip à la première demande puis gardé en
mémoire, jamais sur les polices ni en HEAD.

L'authentification de pilotage est portée à l'identique (requireWebAuth,
crossSiteReject, localHostName, Bearer en temps constant, mêmes messages,
mêmes codes) ; les cas de TestCrossSiteReject sont repris un pour un. La
clé est demandée à l'API interne du Go à chaque requête ; si elle ne
répond pas proprement, la façade ferme (503). /api/ping est la première
route authentifiée servie par Rust.

Le jeton de l'API interne est tiré au hasard à chaque démarrage et passé
au Go par l'environnement. La façade rend 404 sur /internal/* venu de
l'extérieur et efface X-Loki-Internal entrant avant tout relais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:40:10 +02:00

215 lines
7.1 KiB
Rust

//! UI web statique, servie par la façade (lot 2). Mêmes en-têtes que les
//! routes de `web_server.go`, même compression que `web_gzip.go`.
use crate::proxy::Body;
use bytes::Bytes;
use flate2::Compression;
use flate2::write::GzEncoder;
use http_body_util::{BodyExt, Full};
use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Response};
use std::io::Write;
use std::sync::OnceLock;
mod assets {
include!(concat!(env!("OUT_DIR"), "/ui_assets.rs"));
}
struct Asset {
body: &'static [u8],
ctype: &'static str,
cache: &'static str,
/// Version gzip, calculée à la première demande qui l'accepte.
gz: OnceLock<Vec<u8>>,
}
impl Asset {
const fn new(body: &'static [u8], ctype: &'static str, cache: &'static str) -> Self {
Asset {
body,
ctype,
cache,
gz: OnceLock::new(),
}
}
}
const NO_STORE: &str = "no-store, max-age=0";
static INDEX: Asset = Asset::new(assets::INDEX_HTML, "text/html; charset=utf-8", NO_STORE);
static MARKED: Asset = Asset::new(
assets::MARKED_MIN_JS,
"application/javascript",
"public, max-age=86400",
);
static SW: Asset = Asset::new(assets::SW_JS, "application/javascript", NO_STORE);
static MANIFEST: Asset = Asset::new(
assets::MANIFEST_WEBMANIFEST,
"application/manifest+json",
"public, max-age=3600",
);
/// Réponse statique pour cette requête, ou `None` si la route n'est pas une
/// ressource de l'UI (elle part alors au Go, qui rendra son 404 habituel).
pub fn serve(method: &Method, path: &str, headers: &HeaderMap) -> Option<Response<Body>> {
let head = method == Method::HEAD;
if !head && method != Method::GET {
return None;
}
// Comme gzipHandler : HEAD jamais compressé, et Vary posé dès que le
// client accepte gzip (même pour une réponse qui ne l'est pas).
let accepts_gzip = !head
&& headers
.get_all(header::ACCEPT_ENCODING)
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| v.contains("gzip"));
let mut resp = match path {
"/" | "/index.html" => asset(&INDEX, accepts_gzip),
"/marked.min.js" => asset(&MARKED, accepts_gzip),
"/sw.js" => {
let mut r = asset(&SW, accepts_gzip);
r.headers_mut()
.insert("service-worker-allowed", HeaderValue::from_static("/"));
r
}
"/manifest.webmanifest" => asset(&MANIFEST, accepts_gzip),
p if p.starts_with("/fonts/") => font(p)?,
_ => return None,
};
if accepts_gzip {
resp.headers_mut()
.append(header::VARY, HeaderValue::from_static("Accept-Encoding"));
}
if head {
let len = resp.headers().get(header::CONTENT_LENGTH).cloned();
let mut empty = Response::new(full(Bytes::new()));
*empty.headers_mut() = std::mem::take(resp.headers_mut());
if let Some(len) = len {
empty.headers_mut().insert(header::CONTENT_LENGTH, len);
}
return Some(empty);
}
Some(resp)
}
fn asset(a: &'static Asset, gzip: bool) -> Response<Body> {
let (body, encoded) = if gzip {
(a.gz.get_or_init(|| compress(a.body)).as_slice(), true)
} else {
(a.body, false)
};
let mut r = Response::new(full(Bytes::from_static(body)));
let h = r.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(a.ctype));
h.insert(header::CACHE_CONTROL, HeaderValue::from_static(a.cache));
h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len()));
if encoded {
h.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
}
r
}
/// `/fonts/<nom>.woff2` — comme le Go, seul le dernier segment compte.
/// Jamais compressées : woff2 l'est déjà.
fn font(path: &str) -> Option<Response<Body>> {
let name = path.rsplit('/').next()?;
if !name.ends_with(".woff2") {
return None;
}
let (_, body) = assets::FONTS.iter().find(|(n, _)| *n == name)?;
let mut r = Response::new(full(Bytes::from_static(body)));
let h = r.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("font/woff2"));
h.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len()));
Some(r)
}
fn compress(b: &[u8]) -> Vec<u8> {
let mut enc = GzEncoder::new(Vec::with_capacity(b.len() / 3), Compression::default());
enc.write_all(b).expect("gzip en mémoire");
enc.finish().expect("gzip en mémoire")
}
fn full(b: Bytes) -> Body {
Full::new(b).map_err(|n| match n {}).boxed()
}
/// Taille de l'index embarqué (tests).
pub fn index_bytes() -> &'static [u8] {
INDEX.body
}
#[cfg(test)]
mod tests {
use super::*;
fn get(path: &str, gzip: bool) -> Option<Response<Body>> {
let mut h = HeaderMap::new();
if gzip {
h.insert(
header::ACCEPT_ENCODING,
HeaderValue::from_static("gzip, br"),
);
}
serve(&Method::GET, path, &h)
}
fn hv<'a>(r: &'a Response<Body>, k: &str) -> &'a str {
r.headers()
.get(k)
.map(|v| v.to_str().unwrap())
.unwrap_or("")
}
#[test]
fn en_tetes_comme_le_go() {
let r = get("/", false).unwrap();
assert_eq!(hv(&r, "content-type"), "text/html; charset=utf-8");
assert_eq!(hv(&r, "cache-control"), "no-store, max-age=0");
assert_eq!(hv(&r, "vary"), "");
let r = get("/sw.js", false).unwrap();
assert_eq!(hv(&r, "service-worker-allowed"), "/");
assert_eq!(hv(&r, "cache-control"), "no-store, max-age=0");
let r = get("/marked.min.js", false).unwrap();
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
let r = get("/manifest.webmanifest", false).unwrap();
assert_eq!(hv(&r, "content-type"), "application/manifest+json");
}
#[test]
fn polices() {
let (name, _) = assets::FONTS[0];
let r = get(&format!("/fonts/{name}"), true).unwrap();
assert_eq!(hv(&r, "content-type"), "font/woff2");
assert_eq!(hv(&r, "content-encoding"), "");
assert_eq!(hv(&r, "vary"), "Accept-Encoding");
assert!(get("/fonts/absente.woff2", false).is_none());
assert!(get("/fonts/inter.ttf", false).is_none());
}
#[test]
fn gzip_si_accepte() {
let r = get("/", true).unwrap();
assert_eq!(hv(&r, "content-encoding"), "gzip");
assert_eq!(hv(&r, "vary"), "Accept-Encoding");
let len: usize = hv(&r, "content-length").parse().unwrap();
assert!(len < index_bytes().len() / 2, "gzip inefficace : {len}");
}
#[test]
fn head_et_methodes() {
let h = HeaderMap::new();
let r = serve(&Method::HEAD, "/", &h).unwrap();
assert_eq!(hv(&r, "content-length"), index_bytes().len().to_string());
assert!(serve(&Method::POST, "/", &h).is_none());
assert!(get("/api/status", false).is_none());
assert!(get("/index.htm", false).is_none());
}
}