//! 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 == '.')
}