Left over changes

This commit is contained in:
Jack Mechem 2026-05-22 02:19:32 -07:00
parent 7640a235ca
commit b748ef36d6
7 changed files with 219 additions and 10 deletions

View file

@ -25,6 +25,7 @@ tokio = { version = "1.50.0", features = [
"macros",
"rt-multi-thread",
"process",
"time",
] }
tapo = "0.9.0"
zbus = "5.14.0"

View file

@ -24,15 +24,15 @@ use yescrypt::{PasswordHash, PasswordVerifier, Yescrypt};
static JWT_SECRET: OnceLock<String> = 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<SecurityKey>,
pub(crate) struct StoredCredentials {
pub(crate) user_id: Uuid,
pub(crate) credentials: Vec<SecurityKey>,
}
pub struct AppState {
@ -183,13 +183,13 @@ fn verify_shadow_hash(password: &str, hash: &str) -> bool {
.is_ok()
}
fn load_credentials(username: &str) -> Option<StoredCredentials> {
pub(crate) fn load_credentials(username: &str) -> Option<StoredCredentials> {
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));

View file

@ -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

View file

@ -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<TapoDeviceData>,
}
#[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<PowerDeviceReading>,
}

View file

@ -2,3 +2,4 @@ pub mod power;
pub mod services;
pub mod stats;
pub mod system;
pub mod users;

View file

@ -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<Mutex<Vec<models::PowerHistoryEntry>>>;
const HISTORY_FILE: &str = "/var/lib/server-dash-api/power-history.json";
const MAX_HISTORY_DAYS: i64 = 60;
pub fn load_history() -> Vec<models::PowerHistoryEntry> {
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<PowerHistory>,
Query(params): Query<HistoryQuery>,
) -> 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,

79
src/routes/users.rs Normal file
View file

@ -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<CredentialInfo>,
}
#[derive(Serialize)]
struct UsersResponse {
users: Vec<UserInfo>,
}
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<UserInfo> = 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()
}