v0.8.15
This commit is contained in:
+36
-8
@@ -29,7 +29,8 @@ use crate::{
|
||||
};
|
||||
|
||||
const INDEX_HTML: &str = include_str!("../web/index.html");
|
||||
const APP_JS: &str = include_str!("../web/app.js");
|
||||
const NOT_FOUND_HTML: &str = include_str!("../web/404.html");
|
||||
const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.js"));
|
||||
const THEME_INIT_JS: &str = include_str!("../web/theme-init.js");
|
||||
const STYLES_CSS: &str = include_str!("../web/styles.css");
|
||||
const MANIFEST: &str = include_str!("../web/manifest.webmanifest");
|
||||
@@ -37,6 +38,26 @@ const SERVICE_WORKER: &str = include_str!("../web/sw.js");
|
||||
const FAVICON: &str = include_str!("../web/favicon.svg");
|
||||
include!(concat!(env!("OUT_DIR"), "/languages.rs"));
|
||||
|
||||
const SPA_ROUTES: &[&str] = &[
|
||||
"/dashboard",
|
||||
"/devices",
|
||||
"/zones",
|
||||
"/groups",
|
||||
"/schedules",
|
||||
"/automations",
|
||||
"/simulation",
|
||||
"/night-mode",
|
||||
"/home-assistant",
|
||||
"/settings",
|
||||
"/events",
|
||||
"/history",
|
||||
"/history/overview",
|
||||
"/history/zones",
|
||||
"/history/devices",
|
||||
"/history/sensors",
|
||||
"/history/custom",
|
||||
];
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let protected = Router::new()
|
||||
.route("/api/bootstrap", get(bootstrap))
|
||||
@@ -88,23 +109,29 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/integrations/home-assistant/zones/:id/control", post(update_home_assistant_zone_control))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
|
||||
|
||||
let app = Router::new()
|
||||
let mut app = Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/ws", get(websocket))
|
||||
.route("/", get(index))
|
||||
.route("/index.html", get(index))
|
||||
.route("/app.js", get(app_js))
|
||||
.route("/theme-init.js", get(theme_init_js))
|
||||
.route("/styles.css", get(styles_css))
|
||||
.route(APP_JS_ASSET_PATH, get(app_js))
|
||||
.route(THEME_INIT_ASSET_PATH, get(theme_init_js))
|
||||
.route(STYLES_CSS_ASSET_PATH, get(styles_css))
|
||||
.route("/app.js", get(app_js_legacy))
|
||||
.route("/theme-init.js", get(theme_init_js_legacy))
|
||||
.route("/styles.css", get(styles_css_legacy))
|
||||
.route("/manifest.webmanifest", get(manifest))
|
||||
.route("/sw.js", get(service_worker))
|
||||
.route("/favicon.svg", get(favicon))
|
||||
.route("/lang/index.json", get(language_index))
|
||||
.route("/lang/:file", get(language_file))
|
||||
.merge(protected)
|
||||
.merge(home_assistant_api)
|
||||
.fallback(index)
|
||||
;
|
||||
.merge(home_assistant_api);
|
||||
|
||||
for &route in SPA_ROUTES {
|
||||
app = app.route(route, get(index));
|
||||
}
|
||||
let app = app.fallback(not_found);
|
||||
|
||||
let app = if state.config.base_path.is_empty() {
|
||||
app
|
||||
@@ -117,6 +144,7 @@ pub fn router(state: AppState) -> Router {
|
||||
async move { Redirect::permanent(&redirect_to) }
|
||||
}))
|
||||
.nest(&base, app)
|
||||
.fallback(not_found)
|
||||
};
|
||||
|
||||
app
|
||||
|
||||
+51
-11
@@ -1,27 +1,60 @@
|
||||
async fn not_found(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let base = request_base_path(&state, &headers);
|
||||
let home = if base.is_empty() { "/".to_owned() } else { format!("{base}/") };
|
||||
let body = NOT_FOUND_HTML
|
||||
.replace("__GREE_BASE_PATH__", &base)
|
||||
.replace("__GREE_HOME_PATH__", &home)
|
||||
.replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}"))
|
||||
.replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}"));
|
||||
let mut response = Response::new(Body::from(body));
|
||||
*response.status_mut() = StatusCode::NOT_FOUND;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, must-revalidate"));
|
||||
response
|
||||
}
|
||||
|
||||
async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let base = if !state.config.base_path.is_empty() {
|
||||
state.config.base_path.clone()
|
||||
} else {
|
||||
forwarded_prefix(&headers).unwrap_or_default()
|
||||
};
|
||||
let body = INDEX_HTML.replace("__GREE_BASE_PATH__", &base);
|
||||
let base = request_base_path(&state, &headers);
|
||||
let body = INDEX_HTML
|
||||
.replace("__GREE_BASE_PATH__", &base)
|
||||
.replace("__GREE_APP_ASSET__", &format!("{base}{APP_JS_ASSET_PATH}"))
|
||||
.replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}"))
|
||||
.replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}"));
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, must-revalidate"));
|
||||
response
|
||||
}
|
||||
|
||||
fn request_base_path(state: &AppState, headers: &HeaderMap) -> String {
|
||||
if !state.config.base_path.is_empty() {
|
||||
state.config.base_path.clone()
|
||||
} else {
|
||||
forwarded_prefix(headers).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn forwarded_prefix(headers: &HeaderMap) -> Option<String> {
|
||||
let raw = headers.get("x-forwarded-prefix")?.to_str().ok()?.split(',').next()?.trim();
|
||||
if raw.is_empty() || raw == "/" { return Some(String::new()); }
|
||||
if raw.contains('?') || raw.contains('#') || raw.split('/').any(|part| matches!(part, "." | "..")) { return None; }
|
||||
Some(format!("/{}", raw.trim_matches('/')))
|
||||
}
|
||||
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") }
|
||||
async fn theme_init_js() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "public, max-age=86400") }
|
||||
async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") }
|
||||
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "public, max-age=31536000, immutable") }
|
||||
async fn app_js_legacy() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") }
|
||||
async fn theme_init_js() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "public, max-age=31536000, immutable") }
|
||||
async fn theme_init_js_legacy() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "no-cache") }
|
||||
async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "public, max-age=31536000, immutable") }
|
||||
async fn styles_css_legacy() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") }
|
||||
async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") }
|
||||
async fn service_worker() -> Response { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") }
|
||||
async fn service_worker() -> Response {
|
||||
let body = SERVICE_WORKER
|
||||
.replace("__GREE_ASSET_CACHE__", ASSET_BUILD_ID)
|
||||
.replace("__GREE_APP_ASSET__", APP_JS_ASSET_PATH)
|
||||
.replace("__GREE_THEME_INIT_ASSET__", THEME_INIT_ASSET_PATH)
|
||||
.replace("__GREE_STYLES_ASSET__", STYLES_CSS_ASSET_PATH);
|
||||
owned_response(body, "application/javascript; charset=utf-8", "no-cache")
|
||||
}
|
||||
async fn favicon() -> Response { static_response(FAVICON, "image/svg+xml", "public, max-age=86400") }
|
||||
async fn language_index() -> Response {
|
||||
static_response(LANGUAGE_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache")
|
||||
@@ -42,3 +75,10 @@ fn static_response(body: &'static str, content_type: &'static str, cache: &'stat
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
response
|
||||
}
|
||||
|
||||
fn owned_response(body: String, content_type: &'static str, cache: &'static str) -> Response {
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
response
|
||||
}
|
||||
|
||||
+16
-5
@@ -1,13 +1,24 @@
|
||||
async fn security_headers(request: Request, next: Next) -> Response {
|
||||
let is_api = request.uri().path().contains("/api/");
|
||||
let mut response = next.run(request).await;
|
||||
let is_html = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.split(';').next().is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/html")));
|
||||
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"));
|
||||
headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN"));
|
||||
headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin"));
|
||||
headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"));
|
||||
headers.insert(header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"));
|
||||
if is_api { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); }
|
||||
|
||||
if is_html {
|
||||
headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN"));
|
||||
headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"));
|
||||
headers.insert(header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"));
|
||||
}
|
||||
|
||||
if is_api {
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user