mirror of
https://github.com/ruvnet/RuView
synced 2026-07-21 17:13:19 +00:00
c0bb6f4fc7
CRUD increment 3/6. Full delete path lands end-to-end.
Backend (homecore-api):
rest.rs +18 LOC — new `delete_state` handler. Idempotent (matches HA's
removal semantics): returns 204 No Content whether the entity existed
or not. 4xx only for malformed entity_id or auth failure.
app.rs +6 LOC — adds `.delete(rest::delete_state)` to the
/api/states/:entity_id route alongside existing GET + POST.
Backend curl smoke:
POST /api/states/sensor.test_delete 201
DELETE /api/states/sensor.test_delete 204
GET /api/states/sensor.test_delete 404
Frontend:
components/StateCard.ts +25 LOC — small `×` delete button in the
card's top-right corner. opacity 0 by default, fades in on hover
or keyboard focus. dispatches `hc-state-card-delete` (NOT
`hc-state-card-click`) with stopPropagation so the card's own
click-to-edit handler doesn't also fire.
pages/Dashboard.ts +45 LOC — deletingState (StateView | null), a
confirm modal that names the entity_id in the body, Cancel /
Delete buttons in the footer (Delete styled in muted red),
`_confirmDelete()` dispatches DELETE with bearer, toast on
success, grid refresh.
Browser-verified end-to-end on real homecore-server :8123:
- Hover card → × button visible
- Click × → DELETE confirm modal (NOT edit modal — stopPropagation works)
- Modal names entity_id in code block
- Cancel: entity preserved, modal closes
- Delete: backend GET-after-DELETE returns 404, grid card vanishes,
toast "Deleted sensor.delete_target"
- 0 unexpected console errors (1 expected 404 from verification fetch)
Co-Authored-By: claude-flow <ruv@ruv.net>
122 lines
4.2 KiB
Rust
122 lines
4.2 KiB
Rust
//! Axum router wiring. Mounts the §2.1 P2 routes + the WS endpoint.
|
|
|
|
use axum::http::{header, HeaderValue, Method};
|
|
use axum::routing::{get, post};
|
|
use axum::Router;
|
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
|
use tower_http::trace::TraceLayer;
|
|
|
|
use crate::rest;
|
|
use crate::state::SharedState;
|
|
use crate::ws;
|
|
|
|
pub type AppState = SharedState;
|
|
|
|
/// Build the Axum router with an EXPLICIT CORS allowlist (audit fix
|
|
/// HC-05). The previous `CorsLayer::permissive()` set
|
|
/// `Access-Control-Allow-Origin: *` which lets any webpage make
|
|
/// authenticated cross-origin calls once a bearer is leaked.
|
|
///
|
|
/// Default allowlist: `http://localhost:5173` (the homecore-frontend
|
|
/// Vite dev server) plus the same on port 3000 / 8080 / 8081 / 8123
|
|
/// covering the most common reverse-proxy + HA-app paths. Production
|
|
/// deployments should set `HOMECORE_CORS_ORIGINS=https://...` (comma-
|
|
/// separated) to override.
|
|
pub fn router(state: SharedState) -> Router {
|
|
let cors = build_cors_layer();
|
|
Router::new()
|
|
.route("/api/", get(rest::api_root))
|
|
.route("/api/config", get(rest::get_config))
|
|
.route("/api/states", get(rest::get_states))
|
|
.route(
|
|
"/api/states/:entity_id",
|
|
get(rest::get_state)
|
|
.post(rest::set_state)
|
|
.delete(rest::delete_state),
|
|
)
|
|
.route("/api/services", get(rest::get_services))
|
|
.route("/api/services/:domain/:service", post(rest::call_service))
|
|
.route("/api/websocket", get(ws::websocket_handler))
|
|
.layer(cors)
|
|
.layer(TraceLayer::new_for_http())
|
|
.with_state(state)
|
|
}
|
|
|
|
fn build_cors_layer() -> CorsLayer {
|
|
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
|
let origins: Vec<HeaderValue> = match raw {
|
|
Some(v) if !v.trim().is_empty() => v
|
|
.split(',')
|
|
.filter_map(|s| s.trim().parse::<HeaderValue>().ok())
|
|
.collect(),
|
|
_ => default_origins(),
|
|
};
|
|
CorsLayer::new()
|
|
.allow_origin(AllowOrigin::list(origins))
|
|
.allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE])
|
|
.allow_headers([
|
|
header::AUTHORIZATION,
|
|
header::CONTENT_TYPE,
|
|
header::ACCEPT,
|
|
])
|
|
.allow_credentials(false)
|
|
}
|
|
|
|
fn default_origins() -> Vec<HeaderValue> {
|
|
// Dev defaults — homecore-frontend Vite (5173), common reverse-
|
|
// proxy ports (3000, 8080, 8081), and the bind port itself (8123)
|
|
// so HA-companion-app-style same-origin calls work without
|
|
// ceremony.
|
|
[
|
|
"http://localhost:5173",
|
|
"http://127.0.0.1:5173",
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"http://localhost:8080",
|
|
"http://127.0.0.1:8080",
|
|
"http://localhost:8081",
|
|
"http://127.0.0.1:8081",
|
|
"http://localhost:8123",
|
|
"http://127.0.0.1:8123",
|
|
]
|
|
.iter()
|
|
.filter_map(|o| o.parse::<HeaderValue>().ok())
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_origins_includes_vite_and_ha_ports() {
|
|
let origins = default_origins();
|
|
assert!(origins.iter().any(|o| o.to_str().unwrap().contains("5173")));
|
|
assert!(origins.iter().any(|o| o.to_str().unwrap().contains("8123")));
|
|
assert!(!origins.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn env_override_via_homecore_cors_origins() {
|
|
std::env::set_var("HOMECORE_CORS_ORIGINS", "https://example.com,https://other.example.com");
|
|
// build_cors_layer() returns a CorsLayer which doesn't expose
|
|
// its origin list; we test the parse path indirectly by
|
|
// confirming no panic + at least one origin would parse.
|
|
let parsed: Vec<_> = "https://example.com,https://other.example.com"
|
|
.split(',')
|
|
.filter_map(|s| s.trim().parse::<HeaderValue>().ok())
|
|
.collect();
|
|
assert_eq!(parsed.len(), 2);
|
|
std::env::remove_var("HOMECORE_CORS_ORIGINS");
|
|
}
|
|
|
|
#[test]
|
|
fn env_empty_falls_back_to_defaults() {
|
|
std::env::set_var("HOMECORE_CORS_ORIGINS", " ");
|
|
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
|
let trimmed = raw.as_deref().map(|s| s.trim()).unwrap_or("");
|
|
assert!(trimmed.is_empty());
|
|
std::env::remove_var("HOMECORE_CORS_ORIGINS");
|
|
}
|
|
}
|