diff --git a/.gitignore b/.gitignore index 2b96bd2..9685e95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ server/target/ .env +users.toml *.snap.new repomix-output.xml diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..70679ee --- /dev/null +++ b/server/.env.example @@ -0,0 +1,33 @@ +# === Required (Phase 1) === + +SERVER_PORT=3000 +DATA_PATH=/data +USERS_FILE=users.toml +LOG_LEVEL=info +LOG_PATH=/var/log/recorder +LOG_MAX_DAYS=90 + +# === Optional (Phase 2+) === + +# Retention +RETENTION_AUDIO_DAYS=30 +RETENTION_TRANSCRIPT_DAYS=30 +RETENTION_DOCUMENT_DAYS=0 + +# faster-whisper +WHISPER_URL=http://localhost:10300 +WHISPER_TIMEOUT_SECONDS=120 + +# Ollama +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=gemma3:4b +OLLAMA_KEEP_ALIVE=0 + +# LLM provider (Ionos) +LLM_URL=https://openai.ionos.com/openai +LLM_API_KEY= +LLM_MODEL= +LLM_TEMPERATURE=0 + +# Session +SESSION_TIMEOUT_HOURS=8 diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..75de734 --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,131 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::Deserialize; + +/// A single user entry from users.toml. +#[derive(Debug, Deserialize)] +pub struct User { + pub slug: String, + pub api_key: String, + pub web_password: String, + pub role: String, +} + +/// Wrapper for deserializing the [[user]] array from TOML. +#[derive(Deserialize)] +struct UsersFile { + user: Vec, +} + +pub struct Config { + // Phase 1 — required + pub server_port: u16, + pub data_path: PathBuf, + pub log_level: String, + pub log_path: PathBuf, + pub log_max_days: u32, + pub users: Vec, + pub api_keys: HashMap, // api_key_value → slug + + // Phase 2+ — optional with defaults + pub retention_audio_days: u32, + pub retention_transcript_days: u32, + pub retention_document_days: u32, + pub whisper_url: String, + pub whisper_timeout_seconds: u64, + pub ollama_url: String, + pub ollama_model: String, + pub ollama_keep_alive: u32, + pub llm_url: String, + pub llm_api_key: String, + pub llm_model: String, + pub llm_temperature: f32, + pub session_timeout_hours: u32, +} + +impl Config { + pub fn from_env() -> Self { + let users_file = required_env("USERS_FILE"); + let (users, api_keys) = load_users(&users_file); + + Self { + server_port: required_env_parsed("SERVER_PORT"), + data_path: PathBuf::from(required_env("DATA_PATH")), + log_level: optional_env("LOG_LEVEL", "info"), + log_path: PathBuf::from(optional_env("LOG_PATH", "/var/log/recorder")), + log_max_days: optional_env_parsed("LOG_MAX_DAYS", 90), + users, + api_keys, + + retention_audio_days: optional_env_parsed("RETENTION_AUDIO_DAYS", 30), + retention_transcript_days: optional_env_parsed("RETENTION_TRANSCRIPT_DAYS", 30), + retention_document_days: optional_env_parsed("RETENTION_DOCUMENT_DAYS", 0), + whisper_url: optional_env("WHISPER_URL", "http://localhost:10300"), + whisper_timeout_seconds: optional_env_parsed("WHISPER_TIMEOUT_SECONDS", 120), + ollama_url: optional_env("OLLAMA_URL", "http://localhost:11434"), + ollama_model: optional_env("OLLAMA_MODEL", "gemma3:4b"), + ollama_keep_alive: optional_env_parsed("OLLAMA_KEEP_ALIVE", 0), + llm_url: optional_env("LLM_URL", ""), + llm_api_key: optional_env("LLM_API_KEY", ""), + llm_model: optional_env("LLM_MODEL", ""), + llm_temperature: optional_env_parsed("LLM_TEMPERATURE", 0.0), + session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8), + } + } +} + +/// Load users from a TOML file and build the API key lookup map. +fn load_users(path: &str) -> (Vec, HashMap) { + let content = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("Failed to read users file {path}: {e}")); + + let parsed: UsersFile = toml::from_str(&content) + .unwrap_or_else(|e| panic!("Failed to parse users file {path}: {e}")); + + if parsed.user.is_empty() { + panic!("No users defined in {path}. At least one is required."); + } + + let api_keys = parsed + .user + .iter() + .map(|u| (u.api_key.clone(), u.slug.clone())) + .collect(); + + (parsed.user, api_keys) +} + +/// Read a required environment variable. Panics with a clear message if missing. +fn required_env(name: &str) -> String { + std::env::var(name) + .unwrap_or_else(|_| panic!("Missing required environment variable: {name}")) +} + +/// Read a required environment variable and parse it. Panics if missing or unparseable. +fn required_env_parsed(name: &str) -> T +where + T::Err: std::fmt::Display, +{ + let raw = required_env(name); + raw.parse() + .unwrap_or_else(|e| panic!("Invalid value for {name}: {e}")) +} + +/// Read an optional environment variable, falling back to a default. +fn optional_env(name: &str, default: &str) -> String { + std::env::var(name).unwrap_or_else(|_| default.to_owned()) +} + +/// Read an optional environment variable and parse it, falling back to a default. +fn optional_env_parsed(name: &str, default: T) -> T +where + T::Err: std::fmt::Display, +{ + match std::env::var(name) { + Ok(raw) => raw + .parse() + .unwrap_or_else(|e| panic!("Invalid value for {name}: {e}")), + Err(_) => default, + } +} diff --git a/server/src/error.rs b/server/src/error.rs new file mode 100644 index 0000000..2d622a3 --- /dev/null +++ b/server/src/error.rs @@ -0,0 +1,28 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +pub enum AppError { + Unauthorized, + BadRequest(String), + Internal(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()), + Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + Self::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()), + }; + + let body = axum::Json(json!({ "error": message })); + (status, body).into_response() + } +} + +impl From for AppError { + fn from(err: std::io::Error) -> Self { + Self::Internal(err.to_string()) + } +} diff --git a/server/src/main.rs b/server/src/main.rs index e7a11a9..af57347 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,3 +1,12 @@ -fn main() { - println!("Hello, world!"); +mod config; +mod error; + +use config::Config; +use std::sync::Arc; + +#[tokio::main] +async fn main() { + dotenvy::dotenv().ok(); + let config = Arc::new(Config::from_env()); + println!("Server starting on port {}", config.server_port); } diff --git a/server/users.toml.example b/server/users.toml.example new file mode 100644 index 0000000..6d2f730 --- /dev/null +++ b/server/users.toml.example @@ -0,0 +1,11 @@ +[[user]] +slug = "dr_mueller" +api_key = "change-me-to-a-secure-key" +web_password = "$2b$12$..." +role = "doctor" + +[[user]] +slug = "dr_schmidt" +api_key = "change-me-to-a-secure-key" +web_password = "$2b$12$..." +role = "doctor"