feat: add basic website

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 19:46:13 +01:00
commit b439762877
71 changed files with 16576 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
mod validation;
pub use validation::{validate_email, validate_password, validate_username};
use serde::{Deserialize, Serialize};
/// Tokens returned by forest-server after login/register.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthTokens {
pub access_token: String,
pub refresh_token: String,
pub expires_in_seconds: i64,
}
/// Minimal user info from forest-server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub user_id: String,
pub username: String,
pub emails: Vec<UserEmail>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserEmail {
pub email: String,
pub verified: bool,
}
/// A personal access token (metadata only, no raw key).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonalAccessToken {
pub token_id: String,
pub name: String,
pub scopes: Vec<String>,
pub created_at: Option<String>,
pub last_used: Option<String>,
pub expires_at: Option<String>,
}
/// Result of creating a PAT - includes the raw key shown once.
#[derive(Debug, Clone)]
pub struct CreatedToken {
pub token: PersonalAccessToken,
pub raw_token: String,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum AuthError {
#[error("invalid credentials")]
InvalidCredentials,
#[error("already exists: {0}")]
AlreadyExists(String),
#[error("not authenticated")]
NotAuthenticated,
#[error("token expired")]
TokenExpired,
#[error("forest-server unavailable: {0}")]
Unavailable(String),
#[error("{0}")]
Other(String),
}
/// Trait for communicating with forest-server's UsersService.
/// Object-safe via async_trait so we can use `Arc<dyn ForestAuth>`.
#[async_trait::async_trait]
pub trait ForestAuth: Send + Sync {
async fn register(
&self,
username: &str,
email: &str,
password: &str,
) -> Result<AuthTokens, AuthError>;
async fn login(
&self,
identifier: &str,
password: &str,
) -> Result<AuthTokens, AuthError>;
async fn refresh_token(
&self,
refresh_token: &str,
) -> Result<AuthTokens, AuthError>;
async fn logout(&self, refresh_token: &str) -> Result<(), AuthError>;
async fn get_user(&self, access_token: &str) -> Result<User, AuthError>;
async fn list_tokens(
&self,
access_token: &str,
user_id: &str,
) -> Result<Vec<PersonalAccessToken>, AuthError>;
async fn create_token(
&self,
access_token: &str,
user_id: &str,
name: &str,
) -> Result<CreatedToken, AuthError>;
async fn delete_token(
&self,
access_token: &str,
token_id: &str,
) -> Result<(), AuthError>;
}

View File

@@ -0,0 +1,120 @@
#[derive(Debug, PartialEq)]
pub struct ValidationError(pub String);
pub fn validate_email(email: &str) -> Result<(), ValidationError> {
if email.is_empty() {
return Err(ValidationError("Email is required".into()));
}
if !email.contains('@') || !email.contains('.') {
return Err(ValidationError("Invalid email format".into()));
}
if email.len() > 254 {
return Err(ValidationError("Email too long".into()));
}
Ok(())
}
pub fn validate_password(password: &str) -> Result<(), ValidationError> {
if password.is_empty() {
return Err(ValidationError("Password is required".into()));
}
if password.len() < 12 {
return Err(ValidationError(
"Password must be at least 12 characters".into(),
));
}
if password.len() > 1024 {
return Err(ValidationError("Password too long".into()));
}
if !password.chars().any(|c| c.is_uppercase()) {
return Err(ValidationError(
"Password must contain at least one uppercase letter".into(),
));
}
if !password.chars().any(|c| c.is_lowercase()) {
return Err(ValidationError(
"Password must contain at least one lowercase letter".into(),
));
}
if !password.chars().any(|c| c.is_ascii_digit()) {
return Err(ValidationError(
"Password must contain at least one digit".into(),
));
}
Ok(())
}
pub fn validate_username(username: &str) -> Result<(), ValidationError> {
if username.is_empty() {
return Err(ValidationError("Username is required".into()));
}
if username.len() < 3 {
return Err(ValidationError(
"Username must be at least 3 characters".into(),
));
}
if username.len() > 64 {
return Err(ValidationError("Username too long".into()));
}
if !username
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(ValidationError(
"Username can only contain letters, numbers, hyphens, and underscores".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_email() {
assert!(validate_email("user@example.com").is_ok());
assert!(validate_email("a@b.c").is_ok());
}
#[test]
fn invalid_email() {
assert!(validate_email("").is_err());
assert!(validate_email("noat").is_err());
assert!(validate_email("no@dot").is_err());
assert!(validate_email(&format!("{}@b.c", "a".repeat(251))).is_err());
}
#[test]
fn valid_password() {
assert!(validate_password("SecurePass123").is_ok());
assert!(validate_password("MyLongPassphrase1").is_ok());
}
#[test]
fn invalid_password() {
assert!(validate_password("").is_err());
assert!(validate_password("short").is_err());
assert!(validate_password("12345678901").is_err()); // 11 chars
assert!(validate_password(&"a".repeat(1025)).is_err());
assert!(validate_password("alllowercase1").is_err()); // no uppercase
assert!(validate_password("ALLUPPERCASE1").is_err()); // no lowercase
assert!(validate_password("NoDigitsHere!").is_err()); // no digit
}
#[test]
fn valid_username() {
assert!(validate_username("alice").is_ok());
assert!(validate_username("bob-123").is_ok());
assert!(validate_username("foo_bar").is_ok());
}
#[test]
fn invalid_username() {
assert!(validate_username("").is_err());
assert!(validate_username("ab").is_err());
assert!(validate_username("has spaces").is_err());
assert!(validate_username("has@symbol").is_err());
assert!(validate_username(&"a".repeat(65)).is_err());
}
}