diff --git a/shared/.gitignore b/shared/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/shared/.gitignore @@ -0,0 +1 @@ +/target diff --git a/shared/Cargo.toml b/shared/Cargo.toml new file mode 100644 index 0000000..58ec967 --- /dev/null +++ b/shared/Cargo.toml @@ -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"] diff --git a/shared/src/auth.rs b/shared/src/auth.rs new file mode 100644 index 0000000..ee70a6e --- /dev/null +++ b/shared/src/auth.rs @@ -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, + pub avatar_url: Option, + pub roles: Vec, + pub is_active: bool, + pub email_verified: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_login: Option>, + 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, + pub last_name: Option, + pub bio: Option, + pub timezone: Option, + pub locale: Option, + pub preferences: HashMap, + pub categories: Vec, + pub tags: Vec, +} + +/// 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 { + 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, // 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, + pub first_name: Option, + pub last_name: Option, +} + +/// 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, + pub display_name: Option, + pub avatar_url: Option, + pub raw_data: HashMap, +} + +/// 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, + pub expires_at: DateTime, + pub last_accessed: DateTime, + pub ip_address: Option, + pub user_agent: Option, + 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, + 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, + pub first_name: Option, + pub last_name: Option, + pub bio: Option, + pub timezone: Option, + pub locale: Option, + pub preferences: Option>, +} + +/// 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, // 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>, +} + +/// 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, + pub generated_at: DateTime, +} + +/// 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 { + 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 { + 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, + pub allowed_permissions: Vec, + pub required_categories: Vec, + pub required_tags: Vec, + pub deny_categories: Vec, + pub deny_tags: Vec, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 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, + pub resource_type: ResourceType, + pub resource_name: String, + pub action: String, + pub additional_context: HashMap, +} + +/// 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, + pub default_permissions: HashMap>, + pub category_hierarchies: HashMap>, + pub tag_hierarchies: HashMap>, + 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 + } + } +} diff --git a/shared/src/content.rs b/shared/src/content.rs new file mode 100644 index 0000000..622e148 --- /dev/null +++ b/shared/src/content.rs @@ -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 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 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 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, + pub author_id: Option, + 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, + pub date_end: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub published_at: Option>, + pub metadata: HashMap, + pub tags: Vec, + pub category: Option, + pub featured_image: Option, + pub excerpt: Option, + pub seo_title: Option, + pub seo_description: Option, + 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, + ) -> 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) { + 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) -> 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, + pub state: Option, + pub author_id: Option, + pub category: Option, + pub tags: Option>, + pub require_login: Option, + pub date_from: Option>, + pub date_to: Option>, + pub search: Option, + pub limit: Option, + pub offset: Option, + pub sort_by: Option, + pub sort_order: Option, +} + +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) -> 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, + pub author: Option, + pub keywords: Vec, + pub canonical_url: Option, + pub og_title: Option, + pub og_description: Option, + pub og_image: Option, + pub twitter_title: Option, + pub twitter_description: Option, + pub twitter_image: Option, + pub schema_type: Option, + pub reading_time: Option, + pub word_count: Option, +} + +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) -> Self { + self.keywords = keywords; + self + } + + pub fn with_og_data( + mut self, + title: String, + description: String, + image: Option, + ) -> 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, + ) -> 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, + pub container: String, + pub default_content_type: ContentType, + pub default_format: ContentFormat, + pub required_fields: Vec, + pub optional_fields: Vec, + pub default_metadata: HashMap, + 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())); + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs new file mode 100644 index 0000000..4c5c886 --- /dev/null +++ b/shared/src/lib.rs @@ -0,0 +1,451 @@ +//! # RUSTELO Shared +//! +//!
+//! RUSTELO +//!
+//! +//! 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, +} + +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, + pub es: std::collections::HashMap, +} + +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> { + // 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, Box> { + 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::() { + 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, + 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> { + // 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> { + // 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(), + }) + } + } +} diff --git a/shared/src/mod.rs b/shared/src/mod.rs new file mode 100644 index 0000000..e69de29