use std::sync::Arc; use axum::extract::State; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::IntoResponse; use axum::Json; use noisebell_common::{validate_bearer, HistoryEntry, WebhookPayload}; use tokio::sync::Mutex; use tracing::{error, info}; use crate::db; use crate::types::{DoorStatus, WebhookTarget}; use crate::webhook; static OPEN_PNG: &[u8] = include_bytes!("../assets/open.png"); static CLOSED_PNG: &[u8] = include_bytes!("../assets/closed.png"); static OFFLINE_PNG: &[u8] = include_bytes!("../assets/offline.png"); pub struct AppState { pub db: Arc>, pub client: reqwest::Client, pub inbound_api_key: String, pub webhooks: Vec, pub retry_attempts: u32, pub retry_base_delay_secs: u64, } fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() } pub async fn post_webhook( State(state): State>, headers: HeaderMap, Json(body): Json, ) -> StatusCode { if !validate_bearer(&headers, &state.inbound_api_key) { return StatusCode::UNAUTHORIZED; } let Some(status) = DoorStatus::from_str(&body.status) else { return StatusCode::BAD_REQUEST; }; let now = unix_now(); let db = state.db.clone(); let timestamp = body.timestamp; let result = tokio::task::spawn_blocking(move || { let conn = db.blocking_lock(); db::update_state(&conn, status, timestamp, now) }) .await .expect("db task panicked"); if let Err(e) = result { error!(error = %e, "failed to update state from webhook"); return StatusCode::INTERNAL_SERVER_ERROR; } info!(status = status.as_str(), timestamp = body.timestamp, "state updated via webhook"); webhook::forward( &state.client, &state.webhooks, &WebhookPayload { status: status.as_str().to_string(), timestamp: body.timestamp, }, state.retry_attempts, state.retry_base_delay_secs, ) .await; StatusCode::OK } pub async fn get_status(State(state): State>) -> Result, StatusCode> { let db = state.db.clone(); let status = tokio::task::spawn_blocking(move || { let conn = db.blocking_lock(); db::get_status(&conn) }) .await .expect("db task panicked") .map_err(|e| { error!(error = %e, "failed to get status"); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(serde_json::to_value(status).unwrap())) } pub async fn get_info(State(state): State>) -> Result, StatusCode> { let db = state.db.clone(); let info = tokio::task::spawn_blocking(move || { let conn = db.blocking_lock(); db::get_pi_info(&conn) }) .await .expect("db task panicked") .map_err(|e| { error!(error = %e, "failed to get pi info"); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(info)) } pub async fn health() -> StatusCode { StatusCode::OK } pub async fn get_history( State(state): State>, ) -> Result>, StatusCode> { let limit = 100u32; let db = state.db.clone(); let entries = tokio::task::spawn_blocking(move || { let conn = db.blocking_lock(); db::get_history(&conn, limit) }) .await .expect("db task panicked") .map_err(|e| { error!(error = %e, "failed to get history"); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(entries)) } pub async fn get_image_open() -> impl IntoResponse { ([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")], OPEN_PNG) } pub async fn get_image_closed() -> impl IntoResponse { ([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")], CLOSED_PNG) } pub async fn get_image_offline() -> impl IntoResponse { ([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")], OFFLINE_PNG) } pub async fn get_image(State(state): State>) -> impl IntoResponse { let db = state.db.clone(); let status = tokio::task::spawn_blocking(move || { let conn = db.blocking_lock(); db::get_current_status_str(&conn) }) .await .expect("db task panicked") .ok() .flatten(); let image = match status.as_deref() { Some("open") => OPEN_PNG, Some("closed") => CLOSED_PNG, _ => OFFLINE_PNG, }; ([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=5")], image) } pub async fn get_badge(State(state): State>) -> impl IntoResponse { let db = state.db.clone(); let status = tokio::task::spawn_blocking(move || { let conn = db.blocking_lock(); db::get_current_status_str(&conn) }) .await .expect("db task panicked") .ok() .flatten(); let (label, color) = match status.as_deref() { Some("open") => ("open", "#57f287"), Some("closed") => ("closed", "#ed4245"), _ => ("offline", "#99aab5"), }; let label_width = 70u32; let value_width = 10 + label.len() as u32 * 7; let total_width = label_width + value_width; let label_x = label_width as f32 / 2.0; let value_x = label_width as f32 + value_width as f32 / 2.0; let svg = format!( "\ \ \ \ \ \ \ \ \ \ \ \ noisebell\ noisebell\ {label}\ {label}\ " ); ( [ (header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "no-cache, max-age=0"), ], svg, ) }