From b748ef36d649a6db4b7a31ed420bc4d227b08ccb Mon Sep 17 00:00:00 2001 From: Jack Mechem Date: Fri, 22 May 2026 02:19:32 -0700 Subject: [PATCH] Left over changes --- Cargo.toml | 1 + src/auth.rs | 12 +++--- src/main.rs | 24 +++++++++++- src/models.rs | 17 +++++++- src/routes/mod.rs | 1 + src/routes/power.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++- src/routes/users.rs | 79 +++++++++++++++++++++++++++++++++++++ 7 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 src/routes/users.rs diff --git a/Cargo.toml b/Cargo.toml index bdf08dc..eac8095 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ tokio = { version = "1.50.0", features = [ "macros", "rt-multi-thread", "process", + "time", ] } tapo = "0.9.0" zbus = "5.14.0" diff --git a/src/auth.rs b/src/auth.rs index 27ec49d..3caa4f3 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -24,15 +24,15 @@ use yescrypt::{PasswordHash, PasswordVerifier, Yescrypt}; static JWT_SECRET: OnceLock = OnceLock::new(); const ROTATION_DAYS: u64 = 7; -const CREDENTIAL_DIR: &str = "/var/lib/server-dash-api/webauthn-credentials"; +pub(crate) const CREDENTIAL_DIR: &str = "/var/lib/server-dash-api/webauthn-credentials"; const CHALLENGE_TTL: Duration = Duration::from_secs(300); const RP_ID: &str = "jackmechem.dev"; const RP_ORIGIN: &str = "https://dashboard.jackmechem.dev"; #[derive(Serialize, Deserialize)] -struct StoredCredentials { - user_id: Uuid, - credentials: Vec, +pub(crate) struct StoredCredentials { + pub(crate) user_id: Uuid, + pub(crate) credentials: Vec, } pub struct AppState { @@ -183,13 +183,13 @@ fn verify_shadow_hash(password: &str, hash: &str) -> bool { .is_ok() } -fn load_credentials(username: &str) -> Option { +pub(crate) fn load_credentials(username: &str) -> Option { let path = PathBuf::from(CREDENTIAL_DIR).join(format!("{}.json", username)); let data = std::fs::read_to_string(path).ok()?; serde_json::from_str(&data).ok() } -fn save_credentials(username: &str, creds: &StoredCredentials) -> Result<(), String> { +pub(crate) fn save_credentials(username: &str, creds: &StoredCredentials) -> Result<(), String> { let dir = PathBuf::from(CREDENTIAL_DIR); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let path = dir.join(format!("{}.json", username)); diff --git a/src/main.rs b/src/main.rs index 1a76a6b..44012f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ +use axum::extract::Extension; use axum::middleware; use axum::response::Redirect; -use axum::{Router, routing::get, routing::post}; +use axum::{Router, routing::delete, routing::get, routing::post}; use std::sync::Arc; +use tokio::sync::Mutex; mod auth; mod config; @@ -12,7 +14,23 @@ mod routes; async fn main() { let state = Arc::new(auth::AppState::new()); + let power_history: routes::power::PowerHistory = + Arc::new(Mutex::new(routes::power::load_history())); + + let bg_history = Arc::clone(&power_history); + tokio::spawn(async move { + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(300)).await; + routes::power::record_snapshot(&bg_history).await; + } + }); + let protected = Router::new() + .route("/users", get(routes::users::list_users)) + .route( + "/users/{username}/credentials/{cred_id}", + delete(routes::users::delete_credential), + ) .route("/power/{device}/on", post(routes::power::power_on)) .route("/power/{device}/off", post(routes::power::power_off)) .route( @@ -39,12 +57,14 @@ async fn main() { .route("/", get(|| async { Redirect::permanent("/stats") })) .route("/stats", get(routes::stats::get_stats)) .route("/power", get(routes::power::get_power)) + .route("/power/history", get(routes::power::get_power_history)) .route("/auth/login", post(auth::post_login)) .route("/auth/verify", post(auth::post_verify)) .route("/auth/register/start", post(auth::post_register_start)) .route("/auth/register/finish", post(auth::post_register_finish)) .merge(protected) - .with_state(state); + .with_state(state) + .layer(Extension(power_history)); let listener = tokio::net::TcpListener::bind("127.0.0.1:3001") .await diff --git a/src/models.rs b/src/models.rs index c80927a..b84365c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,6 +1,6 @@ use axum::http::StatusCode; use axum::response::Json; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Serialize)] @@ -112,3 +112,18 @@ pub struct TapoPowerResponse { pub timestamp: String, pub devices: Vec, } + +#[derive(Serialize, Deserialize, Clone)] +pub struct PowerDeviceReading { + pub name: String, + pub watts: f64, + pub on: bool, + pub today_wh: u64, + pub month_wh: u64, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PowerHistoryEntry { + pub ts: String, + pub devices: Vec, +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index effd619..829ce33 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,3 +2,4 @@ pub mod power; pub mod services; pub mod stats; pub mod system; +pub mod users; diff --git a/src/routes/power.rs b/src/routes/power.rs index 9f0d7ce..be8483d 100644 --- a/src/routes/power.rs +++ b/src/routes/power.rs @@ -1,10 +1,103 @@ -use axum::extract::Path; +use axum::extract::{Extension, Path, Query}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json}; +use serde::Deserialize; +use std::sync::Arc; use tapo::ApiClient; +use tokio::sync::Mutex; use crate::{config, models}; +pub type PowerHistory = Arc>>; + +const HISTORY_FILE: &str = "/var/lib/server-dash-api/power-history.json"; +const MAX_HISTORY_DAYS: i64 = 60; + +pub fn load_history() -> Vec { + let path = std::path::Path::new(HISTORY_FILE); + if !path.exists() { + return vec![]; + } + match std::fs::read_to_string(path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => vec![], + } +} + +pub async fn record_snapshot(history: &PowerHistory) { + let username = std::env::var("TAPO_USERNAME").unwrap_or_default(); + let password = std::env::var("TAPO_PASSWORD").unwrap_or_default(); + if username.is_empty() || password.is_empty() { + return; + } + + let tasks: Vec<_> = config::TAPO_DEVICES + .iter() + .map(|(name, ip)| { + let username = username.clone(); + let password = password.clone(); + let name = name.to_string(); + let ip = ip.to_string(); + tokio::spawn(async move { query_device(&username, &password, &name, &ip).await }) + }) + .collect(); + + let mut devices = Vec::new(); + for task in tasks { + if let Ok(Ok(data)) = task.await { + devices.push(models::PowerDeviceReading { + name: data.name, + watts: data.current_power_w, + on: data.on, + today_wh: data.today_energy_wh, + month_wh: data.month_energy_wh, + }); + } + } + + if devices.is_empty() { + return; + } + + let entry = models::PowerHistoryEntry { + ts: chrono::Utc::now().to_rfc3339(), + devices, + }; + + let cutoff = (chrono::Utc::now() - chrono::Duration::days(MAX_HISTORY_DAYS)).to_rfc3339(); + + let mut guard = history.lock().await; + guard.push(entry); + guard.retain(|e| e.ts >= cutoff); + + if let Ok(json) = serde_json::to_string(&*guard) { + let _ = std::fs::write(HISTORY_FILE, json); + } +} + +#[derive(Deserialize)] +pub struct HistoryQuery { + #[serde(default = "default_hours")] + pub hours: u32, +} +fn default_hours() -> u32 { + 24 +} + +pub async fn get_power_history( + Extension(history): Extension, + Query(params): Query, +) -> impl IntoResponse { + let hours = params.hours.max(1).min(24 * 60) as i64; + let cutoff = (chrono::Utc::now() - chrono::Duration::hours(hours)).to_rfc3339(); + + let guard = history.lock().await; + let readings: Vec<&models::PowerHistoryEntry> = + guard.iter().filter(|e| e.ts >= cutoff).collect(); + + Json(serde_json::json!({ "readings": readings, "hours": hours })).into_response() +} + async fn query_device( username: &str, password: &str, diff --git a/src/routes/users.rs b/src/routes/users.rs new file mode 100644 index 0000000..15a6966 --- /dev/null +++ b/src/routes/users.rs @@ -0,0 +1,79 @@ +use axum::{extract::Path, http::StatusCode, response::IntoResponse, response::Json}; +use base64::{Engine, engine::general_purpose}; +use serde::Serialize; + +use crate::auth::{load_credentials, save_credentials, CREDENTIAL_DIR}; + +#[derive(Serialize)] +struct CredentialInfo { + id: String, +} + +#[derive(Serialize)] +struct UserInfo { + username: String, + credentials: Vec, +} + +#[derive(Serialize)] +struct UsersResponse { + users: Vec, +} + +pub async fn list_users() -> impl IntoResponse { + let dir = std::path::Path::new(CREDENTIAL_DIR); + let Ok(entries) = std::fs::read_dir(dir) else { + return Json(UsersResponse { users: vec![] }).into_response(); + }; + + let mut users: Vec = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if path.extension()?.to_str() != Some("json") { + return None; + } + let username = path.file_stem()?.to_str()?.to_string(); + let stored = load_credentials(&username)?; + let credentials = stored + .credentials + .iter() + .map(|c| CredentialInfo { + id: general_purpose::URL_SAFE_NO_PAD.encode(c.cred_id()), + }) + .collect(); + Some(UserInfo { username, credentials }) + }) + .collect(); + + users.sort_by(|a, b| a.username.cmp(&b.username)); + Json(UsersResponse { users }).into_response() +} + +pub async fn delete_credential( + Path((username, cred_id)): Path<(String, String)>, +) -> impl IntoResponse { + let Ok(cred_id_bytes) = general_purpose::URL_SAFE_NO_PAD.decode(&cred_id) else { + return (StatusCode::BAD_REQUEST, "Invalid credential ID").into_response(); + }; + + let Some(mut stored) = load_credentials(&username) else { + return (StatusCode::NOT_FOUND, "User not found").into_response(); + }; + + let original_len = stored.credentials.len(); + stored + .credentials + .retain(|c| c.cred_id().as_ref() != cred_id_bytes.as_slice()); + + if stored.credentials.len() == original_len { + return (StatusCode::NOT_FOUND, "Credential not found").into_response(); + } + + if let Err(e) = save_credentials(&username, &stored) { + eprintln!("Failed to save credentials for {}: {}", username, e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to save changes").into_response(); + } + + Json(serde_json::json!({ "success": true })).into_response() +}