templates.rs
1387 lignes · 41502 octets
fn generate_hex_key() -> String { use rand::Rng; let mut rng = rand::rng(); (0..32) .map(|_| format!("{:02x}", rng.random::<u8>())) .collect::<String>() } pub struct SmtpConfig { pub host: String, pub port: u16, pub user: String, pub password: String, pub from: String, pub from_name: String, pub use_tls: bool, pub use_starttls: bool, } pub struct OAuthProviderConfig { pub name: String, pub client_id: String, pub client_secret: String, } pub struct OAuthWizardConfig { pub enabled: bool, pub redirect_base_url: String, pub providers: Vec<OAuthProviderConfig>, } /// How the generated project consumes the federation layer. /// /// - [`FediverseMode::None`] — no federation crate copied, no feature /// declared, no wiring injected in `main.rs`. /// - [`FediverseMode::Headless`] — copies `rustwarden-fediverse` only; /// the consumer project implements its own UI on top of the protocol /// crate. Adds the `fediverse` feature. /// - [`FediverseMode::WithReferenceUi`] — copies both /// `rustwarden-fediverse` and `rustwarden-fediverse-ui`. Adds the /// `fediverse` feature plus a `fediverse-ui` feature that implies /// `fediverse` and pulls in the reference HTML UI. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FediverseMode { /// No federation support. None, /// Bare ActivityPub protocol; the consumer ships its own UI. Headless, /// Protocol plus the reference HTML UI crate. WithReferenceUi, } pub struct ProjectConfig { pub name: String, pub app_display_name: String, pub description: String, pub with_ui: bool, /// Selects the federation layer to wire into the generated project. /// Drives crate copy, `Cargo.toml` features, `main.rs` wiring and /// the `FEDIVERSE_*` env section. pub fediverse_mode: FediverseMode, /// Federated domain declared at boot when `fediverse_mode != None` /// (`FEDIVERSE_DOMAIN`). Stays `None` when federation is disabled. pub fediverse_domain: Option<String>, pub auto_migrate: bool, pub port: u16, pub db_name: String, pub db_user: String, pub db_password: String, pub admin_username: String, pub admin_email: String, pub admin_password: String, pub jwt_secret: String, pub smtp: Option<SmtpConfig>, pub oauth: Option<OAuthWizardConfig>, } impl ProjectConfig { /// Internal helper: `true` when any federation crate must be copied /// and any `FEDIVERSE_*` env section must be emitted. pub fn has_fediverse(&self) -> bool { matches!( self.fediverse_mode, FediverseMode::Headless | FediverseMode::WithReferenceUi ) } /// Internal helper: `true` when the reference UI crate must be /// copied and the `fediverse-ui` feature emitted. pub fn has_fediverse_ui(&self) -> bool { matches!(self.fediverse_mode, FediverseMode::WithReferenceUi) } } pub fn cargo_toml(cfg: &ProjectConfig) -> String { let ui_member = if cfg.with_ui { ", \"crates/rustwarden-ui\"" } else { "" }; let fediverse_member = if cfg.has_fediverse() { ", \"crates/rustwarden-fediverse\"" } else { "" }; let fediverse_ui_member = if cfg.has_fediverse_ui() { ", \"crates/rustwarden-fediverse-ui\"" } else { "" }; let ui_dep = if cfg.with_ui { "\nrustwarden-ui = { path = \"crates/rustwarden-ui\" }" } else { "" }; let fediverse_dep = if cfg.has_fediverse() { "\nrustwarden-fediverse = { path = \"crates/rustwarden-fediverse\", optional = true }" } else { "" }; let fediverse_ui_dep = if cfg.has_fediverse_ui() { "\nrustwarden-fediverse-ui = { path = \"crates/rustwarden-fediverse-ui\", optional = true }" } else { "" }; // Features section: stays empty when federation is disabled. When // enabled, `default = []` so consumers must opt-in explicitly with // `--features fediverse` (or `fediverse-ui`, which implies it). let features_section = match cfg.fediverse_mode { FediverseMode::None => String::new(), FediverseMode::Headless => "\n[features]\n\ default = []\n\ # Active le protocole ActivityPub headless (RFC 0001+).\n\ # Compile et lie la crate `rustwarden-fediverse` (services, routes,\n\ # signatures HTTP) sans aucune UI.\n\ fediverse = [\"dep:rustwarden-fediverse\"]\n" .to_string(), FediverseMode::WithReferenceUi => "\n[features]\n\ default = []\n\ # Active le protocole ActivityPub headless (RFC 0001+).\n\ # Compile et lie la crate `rustwarden-fediverse` (services, routes,\n\ # signatures HTTP) sans aucune UI.\n\ fediverse = [\"dep:rustwarden-fediverse\"]\n\ # Active l'UI HTML de reference pour la federation (handlers, templates,\n\ # carte dashboard). Implique automatiquement `fediverse`.\n\ fediverse-ui = [\"fediverse\", \"dep:rustwarden-fediverse-ui\"]\n" .to_string(), }; format!( r#"[workspace] members = ["crates/rustwarden-core"{ui_member}{fediverse_member}{fediverse_ui_member}] resolver = "2" [workspace.package] edition = "2024" rust-version = "1.94" [workspace.lints.clippy] empty_line_after_doc_comments = "allow" [workspace.lints.rust] missing_docs = "warn" [package] name = "{name}" version = "0.1.0" edition = "2021" description = "{description}" [[bin]] name = "{name}" path = "src/main.rs" {features_section} [dependencies] rustwarden-core = {{ path = "crates/rustwarden-core" }}{ui_dep}{fediverse_dep}{fediverse_ui_dep} axum = {{ version = "0.8", features = ["macros"] }} tokio = {{ version = "1", features = ["full"] }} sea-orm = {{ version = "=2.0.0", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }} tracing = "0.1" anyhow = "1.0" async-trait = "0.1" uuid = {{ version = "1.0", features = ["v4"] }} serde_json = "1.0" [lints.clippy] empty_line_after_doc_comments = "allow" "#, name = cfg.name, description = cfg.description, ui_member = ui_member, fediverse_member = fediverse_member, fediverse_ui_member = fediverse_ui_member, ui_dep = ui_dep, fediverse_dep = fediverse_dep, fediverse_ui_dep = fediverse_ui_dep, features_section = features_section, ) } pub fn main_rs(cfg: &ProjectConfig) -> String { let ui_line = if cfg.with_ui { // Browser SSR routes go through the CSRF-protected channel; the // `/api/v1/*` JSON API rides the machine channel (no CSRF). " .with_ui(rustwarden_ui::browser_routes)\n .merge_machine_routes(rustwarden_ui::api_routes())\n" } else { // Headless: mount the framework's built-in JSON auth API so the // project ships a complete, working authentication surface // (login, refresh, logout, me, 2FA verify, ...) on the machine // channel (Bearer/JSON, not CSRF-vulnerable). " .headless()\n .merge_machine_routes(rustwarden_core::auth::auth_api_routes())\n" }; let migrate_line = if cfg.auto_migrate { " .auto_migrate(true)\n" } else { "" }; let address_line = if cfg.port != 3000 { format!(" .address(\"0.0.0.0:{}\")\n", cfg.port) } else { String::new() }; let email_line = if cfg.smtp.is_none() { " .without_email()\n" } else { "" }; // Wiring conditionnel de la federation ActivityPub. Quand la // federation est activee, on casse la chaine du builder en plusieurs // etapes pour permettre l'ouverture precoce de la connexion DB // requise par le router fediverse, puis on injecte un second bloc // `cfg(feature = "fediverse-ui")` quand l'UI de reference est // demandee. let (builder_open, fediverse_block, fediverse_ui_block, builder_close) = if cfg.has_fediverse() { let fediverse_ui = if cfg.has_fediverse_ui() { r#" // Reference HTML UI for the federation. Opt-in via the // `fediverse-ui` feature, which itself implies `fediverse`. Mounts // the UI router and injects the federation card into the admin // dashboard. #[cfg(feature = "fediverse-ui")] let builder = builder // Famille browser : login fediverse, consent, invite et ecrans // admin sont des formulaires SSR pilotes par cookie de session ; // on les monte sur le canal CSRF-protege. Chaque handler garde son // validate_csrf_token en defense-in-depth ; le layer CSRF global // lit le token dans le body urlencoded et le reconstruit pour // l'extracteur Form<...> aval. L'inbox ActivityPub reste machine. .merge_browser_routes(rustwarden_fediverse_ui::default_routes()) .add_dashboard_card(rustwarden_fediverse_ui::dashboard_card()); "# } else { "" }; ( "let builder = RustwardenApp::builder()\n", r#"; #[cfg(feature = "fediverse")] let builder = { use std::sync::Arc; let db_url = std::env::var("DATABASE_URL") .map_err(|_| anyhow::anyhow!("DATABASE_URL doit etre defini pour la federation"))?; let db = sea_orm::Database::connect(&db_url).await?; let fediverse_config = rustwarden_fediverse::FediverseConfig::from_env() .map_err(|e| anyhow::anyhow!("FediverseConfig invalide : {e}"))?; let fediverse_router = rustwarden_fediverse::routes::router(fediverse_config, db.clone()); let checker: Arc< dyn rustwarden_core::middleware::revocation::RemoteIdentityRevocationChecker, > = Arc::new( rustwarden_fediverse::services::revocation::SeaOrmRemoteIdentityRevocationChecker::new( db.clone(), ), ); // L'inbox ActivityPub (POST /inbox, signature HTTP) ne peut pas // porter de token CSRF : on la monte sur le canal machine. builder .database(db) .register_protected_prefix("/fediverse/consent") .register_protected_prefix("/fediverse/invite") .register_revocation_checker(checker) .merge_machine_routes(fediverse_router) }; "#, fediverse_ui, "\n builder\n .merge_routes(app_routes())\n .build()\n .await?\n .run()\n .await\n", ) } else { ( " RustwardenApp::builder()\n", "", "", " .merge_routes(app_routes())\n .build()\n .await?\n .run()\n .await\n", ) }; let fediverse_use = if cfg.has_fediverse() { "#[allow(unused_imports)]\nuse rustwarden_core::middleware::revocation::RemoteIdentityRevocationChecker;\n" } else { "" }; format!( r#"use axum::{{routing::get, Router}}; use rustwarden_core::prelude::*; use sea_orm::DatabaseConnection; {fediverse_use} #[tokio::main] async fn main() -> anyhow::Result<()> {{ {builder_open} .from_env() {ui_line}{migrate_line}{email_line}{address_line}{fediverse_block}{fediverse_ui_block}{builder_close}}} fn app_routes() -> Router<DatabaseConnection> {{ Router::new() .route("/api/hello", get(hello)) .route("/api/whoami", get(whoami)) }} async fn hello() -> &'static str {{ "Hello from {name}!" }} /// Example protected endpoint. The `AuthUser` extractor validates the JWT /// (`Authorization: Bearer <token>` or the auth cookie) before this handler /// runs — unauthenticated requests get `401`. Obtain a token via /// `POST /api/v1/auth/login`. async fn whoami( user: rustwarden_core::auth::extractors::AuthUser, ) -> axum::Json<serde_json::Value> {{ axum::Json(serde_json::json!({{ "user_id": user.user_id, "username": user.username, "roles": user.roles, }})) }} // ---------------------------------------------------------------------------- // Primitives framework optionnelles (Teams, PAT, SSE, Hooks) // // Voir docs/SERVICES_REFERENCE.md pour la ref d'API complete et // DEVELOPER.md (sections §12, §14, §15) pour les exemples d'integration. // ---------------------------------------------------------------------------- // // Personal Access Token cleanup (tache de fond periodique). // Le framework fournit un `PersonalTokenCleanupProcessor<S: TokenScope>`. Le // scope `S` est un enum metier defini par votre app (ex. ApiRead, ApiWrite). // TODO: definir votre enum Scope + impl TokenScope, puis decommenter dans main // apres le `.build().await?` mais avant `.run()`. // // // use rustwarden_core::prelude::PersonalTokenCleanupProcessor; // // PersonalTokenCleanupProcessor::<MyScope>::new(db_arc.clone()).start(); // // Voir: docs/SERVICES_REFERENCE.md#7-tokens-et-sessions // // Purge des demandes de changement d'e-mail expirees (RFC 0041). // Comme ci-dessus, le framework ne demarre pas ce processeur : retention de // 30 jours apres expiration, aucune variable d'environnement a definir. Sans // lui, seule une purge opportuniste limitee a l'utilisateur courant s'execute // a chaque nouvelle demande. // // // use rustwarden_core::prelude::PendingEmailChangeCleanupProcessor; // // PendingEmailChangeCleanupProcessor::new(db_arc.clone()).start(); // // Voir: docs/SERVICES_REFERENCE.md#emailchangeservice // // Hooks lifecycle (on_user_registered, on_team_created, on_totp_enabled, ...). // Implementer `RustwardenHooks` et passer l'instance via `.hooks(MyHooks)` au // builder. Voir DEVELOPER.md §12. // // Notifications temps-reel (SSE): // rustwarden_core::prelude::{{NotificationBus, InMemoryBus, sse_response}} // Voir DEVELOPER.md §15 pour un exemple de handler SSE. "#, ui_line = ui_line, migrate_line = migrate_line, email_line = email_line, address_line = address_line, name = cfg.name, ) } fn oauth_env_section(cfg: &ProjectConfig) -> String { match &cfg.oauth { Some(oauth) if oauth.enabled => { let encryption_key = generate_hex_key(); let mut lines = format!( r#"# --- OAuth / SSO --- OAUTH_ENABLED=true OAUTH_REDIRECT_BASE_URL={base_url} # Encryption key for OAuth secrets in DB (generated automatically) OAUTH_ENCRYPTION_KEY={encryption_key} "#, base_url = oauth.redirect_base_url, encryption_key = encryption_key, ); for p in &oauth.providers { let upper = p.name.to_uppercase(); lines.push_str(&format!( "OAUTH_{name}_ENABLED=true\nOAUTH_{name}_CLIENT_ID={cid}\nOAUTH_{name}_CLIENT_SECRET={cs}\n", name = upper, cid = p.client_id, cs = p.client_secret, )); } lines } _ => r#"# --- OAuth / SSO (desactive) --- # OAUTH_ENABLED=false # OAUTH_REDIRECT_BASE_URL=http://localhost:3000 # Encryption key for OAuth secrets in DB (generate with: openssl rand -hex 32) # OAUTH_ENCRYPTION_KEY= # OAUTH_GOOGLE_ENABLED=false # OAUTH_GOOGLE_CLIENT_ID= # OAUTH_GOOGLE_CLIENT_SECRET= # OAUTH_GITHUB_ENABLED=false # OAUTH_GITHUB_CLIENT_ID= # OAUTH_GITHUB_CLIENT_SECRET= # OAUTH_DISCORD_ENABLED=false # OAUTH_DISCORD_CLIENT_ID= # OAUTH_DISCORD_CLIENT_SECRET= # OAUTH_MICROSOFT_ENABLED=false # OAUTH_MICROSOFT_CLIENT_ID= # OAUTH_MICROSOFT_CLIENT_SECRET= # OAUTH_MICROSOFT_TENANT=common "# .to_string(), } } fn fediverse_env_section(cfg: &ProjectConfig) -> String { if cfg.has_fediverse() { let domain = cfg .fediverse_domain .clone() .unwrap_or_else(|| "localhost".to_string()); format!( r#"# --- Federation ActivityPub (RFC 0001+) --- # Active uniquement si le binaire est compile avec --features fediverse. FEDIVERSE_DOMAIN={domain} # FEDIVERSE_DEBUG_LOCAL_FEDERATION=true # autorise http:// (dev local uniquement) # FEDIVERSE_AUTHORIZED_FETCH=false # exiger signature sur fetch acteur # FEDIVERSE_OUTBOUND_WORKERS=4 # FEDIVERSE_OUTBOUND_MAX_ATTEMPTS=8 # FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS=30 # FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS=86400 # FEDIVERSE_AUTH_CHALLENGE_TTL_SECS=300 "# ) } else { r#"# --- Federation ActivityPub (RFC 0001+, desactivee) --- # Pour activer : recompiler avec `--features fediverse` puis decommenter. # FEDIVERSE_DOMAIN=example.org # FEDIVERSE_DEBUG_LOCAL_FEDERATION=false # FEDIVERSE_AUTHORIZED_FETCH=false "# .to_string() } } pub fn dot_env(cfg: &ProjectConfig) -> String { let smtp_section = match &cfg.smtp { Some(smtp) => format!( r#"# --- Email SMTP --- SMTP_HOST={host} SMTP_PORT={port} SMTP_USER={user} SMTP_PASSWORD={password} SMTP_FROM={from} SMTP_FROM_NAME={from_name} SMTP_USE_TLS={use_tls} SMTP_USE_STARTTLS={use_starttls} SMTP_ACCEPT_INVALID_CERTS=false EMAIL_BASE_URL=http://localhost:{app_port} # File d'attente email EMAIL_QUEUE_BATCH_SIZE=10 EMAIL_QUEUE_RETRY_ATTEMPTS=5 EMAIL_QUEUE_RETRY_DELAY_SECONDS=300 # Validation email EMAIL_VALIDATION_REQUIRED=true "#, host = smtp.host, port = smtp.port, user = smtp.user, password = smtp.password, from = smtp.from, from_name = smtp.from_name, use_tls = smtp.use_tls, use_starttls = smtp.use_starttls, app_port = cfg.port, ), None => r#"# --- Email SMTP (desactive) --- # SMTP_HOST=smtp.example.com # SMTP_PORT=587 # SMTP_USER=user # SMTP_PASSWORD=password # SMTP_FROM=noreply@example.com # SMTP_FROM_NAME=MonApp # SMTP_USE_TLS=true # SMTP_USE_STARTTLS=true # SMTP_ACCEPT_INVALID_CERTS=false # EMAIL_BASE_URL=http://localhost:3000 # EMAIL_QUEUE_BATCH_SIZE=10 # EMAIL_QUEUE_RETRY_ATTEMPTS=5 # EMAIL_QUEUE_RETRY_DELAY_SECONDS=300 EMAIL_VALIDATION_REQUIRED=false "# .to_string(), }; format!( r#"# ============================================================================= # {name} — Variables d'environnement # ============================================================================= # --- Base de donnees --- DATABASE_URL=postgres://{db_user}:{db_password}@localhost:5432/{db_name} # --- Serveur --- SERVER_HOST=0.0.0.0 SERVER_PORT={port} # --- Logging --- RUST_LOG=debug # --- JWT --- JWT_SECRET={jwt_secret} JWT_EXPIRATION_MINUTES=15 JWT_ISSUER={name} REFRESH_TOKEN_EXPIRATION_DAYS=7 # --- Sessions --- SESSION_TIMEOUT_MINUTES=30 SESSION_BACKEND=seaorm # --- Rate limiting --- RATE_LIMIT_LOGIN_PER_MINUTE=5 RATE_LIMIT_REFRESH_PER_MINUTE=10 RATE_LIMIT_GENERAL_PER_MINUTE=100 # --- Cookies & Debug --- APP_DEBUG=true COOKIE_SECURE=false COOKIE_SAME_SITE=Lax # --- Admin initial --- ADMIN_USERNAME={admin_username} ADMIN_EMAIL={admin_email} ADMIN_PASSWORD={admin_password} {smtp_section} # --- Inscription --- ALLOW_REGISTRATION=false # --- Application --- APP_NAME={app_display_name} # APP_THEME=default # APP_DEBUG=false DEFAULT_LOCALE=fr # --- IMAP bounce processing (optionnel) --- # IMAP_HOST= # IMAP_PORT=993 # IMAP_USER= # IMAP_PASSWORD= # IMAP_USE_TLS=true # IMAP_USE_STARTTLS=false # IMAP_ACCEPT_INVALID_CERTS=false # IMAP_MAILBOX=INBOX # IMAP_POLL_INTERVAL_SECONDS=300 {oauth_section} {fediverse_section} "#, name = cfg.name, app_display_name = cfg.app_display_name, db_user = cfg.db_user, db_password = cfg.db_password, db_name = cfg.db_name, port = cfg.port, jwt_secret = cfg.jwt_secret, admin_username = cfg.admin_username, admin_email = cfg.admin_email, admin_password = cfg.admin_password, smtp_section = smtp_section, oauth_section = oauth_env_section(cfg), fediverse_section = fediverse_env_section(cfg), ) } pub fn dot_env_example() -> String { r#"# ============================================================================= # Rustwarden App — Reference des variables d'environnement # ============================================================================= # --- Base de donnees --- DATABASE_URL=postgres://USER:PASSWORD@localhost:5432/DATABASE # --- Serveur --- SERVER_HOST=0.0.0.0 SERVER_PORT=3000 # --- Logging --- RUST_LOG=debug # --- JWT --- # Required. Generate a unique secret, e.g.: openssl rand -hex 32 # JWT_SECRET= JWT_EXPIRATION_MINUTES=15 JWT_ISSUER=mon-app REFRESH_TOKEN_EXPIRATION_DAYS=7 # --- Sessions --- SESSION_TIMEOUT_MINUTES=30 SESSION_BACKEND=seaorm # REDIS_URL=redis://localhost:6379 # si SESSION_BACKEND=redis # --- Rate limiting --- RATE_LIMIT_LOGIN_PER_MINUTE=5 RATE_LIMIT_REFRESH_PER_MINUTE=10 RATE_LIMIT_GENERAL_PER_MINUTE=100 # --- Cookies & Debug --- # APP_DEBUG=true # Mettre a true en developpement (desactive les cookies Secure) COOKIE_SECURE=false COOKIE_SAME_SITE=Lax # --- Admin initial --- ADMIN_USERNAME=admin ADMIN_EMAIL=admin@example.com # ADMIN_PASSWORD — requis pour creer l'admin au premier boot ; omettre pour ignorer. # Ne pas copier un placeholder. # --- Email SMTP --- SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USER=user SMTP_PASSWORD=password SMTP_FROM=noreply@example.com SMTP_FROM_NAME=MonApp SMTP_USE_TLS=true SMTP_USE_STARTTLS=true SMTP_ACCEPT_INVALID_CERTS=false EMAIL_BASE_URL=http://localhost:3000 EMAIL_QUEUE_BATCH_SIZE=10 EMAIL_QUEUE_RETRY_ATTEMPTS=5 EMAIL_QUEUE_RETRY_DELAY_SECONDS=300 EMAIL_VALIDATION_REQUIRED=true # --- Inscription --- ALLOW_REGISTRATION=false # --- Application --- APP_NAME=Mon Application # APP_THEME=default # APP_DEBUG=false DEFAULT_LOCALE=fr # --- IMAP bounce processing (optionnel) --- # IMAP_HOST= # IMAP_PORT=993 # IMAP_USER= # IMAP_PASSWORD= # IMAP_USE_TLS=true # IMAP_USE_STARTTLS=false # IMAP_ACCEPT_INVALID_CERTS=false # IMAP_MAILBOX=INBOX # IMAP_POLL_INTERVAL_SECONDS=300 # --- OAuth / SSO (optionnel) --- # Encryption key for OAuth secrets in DB (generate with: openssl rand -hex 32) # OAUTH_ENCRYPTION_KEY= # OAUTH_ENABLED=false # OAUTH_REDIRECT_BASE_URL=http://localhost:3000 # OAUTH_GOOGLE_ENABLED=false # OAUTH_GOOGLE_CLIENT_ID= # OAUTH_GOOGLE_CLIENT_SECRET= # OAUTH_GITHUB_ENABLED=false # OAUTH_GITHUB_CLIENT_ID= # OAUTH_GITHUB_CLIENT_SECRET= # OAUTH_DISCORD_ENABLED=false # OAUTH_DISCORD_CLIENT_ID= # OAUTH_DISCORD_CLIENT_SECRET= # OAUTH_MICROSOFT_ENABLED=false # OAUTH_MICROSOFT_CLIENT_ID= # OAUTH_MICROSOFT_CLIENT_SECRET= # OAUTH_MICROSOFT_TENANT=common "# .to_string() } pub fn database_dot_env(cfg: &ProjectConfig) -> String { format!( r#"## environment file for docker-compose COMPOSE_PROJECT_NAME=postgres-{name} POSTGRES_DB={db_name} POSTGRES_USER={db_user} POSTGRES_PASSWORD={db_password} ALLOW_IP_RANGE=0.0.0.0/0 "#, name = cfg.name, db_name = cfg.db_name, db_user = cfg.db_user, db_password = cfg.db_password, ) } pub fn custom_home_html(cfg: &ProjectConfig) -> String { format!( r##"{{% extends "base.html" %}} {{% block navbar %}} {{% include "components/navbar.html" %}} {{% endblock %}} {{% block content %}} <div class="hero min-h-[60vh]"> <div class="hero-content text-center"> <div class="max-w-lg"> <h1 class="text-5xl font-bold">{{{{ welcome_message }}}}</h1> <p class="py-6 text-base-content/70">{{{{ home_subtitle }}}}</p> <p class="text-sm text-base-content/50"> {app_display_name} v{{{{ app_version }}}} </p> </div> </div> </div> {{% endblock %}} "##, app_display_name = cfg.app_display_name, ) } pub fn readme(cfg: &ProjectConfig) -> String { let mode = if cfg.with_ui { "avec interface web" } else { "API seule (headless)" }; let ui_routes = if cfg.with_ui { r#"| `/` | Page d'accueil | | `/login` | Connexion | | `/register` | Inscription | | `/profile` | Profil utilisateur | | `/admin` | Panneau d'administration | "# } else { "" }; format!( r#"# {name} {description} Application generee avec [Rustwarden](https://github.com/monsieurb/rustwarden) -- {mode}. ## Demarrage rapide ### 1. Base de donnees Lancer PostgreSQL avec Docker : ```bash cd database docker compose up -d cd .. ``` ### 2. Lancer l'application ```bash cargo run ``` Ouvrir [http://localhost:{port}](http://localhost:{port}) dans le navigateur. > Les fichiers `.env` (application) et `database/.env` (PostgreSQL) ont ete generes > par le wizard avec vos identifiants. Voir `.env.example` pour la reference des variables. ## Routes disponibles | Chemin | Description | |--------|-------------| | `/api/hello` | Route exemple | {ui_routes} ## Ajouter des routes Modifier la fonction `app_routes()` dans `src/main.rs` : ```rust fn app_routes() -> Router<DatabaseConnection> {{ Router::new() .route("/api/hello", get(hello)) .route("/api/custom", get(my_handler)) }} ``` ## Planifier l'interface (screen-select) Le binaire `screen-select` genere un plan Markdown (`docs/plans/ui-screens.md`) des ecrans d'interface a coder selon les fonctionnalites activees. Pour chaque ecran il precise le niveau d'autorisation (Public / Authentifie / Proprietaire de ressource / Admin) et les points de securite a respecter (CSRF, IDOR, anti-enumeration, token de reset hors contexte JS...), plus deux sections transversales (headers de securite, conventions de rendu sur). Utile surtout pour une UI custom ou en mode API seule. ```bash # Generer / completer le plan des ecrans a coder cargo run -p rustwarden-core --bin screen-select # Recocher automatiquement les ecrans deja cables (analyse des routes) cargo run -p rustwarden-core --bin screen-select -- --check ``` Codez ces ecrans dans une crate dediee `{name}-ui` consommant `rustwarden-core`, montee dans le routeur principal. ## Commandes utiles | Commande | Description | |----------|-------------| | `cargo run` | Lancer l'application | | `cargo run -p rustwarden-core --bin migrate` | Executer les migrations manuellement | | `cargo run -p rustwarden-core --bin seed` | Creer l'utilisateur admin | | `cargo run -p rustwarden-core --bin hash_password` | Hasher un mot de passe | | `cargo run -p rustwarden-core --bin screen-select` | Planifier les ecrans d'UI a coder | | `cargo run -p rustwarden-core --bin screen-select -- --check` | Recocher les ecrans deja implementes | "#, name = cfg.name, description = cfg.description, mode = mode, port = cfg.port, ui_routes = ui_routes, ) } pub fn developer_md(cfg: &ProjectConfig) -> String { format!( r####"# Guide du developpeur — {name} Ce document explique comment ajouter votre logique metier a cette application generee avec **Rustwarden**. --- ## Architecture du projet ``` {name}/ ├── Cargo.toml # Workspace racine ├── src/ │ └── main.rs # Point d'entree, routes personnalisees ├── crates/ │ ├── rustwarden-core/ # Authentification, models, services, hooks │ └── rustwarden-ui/ # Interface web (templates, handlers, routes) ├── locales/ # Fichiers de traduction (YAML) ├── static/ # Fichiers statiques (CSS, JS, images) ├── database/ # Docker Compose pour PostgreSQL ├── .env # Configuration de l'application └── .env.example # Reference des variables d'environnement ``` ### rustwarden-core | Dossier | Role | |---------|------| | `auth/` | JWT, sessions, extracteurs d'authentification | | `config/` | Chargement de la configuration (env, DB) | | `hooks.rs` | Trait `RustwardenHooks` pour intercepter les evenements | | `middleware/` | Auth required, admin required, CSRF, rate limiting | | `models/` | Entites SeaORM (User, Role, Permission, etc.) | | `services/` | Logique metier (UserService, AuthService, etc.) | | `tasks/` | Taches de fond (email queue, cleanup) | | `templates/` | Contexte layout, templates email | | `i18n/` | Internationalisation (extraction locale, chargement traductions) | | `app.rs` | Builder `RustwardenApp::builder()` | ### rustwarden-ui | Dossier | Role | |---------|------| | `handlers/` | Handlers Axum pour chaque page (home, login, admin, etc.) | | `dto/` | Data Transfer Objects pour les templates | | `routes.rs` | Definition des routes UI | | `templates/` | Templates HTML (Askama) | --- ## Ajouter des routes ### Route API simple Dans `src/main.rs`, ajoutez vos handlers dans `app_routes()` : ```rust use axum::{{Router, routing::{{get, post}}, Json}}; use sea_orm::DatabaseConnection; use serde::{{Deserialize, Serialize}}; fn app_routes() -> Router<DatabaseConnection> {{ Router::new() .route("/api/hello", get(hello)) .route("/api/items", get(list_items).post(create_item)) .route("/api/items/:id", get(get_item)) }} #[derive(Serialize)] struct Item {{ id: i32, name: String, }} async fn list_items( State(db): State<DatabaseConnection>, ) -> Json<Vec<Item>> {{ // Requete SeaORM ici Json(vec![]) }} ``` ### Route protegee par authentification Utilisez l'extracteur `Claims` pour exiger un JWT valide : ```rust use rustwarden_core::auth::jwt::Claims; async fn protected_route(claims: Claims) -> String {{ format!("Bonjour, utilisateur {{}}", claims.sub) }} ``` ### Route protegee par role/permission ```rust use rustwarden_core::auth::jwt::Claims; use axum::extract::State; use sea_orm::DatabaseConnection; use rustwarden_core::services::role_service::RoleService; async fn admin_only( claims: Claims, State(db): State<DatabaseConnection>, ) -> Result<String, StatusCode> {{ let roles = RoleService::get_user_roles(&db, claims.user_id()).await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if !roles.iter().any(|r| r.name == "admin") {{ return Err(StatusCode::FORBIDDEN); }} Ok("Zone admin".into()) }} ``` --- ## Ajouter un modele (entite SeaORM) ### 1. Creer la migration Ajoutez un fichier de migration dans `crates/rustwarden-core/src/migrations/` : ```rust use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration {{ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{ manager .create_table( Table::create() .table(Product::Table) .if_not_exists() .col(ColumnDef::new(Product::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(Product::Name).string().not_null()) .col(ColumnDef::new(Product::Price).decimal().not_null()) .col(ColumnDef::new(Product::CreatedAt).timestamp_with_time_zone().not_null()) .to_owned(), ) .await }} async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{ manager .drop_table(Table::drop().table(Product::Table).to_owned()) .await }} }} #[derive(Iden)] enum Product {{ Table, Id, Name, Price, CreatedAt, }} ``` Puis enregistrez la migration dans `crates/rustwarden-core/src/migrations.rs`. ### 2. Creer l'entite Dans `crates/rustwarden-core/src/models/`, creez un fichier `product.rs` : ```rust use sea_orm::entity::prelude::*; use serde::{{Deserialize, Serialize}}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "products")] pub struct Model {{ #[sea_orm(primary_key)] pub id: Uuid, pub name: String, pub price: Decimal, pub created_at: DateTimeWithTimeZone, }} #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {{}} impl ActiveModelBehavior for ActiveModel {{}} ``` N'oubliez pas de declarer le module dans `models/mod.rs`. ### 3. Creer le service Dans `crates/rustwarden-core/src/services/`, creez `product_service.rs` : ```rust use sea_orm::*; use uuid::Uuid; use crate::models::product; pub struct ProductService; impl ProductService {{ pub async fn find_all(db: &DatabaseConnection) -> Result<Vec<product::Model>, DbErr> {{ product::Entity::find().all(db).await }} pub async fn find_by_id(db: &DatabaseConnection, id: Uuid) -> Result<Option<product::Model>, DbErr> {{ product::Entity::find_by_id(id).one(db).await }} pub async fn create( db: &DatabaseConnection, name: String, price: Decimal, ) -> Result<product::Model, DbErr> {{ let model = product::ActiveModel {{ id: Set(Uuid::new_v4()), name: Set(name), price: Set(price), created_at: Set(chrono::Utc::now().into()), }}; model.insert(db).await }} }} ``` --- ## Personnaliser les templates Les templates HTML se trouvent dans `crates/rustwarden-ui/templates/` et utilisent le moteur [Askama](https://djc.github.io/askama/) (syntaxe similaire a Jinja2). | Fichier | Description | |---------|-------------| | `base.html` | Layout de base (head, scripts, structure) | | `components/navbar.html` | Barre de navigation | | `home.html` | Page d'accueil | | `login.html` | Page de connexion / inscription | | `settings/profile.html` | Page profil utilisateur | | `admin/` | Pages d'administration | | `errors/` | Pages d'erreur (401, 403, 500) | ### Variables disponibles dans tous les templates Fournies par `LayoutContext` : | Variable | Description | |----------|-------------| | `page_title` | Titre de la page | | `app_name` | Nom de l'application (depuis `APP_NAME`) | | `app_version` | Version de l'application | | `csrf_token` | Token CSRF | ### Modifier un template 1. Editez directement le fichier `.html` dans `crates/rustwarden-ui/templates/` 2. Si vous ajoutez de nouvelles variables, modifiez le struct `#[derive(Template)]` correspondant dans `crates/rustwarden-ui/src/handlers/` 3. Recompilez avec `cargo build` > **Astuce** : Askama verifie les templates au moment de la compilation. > Si une variable est manquante dans le template, le build echouera avec un message clair. --- ## Utiliser l'authentification ### Claims JWT L'extracteur `Claims` injecte les informations de l'utilisateur authentifie : ```rust use rustwarden_core::auth::jwt::Claims; async fn my_handler(claims: Claims) -> String {{ let user_id = claims.user_id(); // Uuid let username = &claims.sub; // String format!("User: {{}} ({{}})", username, user_id) }} ``` ### Middleware disponibles | Middleware | Usage | |-----------|-------| | `auth_required` | Exige un JWT/session valide | | `admin_required` | Exige le role `admin` | | `rate_limit` | Limitation du nombre de requetes | | `csrf` | Protection CSRF pour les formulaires | ### Roles et permissions Le systeme RBAC fournit des roles avec des permissions granulaires : ```rust use rustwarden_core::services::role_service::RoleService; use rustwarden_core::services::permission_service::PermissionService; // Verifier si un utilisateur a une permission let has_perm = PermissionService::user_has_permission(&db, user_id, "products.create").await?; // Recuperer les roles d'un utilisateur let roles = RoleService::get_user_roles(&db, user_id).await?; ``` --- ## Hooks : intercepter les evenements Implementez le trait `RustwardenHooks` pour reagir aux evenements du framework : ```rust use rustwarden_core::hooks::RustwardenHooks; use async_trait::async_trait; use sea_orm::DatabaseConnection; use uuid::Uuid; struct MyHooks; #[async_trait] impl RustwardenHooks for MyHooks {{ async fn on_user_registered( &self, db: &DatabaseConnection, user_id: Uuid, username: &str, ) -> anyhow::Result<()> {{ // Creer des donnees par defaut pour le nouvel utilisateur println!("Nouveau utilisateur : {{}}", username); Ok(()) }} async fn on_user_login( &self, db: &DatabaseConnection, user_id: Uuid, username: &str, ) -> anyhow::Result<()> {{ // Audit, statistiques, etc. Ok(()) }} }} ``` Activez vos hooks dans `src/main.rs` : ```rust RustwardenApp::builder() .from_env() .hooks(MyHooks) .build() .await? .run() .await ``` ### Hooks disponibles | Hook | Declencheur | |------|-------------| | `on_user_registered` | Apres inscription d'un utilisateur | | `on_user_login` | Apres connexion reussie | | `on_user_logout` | Apres deconnexion | | `on_user_deleted` | Apres suppression d'un utilisateur | | `on_password_changed` | Apres changement de mot de passe | | `on_email_verified` | Apres verification de l'email | | `on_role_changed` | Apres modification des roles | | `on_oauth_login` | Apres connexion OAuth/SSO reussie | | `on_totp_enabled` | Apres activation 2FA/TOTP | | `on_totp_disabled` | Apres desactivation 2FA/TOTP | | `on_resource_registered` | Apres enregistrement d'une ressource | | `on_resource_unregistered` | Apres desenregistrement d'une ressource | | `on_resource_shared` | Apres partage d'une ressource | | `on_resource_unshared` | Apres revocation d'un partage | | `on_team_created` | Apres creation d'une team | | `on_team_deleted` | Apres suppression d'une team | | `on_team_member_added` | Apres ajout d'un membre dans une team | | `on_team_member_removed` | Apres retrait d'un membre d'une team | | `on_audit_event` | Tout evenement audit (authentification, acces admin, etc.) | Tous les hooks ont une implementation par defaut vide dans le trait. Vous n'implementez que ceux qui vous interessent. --- ## Ressources partageables Le framework fournit un systeme generique de partage de ressources entre utilisateurs. Toute entite applicative (document, projet...) peut etre enregistree et partagee. ### Enregistrer une ressource ```rust use rustwarden_core::services::resource_service::ResourceService; // Apres creation de votre entite ResourceService::register(&db, "document", doc.id, user_id).await?; ``` ### Partager ```rust ResourceService::share(&db, "document", doc_id, other_user_id, "read", current_user_id).await?; ``` ### Verifier l'acces ```rust let can_read = ResourceService::user_can_access(&db, user_id, "document", doc_id, "read").await?; ``` ### Niveaux de permission | Niveau | Inclut | |--------|--------| | `read` | lecture seule | | `write` | lecture + ecriture | | `admin` | tout + gestion des partages | Le proprietaire a implicitement tous les droits. Voir `DEVELOPER.md` section "Ressources partageables" pour l'API complete. --- ## Configuration Toutes les variables d'environnement sont dans `.env`. Voir `.env.example` pour la reference complete. ### Variables principales | Variable | Description | Defaut | |----------|-------------|--------| | `APP_NAME` | Nom affiche dans l'UI | `My App` | | `DATABASE_URL` | URL de connexion PostgreSQL | — | | `SERVER_HOST` | Adresse d'ecoute | `0.0.0.0` | | `SERVER_PORT` | Port d'ecoute | `3000` | | `JWT_SECRET` | Cle secrete pour signer les JWT | — | | `JWT_EXPIRATION_MINUTES` | Duree de vie du JWT | `15` | | `REFRESH_TOKEN_EXPIRATION_DAYS` | Duree de vie du refresh token | `7` | | `DEFAULT_LOCALE` | Langue par defaut | `fr` | | `ALLOW_REGISTRATION` | Autoriser l'inscription publique | `false` | | `EMAIL_VALIDATION_REQUIRED` | Exiger la validation email | `true` | | `SMTP_HOST` | Serveur SMTP | — | | `ADMIN_USERNAME` | Nom d'utilisateur admin initial | — | | `ADMIN_EMAIL` | Email admin initial | — | | `ADMIN_PASSWORD` | Mot de passe admin initial | — | ### Desactiver les emails Si le SMTP n'est pas configure, appelez `.without_email()` dans le builder : ```rust RustwardenApp::builder() .from_env() .without_email() .build() .await? .run() .await ``` --- ## Commandes utiles | Commande | Description | |----------|-------------| | `cargo run` | Lancer l'application | | `cargo run -p rustwarden-core --bin migrate` | Executer les migrations manuellement | | `cargo run -p rustwarden-core --bin seed` | Creer/mettre a jour l'admin initial | | `cargo run -p rustwarden-core --bin hash_password` | Hasher un mot de passe bcrypt | | `cargo run -p rustwarden-core --bin change_password` | Changer le mot de passe d'un utilisateur | | `cargo test` | Lancer les tests | | `cargo build --release` | Build optimise pour la production | --- ## Internationalisation (i18n) Les traductions sont dans `locales/` au format YAML : ``` locales/ ├── en.yml ├── fr.yml ├── es.yml └── de.yml ``` Pour ajouter des traductions a votre application, ajoutez des cles dans chaque fichier : ```yaml # locales/fr.yml products: title: "Produits" empty: "Aucun produit trouve" create: "Creer un produit" ``` Utilisez-les dans les handlers via `I18nService` : ```rust use rustwarden_core::i18n::service::I18nService; use rustwarden_core::i18n::extractor::Locale; use std::sync::Arc; use axum::extract::Extension; async fn products_page( locale: Locale, Extension(i18n): Extension<Arc<I18nService>>, ) -> String {{ let title = i18n.t(&locale.0, "products.title"); title }} ``` --- *Documentation generee par le wizard Rustwarden.* "####, name = cfg.name, ) }
GitRust