v0.6.0
This commit is contained in:
+96
-8
@@ -2,9 +2,9 @@ use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{Html, Response},
|
||||
response::{Redirect, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -15,20 +15,22 @@ use rand::{rngs::OsRng, RngCore};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use serde_json::{json, Value};
|
||||
use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
use uuid::Uuid;
|
||||
use crate::{
|
||||
engine,
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
influxdb,
|
||||
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
notifications,
|
||||
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const INDEX_HTML: &str = include_str!("../web/index.html");
|
||||
const APP_JS: &str = include_str!("../web/app.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");
|
||||
const SERVICE_WORKER: &str = include_str!("../web/sw.js");
|
||||
@@ -67,6 +69,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
|
||||
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
|
||||
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
|
||||
.route("/api/integrations/notifications/test", post(test_notifications))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
|
||||
|
||||
let home_assistant_api = Router::new()
|
||||
@@ -76,12 +79,13 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
|
||||
|
||||
Router::new()
|
||||
let 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("/manifest.webmanifest", get(manifest))
|
||||
.route("/sw.js", get(service_worker))
|
||||
@@ -91,9 +95,25 @@ pub fn router(state: AppState) -> Router {
|
||||
.merge(protected)
|
||||
.merge(home_assistant_api)
|
||||
.fallback(index)
|
||||
;
|
||||
|
||||
let app = if state.config.base_path.is_empty() {
|
||||
app
|
||||
} else {
|
||||
let base = state.config.base_path.clone();
|
||||
let redirect_to = format!("{base}/");
|
||||
Router::new()
|
||||
.route(&base, get(move || {
|
||||
let redirect_to = redirect_to.clone();
|
||||
async move { Redirect::permanent(&redirect_to) }
|
||||
}))
|
||||
.nest(&base, app)
|
||||
};
|
||||
|
||||
app
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(middleware::from_fn(security_headers))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -466,7 +486,7 @@ impl ZoneInput {
|
||||
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
||||
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None,
|
||||
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
||||
demand: false, last_action_at: None,
|
||||
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
|
||||
created_at, updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -503,6 +523,8 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
zone.effective_setpoint = existing.effective_setpoint;
|
||||
zone.device_setpoint = existing.device_setpoint;
|
||||
zone.demand = existing.demand;
|
||||
zone.demand_since = existing.demand_since;
|
||||
zone.target_alerted_at = existing.target_alerted_at;
|
||||
zone.last_action_at = existing.last_action_at;
|
||||
let settings = state.settings.read().await.clone();
|
||||
canonicalize_zone_ha_entity(&mut zone, &settings);
|
||||
@@ -1129,6 +1151,15 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
}
|
||||
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
||||
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
||||
if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; }
|
||||
if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; }
|
||||
if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; }
|
||||
if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; }
|
||||
input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400);
|
||||
input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100);
|
||||
input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60);
|
||||
if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); }
|
||||
if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); }
|
||||
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
||||
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
|
||||
normalize_sensor_aliases(&mut input);
|
||||
@@ -1316,6 +1347,29 @@ async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<Ha
|
||||
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
|
||||
}
|
||||
|
||||
async fn test_notifications(State(state): State<AppState>, Json(mut input): Json<NotificationSettings>) -> Result<Json<Value>, AppError> {
|
||||
let old = state.settings.read().await.notifications.clone();
|
||||
if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; }
|
||||
if input.pushover_user_key.trim().is_empty() { input.pushover_user_key = old.pushover_user_key; }
|
||||
if input.slack_webhook_url.trim().is_empty() { input.slack_webhook_url = old.slack_webhook_url; }
|
||||
if input.discord_webhook_url.trim().is_empty() { input.discord_webhook_url = old.discord_webhook_url; }
|
||||
notifications::test(&state, input).await.map_err(AppError::Device)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
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 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")); }
|
||||
response
|
||||
}
|
||||
|
||||
fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
json!({
|
||||
"controller_id": settings.controller_id,
|
||||
@@ -1333,6 +1387,21 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
"suppress_device_beep": settings.suppress_device_beep,
|
||||
"debug": settings.debug,
|
||||
"night_mode": settings.night_mode,
|
||||
"notifications": {
|
||||
"enabled": settings.notifications.enabled,
|
||||
"mode": settings.notifications.mode,
|
||||
"provider": settings.notifications.provider,
|
||||
"pushover_app_token": "",
|
||||
"pushover_user_key": "",
|
||||
"pushover_configured": !settings.notifications.pushover_app_token.trim().is_empty() && !settings.notifications.pushover_user_key.trim().is_empty(),
|
||||
"slack_webhook_url": "",
|
||||
"slack_configured": !settings.notifications.slack_webhook_url.trim().is_empty(),
|
||||
"discord_webhook_url": "",
|
||||
"discord_configured": !settings.notifications.discord_webhook_url.trim().is_empty(),
|
||||
"cooldown_seconds": settings.notifications.cooldown_seconds,
|
||||
"communication_failure_threshold": settings.notifications.communication_failure_threshold,
|
||||
"target_timeout_minutes": settings.notifications.target_timeout_minutes,
|
||||
},
|
||||
"influxdb": {
|
||||
"enabled": settings.influxdb.enabled,
|
||||
"version": settings.influxdb.version,
|
||||
@@ -1399,8 +1468,27 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn index() -> Html<&'static str> { Html(INDEX_HTML) }
|
||||
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 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
|
||||
}
|
||||
|
||||
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 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") }
|
||||
|
||||
Reference in New Issue
Block a user