use axum::Json; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{post, put}; use axum::{Router, routing::get}; use serde_json::json; use sqlx::SqlitePool; mod auth; mod health; mod locations; mod notifications; mod users; mod weather_poller; mod weather_thresholds; // --- users_api --- mod users_api { use super::*; use crate::auth::AuthUser; use crate::users::{User, UserRepository, UserRole}; use axum::{ Json, extract::{Path, State}, }; use serde::Deserialize; use std::sync::Arc; #[derive(Deserialize)] pub struct CreateUser { pub role: Option, } #[derive(Deserialize)] pub struct UpdateUser { pub role: UserRole, } pub async fn list_users( AuthUser(_): AuthUser, State(pool): State>, ) -> Result>, String> { let repo = UserRepository { db: &pool }; repo.list_users().await.map(Json).map_err(|e| e.to_string()) } pub async fn get_user( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result, String> { let repo = UserRepository { db: &pool }; repo.get_user_by_id(id) .await .map_err(|e| e.to_string())? .map(Json) .ok_or_else(|| "User not found".to_string()) } pub async fn create_user( AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = UserRepository { db: &pool }; repo.create_user(None, payload.role) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn update_user( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = UserRepository { db: &pool }; repo.update_user(id, payload.role) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn delete_user( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = UserRepository { db: &pool }; repo.delete_user(id).await.map_err(|e| e.to_string()) } } // --- locations_api --- mod locations_api { use super::*; use crate::auth::AuthUser; use crate::locations::{Location, LocationRepository}; use axum::{ Json, extract::{Path, State}, }; use serde::Deserialize; use std::sync::Arc; #[derive(Deserialize)] pub struct CreateLocation { pub latitude: f64, pub longitude: f64, } #[derive(Deserialize)] pub struct UpdateLocation { pub latitude: f64, pub longitude: f64, } pub async fn list_locations( AuthUser(_): AuthUser, State(pool): State>, ) -> Result>, String> { let repo = LocationRepository { db: &pool }; repo.list_locations() .await .map(Json) .map_err(|e| e.to_string()) } pub async fn get_location( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result, String> { let repo = LocationRepository { db: &pool }; repo.get_location(id) .await .map_err(|e| e.to_string())? .map(Json) .ok_or_else(|| "Location not found".to_string()) } pub async fn create_location( AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = LocationRepository { db: &pool }; repo.create_location(payload.latitude, payload.longitude, user.id) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn update_location( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = LocationRepository { db: &pool }; repo.update_location(id, payload.latitude, payload.longitude) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn delete_location( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = LocationRepository { db: &pool }; repo.delete_location(id).await.map_err(|e| e.to_string()) } } // --- thresholds_api --- mod thresholds_api { use super::*; use crate::auth::AuthUser; use crate::weather_thresholds::{WeatherThreshold, WeatherThresholdRepository}; use axum::{ Json, extract::{Path, State}, }; use serde::Deserialize; use std::sync::Arc; #[derive(Deserialize)] pub struct CreateThreshold { pub condition_type: String, pub threshold_value: f64, pub operator: String, pub enabled: bool, pub description: Option, } #[derive(Deserialize)] pub struct UpdateThreshold { pub condition_type: String, pub threshold_value: f64, pub operator: String, pub enabled: bool, pub description: Option, } pub async fn list_thresholds( AuthUser(user): AuthUser, State(pool): State>, ) -> impl axum::response::IntoResponse { let repo = WeatherThresholdRepository { db: &pool }; match repo.list_thresholds(user.id).await { Ok(thresholds) => Json(thresholds).into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn get_threshold( Path(id): Path, AuthUser(user): AuthUser, State(pool): State>, ) -> Result, String> { let repo = WeatherThresholdRepository { db: &pool }; repo.get_threshold(id, user.id) .await .map_err(|e| e.to_string())? .map(Json) .ok_or_else(|| "Threshold not found".to_string()) } pub async fn create_threshold( AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = WeatherThresholdRepository { db: &pool }; repo.create_threshold( user.id, payload.condition_type, payload.threshold_value, payload.operator, payload.enabled, payload.description, ) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn update_threshold( Path(id): Path, AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = WeatherThresholdRepository { db: &pool }; repo.update_threshold(crate::weather_thresholds::WeatherThresholdUpdateInput { id, user_id: user.id, condition_type: payload.condition_type, threshold_value: payload.threshold_value, operator: payload.operator, enabled: payload.enabled, description: payload.description, }) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn delete_threshold( Path(id): Path, AuthUser(user): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = WeatherThresholdRepository { db: &pool }; repo.delete_threshold(id, user.id) .await .map_err(|e| e.to_string()) } } // --- notifications_api --- mod notifications_api { use super::*; use crate::auth::AuthUser; use crate::notifications::{ NtfySettings, NtfySettingsRepository, SmtpSettings, SmtpSettingsRepository, }; use axum::{ Json, extract::{Path, State}, }; use serde::Deserialize; use std::sync::Arc; // NTFY #[derive(Deserialize)] pub struct CreateNtfy { pub enabled: bool, pub topic: String, pub server_url: String, pub priority: i32, pub title_template: Option, pub message_template: Option, } #[derive(Deserialize)] pub struct UpdateNtfy { pub enabled: bool, pub topic: String, pub server_url: String, pub priority: i32, pub title_template: Option, pub message_template: Option, } pub async fn get_ntfy_settings( AuthUser(user): AuthUser, State(pool): State>, ) -> Result, String> { let repo = NtfySettingsRepository { db: &pool }; repo.get_by_user(user.id) .await .map_err(|e| e.to_string())? .map(Json) .ok_or_else(|| "NTFY settings not found".to_string()) } pub async fn create_ntfy_settings( AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = NtfySettingsRepository { db: &pool }; repo.create(crate::notifications::NtfySettingsInput { user_id: user.id, enabled: payload.enabled, topic: payload.topic, server_url: payload.server_url, priority: payload.priority, title_template: payload.title_template, message_template: payload.message_template, }) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn update_ntfy_settings( Path(id): Path, AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = NtfySettingsRepository { db: &pool }; repo.update( id, crate::notifications::NtfySettingsInput { user_id: user.id, enabled: payload.enabled, topic: payload.topic, server_url: payload.server_url, priority: payload.priority, title_template: payload.title_template, message_template: payload.message_template, }, ) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn delete_ntfy_settings( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = NtfySettingsRepository { db: &pool }; repo.delete(id).await.map_err(|e| e.to_string()) } // SMTP #[derive(Deserialize)] pub struct CreateSmtp { pub enabled: bool, pub email: String, pub smtp_server: String, pub smtp_port: i32, pub username: Option, pub password: Option, pub use_tls: bool, pub from_email: Option, pub from_name: Option, pub subject_template: Option, pub body_template: Option, } #[derive(Deserialize)] pub struct UpdateSmtp { pub enabled: bool, pub email: String, pub smtp_server: String, pub smtp_port: i32, pub username: Option, pub password: Option, pub use_tls: bool, pub from_email: Option, pub from_name: Option, pub subject_template: Option, pub body_template: Option, } pub async fn get_smtp_settings( AuthUser(user): AuthUser, State(pool): State>, ) -> Result, String> { let repo = SmtpSettingsRepository { db: &pool }; repo.get_by_user(user.id) .await .map_err(|e| e.to_string())? .map(Json) .ok_or_else(|| "SMTP settings not found".to_string()) } pub async fn create_smtp_settings( AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = SmtpSettingsRepository { db: &pool }; repo.create(crate::notifications::SmtpSettingsInput { user_id: user.id, enabled: payload.enabled, email: payload.email, smtp_server: payload.smtp_server, smtp_port: payload.smtp_port, username: payload.username, password: payload.password, use_tls: payload.use_tls, from_email: payload.from_email, from_name: payload.from_name, subject_template: payload.subject_template, body_template: payload.body_template, }) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn update_smtp_settings( Path(id): Path, AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = SmtpSettingsRepository { db: &pool }; repo.update( id, crate::notifications::SmtpSettingsInput { user_id: user.id, enabled: payload.enabled, email: payload.email, smtp_server: payload.smtp_server, smtp_port: payload.smtp_port, username: payload.username, password: payload.password, use_tls: payload.use_tls, from_email: payload.from_email, from_name: payload.from_name, subject_template: payload.subject_template, body_template: payload.body_template, }, ) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn delete_smtp_settings( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = SmtpSettingsRepository { db: &pool }; repo.delete(id).await.map_err(|e| e.to_string()) } } // --- app_with_state --- pub fn error_response(status: StatusCode, message: &str) -> axum::response::Response { (status, Json(json!({"error": message}))).into_response() } pub async fn not_found() -> axum::response::Response { error_response(StatusCode::NOT_FOUND, "Not Found") } pub fn app_with_state(pool: std::sync::Arc) -> Router { Router::new() .route("/health", get(health::health_handler)) .nest( "/api/users", Router::new() .route("/", get(users_api::list_users).post(users_api::create_user)) .route( "/{id}", get(users_api::get_user) .put(users_api::update_user) .delete(users_api::delete_user), ), ) .nest( "/api/locations", Router::new() .route( "/", get(locations_api::list_locations).post(locations_api::create_location), ) .route( "/{id}", get(locations_api::get_location) .put(locations_api::update_location) .delete(locations_api::delete_location), ), ) .nest( "/api/weather-thresholds", Router::new() .route( "/", get(thresholds_api::list_thresholds).post(thresholds_api::create_threshold), ) .route( "/{id}", get(thresholds_api::get_threshold) .put(thresholds_api::update_threshold) .delete(thresholds_api::delete_threshold), ), ) .nest( "/api/ntfy-settings", Router::new() .route("/me", get(notifications_api::get_ntfy_settings)) .route("/", post(notifications_api::create_ntfy_settings)) .route( "/{id}", put(notifications_api::update_ntfy_settings) .delete(notifications_api::delete_ntfy_settings), ), ) .nest( "/api/smtp-settings", Router::new() .route("/me", get(notifications_api::get_smtp_settings)) .route("/", post(notifications_api::create_smtp_settings)) .route( "/{id}", put(notifications_api::update_smtp_settings) .delete(notifications_api::delete_smtp_settings), ), ) .fallback(not_found) .with_state(pool) } use std::env; use std::net::SocketAddr; use std::sync::Arc; use tokio::fs; use uuid::Uuid; #[tokio::main] async fn main() { // Set up database path let db_path = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://./data/silmataivas.db".to_string()); // Ensure data directory exists let _ = fs::create_dir_all("./data").await; // Connect to SQLite let pool = SqlitePool::connect(&db_path) .await .expect("Failed to connect to DB"); // Create initial admin user if none exists { let repo = users::UserRepository { db: &pool }; match repo.any_admin_exists().await { Ok(false) => { let admin_token = env::var("ADMIN_TOKEN").unwrap_or_else(|_| Uuid::new_v4().to_string()); match repo .create_user(Some(admin_token.clone()), Some(users::UserRole::Admin)) .await { Ok(_) => println!("Initial admin user created. Token: {admin_token}"), Err(e) => eprintln!("Failed to create initial admin user: {e}"), } } Ok(true) => { // At least one admin exists, do nothing } Err(e) => { eprintln!("Failed to check for existing admin users: {e}"); } } } let app = app_with_state(Arc::new(pool)); let addr = SocketAddr::from(([0, 0, 0, 0], 4000)); let listener = tokio::net::TcpListener::bind(addr) .await .expect("Failed to bind address"); println!("Listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); } #[cfg(test)] mod tests { use super::*; use axum::body::Body; use axum::body::to_bytes; use axum::http::{Request, StatusCode}; use tower::ServiceExt; // for `oneshot` #[tokio::test] async fn test_health_endpoint() { let app = app_with_state(std::sync::Arc::new( sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(), )); let response = app .oneshot( Request::builder() .uri("/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), 1024).await.unwrap(); assert_eq!(&body[..], b"{\"status\":\"ok\"}"); } }