chore: add shared code
This commit is contained in:
parent
80d441fe36
commit
348f93955c
1
shared/.gitignore
vendored
Normal file
1
shared/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
45
shared/Cargo.toml
Normal file
45
shared/Cargo.toml
Normal file
@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = ["Rustelo Contributors"]
|
||||
license = "MIT"
|
||||
description = "Shared types and utilities for Rustelo web application template"
|
||||
documentation = "https://docs.rs/shared"
|
||||
repository = "https://github.com/yourusername/rustelo"
|
||||
homepage = "https://rustelo.dev"
|
||||
readme = "../../README.md"
|
||||
keywords = ["rust", "web", "leptos", "shared", "types"]
|
||||
categories = ["web-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
leptos = { workspace = true, features = ["hydrate", "ssr"] }
|
||||
leptos_router = { workspace = true, features = ["ssr"] }
|
||||
leptos_meta = { workspace = true }
|
||||
reqwasm = "0.5"
|
||||
wasm-bindgen = "0.2.100"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
leptos_config = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
fluent = { workspace = true }
|
||||
fluent-bundle = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
|
||||
# Authentication & Authorization (shared types)
|
||||
uuid = { version = "1.17", features = ["v4", "serde", "js"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "2.0.12"
|
||||
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ssr = []
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
# Configuration for docs.rs
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
605
shared/src/auth.rs
Normal file
605
shared/src/auth.rs
Normal file
@ -0,0 +1,605 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// User authentication and profile information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub roles: Vec<Role>,
|
||||
pub is_active: bool,
|
||||
pub email_verified: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login: Option<DateTime<Utc>>,
|
||||
pub profile: UserProfile,
|
||||
pub two_factor_enabled: bool,
|
||||
}
|
||||
|
||||
/// Extended user profile information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct UserProfile {
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub locale: Option<String>,
|
||||
pub preferences: HashMap<String, String>,
|
||||
pub categories: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// User roles for RBAC
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub enum Role {
|
||||
Admin,
|
||||
Moderator,
|
||||
User,
|
||||
Guest,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn permissions(&self) -> Vec<Permission> {
|
||||
match self {
|
||||
Role::Admin => vec![
|
||||
Permission::ReadUsers,
|
||||
Permission::WriteUsers,
|
||||
Permission::DeleteUsers,
|
||||
Permission::ReadContent,
|
||||
Permission::WriteContent,
|
||||
Permission::DeleteContent,
|
||||
Permission::ManageRoles,
|
||||
Permission::ManageSystem,
|
||||
],
|
||||
Role::Moderator => vec![
|
||||
Permission::ReadUsers,
|
||||
Permission::ReadContent,
|
||||
Permission::WriteContent,
|
||||
Permission::DeleteContent,
|
||||
],
|
||||
Role::User => vec![Permission::ReadContent, Permission::WriteContent],
|
||||
Role::Guest => vec![Permission::ReadContent],
|
||||
Role::Custom(_) => vec![], // Custom roles need to be defined in the database
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Permissions for fine-grained access control
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub enum Permission {
|
||||
ReadUsers,
|
||||
WriteUsers,
|
||||
DeleteUsers,
|
||||
ReadContent,
|
||||
WriteContent,
|
||||
DeleteContent,
|
||||
ManageRoles,
|
||||
ManageSystem,
|
||||
// Database access permissions
|
||||
ReadDatabase(String),
|
||||
WriteDatabase(String),
|
||||
DeleteDatabase(String),
|
||||
// File access permissions
|
||||
ReadFile(String),
|
||||
WriteFile(String),
|
||||
DeleteFile(String),
|
||||
// Category-based permissions
|
||||
AccessCategory(String),
|
||||
// Tag-based permissions
|
||||
AccessTag(String),
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
/// JWT token claims
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
pub sub: String, // Subject (user ID)
|
||||
pub email: String, // User email
|
||||
pub roles: Vec<Role>, // User roles
|
||||
pub exp: usize, // Expiration time
|
||||
pub iat: usize, // Issued at
|
||||
pub iss: String, // Issuer
|
||||
}
|
||||
|
||||
/// Login credentials
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct LoginCredentials {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub remember_me: bool,
|
||||
}
|
||||
|
||||
/// User registration data
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RegisterUserData {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub display_name: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
}
|
||||
|
||||
/// OAuth2 provider information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum OAuthProvider {
|
||||
Google,
|
||||
GitHub,
|
||||
Discord,
|
||||
Microsoft,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl OAuthProvider {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
OAuthProvider::Google => "google",
|
||||
OAuthProvider::GitHub => "github",
|
||||
OAuthProvider::Discord => "discord",
|
||||
OAuthProvider::Microsoft => "microsoft",
|
||||
OAuthProvider::Custom(name) => name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth2 user information from provider
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct OAuthUserInfo {
|
||||
pub provider: OAuthProvider,
|
||||
pub provider_id: String,
|
||||
pub email: String,
|
||||
pub username: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub raw_data: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Password reset request
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PasswordResetRequest {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Password reset confirmation
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PasswordResetConfirm {
|
||||
pub token: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// Email verification request
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EmailVerificationRequest {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Session information
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub user_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub last_accessed: DateTime<Utc>,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
/// Authentication response
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AuthResponse {
|
||||
pub user: User,
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: i64,
|
||||
pub token_type: String,
|
||||
pub requires_2fa: bool, // Indicates if 2FA is required for this login
|
||||
}
|
||||
|
||||
/// Token refresh request
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RefreshTokenRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// User update data
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UpdateUserData {
|
||||
pub display_name: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub locale: Option<String>,
|
||||
pub preferences: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Password change request
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// 2FA setup request
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Setup2FARequest {
|
||||
pub password: String, // Current password for verification
|
||||
}
|
||||
|
||||
/// 2FA setup response
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Setup2FAResponse {
|
||||
pub secret: String, // Base32 encoded secret
|
||||
pub qr_code_url: String, // QR code data URL
|
||||
pub backup_codes: Vec<String>, // Recovery codes
|
||||
}
|
||||
|
||||
/// 2FA verification request
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Verify2FARequest {
|
||||
pub code: String, // 6-digit TOTP code or backup code
|
||||
}
|
||||
|
||||
/// 2FA login request (after initial login)
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Login2FARequest {
|
||||
pub email: String,
|
||||
pub code: String, // 6-digit TOTP code or backup code
|
||||
pub remember_me: bool,
|
||||
}
|
||||
|
||||
/// 2FA status response
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TwoFactorStatus {
|
||||
pub is_enabled: bool,
|
||||
pub backup_codes_remaining: u32,
|
||||
pub last_used: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 2FA disable request
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Disable2FARequest {
|
||||
pub password: String,
|
||||
pub code: String, // TOTP code or backup code
|
||||
}
|
||||
|
||||
/// Generate new backup codes request
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct GenerateBackupCodesRequest {
|
||||
pub password: String,
|
||||
pub code: String, // TOTP code for verification
|
||||
}
|
||||
|
||||
/// Backup codes response
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BackupCodesResponse {
|
||||
pub codes: Vec<String>,
|
||||
pub generated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// API error types
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("Invalid credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("User not found")]
|
||||
UserNotFound,
|
||||
#[error("Email already exists")]
|
||||
EmailAlreadyExists,
|
||||
#[error("Username already exists")]
|
||||
UsernameAlreadyExists,
|
||||
#[error("Invalid token")]
|
||||
InvalidToken,
|
||||
#[error("Token expired")]
|
||||
TokenExpired,
|
||||
#[error("Insufficient permissions")]
|
||||
InsufficientPermissions,
|
||||
#[error("Account not verified")]
|
||||
AccountNotVerified,
|
||||
#[error("Account suspended")]
|
||||
AccountSuspended,
|
||||
#[error("Rate limit exceeded")]
|
||||
RateLimitExceeded,
|
||||
#[error("OAuth error: {0}")]
|
||||
OAuthError(String),
|
||||
#[error("Database error")]
|
||||
DatabaseError,
|
||||
#[error("Internal server error")]
|
||||
InternalError,
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
#[error("Two-factor authentication required")]
|
||||
TwoFactorRequired,
|
||||
#[error("Invalid 2FA code")]
|
||||
Invalid2FACode,
|
||||
#[error("2FA already enabled")]
|
||||
TwoFactorAlreadyEnabled,
|
||||
#[error("2FA not enabled")]
|
||||
TwoFactorNotEnabled,
|
||||
#[error("Invalid backup code")]
|
||||
InvalidBackupCode,
|
||||
#[error("2FA setup required")]
|
||||
TwoFactorSetupRequired,
|
||||
#[error("Too many 2FA attempts")]
|
||||
TooMany2FAAttempts,
|
||||
}
|
||||
|
||||
impl Default for UserProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
bio: None,
|
||||
timezone: None,
|
||||
locale: None,
|
||||
preferences: HashMap::new(),
|
||||
categories: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Check if user has a specific role
|
||||
pub fn has_role(&self, role: &Role) -> bool {
|
||||
self.roles.contains(role)
|
||||
}
|
||||
|
||||
/// Check if user has a specific permission
|
||||
pub fn has_permission(&self, permission: &Permission) -> bool {
|
||||
self.roles
|
||||
.iter()
|
||||
.any(|role| role.permissions().contains(permission))
|
||||
}
|
||||
|
||||
/// Get all permissions for this user
|
||||
pub fn get_permissions(&self) -> Vec<Permission> {
|
||||
self.roles
|
||||
.iter()
|
||||
.flat_map(|role| role.permissions())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if user is admin
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.has_role(&Role::Admin)
|
||||
}
|
||||
|
||||
/// Check if user is moderator or admin
|
||||
pub fn is_moderator_or_admin(&self) -> bool {
|
||||
self.has_role(&Role::Admin) || self.has_role(&Role::Moderator)
|
||||
}
|
||||
|
||||
/// Get display name or fallback to username
|
||||
pub fn display_name_or_username(&self) -> &str {
|
||||
self.display_name.as_ref().unwrap_or(&self.username)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper trait for authorization checks
|
||||
pub trait HasPermissions {
|
||||
fn has_permission(&self, permission: &Permission) -> bool;
|
||||
fn has_role(&self, role: &Role) -> bool;
|
||||
fn is_admin(&self) -> bool;
|
||||
}
|
||||
|
||||
impl HasPermissions for User {
|
||||
fn has_permission(&self, permission: &Permission) -> bool {
|
||||
self.has_permission(permission)
|
||||
}
|
||||
|
||||
fn has_role(&self, role: &Role) -> bool {
|
||||
self.has_role(role)
|
||||
}
|
||||
|
||||
fn is_admin(&self) -> bool {
|
||||
self.is_admin()
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPermissions for Option<User> {
|
||||
fn has_permission(&self, permission: &Permission) -> bool {
|
||||
self.as_ref()
|
||||
.map_or(false, |user| user.has_permission(permission))
|
||||
}
|
||||
|
||||
fn has_role(&self, role: &Role) -> bool {
|
||||
self.as_ref().map_or(false, |user| user.has_role(role))
|
||||
}
|
||||
|
||||
fn is_admin(&self) -> bool {
|
||||
self.as_ref().map_or(false, |user| user.is_admin())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource access rule for RBAC
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AccessRule {
|
||||
pub id: String,
|
||||
pub resource_type: ResourceType,
|
||||
pub resource_name: String,
|
||||
pub allowed_roles: Vec<Role>,
|
||||
pub allowed_permissions: Vec<Permission>,
|
||||
pub required_categories: Vec<String>,
|
||||
pub required_tags: Vec<String>,
|
||||
pub deny_categories: Vec<String>,
|
||||
pub deny_tags: Vec<String>,
|
||||
pub is_active: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Resource types that can be protected
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub enum ResourceType {
|
||||
Database,
|
||||
File,
|
||||
Directory,
|
||||
Content,
|
||||
Api,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
/// Access control context for evaluating permissions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccessContext {
|
||||
pub user: Option<User>,
|
||||
pub resource_type: ResourceType,
|
||||
pub resource_name: String,
|
||||
pub action: String,
|
||||
pub additional_context: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Permission evaluation result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AccessResult {
|
||||
Allow,
|
||||
Deny,
|
||||
RequireAdditionalAuth,
|
||||
}
|
||||
|
||||
/// RBAC configuration that can be loaded from TOML
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RBACConfig {
|
||||
pub rules: Vec<AccessRule>,
|
||||
pub default_permissions: HashMap<String, Vec<Permission>>,
|
||||
pub category_hierarchies: HashMap<String, Vec<String>>,
|
||||
pub tag_hierarchies: HashMap<String, Vec<String>>,
|
||||
pub cache_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Check if user has access to a specific category
|
||||
pub fn has_category(&self, category: &str) -> bool {
|
||||
self.profile.categories.contains(&category.to_string())
|
||||
}
|
||||
|
||||
/// Check if user has access to a specific tag
|
||||
pub fn has_tag(&self, tag: &str) -> bool {
|
||||
self.profile.tags.contains(&tag.to_string())
|
||||
}
|
||||
|
||||
/// Check if user has any of the required categories
|
||||
pub fn has_any_category(&self, categories: &[String]) -> bool {
|
||||
categories.iter().any(|cat| self.has_category(cat))
|
||||
}
|
||||
|
||||
/// Check if user has any of the required tags
|
||||
pub fn has_any_tag(&self, tags: &[String]) -> bool {
|
||||
tags.iter().any(|tag| self.has_tag(tag))
|
||||
}
|
||||
|
||||
/// Check if user has all required categories
|
||||
pub fn has_all_categories(&self, categories: &[String]) -> bool {
|
||||
categories.iter().all(|cat| self.has_category(cat))
|
||||
}
|
||||
|
||||
/// Check if user has all required tags
|
||||
pub fn has_all_tags(&self, tags: &[String]) -> bool {
|
||||
tags.iter().all(|tag| self.has_tag(tag))
|
||||
}
|
||||
|
||||
/// Check if user is denied by any category
|
||||
pub fn is_denied_by_categories(&self, deny_categories: &[String]) -> bool {
|
||||
deny_categories.iter().any(|cat| self.has_category(cat))
|
||||
}
|
||||
|
||||
/// Check if user is denied by any tag
|
||||
pub fn is_denied_by_tags(&self, deny_tags: &[String]) -> bool {
|
||||
deny_tags.iter().any(|tag| self.has_tag(tag))
|
||||
}
|
||||
}
|
||||
|
||||
impl AccessRule {
|
||||
/// Evaluate if a user has access based on this rule
|
||||
pub fn evaluate(&self, context: &AccessContext) -> AccessResult {
|
||||
if !self.is_active {
|
||||
return AccessResult::Deny;
|
||||
}
|
||||
|
||||
// Check resource type and name match
|
||||
if self.resource_type != context.resource_type {
|
||||
return AccessResult::Deny;
|
||||
}
|
||||
|
||||
// Check if resource name matches (supports wildcards)
|
||||
if !self.matches_resource_name(&context.resource_name) {
|
||||
return AccessResult::Deny;
|
||||
}
|
||||
|
||||
let Some(user) = &context.user else {
|
||||
return AccessResult::Deny;
|
||||
};
|
||||
|
||||
// Check deny conditions first
|
||||
if user.is_denied_by_categories(&self.deny_categories)
|
||||
|| user.is_denied_by_tags(&self.deny_tags)
|
||||
{
|
||||
return AccessResult::Deny;
|
||||
}
|
||||
|
||||
// Check role requirements
|
||||
if !self.allowed_roles.is_empty()
|
||||
&& !self.allowed_roles.iter().any(|role| user.has_role(role))
|
||||
{
|
||||
return AccessResult::Deny;
|
||||
}
|
||||
|
||||
// Check permission requirements
|
||||
if !self.allowed_permissions.is_empty()
|
||||
&& !self
|
||||
.allowed_permissions
|
||||
.iter()
|
||||
.any(|perm| user.has_permission(perm))
|
||||
{
|
||||
return AccessResult::Deny;
|
||||
}
|
||||
|
||||
// Check category requirements
|
||||
if !self.required_categories.is_empty() && !user.has_any_category(&self.required_categories)
|
||||
{
|
||||
return AccessResult::Deny;
|
||||
}
|
||||
|
||||
// Check tag requirements
|
||||
if !self.required_tags.is_empty() && !user.has_any_tag(&self.required_tags) {
|
||||
return AccessResult::Deny;
|
||||
}
|
||||
|
||||
AccessResult::Allow
|
||||
}
|
||||
|
||||
fn matches_resource_name(&self, resource_name: &str) -> bool {
|
||||
// Simple wildcard matching - can be enhanced with regex
|
||||
if self.resource_name == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.resource_name.ends_with("*") {
|
||||
let prefix = &self.resource_name[..self.resource_name.len() - 1];
|
||||
return resource_name.starts_with(prefix);
|
||||
}
|
||||
|
||||
self.resource_name == resource_name
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RBACConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rules: Vec::new(),
|
||||
default_permissions: HashMap::new(),
|
||||
category_hierarchies: HashMap::new(),
|
||||
tag_hierarchies: HashMap::new(),
|
||||
cache_ttl_seconds: 300, // 5 minutes
|
||||
}
|
||||
}
|
||||
}
|
661
shared/src/content.rs
Normal file
661
shared/src/content.rs
Normal file
@ -0,0 +1,661 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ContentType {
|
||||
Blog,
|
||||
Page,
|
||||
Article,
|
||||
Documentation,
|
||||
Tutorial,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl ContentType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ContentType::Blog => "blog",
|
||||
ContentType::Page => "page",
|
||||
ContentType::Article => "article",
|
||||
ContentType::Documentation => "documentation",
|
||||
ContentType::Tutorial => "tutorial",
|
||||
ContentType::Custom(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ContentType {
|
||||
fn from(s: String) -> Self {
|
||||
match s.as_str() {
|
||||
"blog" => ContentType::Blog,
|
||||
"page" => ContentType::Page,
|
||||
"article" => ContentType::Article,
|
||||
"documentation" => ContentType::Documentation,
|
||||
"tutorial" => ContentType::Tutorial,
|
||||
_ => ContentType::Custom(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ContentState {
|
||||
Draft,
|
||||
Published,
|
||||
Archived,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl ContentState {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ContentState::Draft => "draft",
|
||||
ContentState::Published => "published",
|
||||
ContentState::Archived => "archived",
|
||||
ContentState::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ContentState {
|
||||
fn from(s: String) -> Self {
|
||||
match s.as_str() {
|
||||
"draft" => ContentState::Draft,
|
||||
"published" => ContentState::Published,
|
||||
"archived" => ContentState::Archived,
|
||||
"scheduled" => ContentState::Scheduled,
|
||||
_ => ContentState::Draft,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ContentFormat {
|
||||
Markdown,
|
||||
Html,
|
||||
PlainText,
|
||||
}
|
||||
|
||||
impl ContentFormat {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ContentFormat::Markdown => "markdown",
|
||||
ContentFormat::Html => "html",
|
||||
ContentFormat::PlainText => "plaintext",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ContentFormat {
|
||||
fn from(s: String) -> Self {
|
||||
match s.as_str() {
|
||||
"markdown" => ContentFormat::Markdown,
|
||||
"html" => ContentFormat::Html,
|
||||
"plaintext" => ContentFormat::PlainText,
|
||||
_ => ContentFormat::Markdown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PageContent {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub name: String,
|
||||
pub author: Option<String>,
|
||||
pub author_id: Option<Uuid>,
|
||||
pub content_type: ContentType,
|
||||
pub content_format: ContentFormat,
|
||||
pub content: String,
|
||||
pub container: String,
|
||||
pub state: ContentState,
|
||||
pub require_login: bool,
|
||||
pub date_init: DateTime<Utc>,
|
||||
pub date_end: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub published_at: Option<DateTime<Utc>>,
|
||||
pub metadata: HashMap<String, String>,
|
||||
pub tags: Vec<String>,
|
||||
pub category: Option<String>,
|
||||
pub featured_image: Option<String>,
|
||||
pub excerpt: Option<String>,
|
||||
pub seo_title: Option<String>,
|
||||
pub seo_description: Option<String>,
|
||||
pub allow_comments: bool,
|
||||
pub view_count: i64,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
impl PageContent {
|
||||
pub fn new(
|
||||
slug: String,
|
||||
title: String,
|
||||
name: String,
|
||||
content_type: ContentType,
|
||||
content: String,
|
||||
container: String,
|
||||
author_id: Option<Uuid>,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
slug,
|
||||
title,
|
||||
name,
|
||||
author: None,
|
||||
author_id,
|
||||
content_type,
|
||||
content_format: ContentFormat::Markdown,
|
||||
content,
|
||||
container,
|
||||
state: ContentState::Draft,
|
||||
require_login: false,
|
||||
date_init: now,
|
||||
date_end: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
published_at: None,
|
||||
metadata: HashMap::new(),
|
||||
tags: Vec::new(),
|
||||
category: None,
|
||||
featured_image: None,
|
||||
excerpt: None,
|
||||
seo_title: None,
|
||||
seo_description: None,
|
||||
allow_comments: true,
|
||||
view_count: 0,
|
||||
sort_order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_published(&self) -> bool {
|
||||
matches!(self.state, ContentState::Published)
|
||||
}
|
||||
|
||||
pub fn is_accessible(&self, user_authenticated: bool) -> bool {
|
||||
if self.require_login && !user_authenticated {
|
||||
return false;
|
||||
}
|
||||
|
||||
match self.state {
|
||||
ContentState::Published => true,
|
||||
ContentState::Scheduled => {
|
||||
if let Some(publish_date) = self.published_at {
|
||||
Utc::now() >= publish_date
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
let now = Utc::now();
|
||||
|
||||
if now < self.date_init {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(end_date) = self.date_end {
|
||||
if now > end_date {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn should_display(&self, user_authenticated: bool) -> bool {
|
||||
self.is_accessible(user_authenticated) && self.is_active()
|
||||
}
|
||||
|
||||
pub fn set_published(&mut self) {
|
||||
self.state = ContentState::Published;
|
||||
self.published_at = Some(Utc::now());
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn set_scheduled(&mut self, publish_date: DateTime<Utc>) {
|
||||
self.state = ContentState::Scheduled;
|
||||
self.published_at = Some(publish_date);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn add_tag(&mut self, tag: String) {
|
||||
if !self.tags.contains(&tag) {
|
||||
self.tags.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_tag(&mut self, tag: &str) {
|
||||
self.tags.retain(|t| t != tag);
|
||||
}
|
||||
|
||||
pub fn set_metadata(&mut self, key: String, value: String) {
|
||||
self.metadata.insert(key, value);
|
||||
}
|
||||
|
||||
pub fn get_metadata(&self, key: &str) -> Option<&String> {
|
||||
self.metadata.get(key)
|
||||
}
|
||||
|
||||
pub fn increment_view_count(&mut self) {
|
||||
self.view_count += 1;
|
||||
}
|
||||
|
||||
// Builder methods
|
||||
pub fn with_content_format(mut self, format: ContentFormat) -> Self {
|
||||
self.content_format = format;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_state(mut self, state: ContentState) -> Self {
|
||||
self.state = state;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_author(mut self, author: String) -> Self {
|
||||
self.author = Some(author);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
|
||||
self.tags = tags;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: String) -> Self {
|
||||
self.category = Some(category);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_excerpt(mut self, excerpt: String) -> Self {
|
||||
self.excerpt = Some(excerpt);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_featured_image(mut self, image: String) -> Self {
|
||||
self.featured_image = Some(image);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_require_login(mut self, require_login: bool) -> Self {
|
||||
self.require_login = require_login;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContentQuery {
|
||||
pub content_type: Option<ContentType>,
|
||||
pub state: Option<ContentState>,
|
||||
pub author_id: Option<Uuid>,
|
||||
pub category: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub require_login: Option<bool>,
|
||||
pub date_from: Option<DateTime<Utc>>,
|
||||
pub date_to: Option<DateTime<Utc>>,
|
||||
pub search: Option<String>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
|
||||
impl ContentQuery {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
content_type: None,
|
||||
state: None,
|
||||
author_id: None,
|
||||
category: None,
|
||||
tags: None,
|
||||
require_login: None,
|
||||
date_from: None,
|
||||
date_to: None,
|
||||
search: None,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: None,
|
||||
sort_order: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_content_type(mut self, content_type: ContentType) -> Self {
|
||||
self.content_type = Some(content_type);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_state(mut self, state: ContentState) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_author(mut self, author_id: Uuid) -> Self {
|
||||
self.author_id = Some(author_id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: String) -> Self {
|
||||
self.category = Some(category);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
|
||||
self.tags = Some(tags);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_pagination(mut self, limit: i64, offset: i64) -> Self {
|
||||
self.limit = Some(limit);
|
||||
self.offset = Some(offset);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_search(mut self, search: String) -> Self {
|
||||
self.search = Some(search);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn published_only(mut self) -> Self {
|
||||
self.state = Some(ContentState::Published);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn public_only(mut self) -> Self {
|
||||
self.require_login = Some(false);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ContentQuery {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContentMetadata {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub keywords: Vec<String>,
|
||||
pub canonical_url: Option<String>,
|
||||
pub og_title: Option<String>,
|
||||
pub og_description: Option<String>,
|
||||
pub og_image: Option<String>,
|
||||
pub twitter_title: Option<String>,
|
||||
pub twitter_description: Option<String>,
|
||||
pub twitter_image: Option<String>,
|
||||
pub schema_type: Option<String>,
|
||||
pub reading_time: Option<i32>,
|
||||
pub word_count: Option<i32>,
|
||||
}
|
||||
|
||||
impl ContentMetadata {
|
||||
pub fn new(title: String) -> Self {
|
||||
Self {
|
||||
title,
|
||||
description: None,
|
||||
author: None,
|
||||
keywords: Vec::new(),
|
||||
canonical_url: None,
|
||||
og_title: None,
|
||||
og_description: None,
|
||||
og_image: None,
|
||||
twitter_title: None,
|
||||
twitter_description: None,
|
||||
twitter_image: None,
|
||||
schema_type: None,
|
||||
reading_time: None,
|
||||
word_count: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, description: String) -> Self {
|
||||
self.description = Some(description);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_author(mut self, author: String) -> Self {
|
||||
self.author = Some(author);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_keywords(mut self, keywords: Vec<String>) -> Self {
|
||||
self.keywords = keywords;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_og_data(
|
||||
mut self,
|
||||
title: String,
|
||||
description: String,
|
||||
image: Option<String>,
|
||||
) -> Self {
|
||||
self.og_title = Some(title);
|
||||
self.og_description = Some(description);
|
||||
self.og_image = image;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_twitter_data(
|
||||
mut self,
|
||||
title: String,
|
||||
description: String,
|
||||
image: Option<String>,
|
||||
) -> Self {
|
||||
self.twitter_title = Some(title);
|
||||
self.twitter_description = Some(description);
|
||||
self.twitter_image = image;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn estimate_reading_time(&mut self, content: &str) {
|
||||
let words = content.split_whitespace().count();
|
||||
self.word_count = Some(words as i32);
|
||||
// Average reading speed: 200 words per minute
|
||||
self.reading_time = Some(((words as f32 / 200.0).ceil() as i32).max(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContentTemplate {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub container: String,
|
||||
pub default_content_type: ContentType,
|
||||
pub default_format: ContentFormat,
|
||||
pub required_fields: Vec<String>,
|
||||
pub optional_fields: Vec<String>,
|
||||
pub default_metadata: HashMap<String, String>,
|
||||
pub supports_comments: bool,
|
||||
pub supports_tags: bool,
|
||||
pub supports_categories: bool,
|
||||
pub require_login_default: bool,
|
||||
}
|
||||
|
||||
impl ContentTemplate {
|
||||
pub fn new(name: String, container: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description: None,
|
||||
container,
|
||||
default_content_type: ContentType::Page,
|
||||
default_format: ContentFormat::Markdown,
|
||||
required_fields: vec!["title".to_string(), "content".to_string()],
|
||||
optional_fields: Vec::new(),
|
||||
default_metadata: HashMap::new(),
|
||||
supports_comments: true,
|
||||
supports_tags: true,
|
||||
supports_categories: true,
|
||||
require_login_default: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blog_template() -> Self {
|
||||
Self {
|
||||
name: "Blog Post".to_string(),
|
||||
description: Some("Template for blog posts".to_string()),
|
||||
container: "blog-container".to_string(),
|
||||
default_content_type: ContentType::Blog,
|
||||
default_format: ContentFormat::Markdown,
|
||||
required_fields: vec![
|
||||
"title".to_string(),
|
||||
"content".to_string(),
|
||||
"author".to_string(),
|
||||
],
|
||||
optional_fields: vec!["excerpt".to_string(), "featured_image".to_string()],
|
||||
default_metadata: HashMap::new(),
|
||||
supports_comments: true,
|
||||
supports_tags: true,
|
||||
supports_categories: true,
|
||||
require_login_default: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_template() -> Self {
|
||||
Self {
|
||||
name: "Static Page".to_string(),
|
||||
description: Some("Template for static pages".to_string()),
|
||||
container: "page-container".to_string(),
|
||||
default_content_type: ContentType::Page,
|
||||
default_format: ContentFormat::Markdown,
|
||||
required_fields: vec!["title".to_string(), "content".to_string()],
|
||||
optional_fields: vec!["seo_title".to_string(), "seo_description".to_string()],
|
||||
default_metadata: HashMap::new(),
|
||||
supports_comments: false,
|
||||
supports_tags: false,
|
||||
supports_categories: false,
|
||||
require_login_default: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn documentation_template() -> Self {
|
||||
Self {
|
||||
name: "Documentation".to_string(),
|
||||
description: Some("Template for documentation pages".to_string()),
|
||||
container: "docs-container".to_string(),
|
||||
default_content_type: ContentType::Documentation,
|
||||
default_format: ContentFormat::Markdown,
|
||||
required_fields: vec!["title".to_string(), "content".to_string()],
|
||||
optional_fields: vec!["category".to_string()],
|
||||
default_metadata: HashMap::new(),
|
||||
supports_comments: false,
|
||||
supports_tags: true,
|
||||
supports_categories: true,
|
||||
require_login_default: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_page_content_creation() {
|
||||
let content = PageContent::new(
|
||||
"test-page".to_string(),
|
||||
"Test Page".to_string(),
|
||||
"test-page".to_string(),
|
||||
ContentType::Page,
|
||||
"# Test Content".to_string(),
|
||||
"page-container".to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(content.slug, "test-page");
|
||||
assert_eq!(content.title, "Test Page");
|
||||
assert_eq!(content.content_type, ContentType::Page);
|
||||
assert_eq!(content.state, ContentState::Draft);
|
||||
assert!(!content.require_login);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_accessibility() {
|
||||
let mut content = PageContent::new(
|
||||
"test".to_string(),
|
||||
"Test".to_string(),
|
||||
"test".to_string(),
|
||||
ContentType::Page,
|
||||
"Content".to_string(),
|
||||
"container".to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
// Draft content should not be accessible
|
||||
assert!(!content.is_accessible(false));
|
||||
assert!(!content.is_accessible(true));
|
||||
|
||||
// Published content should be accessible
|
||||
content.set_published();
|
||||
assert!(content.is_accessible(false));
|
||||
assert!(content.is_accessible(true));
|
||||
|
||||
// Login required content
|
||||
content.require_login = true;
|
||||
assert!(!content.is_accessible(false));
|
||||
assert!(content.is_accessible(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_query_builder() {
|
||||
let query = ContentQuery::new()
|
||||
.with_content_type(ContentType::Blog)
|
||||
.with_state(ContentState::Published)
|
||||
.with_pagination(10, 0)
|
||||
.public_only();
|
||||
|
||||
assert_eq!(query.content_type, Some(ContentType::Blog));
|
||||
assert_eq!(query.state, Some(ContentState::Published));
|
||||
assert_eq!(query.limit, Some(10));
|
||||
assert_eq!(query.offset, Some(0));
|
||||
assert_eq!(query.require_login, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_metadata() {
|
||||
let mut metadata = ContentMetadata::new("Test Title".to_string())
|
||||
.with_description("Test description".to_string())
|
||||
.with_author("Test Author".to_string());
|
||||
|
||||
metadata.estimate_reading_time(
|
||||
"This is a test content with some words to estimate reading time.",
|
||||
);
|
||||
|
||||
assert_eq!(metadata.title, "Test Title");
|
||||
assert_eq!(metadata.description, Some("Test description".to_string()));
|
||||
assert_eq!(metadata.author, Some("Test Author".to_string()));
|
||||
assert!(metadata.reading_time.is_some());
|
||||
assert!(metadata.word_count.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_templates() {
|
||||
let blog_template = ContentTemplate::blog_template();
|
||||
assert_eq!(blog_template.name, "Blog Post");
|
||||
assert_eq!(blog_template.default_content_type, ContentType::Blog);
|
||||
assert!(blog_template.supports_comments);
|
||||
assert!(blog_template.supports_tags);
|
||||
|
||||
let page_template = ContentTemplate::page_template();
|
||||
assert_eq!(page_template.name, "Static Page");
|
||||
assert_eq!(page_template.default_content_type, ContentType::Page);
|
||||
assert!(!page_template.supports_comments);
|
||||
assert!(!page_template.supports_tags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_type_conversion() {
|
||||
let content_type = ContentType::from("blog".to_string());
|
||||
assert_eq!(content_type, ContentType::Blog);
|
||||
|
||||
let custom_type = ContentType::from("custom_type".to_string());
|
||||
assert_eq!(custom_type, ContentType::Custom("custom_type".to_string()));
|
||||
}
|
||||
}
|
451
shared/src/lib.rs
Normal file
451
shared/src/lib.rs
Normal file
@ -0,0 +1,451 @@
|
||||
//! # RUSTELO Shared
|
||||
//!
|
||||
//! <div align="center">
|
||||
//! <img src="../logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="300" />
|
||||
//! </div>
|
||||
//!
|
||||
//! Shared types, utilities, and functionality for the RUSTELO web application framework.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! The shared crate contains common types, utilities, and functionality that are used across
|
||||
//! both the client and server components of RUSTELO applications. This includes authentication
|
||||
//! types, content management structures, internationalization support, and configuration utilities.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **🔐 Authentication Types** - Shared auth structures and utilities
|
||||
//! - **📄 Content Management** - Common content types and processing
|
||||
//! - **🌐 Internationalization** - Multi-language support with Fluent
|
||||
//! - **⚙️ Configuration** - Shared configuration management
|
||||
//! - **🎨 Menu System** - Navigation and menu configuration
|
||||
//! - **📋 Type Safety** - Strongly typed interfaces for client-server communication
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! The shared crate is organized into several key modules:
|
||||
//!
|
||||
//! - [`auth`] - Authentication types and utilities
|
||||
//! - [`content`] - Content management types and processing
|
||||
//!
|
||||
//! Additional functionality includes:
|
||||
//! - Menu configuration and internationalization
|
||||
//! - Fluent resource management
|
||||
//! - Content file loading utilities
|
||||
//! - Type-safe configuration structures
|
||||
//!
|
||||
//! ## Quick Start
|
||||
//!
|
||||
//! ### Menu Configuration
|
||||
//!
|
||||
//! ```rust
|
||||
//! use shared::{MenuConfig, load_menu_toml};
|
||||
//!
|
||||
//! // Load menu from TOML file
|
||||
//! let menu = load_menu_toml().unwrap_or_default();
|
||||
//!
|
||||
//! // Access menu items
|
||||
//! for item in menu.menu {
|
||||
//! println!("Route: {}, Label (EN): {}", item.route, item.label.en);
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Internationalization
|
||||
//!
|
||||
//! ```rust
|
||||
//! use shared::{get_bundle, t};
|
||||
//! use std::collections::HashMap;
|
||||
//!
|
||||
//! // Get localization bundle
|
||||
//! let bundle = get_bundle("en").expect("Failed to load English bundle");
|
||||
//!
|
||||
//! // Translate text
|
||||
//! let welcome_msg = t(&bundle, "welcome", None);
|
||||
//! println!("{}", welcome_msg);
|
||||
//!
|
||||
//! // Translate with arguments
|
||||
//! let mut args = HashMap::new();
|
||||
//! args.insert("name", "RUSTELO");
|
||||
//! let greeting = t(&bundle, "greeting", Some(&args));
|
||||
//! ```
|
||||
//!
|
||||
//! ## Type Definitions
|
||||
//!
|
||||
//! ### Menu System
|
||||
//!
|
||||
//! ```rust
|
||||
//! use shared::{MenuConfig, MenuItem, MenuLabel};
|
||||
//!
|
||||
//! let menu_item = MenuItem {
|
||||
//! route: "/about".to_string(),
|
||||
//! label: MenuLabel {
|
||||
//! en: "About".to_string(),
|
||||
//! es: "Acerca de".to_string(),
|
||||
//! },
|
||||
//! };
|
||||
//! ```
|
||||
//!
|
||||
//! ### Text Localization
|
||||
//!
|
||||
//! ```rust
|
||||
//! use shared::Texts;
|
||||
//! use std::collections::HashMap;
|
||||
//!
|
||||
//! let mut texts = Texts::default();
|
||||
//! texts.en.insert("welcome".to_string(), "Welcome".to_string());
|
||||
//! texts.es.insert("welcome".to_string(), "Bienvenido".to_string());
|
||||
//! ```
|
||||
//!
|
||||
//! ## Internationalization Support
|
||||
//!
|
||||
//! RUSTELO uses [Fluent](https://projectfluent.org/) for internationalization:
|
||||
//!
|
||||
//! - **Resource Loading** - Automatic loading of .ftl files
|
||||
//! - **Language Fallback** - Graceful fallback to English
|
||||
//! - **Parameter Substitution** - Dynamic text with variables
|
||||
//! - **Pluralization** - Proper plural forms for different languages
|
||||
//!
|
||||
//! ### Supported Languages
|
||||
//!
|
||||
//! - **English (en)** - Primary language
|
||||
//! - **Spanish (es)** - Secondary language
|
||||
//! - **Extensible** - Easy to add more languages
|
||||
//!
|
||||
//! ## Configuration Management
|
||||
//!
|
||||
//! The shared crate provides utilities for loading configuration from various sources:
|
||||
//!
|
||||
//! - **TOML Files** - Structured configuration files
|
||||
//! - **Environment Variables** - Runtime configuration
|
||||
//! - **Fallback Defaults** - Graceful degradation
|
||||
//!
|
||||
//! ## Error Handling
|
||||
//!
|
||||
//! All functions return `Result` types for proper error handling:
|
||||
//!
|
||||
//! ```rust
|
||||
//! use shared::load_menu_toml;
|
||||
//!
|
||||
//! match load_menu_toml() {
|
||||
//! Ok(menu) => println!("Loaded {} menu items", menu.menu.len()),
|
||||
//! Err(e) => eprintln!("Failed to load menu: {}", e),
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Cross-Platform Support
|
||||
//!
|
||||
//! The shared crate is designed to work across different targets:
|
||||
//!
|
||||
//! - **Server** - Native Rust environments
|
||||
//! - **Client** - WebAssembly (WASM) environments
|
||||
//! - **Testing** - Development and CI environments
|
||||
//!
|
||||
//! ## Performance Considerations
|
||||
//!
|
||||
//! - **Lazy Loading** - Resources loaded on demand
|
||||
//! - **Caching** - Efficient resource reuse
|
||||
//! - **Memory Management** - Careful memory usage for WASM
|
||||
//! - **Bundle Size** - Optimized for small WASM bundles
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! ### Creating a Multi-language Menu
|
||||
//!
|
||||
//! ```rust
|
||||
//! use shared::{MenuConfig, MenuItem, MenuLabel};
|
||||
//!
|
||||
//! let menu = MenuConfig {
|
||||
//! menu: vec![
|
||||
//! MenuItem {
|
||||
//! route: "/".to_string(),
|
||||
//! label: MenuLabel {
|
||||
//! en: "Home".to_string(),
|
||||
//! es: "Inicio".to_string(),
|
||||
//! },
|
||||
//! },
|
||||
//! MenuItem {
|
||||
//! route: "/about".to_string(),
|
||||
//! label: MenuLabel {
|
||||
//! en: "About".to_string(),
|
||||
//! es: "Acerca de".to_string(),
|
||||
//! },
|
||||
//! },
|
||||
//! ],
|
||||
//! };
|
||||
//! ```
|
||||
//!
|
||||
//! ### Loading and Using Fluent Resources
|
||||
//!
|
||||
//! ```rust
|
||||
//! use shared::{get_bundle, t};
|
||||
//! use std::collections::HashMap;
|
||||
//!
|
||||
//! // Load Spanish bundle
|
||||
//! let bundle = get_bundle("es").expect("Failed to load Spanish bundle");
|
||||
//!
|
||||
//! // Simple translation
|
||||
//! let app_title = t(&bundle, "app-title", None);
|
||||
//!
|
||||
//! // Translation with variables
|
||||
//! let mut args = HashMap::new();
|
||||
//! args.insert("user", "María");
|
||||
//! let welcome = t(&bundle, "welcome-user", Some(&args));
|
||||
//! ```
|
||||
//!
|
||||
//! ## Contributing
|
||||
//!
|
||||
//! When adding new shared functionality:
|
||||
//!
|
||||
//! 1. **Keep it Generic** - Ensure it's useful for both client and server
|
||||
//! 2. **Document Types** - Add comprehensive documentation
|
||||
//! 3. **Handle Errors** - Use proper error types and handling
|
||||
//! 4. **Test Thoroughly** - Add tests for all platforms
|
||||
//! 5. **Consider Performance** - Optimize for WASM environments
|
||||
//!
|
||||
//! ## License
|
||||
//!
|
||||
//! This project is licensed under the MIT License - see the [LICENSE](https://github.com/yourusername/rustelo/blob/main/LICENSE) file for details.
|
||||
|
||||
#![allow(unused_imports)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod auth;
|
||||
pub mod content;
|
||||
|
||||
use fluent::{FluentBundle, FluentResource};
|
||||
use fluent_bundle::FluentArgs;
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MenuLabel {
|
||||
pub en: String,
|
||||
pub es: String,
|
||||
}
|
||||
|
||||
impl Default for MenuLabel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
en: "Menu".to_string(),
|
||||
es: "Menú".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MenuItem {
|
||||
pub route: String,
|
||||
pub label: MenuLabel,
|
||||
}
|
||||
|
||||
impl Default for MenuItem {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
route: "/".to_string(),
|
||||
label: MenuLabel::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MenuConfig {
|
||||
pub menu: Vec<MenuItem>,
|
||||
}
|
||||
|
||||
impl Default for MenuConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
menu: vec![
|
||||
MenuItem {
|
||||
route: "/".to_string(),
|
||||
label: MenuLabel {
|
||||
en: "Home".to_string(),
|
||||
es: "Inicio".to_string(),
|
||||
},
|
||||
},
|
||||
MenuItem {
|
||||
route: "/about".to_string(),
|
||||
label: MenuLabel {
|
||||
en: "About".to_string(),
|
||||
es: "Acerca de".to_string(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct Texts {
|
||||
pub en: std::collections::HashMap<String, String>,
|
||||
pub es: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for Texts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
en: std::collections::HashMap::new(),
|
||||
es: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load FTL resources from files instead of hardcoded content
|
||||
fn load_en_ftl() -> &'static str {
|
||||
Box::leak(
|
||||
std::fs::read_to_string(
|
||||
get_content_path("en.ftl")
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("content/en.ftl")),
|
||||
)
|
||||
.unwrap_or_else(|_| "app-title = Rustelo App\nwelcome = Welcome".to_string())
|
||||
.into_boxed_str(),
|
||||
)
|
||||
}
|
||||
|
||||
fn load_es_ftl() -> &'static str {
|
||||
Box::leak(
|
||||
std::fs::read_to_string(
|
||||
get_content_path("es.ftl")
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("content/es.ftl")),
|
||||
)
|
||||
.unwrap_or_else(|_| "app-title = Aplicación Rustelo\nwelcome = Bienvenido".to_string())
|
||||
.into_boxed_str(),
|
||||
)
|
||||
}
|
||||
|
||||
// Dynamic FTL resources loaded from files
|
||||
static EN_FTL: std::sync::OnceLock<&str> = std::sync::OnceLock::new();
|
||||
static ES_FTL: std::sync::OnceLock<&str> = std::sync::OnceLock::new();
|
||||
|
||||
fn get_en_ftl() -> &'static str {
|
||||
EN_FTL.get_or_init(|| load_en_ftl())
|
||||
}
|
||||
|
||||
fn get_es_ftl() -> &'static str {
|
||||
ES_FTL.get_or_init(|| load_es_ftl())
|
||||
}
|
||||
|
||||
// Content loading utilities
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn get_content_path(filename: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
// Try to get root path from environment or use current directory
|
||||
let root_path = std::env::var("ROOT_PATH")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
|
||||
let content_path = root_path.join("content").join(filename);
|
||||
|
||||
// If the file exists, return the path
|
||||
if content_path.exists() {
|
||||
Ok(content_path)
|
||||
} else {
|
||||
// Fallback to relative path
|
||||
let fallback_path = PathBuf::from("content").join(filename);
|
||||
if fallback_path.exists() {
|
||||
Ok(fallback_path)
|
||||
} else {
|
||||
Err(format!("Content file not found: {}", filename).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_bundle(lang: &str) -> Result<FluentBundle<FluentResource>, Box<dyn std::error::Error>> {
|
||||
let langid: LanguageIdentifier = lang.parse().unwrap_or_else(|_| {
|
||||
"en".parse().unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"Critical error: Default language 'en' failed to parse: {}",
|
||||
e
|
||||
);
|
||||
// This should never happen, but we'll create a minimal fallback
|
||||
LanguageIdentifier::from_parts(
|
||||
unic_langid::subtags::Language::from_bytes(b"en").unwrap_or_else(|e| {
|
||||
eprintln!("Critical error: failed to create 'en' language: {}", e);
|
||||
// Fallback to creating a new language identifier from scratch
|
||||
match "en".parse::<unic_langid::subtags::Language>() {
|
||||
Ok(lang) => lang,
|
||||
Err(_) => {
|
||||
// If even this fails, we'll use the default language
|
||||
eprintln!("Using default language as final fallback");
|
||||
unic_langid::subtags::Language::default()
|
||||
}
|
||||
}
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
})
|
||||
});
|
||||
let ftl_str = match lang {
|
||||
"es" => get_es_ftl(),
|
||||
_ => get_en_ftl(),
|
||||
};
|
||||
let res = FluentResource::try_new(ftl_str.to_string())
|
||||
.map_err(|e| format!("Failed to parse FTL resource: {:?}", e))?;
|
||||
let mut bundle = FluentBundle::new(vec![langid]);
|
||||
bundle
|
||||
.add_resource(res)
|
||||
.map_err(|e| format!("Failed to add FTL resource to bundle: {:?}", e))?;
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
pub fn t(
|
||||
bundle: &FluentBundle<FluentResource>,
|
||||
key: &str,
|
||||
args: Option<&HashMap<&str, &str>>,
|
||||
) -> String {
|
||||
let msg = bundle.get_message(key).and_then(|m| m.value());
|
||||
if let Some(msg) = msg {
|
||||
let mut errors = vec![];
|
||||
let fargs = args.map(|a| {
|
||||
let mut f = FluentArgs::new();
|
||||
for (k, v) in a.iter() {
|
||||
f.set(*k, *v);
|
||||
}
|
||||
f
|
||||
});
|
||||
bundle
|
||||
.format_pattern(msg, fargs.as_ref(), &mut errors)
|
||||
.to_string()
|
||||
} else {
|
||||
key.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_menu_toml() -> Result<MenuConfig, Box<dyn std::error::Error>> {
|
||||
// Try to load from file system first
|
||||
match get_content_path("menu.toml") {
|
||||
Ok(path) => {
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read menu.toml from {}: {}", path.display(), e))?;
|
||||
toml::from_str(&content).map_err(|e| format!("Failed to parse menu.toml: {}", e).into())
|
||||
}
|
||||
Err(_) => {
|
||||
// Return default menu if file not found
|
||||
Ok(MenuConfig { menu: vec![] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_texts_toml() -> Result<Texts, Box<dyn std::error::Error>> {
|
||||
// Try to load from file system first
|
||||
match get_content_path("texts.toml") {
|
||||
Ok(path) => {
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read texts.toml from {}: {}", path.display(), e))?;
|
||||
toml::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse texts.toml: {}", e).into())
|
||||
}
|
||||
Err(_) => {
|
||||
// Return default texts if file not found
|
||||
Ok(Texts {
|
||||
en: std::collections::HashMap::new(),
|
||||
es: std::collections::HashMap::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
0
shared/src/mod.rs
Normal file
0
shared/src/mod.rs
Normal file
Loading…
x
Reference in New Issue
Block a user