1use axum::http::{header, HeaderValue, Method};
4use axum::routing::{get, post};
5use axum::Router;
6use tower_http::cors::{AllowOrigin, CorsLayer};
7use tower_http::trace::TraceLayer;
8
9use crate::rest;
10use crate::state::SharedState;
11use crate::ws;
12
13pub type AppState = SharedState;
14
15pub fn router(state: SharedState) -> Router {
26 let cors = build_cors_layer();
27 Router::new()
28 .route("/api/", get(rest::api_root))
29 .route("/api/config", get(rest::get_config))
30 .route("/api/components", get(rest::get_components))
31 .route("/api/states", get(rest::get_states))
32 .route(
33 "/api/states/:entity_id",
34 get(rest::get_state)
35 .post(rest::set_state)
36 .delete(rest::delete_state),
37 )
38 .route("/api/services", get(rest::get_services))
39 .route("/api/services/:domain/:service", post(rest::call_service))
40 .route("/api/events", get(rest::get_events))
41 .route("/api/events/:event_type", post(rest::fire_event))
42 .route("/api/template", post(rest::render_template))
43 .route("/api/config/core/check_config", post(rest::check_config))
44 .route("/api/error_log", get(rest::error_log))
45 .route("/api/history/period", get(rest::get_history))
46 .route(
47 "/api/history/period/:start_time",
48 get(rest::get_history_period),
49 )
50 .route("/api/logbook", get(rest::get_logbook))
51 .route("/api/logbook/:start_time", get(rest::get_logbook_period))
52 .route("/api/calendars", get(rest::get_calendars))
53 .route("/api/calendars/:entity_id", get(rest::get_calendar_events))
54 .route("/api/camera_proxy/:entity_id", get(rest::get_camera_proxy))
55 .route("/api/homecore/compatibility", get(rest::compatibility))
56 .route("/api/websocket", get(ws::websocket_handler))
57 .layer(cors)
58 .layer(TraceLayer::new_for_http())
59 .with_state(state)
60}
61
62pub fn build_cors_layer() -> CorsLayer {
67 let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
68 let origins: Vec<HeaderValue> = match raw {
69 Some(v) if !v.trim().is_empty() => v
70 .split(',')
71 .filter_map(|s| s.trim().parse::<HeaderValue>().ok())
72 .collect(),
73 _ => default_origins(),
74 };
75 CorsLayer::new()
76 .allow_origin(AllowOrigin::list(origins))
77 .allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE])
78 .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE, header::ACCEPT])
79 .allow_credentials(false)
80}
81
82fn default_origins() -> Vec<HeaderValue> {
83 [
88 "http://localhost:5173",
89 "http://127.0.0.1:5173",
90 "http://localhost:3000",
91 "http://127.0.0.1:3000",
92 "http://localhost:8080",
93 "http://127.0.0.1:8080",
94 "http://localhost:8081",
95 "http://127.0.0.1:8081",
96 "http://localhost:8123",
97 "http://127.0.0.1:8123",
98 ]
99 .iter()
100 .filter_map(|o| o.parse::<HeaderValue>().ok())
101 .collect()
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
112
113 #[test]
114 fn default_origins_includes_vite_and_ha_ports() {
115 let origins = default_origins();
116 assert!(origins.iter().any(|o| o.to_str().unwrap().contains("5173")));
117 assert!(origins.iter().any(|o| o.to_str().unwrap().contains("8123")));
118 assert!(!origins.is_empty());
119 }
120
121 #[test]
122 fn env_override_via_homecore_cors_origins() {
123 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
124 std::env::set_var(
125 "HOMECORE_CORS_ORIGINS",
126 "https://example.com,https://other.example.com",
127 );
128 let parsed: Vec<_> = "https://example.com,https://other.example.com"
132 .split(',')
133 .filter_map(|s| s.trim().parse::<HeaderValue>().ok())
134 .collect();
135 assert_eq!(parsed.len(), 2);
136 std::env::remove_var("HOMECORE_CORS_ORIGINS");
137 }
138
139 #[test]
140 fn env_empty_falls_back_to_defaults() {
141 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
142 std::env::set_var("HOMECORE_CORS_ORIGINS", " ");
143 let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
144 let trimmed = raw.as_deref().map(|s| s.trim()).unwrap_or("");
145 assert!(trimmed.is_empty());
146 std::env::remove_var("HOMECORE_CORS_ORIGINS");
147 }
148}