howler-core 0.1.0

Core library for the Howler wolf-tracking platform: database, data ingestion, ML, web server, and real-time streaming
Documentation
use anyhow::{Context, Result};
use argon2::{
    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
    Argon2,
};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use uuid::Uuid;

use rand::Rng;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserRole {
    Admin,
    Researcher,
    Viewer,
}

impl UserRole {
    pub fn as_str(&self) -> &str {
        match self {
            UserRole::Admin => "Admin",
            UserRole::Researcher => "Researcher",
            UserRole::Viewer => "Viewer",
        }
    }

    #[allow(clippy::should_implement_trait)]
    pub fn from_str(s: &str) -> Result<Self> {
        match s {
            "Admin" => Ok(UserRole::Admin),
            "Researcher" => Ok(UserRole::Researcher),
            "Viewer" => Ok(UserRole::Viewer),
            _ => anyhow::bail!("Invalid role: {}", s),
        }
    }
}

#[derive(Debug, Clone)]
pub struct User {
    pub id: String,
    pub username: String,
    pub email: String,
    pub password_hash: String,
    pub role: UserRole,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone)]
pub struct Session {
    pub user_id: String,
    pub token: String,
    pub expires_at: DateTime<Utc>,
}

pub struct AuthService<'a> {
    conn: &'a Connection,
}

impl<'a> AuthService<'a> {
    pub fn new(conn: &'a Connection) -> Self {
        AuthService { conn }
    }

    pub fn init_auth_schema(&self) -> Result<()> {
        self.conn
            .execute_batch(
                "CREATE TABLE IF NOT EXISTS users (
                    id TEXT PRIMARY KEY,
                    username TEXT NOT NULL UNIQUE,
                    email TEXT NOT NULL UNIQUE,
                    password_hash TEXT NOT NULL,
                    role TEXT NOT NULL DEFAULT 'Viewer',
                    created_at TEXT NOT NULL
                );

                CREATE TABLE IF NOT EXISTS sessions (
                    user_id TEXT NOT NULL,
                    token TEXT PRIMARY KEY,
                    expires_at TEXT NOT NULL,
                    FOREIGN KEY (user_id) REFERENCES users(id)
                );

                CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
                CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
            )
            .context("Failed to create auth schema")?;
        Ok(())
    }

    pub fn register(
        &self,
        username: &str,
        email: &str,
        password: &str,
        role: UserRole,
    ) -> Result<User> {
        let salt = SaltString::generate(&mut OsRng);
        let argon2 = Argon2::default();
        let password_hash = argon2
            .hash_password(password.as_bytes(), &salt)
            .map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e))?
            .to_string();

        let id = Uuid::new_v4().to_string();
        let created_at = Utc::now();

        self.conn
            .execute(
                "INSERT INTO users (id, username, email, password_hash, role, created_at)
                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
                params![
                    id,
                    username,
                    email,
                    password_hash,
                    role.as_str(),
                    created_at.to_rfc3339(),
                ],
            )
            .context("Failed to insert user")?;

        Ok(User {
            id,
            username: username.to_string(),
            email: email.to_string(),
            password_hash,
            role,
            created_at,
        })
    }

    pub fn login(&self, username: &str, password: &str) -> Result<Session> {
        let user = self
            .get_user_by_username(username)?
            .ok_or_else(|| anyhow::anyhow!("User not found"))?;

        let parsed_hash = PasswordHash::new(&user.password_hash)
            .map_err(|e| anyhow::anyhow!("Failed to parse password hash: {}", e))?;

        Argon2::default()
            .verify_password(password.as_bytes(), &parsed_hash)
            .map_err(|_| anyhow::anyhow!("Invalid password"))?;

        let token: String = rand::thread_rng()
            .sample_iter(&rand::distributions::Alphanumeric)
            .take(64)
            .map(char::from)
            .collect();

        let expires_at = Utc::now() + chrono::Duration::hours(24);

        self.conn
            .execute("DELETE FROM sessions WHERE user_id = ?1", params![user.id])
            .context("Failed to clean old sessions")?;

        self.conn
            .execute(
                "INSERT INTO sessions (user_id, token, expires_at)
                 VALUES (?1, ?2, ?3)",
                params![user.id, token, expires_at.to_rfc3339()],
            )
            .context("Failed to create session")?;

        Ok(Session {
            user_id: user.id,
            token,
            expires_at,
        })
    }

    pub fn validate_token(&self, token: &str) -> Result<Option<User>> {
        let result = self.conn.query_row(
            "SELECT u.id, u.username, u.email, u.password_hash, u.role, u.created_at
             FROM users u
             JOIN sessions s ON u.id = s.user_id
             WHERE s.token = ?1 AND s.expires_at > datetime('now')",
            params![token],
            |row| {
                Ok(User {
                    id: row.get(0)?,
                    username: row.get(1)?,
                    email: row.get(2)?,
                    password_hash: row.get(3)?,
                    role: UserRole::from_str(&row.get::<_, String>(4)?).unwrap_or(UserRole::Viewer),
                    created_at: {
                        let s: String = row.get(5)?;
                        DateTime::parse_from_rfc3339(&s)
                            .map(|dt| dt.with_timezone(&Utc))
                            .unwrap_or_else(|_| Utc::now())
                    },
                })
            },
        );

        match result {
            Ok(user) => Ok(Some(user)),
            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
            Err(e) => Err(e.into()),
        }
    }

    pub fn get_user(&self, user_id: &str) -> Result<Option<User>> {
        let result = self.conn.query_row(
            "SELECT id, username, email, password_hash, role, created_at
             FROM users WHERE id = ?1",
            params![user_id],
            |row| {
                Ok(User {
                    id: row.get(0)?,
                    username: row.get(1)?,
                    email: row.get(2)?,
                    password_hash: row.get(3)?,
                    role: UserRole::from_str(&row.get::<_, String>(4)?).unwrap_or(UserRole::Viewer),
                    created_at: {
                        let s: String = row.get(5)?;
                        DateTime::parse_from_rfc3339(&s)
                            .map(|dt| dt.with_timezone(&Utc))
                            .unwrap_or_else(|_| Utc::now())
                    },
                })
            },
        );

        match result {
            Ok(user) => Ok(Some(user)),
            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
            Err(e) => Err(e.into()),
        }
    }

    fn get_user_by_username(&self, username: &str) -> Result<Option<User>> {
        let result = self.conn.query_row(
            "SELECT id, username, email, password_hash, role, created_at
             FROM users WHERE username = ?1",
            params![username],
            |row| {
                Ok(User {
                    id: row.get(0)?,
                    username: row.get(1)?,
                    email: row.get(2)?,
                    password_hash: row.get(3)?,
                    role: UserRole::from_str(&row.get::<_, String>(4)?).unwrap_or(UserRole::Viewer),
                    created_at: {
                        let s: String = row.get(5)?;
                        DateTime::parse_from_rfc3339(&s)
                            .map(|dt| dt.with_timezone(&Utc))
                            .unwrap_or_else(|_| Utc::now())
                    },
                })
            },
        );

        match result {
            Ok(user) => Ok(Some(user)),
            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
            Err(e) => Err(e.into()),
        }
    }

    pub fn logout(&self, token: &str) -> Result<()> {
        self.conn
            .execute("DELETE FROM sessions WHERE token = ?1", params![token])
            .context("Failed to delete session")?;
        Ok(())
    }

    pub fn list_users(&self) -> Result<Vec<User>> {
        let mut stmt = self
            .conn
            .prepare("SELECT id, username, email, password_hash, role, created_at FROM users")?;

        let users = stmt
            .query_map([], |row| {
                Ok(User {
                    id: row.get(0)?,
                    username: row.get(1)?,
                    email: row.get(2)?,
                    password_hash: row.get(3)?,
                    role: UserRole::from_str(&row.get::<_, String>(4)?).unwrap_or(UserRole::Viewer),
                    created_at: {
                        let s: String = row.get(5)?;
                        DateTime::parse_from_rfc3339(&s)
                            .map(|dt| dt.with_timezone(&Utc))
                            .unwrap_or_else(|_| Utc::now())
                    },
                })
            })?
            .collect::<Result<Vec<_>, _>>()?;

        Ok(users)
    }
}