mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 05:28:15 +02:00
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>
This commit is contained in:
1 parent
a99d988e70
commit
d4fa4488cc
13 files changed
+1023
-18
No files matched your search
@@ -0,0 +1,214 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user