Ferrit Explore
中文·繁體·EN·日本語 Sign in Register
cielxl / ferrit / src / store.rs
//! Persistent storage for users and repositories.
//!
//! Ferrit keeps metadata in a single JSON document (`data/db.json`) guarded by a
//! mutex. Git object data itself lives on disk as bare repositories; this store
//! only tracks the metadata around them. This mirrors how Gitea separates its
//! relational metadata from the on-disk git repositories, just in a minimal form.

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::Mutex;

#[derive(Serialize, Deserialize, Clone)]
pub struct User {
    pub id: u64,
    pub username: String,
    pub email: String,
    /// Stored as `salt$hash`.
    pub pass: String,
    pub created: i64,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct Repo {
    pub id: u64,
    pub owner: String,
    pub name: String,
    pub description: String,
    pub private: bool,
    pub created: i64,
}

#[derive(Serialize, Deserialize, Default)]
struct Data {
    users: Vec<User>,
    repos: Vec<Repo>,
    next_user: u64,
    next_repo: u64,
}

pub struct Store {
    path: PathBuf,
    data: Mutex<Data>,
}

fn now() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

fn sha256_hex(input: &str) -> String {
    let mut h = Sha256::new();
    h.update(input.as_bytes());
    hex::encode(h.finalize())
}

fn random_hex(bytes: usize) -> String {
    use rand::RngCore;
    let mut buf = vec![0u8; bytes];
    rand::thread_rng().fill_bytes(&mut buf);
    hex::encode(buf)
}

/// Derive a password hash by iterating SHA-256 over `salt || password`.
fn derive(password: &str, salt: &str) -> String {
    let mut acc = format!("{salt}{password}");
    for _ in 0..50_000 {
        acc = sha256_hex(&acc);
    }
    acc
}

pub fn hash_password(password: &str) -> String {
    let salt = random_hex(16);
    let hash = derive(password, &salt);
    format!("{salt}${hash}")
}

pub fn verify_password(password: &str, stored: &str) -> bool {
    match stored.split_once('$') {
        Some((salt, hash)) => derive(password, salt) == hash,
        None => false,
    }
}

#[derive(Debug)]
pub enum StoreError {
    UserExists,
    RepoExists,
    NotFound,
    Invalid(String),
}

impl std::fmt::Display for StoreError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            StoreError::UserExists => write!(f, "username or email already taken"),
            StoreError::RepoExists => write!(f, "a repository with that name already exists"),
            StoreError::NotFound => write!(f, "not found"),
            StoreError::Invalid(m) => write!(f, "{m}"),
        }
    }
}

impl Store {
    pub fn load(path: PathBuf) -> Self {
        let data = std::fs::read_to_string(&path)
            .ok()
            .and_then(|s| serde_json::from_str::<Data>(&s).ok())
            .unwrap_or_default();
        Store {
            path,
            data: Mutex::new(data),
        }
    }

    fn save(&self, data: &Data) {
        if let Ok(json) = serde_json::to_string_pretty(data) {
            let _ = std::fs::write(&self.path, json);
        }
    }

    pub fn create_user(
        &self,
        username: &str,
        email: &str,
        password: &str,
    ) -> Result<User, StoreError> {
        let username = username.trim();
        if !valid_name(username) {
            return Err(StoreError::Invalid(
                "username must be 1-39 chars: letters, digits, '-' or '_'".into(),
            ));
        }
        if password.len() < 6 {
            return Err(StoreError::Invalid(
                "password must be at least 6 characters".into(),
            ));
        }
        let mut data = self.data.lock().unwrap();
        let lower = username.to_lowercase();
        if data
            .users
            .iter()
            .any(|u| u.username.to_lowercase() == lower || u.email == email)
        {
            return Err(StoreError::UserExists);
        }
        data.next_user += 1;
        let user = User {
            id: data.next_user,
            username: username.to_string(),
            email: email.to_string(),
            pass: hash_password(password),
            created: now(),
        };
        data.users.push(user.clone());
        self.save(&data);
        Ok(user)
    }

    pub fn authenticate(&self, username: &str, password: &str) -> Option<User> {
        let data = self.data.lock().unwrap();
        let lower = username.to_lowercase();
        let user = data
            .users
            .iter()
            .find(|u| u.username.to_lowercase() == lower)?;
        if verify_password(password, &user.pass) {
            Some(user.clone())
        } else {
            None
        }
    }

    pub fn user_by_id(&self, id: u64) -> Option<User> {
        self.data
            .lock()
            .unwrap()
            .users
            .iter()
            .find(|u| u.id == id)
            .cloned()
    }

    pub fn user_by_name(&self, name: &str) -> Option<User> {
        let lower = name.to_lowercase();
        self.data
            .lock()
            .unwrap()
            .users
            .iter()
            .find(|u| u.username.to_lowercase() == lower)
            .cloned()
    }

    pub fn create_repo(
        &self,
        owner: &str,
        name: &str,
        description: &str,
        private: bool,
    ) -> Result<Repo, StoreError> {
        let name = name.trim();
        if !valid_name(name) {
            return Err(StoreError::Invalid(
                "repository name must be 1-39 chars: letters, digits, '-', '_' or '.'".into(),
            ));
        }
        let mut data = self.data.lock().unwrap();
        let owner_lower = owner.to_lowercase();
        let name_lower = name.to_lowercase();
        if data
            .repos
            .iter()
            .any(|r| r.owner.to_lowercase() == owner_lower && r.name.to_lowercase() == name_lower)
        {
            return Err(StoreError::RepoExists);
        }
        data.next_repo += 1;
        let repo = Repo {
            id: data.next_repo,
            owner: owner.to_string(),
            name: name.to_string(),
            description: description.to_string(),
            private,
            created: now(),
        };
        data.repos.push(repo.clone());
        self.save(&data);
        Ok(repo)
    }

    pub fn repo(&self, owner: &str, name: &str) -> Option<Repo> {
        let owner_lower = owner.to_lowercase();
        let name_lower = name.trim_end_matches(".git").to_lowercase();
        self.data
            .lock()
            .unwrap()
            .repos
            .iter()
            .find(|r| r.owner.to_lowercase() == owner_lower && r.name.to_lowercase() == name_lower)
            .cloned()
    }

    pub fn repos_by_owner(&self, owner: &str) -> Vec<Repo> {
        let owner_lower = owner.to_lowercase();
        let mut v: Vec<Repo> = self
            .data
            .lock()
            .unwrap()
            .repos
            .iter()
            .filter(|r| r.owner.to_lowercase() == owner_lower)
            .cloned()
            .collect();
        v.sort_by(|a, b| b.created.cmp(&a.created));
        v
    }

    pub fn public_repos(&self) -> Vec<Repo> {
        let mut v: Vec<Repo> = self
            .data
            .lock()
            .unwrap()
            .repos
            .iter()
            .filter(|r| !r.private)
            .cloned()
            .collect();
        v.sort_by(|a, b| b.created.cmp(&a.created));
        v
    }

    pub fn user_count(&self) -> usize {
        self.data.lock().unwrap().users.len()
    }
}

/// Names usable as URL path segments: 1-39 chars, no slashes or spaces.
pub fn valid_name(name: &str) -> bool {
    let n = name.trim_end_matches(".git");
    if n.is_empty() || n.len() > 39 {
        return false;
    }
    if n == "." || n == ".." {
        return false;
    }
    n.chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}