diff --git a/Cargo.lock b/Cargo.lock
index a9133d2..e3f91ee 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -285,6 +285,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tower-http 0.5.2",
+ "urlencoding",
"uuid",
"zip",
]
@@ -2128,6 +2129,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
diff --git a/Cargo.toml b/Cargo.toml
index dc866fe..9f702e3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,6 +24,7 @@ tokio-stream = { version = "0.1", optional = true }
uuid = { version = "1", features = ["v4"], optional = true }
tower-http = { version = "0.5", features = ["cors"], optional = true }
+urlencoding = "2"
csv = "1"
reqwest = { version = "0.12", features = ["blocking", "json"] }
zip = "2"
diff --git a/Dockerfile b/Dockerfile
index 356bff7..27a7fd6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/li
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
-COPY dashboard.html ./
+COPY dashboard.html login.html ./
RUN cargo build --release --features sse
# Runtime stage
diff --git a/dashboard.html b/dashboard.html
index 2c70bed..345bb9c 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -19,8 +19,9 @@
.header-left { flex: 1; }
.subtitle { color: var(--muted); font-size: 0.9rem; }
.controls { display: flex; gap: 8px; align-items: center; }
- .controls select { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; font-size: 0.85rem; cursor: pointer; }
- .controls select:focus { outline: none; border-color: var(--accent); }
+ .controls select, .controls button { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; font-size: 0.85rem; cursor: pointer; }
+ .controls select:focus, .controls button:focus { outline: none; border-color: var(--accent); }
+ .controls button:hover { background: var(--border); }
.grid { display: grid; gap: 16px; margin-bottom: 24px; }
.grid-4 { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.grid-2 { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
@@ -70,6 +71,7 @@
Chomp Dashboard
+
@@ -308,6 +310,11 @@ Chomp Dashboard
}
}
+async function logout() {
+ await fetch('/logout', { method: 'POST' });
+ window.location.href = '/login';
+}
+
document.getElementById('dayRange').addEventListener('change', () => {
loadDashboard().catch(err => {
document.getElementById('summary-cards').innerHTML = `Error
Failed to load
${err.message}
`;
diff --git a/login.html b/login.html
new file mode 100644
index 0000000..c659e0c
--- /dev/null
+++ b/login.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+Chomp — Login
+
+
+
+
+
Chomp
+
Enter your auth key to access the dashboard.
+
+
+
+
+
diff --git a/src/sse.rs b/src/sse.rs
index f9d7144..bc1c4a8 100644
--- a/src/sse.rs
+++ b/src/sse.rs
@@ -5,7 +5,7 @@ use axum::{
middleware::{self, Next},
response::{
sse::{Event, KeepAlive},
- Html, IntoResponse, Response, Sse,
+ Html, IntoResponse, Redirect, Response, Sse,
},
routing::{delete, get, post, put},
Json, Router,
@@ -83,6 +83,8 @@ pub async fn serve_sse(port: u16, host: &str, auth_key: Option<&str>) -> Result<
))
// Public routes (after route_layer)
.route("/health", get(health_handler))
+ .route("/login", get(login_page_handler).post(login_handler))
+ .route("/logout", post(logout_handler))
.layer(cors)
.with_state(state);
@@ -104,34 +106,64 @@ pub async fn serve_sse(port: u16, host: &str, auth_key: Option<&str>) -> Result<
Ok(())
}
-/// Middleware that checks for a valid Bearer token when auth is enabled.
+/// Extract the chomp_session cookie value from a request.
+fn get_session_cookie(request: &Request) -> Option {
+ request
+ .headers()
+ .get("cookie")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|cookies| {
+ cookies.split(';').find_map(|c| {
+ let c = c.trim();
+ c.strip_prefix("chomp_session=").map(String::from)
+ })
+ })
+}
+
+/// Returns true if the request looks like it came from a browser expecting HTML.
+fn is_browser_request(request: &Request) -> bool {
+ request
+ .headers()
+ .get("accept")
+ .and_then(|v| v.to_str().ok())
+ .map(|v| v.contains("text/html"))
+ .unwrap_or(false)
+}
+
+/// Middleware that checks for a valid Bearer token or session cookie when auth is enabled.
async fn auth_middleware(
State(state): State>,
request: Request,
next: Next,
) -> Response {
if let Some(expected_key) = &state.auth_key {
- let auth_header = request
+ // Check Bearer token first
+ let bearer_ok = request
.headers()
.get("authorization")
- .and_then(|v| v.to_str().ok());
-
- match auth_header {
- Some(header) if header.starts_with("Bearer ") => {
- let token = &header[7..];
- if token != expected_key.as_str() {
- return Response::builder()
- .status(StatusCode::UNAUTHORIZED)
- .body("Invalid auth key".into())
- .unwrap();
- }
- }
- _ => {
- return Response::builder()
- .status(StatusCode::UNAUTHORIZED)
- .body("Missing Authorization: Bearer header".into())
- .unwrap();
+ .and_then(|v| v.to_str().ok())
+ .and_then(|h| h.strip_prefix("Bearer "))
+ .map(|token| token == expected_key.as_str())
+ .unwrap_or(false);
+
+ // Then check session cookie
+ let cookie_ok = get_session_cookie(&request)
+ .map(|token| token == *expected_key)
+ .unwrap_or(false);
+
+ if !bearer_ok && !cookie_ok {
+ // Redirect browsers to login page; return 401 for API clients
+ if is_browser_request(&request) {
+ let path = request.uri().path_and_query()
+ .map(|pq| pq.as_str())
+ .unwrap_or("/dashboard");
+ let login_url = format!("/login?next={}", urlencoding::encode(path));
+ return Redirect::to(&login_url).into_response();
}
+ return Response::builder()
+ .status(StatusCode::UNAUTHORIZED)
+ .body("Missing or invalid Authorization: Bearer header".into())
+ .unwrap();
}
}
@@ -548,6 +580,64 @@ async fn stats_handler() -> impl IntoResponse {
}
}
+/// GET /login — serves the login page.
+async fn login_page_handler(State(state): State>) -> Response {
+ if state.auth_key.is_none() {
+ // No auth configured — redirect straight to dashboard
+ return Redirect::to("/dashboard").into_response();
+ }
+ let html = include_str!("../login.html");
+ Html(html).into_response()
+}
+
+#[derive(Deserialize)]
+struct LoginRequest {
+ key: String,
+}
+
+/// POST /login — validates the auth key and sets a session cookie.
+async fn login_handler(
+ State(state): State>,
+ Json(body): Json,
+) -> Response {
+ let expected = match &state.auth_key {
+ Some(k) => k,
+ None => {
+ // No auth configured — just succeed
+ return StatusCode::OK.into_response();
+ }
+ };
+
+ if body.key != *expected {
+ return StatusCode::UNAUTHORIZED.into_response();
+ }
+
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(
+ "set-cookie",
+ format!(
+ "chomp_session={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}",
+ body.key,
+ 60 * 60 * 24 * 30 // 30 days
+ ),
+ )
+ .body("OK".into())
+ .unwrap()
+}
+
+/// POST /logout — clears the session cookie.
+async fn logout_handler() -> Response {
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(
+ "set-cookie",
+ "chomp_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
+ )
+ .body("OK".into())
+ .unwrap()
+}
+
/// GET /health — simple health check.
async fn health_handler() -> Json {
Json(serde_json::json!({