Login
4 branches 0 tags
Ben (Desktop/Arch) More integration tests 403a2ab 1 month ago 159 Commits
rubhub / src / models / user.rs
use anyhow::{Result, anyhow};
use russh::keys::ssh_key;
use serde::{Deserialize, Serialize};

use time::OffsetDateTime;
use uuid::Uuid;

use crate::{
    GlobalState, Project,
    services::{
        fs::atomic_write,
        password::{PasswordVerification, hash_password, verify_password_hash},
        validation::{slugify, validate_username},
    },
};

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct User {
    pub id: Uuid,
    pub created_at: OffsetDateTime,
    pub last_login: Option<OffsetDateTime>,

    pub slug: String,
    pub name: String,
    pub email: String,
    pub description: String,
    pub website: String,

    pub default_main_branch: String,

    pub password_hash: String,
    pub ssh_keys: Vec<String>,
}

impl User {
    pub async fn login(state: &GlobalState, slug: &str, password: &str) -> Result<Self> {
        let mut user = Self::load(state, slug).await?;

        match verify_password_hash(password, &user.password_hash) {
            PasswordVerification::Invalid | PasswordVerification::Error => {
                Err(anyhow!("Invalid Password"))
            }
            PasswordVerification::ValidNeedsRehash { new_hash } => {
                user.password_hash = new_hash;
                user.last_login = Some(time::OffsetDateTime::now_utc());
                user.save(state).await?;
                Ok(user)
            }
            PasswordVerification::Valid => {
                user.last_login = Some(time::OffsetDateTime::now_utc());
                user.save(state).await?;
                Ok(user)
            }
        }
    }

    pub fn validate_ssh_key(&self, ssh_key: &ssh_key::PublicKey) -> Result<()> {
        for key in &self.ssh_keys {
            let Ok(key) = ssh_key::PublicKey::from_openssh(key) else {
                continue;
            };
            if key.key_data() == ssh_key.key_data() {
                return Ok(());
            }
        }
        Err(anyhow!("PublicKey doesn't match user"))
    }

    pub async fn load(state: &GlobalState, slug: &str) -> Result<Self> {
        if validate_username(slug).is_err() {
            return Err(anyhow!("Invalid username"));
        }
        let path = state.config.git_root.join(format!("!{slug}.json"));
        let data = tokio::fs::read(path).await.map_err(|e| {
            if e.kind() == std::io::ErrorKind::NotFound {
                anyhow!("No such user")
            } else {
                anyhow!(e)
            }
        })?;
        let data = String::from_utf8_lossy(&data);
        let user: User = serde_json::from_str(&data)?;

        Ok(user)
    }

    pub async fn save(&self, state: &GlobalState) -> Result<()> {
        if validate_username(&self.slug).is_err() {
            return Err(anyhow!("Invalid username"));
        }
        let path = state.config.git_root.join(format!("!{}.json", self.slug));
        let data = serde_json::to_string(&self)?;
        atomic_write(path, data).await?;

        Ok(())
    }

    pub fn new(name: &str, email: &str, password: &str) -> Result<Self> {
        let slug = slugify(name);
        if validate_username(&slug).is_err() {
            return Err(anyhow!("Invalid username/slug"));
        }

        let password_hash = hash_password(password).map_err(|_| anyhow!("Can't hash password"))?;

        Ok(Self {
            id: Uuid::new_v4(),
            created_at: time::OffsetDateTime::now_utc(),
            last_login: None,
            name: name.to_string(),
            slug,
            email: email.to_string(),
            password_hash,
            description: "".to_string(),
            website: "".to_string(),

            default_main_branch: "main".to_string(),
            ssh_keys: vec![],
        })
    }

    pub async fn projects(&self, state: &GlobalState) -> Result<Vec<Project>> {
        let mut ret = vec![];

        let path = state.config.git_root.join(&self.slug);
        let Ok(mut entries) = tokio::fs::read_dir(path).await else {
            return Ok(ret);
        };

        while let Some(entry) = entries.next_entry().await? {
            let meta = entry.metadata().await?;
            if meta.is_dir() {
                let file_name = entry.file_name();
                let project_slug = file_name.to_string_lossy();
                if let Ok(project) = Project::load(state, &self.slug, &project_slug).await {
                    ret.push(project);
                }
            };
        }
        Ok(ret)
    }

    pub async fn sidebar_projects(&self, state: &GlobalState) -> Vec<Project> {
        let mut projects = self.projects(state).await.unwrap_or_default();

        // Sort alphabetically by project name (case-insensitive)
        projects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));

        // Limit to 10 projects
        projects.truncate(10);

        projects
    }

    pub fn uri(&self) -> String {
        format!("/~{}", self.slug)
    }
}