Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -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)); }
Expand Down Expand Up @@ -70,6 +71,7 @@ <h1>Chomp Dashboard</h1>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
</select>
<button id="logoutBtn" title="Sign out" onclick="logout()">Sign out</button>
</div>
</div>

Expand Down Expand Up @@ -308,6 +310,11 @@ <h1>Chomp Dashboard</h1>
}
}

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 = `<div class="card"><h3>Error</h3><div class="value miss">Failed to load</div><div class="detail">${err.message}</div></div>`;
Expand Down
55 changes: 55 additions & 0 deletions login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chomp — Login</title>
<style>
:root {
--bg: #0f1117; --card: #1a1d27; --border: #2a2d3a;
--text: #e4e4e7; --muted: #8b8d97; --accent: #818cf8;
--red: #f87171;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 40px; width: 100%; max-width: 380px; margin: 20px; }
h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 4px; }
.subtitle { color: var(--muted); font-size: 0.85rem; margin-bottom: 24px; }
label { display: block; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
input[type="password"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; font-size: 0.95rem; outline: none; transition: border-color 0.2s; }
input[type="password"]:focus { border-color: var(--accent); }
button { width: 100%; background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 10px; font-size: 0.95rem; font-weight: 600; cursor: pointer; margin-top: 16px; transition: opacity 0.2s; }
button:hover { opacity: 0.9; }
.error { color: var(--red); font-size: 0.85rem; margin-top: 12px; display: none; }
</style>
</head>
<body>
<div class="login-card">
<h1>Chomp</h1>
<p class="subtitle">Enter your auth key to access the dashboard.</p>
<form id="loginForm">
<label for="key">Auth Key</label>
<input type="password" id="key" name="key" placeholder="Enter auth key" autofocus required>
<button type="submit">Sign in</button>
<p class="error" id="error">Invalid auth key. Please try again.</p>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const key = document.getElementById('key').value;
const resp = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key })
});
if (resp.ok) {
const next = new URLSearchParams(window.location.search).get('next') || '/dashboard';
window.location.href = next;
} else {
document.getElementById('error').style.display = 'block';
}
});
</script>
</body>
</html>
130 changes: 110 additions & 20 deletions src/sse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand All @@ -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<String> {
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<Arc<AppState>>,
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 <key> 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 <key> header".into())
.unwrap();
}
}

Expand Down Expand Up @@ -548,6 +580,64 @@ async fn stats_handler() -> impl IntoResponse {
}
}

/// GET /login — serves the login page.
async fn login_page_handler(State(state): State<Arc<AppState>>) -> 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<Arc<AppState>>,
Json(body): Json<LoginRequest>,
) -> 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<serde_json::Value> {
Json(serde_json::json!({
Expand Down
Loading