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,
    )
}