2c6062a53e
The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.
The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.
Pre-production: no transition redirects.
56 lines
2.0 KiB
Rust
56 lines
2.0 KiB
Rust
//! SSE endpoint streaming case events to the web UI.
|
|
//!
|
|
//! One long-lived connection per browser tab. Non-admins only see events
|
|
//! for their own user_slug; admins see events across all users
|
|
//! (useful for support/monitoring). A 15s keep-alive comment keeps idle
|
|
//! connections alive through reverse proxies / NAT.
|
|
|
|
use std::convert::Infallible;
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::State;
|
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
|
use futures_util::stream::Stream;
|
|
use tokio_stream::StreamExt;
|
|
use tokio_stream::wrappers::BroadcastStream;
|
|
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
|
|
|
use crate::auth::AuthenticatedWebUser;
|
|
use crate::events::EventSender;
|
|
|
|
/// `GET /events` — `text/event-stream`, one connection per tab.
|
|
pub async fn handle_events(
|
|
user: AuthenticatedWebUser,
|
|
State(events_tx): State<EventSender>,
|
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
|
let rx = events_tx.subscribe();
|
|
let is_admin = user.is_admin();
|
|
let slug = user.slug.clone();
|
|
|
|
let stream = BroadcastStream::new(rx).filter_map(move |res| match res {
|
|
Ok(evt) => {
|
|
// Non-admins only see events for their own user_slug.
|
|
if !is_admin && evt.user_slug != slug {
|
|
return None;
|
|
}
|
|
// Serialization of `CaseEvent` is infallible in practice (plain
|
|
// struct with String + enum fields); on the unexpected error
|
|
// path we drop the event rather than tear down the stream.
|
|
let json = serde_json::to_string(&evt).ok()?;
|
|
Some(Ok(Event::default().event("case").data(json)))
|
|
}
|
|
// Lagged subscribers: skip silently. The next real event or the
|
|
// user's next navigation will resync state from the filesystem.
|
|
Err(BroadcastStreamRecvError::Lagged(n)) => {
|
|
tracing::debug!(skipped = n, "sse subscriber lagged");
|
|
None
|
|
}
|
|
});
|
|
|
|
Sse::new(stream).keep_alive(
|
|
KeepAlive::new()
|
|
.interval(Duration::from_secs(15))
|
|
.text("keepalive"),
|
|
)
|
|
}
|