chore: add shared code

This commit is contained in:
Jesús Pérex 2025-07-07 23:06:11 +01:00
parent 80d441fe36
commit 348f93955c
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
6 changed files with 1763 additions and 0 deletions

1
shared/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

45
shared/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View File