Files
doctate/server/src/config.rs
T
Brummel 1e3cc9574c Refactor user config validation and add transcript display
Introduces a dedicated function `validate_and_index_users` to
encapsulate the logic for validating user configurations, checking for
duplicate slugs and API keys. This function returns a `Result` to handle
errors gracefully, and its usage in `load_users` is updated to panic
with a more informative error message.

Additionally, this commit modifies the web routing to fetch and display
audio transcripts. When scanning recordings, it now attempts to read a
corresponding `.transcript.txt` file. If found, the transcript content
is included in the `RecordingView` and rendered in the `cases.html`
template. If the transcript file is not found, a "Transcription pending"
message is displayed.

The `transcribe` function in `whisper.rs` is updated to explicitly set
the language to German (`language=de`), which can improve transcription
accuracy by skipping language detection. The `encode=false` query
parameter has been removed, as the service is designed to handle encoded
audio.

Finally, tests have been added for the new `validate_and_index_users`
function to ensure duplicate slugs and API keys are correctly rejected.
Integration tests in `web_test.rs` have been updated to verify the
rendering of transcripts and the pending status.
2026-04-13 17:08:21 +02:00

186 lines
6.1 KiB
Rust

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<User>,
}
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<User>,
pub api_keys: HashMap<String, String>, // 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<User>, HashMap<String, String>) {
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 = validate_and_index_users(&parsed.user)
.unwrap_or_else(|e| panic!("Invalid users file {path}: {e}"));
(parsed.user, api_keys)
}
/// Build the api_key → slug lookup map. Rejects duplicate slugs (collide on
/// data directories) and duplicate api_keys (ambiguous authentication).
pub fn validate_and_index_users(users: &[User]) -> Result<HashMap<String, String>, String> {
let mut slugs: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut api_keys: HashMap<String, String> = HashMap::new();
for u in users {
if !slugs.insert(u.slug.as_str()) {
return Err(format!("duplicate slug: {}", u.slug));
}
if let Some(existing) = api_keys.insert(u.api_key.clone(), u.slug.clone()) {
return Err(format!(
"duplicate api_key shared by users {existing} and {}",
u.slug
));
}
}
Ok(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<T: std::str::FromStr>(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<T: std::str::FromStr>(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,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn u(slug: &str, api_key: &str) -> User {
User {
slug: slug.into(),
api_key: api_key.into(),
web_password: String::new(),
role: "doctor".into(),
}
}
#[test]
fn accepts_distinct_users() {
let users = vec![u("a", "k1"), u("b", "k2")];
let map = validate_and_index_users(&users).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map.get("k1").unwrap(), "a");
}
#[test]
fn rejects_duplicate_api_keys() {
let users = vec![u("a", "same"), u("b", "same")];
let err = validate_and_index_users(&users).unwrap_err();
assert!(err.contains("duplicate api_key"), "got: {err}");
}
#[test]
fn rejects_duplicate_slugs() {
let users = vec![u("same", "k1"), u("same", "k2")];
let err = validate_and_index_users(&users).unwrap_err();
assert!(err.contains("duplicate slug"), "got: {err}");
}
}