remove cdm
EBO <eric.bouhana@softalys.com> committé le 2026-03-20 14:42
d5ab015d402779837245cb904fd97b1f7d84a36a
1 parent(s)
58 fichiers modifiés
+3234
-854
A
.cursor/plans/admin_oauth_settings_ui_7ca3f1fd.plan.md
+276
-0
@@ -0,0 +1,276 @@
| 1 | +--- | |
| 2 | +name: Admin OAuth settings UI | |
| 3 | +overview: "Refonte complete de la section OAuth dans /admin/settings : configuration des credentials (client_id/secret) directement dans l'interface, chiffrement AES-256-GCM des secrets en DB, toggles par provider, et options auto-register/link. Redemarrage requis apres modification des credentials." | |
| 4 | +todos: | |
| 5 | + - id: crypto-service | |
| 6 | + content: Creer CryptoService (AES-256-GCM) + dependances aes-gcm/base64 dans Cargo.toml | |
| 7 | + status: completed | |
| 8 | + - id: db-settings-keys | |
| 9 | + content: Ajouter les cles app_settings pour les credentials OAuth (client_id, client_secret, tenant, auto_register, link_existing, redirect_base_url) | |
| 10 | + status: completed | |
| 11 | + - id: oauth-config-load-db | |
| 12 | + content: Ajouter OAuthConfig::load(db) async qui lit la DB (dechiffre secrets) puis fallback env vars | |
| 13 | + status: completed | |
| 14 | + - id: app-startup-db-config | |
| 15 | + content: Modifier app.rs pour utiliser OAuthConfig::load(db) au lieu de from_env() au demarrage | |
| 16 | + status: completed | |
| 17 | + - id: admin-handler-oauth | |
| 18 | + content: Ajouter les champs OAuth a AdminSettingsTemplate, AppSettingsTemplateContext, AdminSettingsFormData + logique GET/PUT | |
| 19 | + status: completed | |
| 20 | + - id: admin-template-redesign | |
| 21 | + content: Refondre la carte OAuth dans templates/admin/settings.html (credentials, toggles, collapse par provider, redirect URI, bandeau restart) | |
| 22 | + status: completed | |
| 23 | + - id: locales-oauth-admin | |
| 24 | + content: Ajouter les cles admin.settings.oauth_* dans les 4 fichiers locales (dans le bloc admin.settings existant) | |
| 25 | + status: completed | |
| 26 | + - id: env-encryption-key | |
| 27 | + content: Ajouter OAUTH_ENCRYPTION_KEY dans .env.example, CLI wizard, et documentation | |
| 28 | + status: completed | |
| 29 | + - id: final-check-docs | |
| 30 | + content: cargo check + mise a jour DEVELOPER.md et docs/configuration-flows.md | |
| 31 | + status: completed | |
| 32 | +isProject: false | |
| 33 | +--- | |
| 34 | + | |
| 35 | +# Refonte Admin OAuth Settings -- Configuration complete via UI | |
| 36 | + | |
| 37 | +## Etat actuel et problemes | |
| 38 | + | |
| 39 | +La carte OAuth dans `/admin/settings` ne contient que des **toggles on/off** sans champs de saisie pour les credentials. De plus : | |
| 40 | + | |
| 41 | +- Les traductions `admin.settings.oauth_`* sont **absentes** des locales (supprimees lors du fix YAML) | |
| 42 | +- `AdminSettingsTemplate`, `AppSettingsTemplateContext` et `AdminSettingsFormData` dans [admin_handler.rs](crates/rustwarden-ui/src/handlers/admin_handler.rs) n'ont **aucun champ OAuth** | |
| 43 | +- `OAuthConfig::from_env()` dans [config/oauth.rs](crates/rustwarden-core/src/config/oauth.rs) ne lit que les variables d'environnement | |
| 44 | +- Les variables template `{{ oauth_sso_title }}`, `{{ oauth_enabled_label }}`, etc. sont utilisees dans le template mais **non definies** dans le struct Rust | |
| 45 | + | |
| 46 | +--- | |
| 47 | + | |
| 48 | +## Architecture cible | |
| 49 | + | |
| 50 | +```mermaid | |
| 51 | +flowchart TD | |
| 52 | + AdminUI["Admin Settings UI"] | |
| 53 | + DB["app_settings DB"] | |
| 54 | + CryptoSvc["CryptoService AES-256-GCM"] | |
| 55 | + OAuthCfg["OAuthConfig::load"] | |
| 56 | + EnvVars["Variables .env"] | |
| 57 | + Registry["ProviderRegistry"] | |
| 58 | + Handlers["OAuth Handlers"] | |
| 59 | + | |
| 60 | + AdminUI -->|"save credentials"| CryptoSvc | |
| 61 | + CryptoSvc -->|"encrypted value"| DB | |
| 62 | + OAuthCfg -->|"read + decrypt"| DB | |
| 63 | + OAuthCfg -->|"fallback"| EnvVars | |
| 64 | + OAuthCfg -->|"startup"| Registry | |
| 65 | + Registry --> Handlers | |
| 66 | +``` | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | +**Principe** : Les credentials sont stockees chiffrees en DB. Au demarrage, `OAuthConfig::load(db)` lit la DB (dechiffre les secrets), puis tombe en fallback sur les variables d'environnement pour les valeurs absentes. Le `ProviderRegistry` est construit une seule fois au demarrage. Un bandeau dans l'interface indique qu'un redemarrage est requis apres modification des credentials. | |
| 71 | + | |
| 72 | +--- | |
| 73 | + | |
| 74 | +## 1. Service de chiffrement (`CryptoService`) | |
| 75 | + | |
| 76 | +**Nouveau fichier** : `crates/rustwarden-core/src/services/crypto_service.rs` | |
| 77 | + | |
| 78 | +**Dependance** : ajouter `aes-gcm = "0.10"` et `base64 = "0.22"` dans [Cargo.toml](crates/rustwarden-core/Cargo.toml) | |
| 79 | + | |
| 80 | +```rust | |
| 81 | +pub struct CryptoService; | |
| 82 | + | |
| 83 | +impl CryptoService { | |
| 84 | + fn get_key() -> Result<[u8; 32], AppError> { | |
| 85 | + let key_hex = env::var("OAUTH_ENCRYPTION_KEY") | |
| 86 | + .map_err(|_| AppError::internal("OAUTH_ENCRYPTION_KEY not set"))?; | |
| 87 | + // Decode hex -> 32 bytes | |
| 88 | + } | |
| 89 | + | |
| 90 | + pub fn encrypt(plaintext: &str) -> Result<String, AppError> { | |
| 91 | + // AES-256-GCM encrypt -> base64(nonce || ciphertext) | |
| 92 | + } | |
| 93 | + | |
| 94 | + pub fn decrypt(encrypted_b64: &str) -> Result<String, AppError> { | |
| 95 | + // base64 decode -> split nonce/ciphertext -> AES-256-GCM decrypt | |
| 96 | + } | |
| 97 | +} | |
| 98 | +``` | |
| 99 | + | |
| 100 | +Le format stocke en DB sera : `enc:` + base64(nonce_12bytes || ciphertext || tag_16bytes). Le prefixe `enc:` permet de distinguer les valeurs chiffrees des valeurs en clair (migration douce). | |
| 101 | + | |
| 102 | +--- | |
| 103 | + | |
| 104 | +## 2. Nouvelles cles `app_settings` en DB | |
| 105 | + | |
| 106 | +Ajouter dans `initialize_default_settings` de [app_settings_service.rs](crates/rustwarden-core/src/services/app_settings_service.rs) : | |
| 107 | + | |
| 108 | +- `oauth_redirect_base_url` : `""` (vide par defaut, fallback sur env) | |
| 109 | +- `oauth_auto_register` : `"true"` | |
| 110 | +- `oauth_link_existing_account` : `"true"` | |
| 111 | +- Par provider (google, github, discord, microsoft) : | |
| 112 | + - `oauth_{name}_client_id` : `""` | |
| 113 | + - `oauth_{name}_client_secret` : `""` (sera chiffre via CryptoService) | |
| 114 | + - `oauth_microsoft_tenant` : `""` (uniquement pour Microsoft) | |
| 115 | + | |
| 116 | +--- | |
| 117 | + | |
| 118 | +## 3. `OAuthConfig::load(db)` -- Lecture DB + fallback env | |
| 119 | + | |
| 120 | +Modifier [config/oauth.rs](crates/rustwarden-core/src/config/oauth.rs) : | |
| 121 | + | |
| 122 | +- Ajouter une methode **async** `pub async fn load(db: &DatabaseConnection) -> Self` | |
| 123 | +- Pour chaque provider, lire d'abord `app_settings` en DB : | |
| 124 | + - `oauth_{name}_client_id` -> si non vide, utiliser | |
| 125 | + - `oauth_{name}_client_secret` -> si non vide, dechiffrer via `CryptoService::decrypt()` | |
| 126 | + - Sinon fallback sur `OAUTH_{NAME}_CLIENT_ID` / `OAUTH_{NAME}_CLIENT_SECRET` en env | |
| 127 | +- `oauth_enabled` et `oauth_{name}_enabled` : lire de la DB | |
| 128 | +- Conserver `from_env()` comme fallback si la DB n'est pas disponible | |
| 129 | + | |
| 130 | +--- | |
| 131 | + | |
| 132 | +## 4. Modification du demarrage (`app.rs`) | |
| 133 | + | |
| 134 | +Dans [app.rs](crates/rustwarden-core/src/app.rs), remplacer : | |
| 135 | + | |
| 136 | +```rust | |
| 137 | +let oauth_config = OAuthConfig::from_env(); | |
| 138 | +``` | |
| 139 | + | |
| 140 | +par : | |
| 141 | + | |
| 142 | +```rust | |
| 143 | +let oauth_config = OAuthConfig::load(&db).await; | |
| 144 | +``` | |
| 145 | + | |
| 146 | +Le reste de la construction du `ProviderRegistry` reste identique (une seule fois au demarrage). | |
| 147 | + | |
| 148 | +--- | |
| 149 | + | |
| 150 | +## 5. Refonte du template admin settings | |
| 151 | + | |
| 152 | +### 5.1 Struct Rust (`admin_handler.rs`) | |
| 153 | + | |
| 154 | +Ajouter a `AdminSettingsTemplate` les champs de traduction OAuth : | |
| 155 | + | |
| 156 | +- `oauth_sso_title`, `oauth_enabled_label`, `oauth_enabled_desc` | |
| 157 | +- `oauth_redirect_url_label`, `oauth_redirect_url_desc` | |
| 158 | +- `oauth_auto_register_label`, `oauth_auto_register_desc` | |
| 159 | +- `oauth_link_existing_label`, `oauth_link_existing_desc` | |
| 160 | +- `oauth_client_id_label`, `oauth_client_secret_label` | |
| 161 | +- `oauth_tenant_label` (Microsoft) | |
| 162 | +- `oauth_restart_warning`, `oauth_env_info` | |
| 163 | +- Par provider : label et description du protocole | |
| 164 | + | |
| 165 | +Ajouter a `AppSettingsTemplateContext` : | |
| 166 | + | |
| 167 | +- `oauth_enabled`, `oauth_redirect_base_url` | |
| 168 | +- `oauth_auto_register`, `oauth_link_existing_account` | |
| 169 | +- Par provider : `oauth_{name}_enabled`, `oauth_{name}_client_id`, `oauth_{name}_has_secret` (boolean, pas le secret lui-meme) | |
| 170 | + | |
| 171 | +Ajouter a `AdminSettingsFormData` : | |
| 172 | + | |
| 173 | +- `oauth_enabled`, `oauth_redirect_base_url` | |
| 174 | +- `oauth_auto_register`, `oauth_link_existing_account` | |
| 175 | +- Par provider : `oauth_{name}_enabled`, `oauth_{name}_client_id`, `oauth_{name}_client_secret` | |
| 176 | +- `oauth_microsoft_tenant` | |
| 177 | + | |
| 178 | +### 5.2 GET handler | |
| 179 | + | |
| 180 | +Dans `get_app_settings`, charger les nouvelles cles et les passer au template. Les secrets ne sont **jamais** envoyes au frontend -- seulement un boolean `has_secret` pour afficher "Configure" vs "Non configure". | |
| 181 | + | |
| 182 | +### 5.3 PUT handler | |
| 183 | + | |
| 184 | +Dans `update_app_settings`, sauvegarder les nouvelles cles. Les secrets sont **chiffres** via `CryptoService::encrypt()` avant stockage. Si le champ secret est vide dans le formulaire et qu'un secret existe deja, ne pas l'ecraser. | |
| 185 | + | |
| 186 | +### 5.4 Template HTML | |
| 187 | + | |
| 188 | +Refonte complete de la carte OAuth dans [settings.html](templates/admin/settings.html) : | |
| 189 | + | |
| 190 | +``` | |
| 191 | ++-----------------------------------------------------------+ | |
| 192 | +| OAuth / SSO [toggle] | | |
| 193 | +| | | |
| 194 | +| URL de redirection: https://app.example.com [copier] | | |
| 195 | +| Auto-inscription: [toggle] | | |
| 196 | +| Liaison compte existant: [toggle] | | |
| 197 | +| | | |
| 198 | +| --- Google (OpenID Connect) -------- [toggle] ---------- | | |
| 199 | +| | Client ID: [________________________] | | | |
| 200 | +| | Client Secret: [••••••••••] [Modifier] | | | |
| 201 | +| | Redirect URI: https://app.../callback [copier] | | | |
| 202 | +| +-------------------------------------------------------+ | | |
| 203 | +| | | |
| 204 | +| --- GitHub (OAuth2) ---------------- [toggle] ---------- | | |
| 205 | +| | Client ID: [________________________] | | | |
| 206 | +| | Client Secret: [••••••••••] [Modifier] | | | |
| 207 | +| | Redirect URI: https://app.../callback [copier] | | | |
| 208 | +| +-------------------------------------------------------+ | | |
| 209 | +| | | |
| 210 | +| --- Discord (OAuth2) --------------- [toggle] ---------- | | |
| 211 | +| | ... | | | |
| 212 | +| +-------------------------------------------------------+ | | |
| 213 | +| | | |
| 214 | +| --- Microsoft (OpenID Connect) ----- [toggle] ---------- | | |
| 215 | +| | Client ID: [________________________] | | | |
| 216 | +| | Client Secret: [••••••••••] [Modifier] | | | |
| 217 | +| | Tenant ID: [________________________] | | | |
| 218 | +| | Redirect URI: https://app.../callback [copier] | | | |
| 219 | +| +-------------------------------------------------------+ | | |
| 220 | +| | | |
| 221 | +| [!] Les modifications de credentials necessitent un | | |
| 222 | +| redemarrage de l'application. | | |
| 223 | ++-----------------------------------------------------------+ | |
| 224 | +``` | |
| 225 | + | |
| 226 | +Chaque provider est dans un **collapse DaisyUI** (ou div collapsible) qui s'affiche quand le provider est active. Le secret n'est jamais affiche -- seulement un placeholder "Configure" ou un indicateur "Deja configure". | |
| 227 | + | |
| 228 | +--- | |
| 229 | + | |
| 230 | +## 6. Traductions (locales) | |
| 231 | + | |
| 232 | +Ajouter les cles suivantes **dans le bloc `admin.settings` existant** de chaque fichier locale : | |
| 233 | + | |
| 234 | +- `locales/fr.yml` : fusionner sous `admin.settings:` existant (ligne ~376) | |
| 235 | +- `locales/en.yml` : idem | |
| 236 | +- `locales/de.yml` : idem | |
| 237 | +- `locales/es.yml` : idem | |
| 238 | + | |
| 239 | +Cles a ajouter (exemple fr) : | |
| 240 | + | |
| 241 | +- `oauth_sso_title: "OAuth / SSO"` | |
| 242 | +- `oauth_enabled_label: "Activer OAuth"` | |
| 243 | +- `oauth_enabled_desc: "Autorise la connexion via des fournisseurs externes"` | |
| 244 | +- `oauth_redirect_url_label: "URL de redirection de base"` | |
| 245 | +- `oauth_auto_register_label: "Auto-inscription"` | |
| 246 | +- `oauth_auto_register_desc: "Creer automatiquement un compte si l'email n'existe pas"` | |
| 247 | +- `oauth_link_existing_label: "Lier les comptes existants"` | |
| 248 | +- `oauth_link_existing_desc: "Lier automatiquement si l'email verifie correspond"` | |
| 249 | +- `oauth_client_id: "Client ID"` | |
| 250 | +- `oauth_client_secret: "Client Secret"` | |
| 251 | +- `oauth_tenant: "Tenant ID"` | |
| 252 | +- `oauth_redirect_uri: "URI de redirection"` | |
| 253 | +- `oauth_copy_uri: "Copier"` | |
| 254 | +- `oauth_secret_configured: "Configure"` | |
| 255 | +- `oauth_secret_not_configured: "Non configure"` | |
| 256 | +- `oauth_secret_change: "Modifier"` | |
| 257 | +- `oauth_restart_warning: "Les modifications de credentials necessitent un redemarrage."` | |
| 258 | +- `oauth_env_info: "Les credentials peuvent aussi etre configurees via variables d'environnement."` | |
| 259 | + | |
| 260 | +--- | |
| 261 | + | |
| 262 | +## 7. Variable d'environnement `OAUTH_ENCRYPTION_KEY` | |
| 263 | + | |
| 264 | +- Generer une cle hex 64 caracteres (32 bytes) aleatoire | |
| 265 | +- Ajouter dans `.env.example` et dans la documentation | |
| 266 | +- Si la cle n'est pas definie et qu'un admin tente de sauvegarder un secret, retourner une erreur explicite | |
| 267 | +- Le CLI wizard (`rustwarden-cli`) generera automatiquement cette cle | |
| 268 | + | |
| 269 | +--- | |
| 270 | + | |
| 271 | +## 8. Verification et documentation | |
| 272 | + | |
| 273 | +- `cargo check` avec et sans feature `oauth` | |
| 274 | +- Mettre a jour `DEVELOPER.md` section OAuth avec la configuration via admin UI | |
| 275 | +- Mettre a jour `docs/configuration-flows.md` avec les nouvelles variables | |
| 276 | + |
A
.cursor/plans/cli_wizard_start-project_6d2dbcf2.plan.md
+110
-0
@@ -0,0 +1,110 @@
| 1 | +--- | |
| 2 | +name: CLI Wizard start-project | |
| 3 | +overview: Creer un CLI separe `rustwarden-cli` dans `tools/` avec une sous-commande `start-project` qui genere interactivement un nouveau crate applicatif dans un dossier cible choisi par l'utilisateur, pret a demarrer. | |
| 4 | +todos: | |
| 5 | + - id: cli-crate | |
| 6 | + content: Creer tools/rustwarden-cli/ avec Cargo.toml et ajouter au workspace | |
| 7 | + status: completed | |
| 8 | + - id: cli-main | |
| 9 | + content: Implementer main.rs avec clap (sous-commande start-project) | |
| 10 | + status: completed | |
| 11 | + - id: cli-templates | |
| 12 | + content: Ecrire les templates de fichiers generes dans templates.rs | |
| 13 | + status: completed | |
| 14 | + - id: cli-wizard | |
| 15 | + content: Implementer le wizard interactif avec dialoguer dans start_project.rs | |
| 16 | + status: completed | |
| 17 | + - id: cli-generation | |
| 18 | + content: Implementer la generation des fichiers, mise a jour workspace, et resume final | |
| 19 | + status: completed | |
| 20 | + - id: cli-test | |
| 21 | + content: Tester le CLI avec cargo run -p rustwarden-cli -- start-project | |
| 22 | + status: completed | |
| 23 | +isProject: false | |
| 24 | +--- | |
| 25 | + | |
| 26 | +# CLI Wizard `rustwarden-cli start-project` | |
| 27 | + | |
| 28 | +## Architecture | |
| 29 | + | |
| 30 | +Le CLI est un binaire independant dans `tools/rustwarden-cli/`, ajoute au workspace mais sans dependance vers rustwarden-core/ui (separation des responsabilites). Il genere des fichiers a partir de templates embarques. | |
| 31 | + | |
| 32 | +```mermaid | |
| 33 | +flowchart LR | |
| 34 | + CLI["rustwarden-cli start-project"] --> Questions["Questions interactives"] | |
| 35 | + Questions --> Gen["Generation fichiers"] | |
| 36 | + Gen --> Dir["dossier-cible/mon-app/"] | |
| 37 | + Gen --> Workspace["Mise a jour Cargo.toml workspace"] | |
| 38 | + Gen --> Summary["Resume final"] | |
| 39 | +``` | |
| 40 | + | |
| 41 | +Le wizard demande un **dossier cible** (defaut : `crates/`). Si le dossier existe, il demande confirmation avant de l'utiliser. S'il n'existe pas, il le cree. Les chemins de dependance (`path = ...`) sont calcules relativement a l'emplacement choisi. | |
| 42 | + | |
| 43 | +Quand de nouvelles fonctionnalites sont ajoutees au framework (SSO, OAuth...), les projets existants en beneficient automatiquement sans modifier le wizard. | |
| 44 | + | |
| 45 | +## Structure du CLI | |
| 46 | + | |
| 47 | +``` | |
| 48 | +tools/rustwarden-cli/ | |
| 49 | + Cargo.toml # clap, dialoguer, console | |
| 50 | + src/ | |
| 51 | + main.rs # Point d'entree, sous-commande clap | |
| 52 | + start_project.rs # Wizard interactif + generation | |
| 53 | + templates.rs # Templates de fichiers (Cargo.toml, main.rs, README) | |
| 54 | +``` | |
| 55 | + | |
| 56 | +## Questions du wizard (dialoguer) | |
| 57 | + | |
| 58 | +1. **Nom du projet** (Input) -- ex: `mon-app` (valide: caracteres alphanumeriques et tirets uniquement) | |
| 59 | +2. **Description** (Input) -- courte description du projet | |
| 60 | +3. **Dossier cible** (Input, defaut: `crates/`) -- chemin relatif ou absolu. Si le dossier `<cible>/<nom>/` existe deja, le wizard affiche son contenu et demande confirmation (Confirm). S'il n'existe pas, il le cree. | |
| 61 | +4. **Mode UI** (Select) -- `Avec interface web (rustwarden-ui)` / `API seule (headless)` | |
| 62 | +5. **Auto-migration** (Confirm) -- Lancer les migrations au demarrage ? | |
| 63 | +6. **Port serveur** (Input, defaut 3000) -- pour eviter les conflits entre apps | |
| 64 | + | |
| 65 | +## Fichiers generes | |
| 66 | + | |
| 67 | +Le dossier final est `<cible>/<nom>/`. Les chemins `path = "..."` dans le Cargo.toml genere sont calcules relativement depuis ce dossier vers `crates/rustwarden-core` et `crates/rustwarden-ui`. | |
| 68 | + | |
| 69 | +### `<cible>/<nom>/Cargo.toml` | |
| 70 | + | |
| 71 | +Basee sur [app/Cargo.toml](app/Cargo.toml) avec: | |
| 72 | + | |
| 73 | +- `name = "<nom>"` | |
| 74 | +- `rustwarden-core = { path = "<chemin-relatif-calcule>/rustwarden-core" }` | |
| 75 | +- `rustwarden-ui` inclus uniquement si mode UI choisi (meme logique de chemin relatif) | |
| 76 | + | |
| 77 | +### `<cible>/<nom>/src/main.rs` | |
| 78 | + | |
| 79 | +Basee sur [template/src/main.rs](template/src/main.rs) avec: | |
| 80 | + | |
| 81 | +- `.with_ui()` ou `.headless()` selon le choix | |
| 82 | +- `.auto_migrate(true/false)` selon le choix | |
| 83 | +- `.address("0.0.0.0:<port>")` si port != 3000 | |
| 84 | +- Route exemple `/api/hello` | |
| 85 | + | |
| 86 | +### `<cible>/<nom>/README.md` | |
| 87 | + | |
| 88 | +Documentation minimaliste en 3 etapes : | |
| 89 | + | |
| 90 | +``` | |
| 91 | +1. Configurer .env -> copier depuis .env.example | |
| 92 | +2. Lancer l'application -> cargo run -p <nom> | |
| 93 | +3. Ouvrir le navigateur -> http://localhost:<port> | |
| 94 | +``` | |
| 95 | + | |
| 96 | +Plus la liste des routes disponibles et comment ajouter ses propres routes. | |
| 97 | + | |
| 98 | +## Actions sur le workspace | |
| 99 | + | |
| 100 | +- Verifier/creer le dossier `<cible>/<nom>/` (avec confirmation si existant) | |
| 101 | +- Ajouter `"<cible>/<nom>"` dans `members` de [Cargo.toml](Cargo.toml) (racine workspace) | |
| 102 | +- Afficher un resume colore (console) avec les commandes pour demarrer | |
| 103 | + | |
| 104 | +## Dependances du CLI | |
| 105 | + | |
| 106 | +- `clap` (v4) -- parsing des arguments/sous-commandes | |
| 107 | +- `dialoguer` -- prompts interactifs (Input, Select, Confirm) | |
| 108 | +- `console` -- couleurs et styles dans le terminal | |
| 109 | + | |
| 110 | +Aucune dependance vers rustwarden-core/ui : le CLI reste leger et independant du framework. | |
| 110 | < \ No newline at end of file |
A
.cursor/plans/framework_customization_and_docs_ad0fe1ef.plan.md
+107
-0
@@ -0,0 +1,107 @@
| 1 | +--- | |
| 2 | +name: Framework customization and docs | |
| 3 | +overview: Rendre le framework neutre (sans branding RustWarden en dur), ajouter APP_NAME configurable, generer une page d'accueil personnalisee par le wizard, et creer une documentation developpeur pour exploiter le framework. | |
| 4 | +todos: | |
| 5 | + - id: app-name-config | |
| 6 | + content: "Phase 1 : Rendre APP_NAME configurable dans AppConfig, LayoutContext et les handlers" | |
| 7 | + status: completed | |
| 8 | + - id: debrand-templates | |
| 9 | + content: "Phase 2 : Remplacer toutes les occurrences RustWarden en dur dans les templates HTML" | |
| 10 | + status: completed | |
| 11 | + - id: wizard-rebrand | |
| 12 | + content: "Phase 3 : Ajouter app_display_name au wizard + rebrand_templates() + APP_NAME dans .env" | |
| 13 | + status: completed | |
| 14 | + - id: custom-home | |
| 15 | + content: "Phase 4 : Generer une page d'accueil personnalisee dans le wizard" | |
| 16 | + status: completed | |
| 17 | + - id: developer-docs | |
| 18 | + content: "Phase 5 : Generer DEVELOPER.md dans le projet cree par le wizard" | |
| 19 | + status: completed | |
| 20 | +isProject: false | |
| 21 | +--- | |
| 22 | + | |
| 23 | +# Framework : personnalisation et documentation | |
| 24 | + | |
| 25 | +## Phase 1 : APP_NAME configurable dans le framework | |
| 26 | + | |
| 27 | +Actuellement `app_name` est code en dur a `"RustWarden"` dans 3 endroits du code Rust : | |
| 28 | + | |
| 29 | +- [crates/rustwarden-core/src/templates/mod.rs](crates/rustwarden-core/src/templates/mod.rs) ligne 47 : `app_name: "RustWarden".to_string()` | |
| 30 | +- [crates/rustwarden-core/src/templates/mod.rs](crates/rustwarden-core/src/templates/mod.rs) ligne 55 : `Self::new("RustWarden", "")` | |
| 31 | +- [crates/rustwarden-core/src/config/app.rs](crates/rustwarden-core/src/config/app.rs) ligne 51 : `app_name: "RustWarden".to_string()` | |
| 32 | + | |
| 33 | +**Changements :** | |
| 34 | + | |
| 35 | +- `AppConfig::from_env()` : lire `APP_NAME` depuis l'env (fallback: DB `app_settings`, puis "My App") | |
| 36 | +- `LayoutContext::new()` : accepter `app_name` en parametre au lieu de le coder en dur | |
| 37 | +- Tous les handlers qui appellent `LayoutContext::new("", "")` doivent passer le `app_name` depuis `AppConfig` ou `app_settings` | |
| 38 | + | |
| 39 | +## Phase 2 : De-brander les templates HTML | |
| 40 | + | |
| 41 | +12+ occurrences de "RustWarden" en dur dans les templates. Remplacer par `{{ app_name }}` pour les templates Askama, et par un texte neutre pour les pages statiques. | |
| 42 | + | |
| 43 | +| Fichier | Changement | | |
| 44 | +|---------|-----------| | |
| 45 | +| `navbar.html` ligne 9 | `alt="RustWarden Logo"` -> `alt="{{ app_name }}"` | | |
| 46 | +| `navbar.html` ligne 10 | `<span>RustWarden</span>` -> `<span>{{ app_name }}</span>` | | |
| 47 | +| `login.html` ligne 61 | `<h1>RustWarden</h1>` -> `<h1>{{ app_name }}</h1>` | | |
| 48 | +| `login.html` ligne 59 | `alt="RustWarden Logo"` -> `alt="{{ app_name }}"` | | |
| 49 | +| `base.html` ligne 20 | fallback meta : `RustWarden - web boilerplate...` -> `{{ app_name }}` | | |
| 50 | +| `base.html` ligne 5 | `lang="fr"` -> `lang="{{ current_locale }}"` (bonus) | | |
| 51 | +| `errors/401.html` titre | `- RustWarden` -> texte neutre | | |
| 52 | +| `errors/403.html` titre | `- RustWarden` -> texte neutre | | |
| 53 | +| `errors/500.html` titre | `- RustWarden` -> texte neutre | | |
| 54 | +| `email_verified.html` | `- RustWarden` -> texte neutre | | |
| 55 | + | |
| 56 | +## Phase 3 : Wizard -- remplacement automatique du branding | |
| 57 | + | |
| 58 | +Apres la copie des crates dans le projet genere, le wizard remplace "RustWarden" par le nom de l'application dans tous les fichiers `.html` copies. | |
| 59 | + | |
| 60 | +**Changements dans** [tools/rustwarden-cli/src/start_project.rs](tools/rustwarden-cli/src/start_project.rs) : | |
| 61 | + | |
| 62 | +- Ajouter une question "Nom de l'application" (affiche dans l'UI, different du nom technique du crate) | |
| 63 | +- Ajouter `APP_NAME` dans le `.env` genere | |
| 64 | +- Ajouter une fonction `rebrand_templates()` qui parcourt `crates/rustwarden-ui/templates/` et `crates/rustwarden-core/templates/` dans le projet genere et remplace les occurrences restantes | |
| 65 | + | |
| 66 | +**Changements dans** [tools/rustwarden-cli/src/templates.rs](tools/rustwarden-cli/src/templates.rs) : | |
| 67 | + | |
| 68 | +- Ajouter `app_display_name` a `ProjectConfig` | |
| 69 | +- Ajouter `APP_NAME=<nom>` dans `dot_env()` | |
| 70 | + | |
| 71 | +## Phase 4 : Page d'accueil personnalisee | |
| 72 | + | |
| 73 | +La page `/home` actuelle affiche un dashboard Rustwarden (stats du compte). Le wizard genere une page d'accueil neutre pour l'application, qui ecrase le `home.html` copie. | |
| 74 | + | |
| 75 | +**Nouveau template** genere par le wizard dans `<projet>/crates/rustwarden-ui/templates/home.html` : | |
| 76 | + | |
| 77 | +```html | |
| 78 | +{% extends "base.html" %} | |
| 79 | +{% block navbar %}{% include "components/navbar.html" %}{% endblock %} | |
| 80 | +{% block content %} | |
| 81 | +<div class="hero min-h-[60vh]"> | |
| 82 | + <div class="hero-content text-center"> | |
| 83 | + <div class="max-w-md"> | |
| 84 | + <h1 class="text-5xl font-bold">{{ welcome_message }}</h1> | |
| 85 | + <p class="py-6">{{ home_subtitle }}</p> | |
| 86 | + </div> | |
| 87 | + </div> | |
| 88 | +</div> | |
| 89 | +{% endblock %} | |
| 90 | +``` | |
| 91 | + | |
| 92 | +Le handler `home_handler.rs` continue de fonctionner car les variables du template (`welcome_message`, `home_subtitle`, etc.) sont deja presentes -- on simplifie juste le HTML. | |
| 93 | + | |
| 94 | +## Phase 5 : Documentation developpeur | |
| 95 | + | |
| 96 | +Generer un `DEVELOPER.md` dans le projet cree par le wizard. Contenu : | |
| 97 | + | |
| 98 | +- **Architecture** : structure du projet, role de chaque crate | |
| 99 | +- **Ajouter des routes** : exemple complet avec handler Axum | |
| 100 | +- **Ajouter un modele** : creer une entite SeaORM, migration, service | |
| 101 | +- **Personnaliser les templates** : ou sont les fichiers, comment les modifier | |
| 102 | +- **Utiliser l'authentification** : Claims, middleware, roles/permissions | |
| 103 | +- **Hooks** : intercepter login, register, logout, etc. | |
| 104 | +- **Configuration** : toutes les variables d'env expliquees | |
| 105 | +- **Commandes utiles** : migrate, seed, hash_password | |
| 106 | + | |
| 107 | +Ce fichier est genere par le wizard dans `templates.rs` via une fonction `developer_md()`. |
A
.cursor/plans/mise_a_jour_dependances_e361580b.plan.md
+140
-0
@@ -0,0 +1,140 @@
| 1 | +--- | |
| 2 | +name: Mise a jour dependances | |
| 3 | +overview: Mettre a jour les dependances de Rustwarden vers leurs dernieres versions stables, en gerant les breaking changes API pour chaque crate concernee. | |
| 4 | +todos: | |
| 5 | + - id: check-rust-version | |
| 6 | + content: Verifier la version de Rust installee (>= 1.88 requis pour askama 0.15) | |
| 7 | + status: completed | |
| 8 | + - id: phase1-thiserror | |
| 9 | + content: Mettre a jour thiserror 1.0 -> 2.0 dans Cargo.toml | |
| 10 | + status: completed | |
| 11 | + - id: phase1-bcrypt | |
| 12 | + content: Mettre a jour bcrypt 0.15 -> 0.19 dans Cargo.toml | |
| 13 | + status: completed | |
| 14 | + - id: phase2-askama | |
| 15 | + content: "Mettre a jour askama 0.14 -> 0.15 + ajouter #[askama::filter_fn] aux filtres custom" | |
| 16 | + status: completed | |
| 17 | + - id: phase2-jsonwebtoken | |
| 18 | + content: Mettre a jour jsonwebtoken 9 -> 10 avec feature rust_crypto + adapter jwt.rs | |
| 19 | + status: completed | |
| 20 | + - id: phase2-rand | |
| 21 | + content: Mettre a jour rand 0.8 -> 0.9 + renommer API dans 4 fichiers | |
| 22 | + status: completed | |
| 23 | + - id: phase2-axum-extra | |
| 24 | + content: Mettre a jour axum-extra 0.9 -> 0.12 + verifier compatibilite cookie API | |
| 25 | + status: completed | |
| 26 | + - id: phase3-verify | |
| 27 | + content: cargo check + cargo test pour valider toutes les mises a jour | |
| 28 | + status: completed | |
| 29 | + - id: cargo-update-patches | |
| 30 | + content: cargo update pour les versions patch (cookie-factory, generic-array, matchit) | |
| 31 | + status: completed | |
| 32 | +isProject: false | |
| 33 | +--- | |
| 34 | + | |
| 35 | +# Mise a jour des dependances Rustwarden | |
| 36 | + | |
| 37 | +## Analyse des risques | |
| 38 | + | |
| 39 | +Apres recherche, les mises a jour se repartissent en 3 categories : | |
| 40 | + | |
| 41 | +### Faisables (6 crates) | |
| 42 | + | |
| 43 | + | |
| 44 | +| Crate | Actuel | Cible | Risque | Breaking changes | | |
| 45 | +| ------------ | ------ | ----- | ------ | -------------------------------------------------------------------------------------------- | | |
| 46 | +| thiserror | 1.0 | 2.0 | Faible | Aucun impact (usage simple `{0}` dans `error.rs`) | | |
| 47 | +| bcrypt | 0.15 | 0.19 | Faible | API `hash`/`verify`/`DEFAULT_COST` inchangee | | |
| 48 | +| askama | 0.14 | 0.15 | Moyen | Filtres custom doivent ajouter `#[askama::filter_fn]` (2 filtres dans `settings_handler.rs`) | | |
| 49 | +| jsonwebtoken | 9.3 | 10.3 | Moyen | Ajout feature `rust_crypto`, backend crypto via traits | | |
| 50 | +| rand | 0.8 | 0.9 | Moyen | `thread_rng()` -> `rng()`, `gen_range()` -> `random_range()` (4 fichiers) | | |
| 51 | +| axum-extra | 0.9 | 0.12 | Moyen | Feature `cookie` toujours presente, `cookie` dep mise a jour vers 0.18 | | |
| 52 | + | |
| 53 | + | |
| 54 | +### Bloques (2 crates) | |
| 55 | + | |
| 56 | + | |
| 57 | +| Crate | Actuel | Cible | Bloqueur | | |
| 58 | +| ------------------------------ | ------ | ----- | ----------------------------------------------------------------------------------- | | |
| 59 | +| **tower-sessions** | 0.14 | 0.15 | `axum-login 0.18` exige `tower-sessions ^0.14` -- pas de version compatible publiee | | |
| 60 | +| **tower-sessions-redis-store** | 0.14 | 0.16 | Depend de tower-sessions, donc bloque aussi | | |
| 61 | + | |
| 62 | + | |
| 63 | +### Prerequis | |
| 64 | + | |
| 65 | +- **Rust >= 1.88** requis par askama 0.15. A verifier avant de commencer. | |
| 66 | + | |
| 67 | +--- | |
| 68 | + | |
| 69 | +## Phase 1 -- Risque faible | |
| 70 | + | |
| 71 | +### 1.1 thiserror 1.0 -> 2.0 | |
| 72 | + | |
| 73 | +- Modifier `"1.0"` -> `"2.0"` dans [crates/rustwarden-core/Cargo.toml](crates/rustwarden-core/Cargo.toml) | |
| 74 | +- Le fichier [crates/rustwarden-core/src/error.rs](crates/rustwarden-core/src/error.rs) n'utilise que des patterns `{0}` simples et un seul `#[from]` -- aucune modification de code necessaire. | |
| 75 | + | |
| 76 | +### 1.2 bcrypt 0.15 -> 0.19 | |
| 77 | + | |
| 78 | +- Modifier `"0.15"` -> `"0.19"` dans [crates/rustwarden-core/Cargo.toml](crates/rustwarden-core/Cargo.toml) | |
| 79 | +- API `hash()`, `verify()`, `DEFAULT_COST` inchangee -- aucune modification de code. | |
| 80 | + | |
| 81 | +--- | |
| 82 | + | |
| 83 | +## Phase 2 -- Risque moyen | |
| 84 | + | |
| 85 | +### 2.1 askama 0.14 -> 0.15 | |
| 86 | + | |
| 87 | +- Modifier `"0.14"` -> `"0.15"` dans `rustwarden-core/Cargo.toml` et `rustwarden-ui/Cargo.toml` | |
| 88 | +- Ajouter `#[askama::filter_fn]` aux 2 filtres custom dans [crates/rustwarden-ui/src/handlers/settings_handler.rs](crates/rustwarden-ui/src/handlers/settings_handler.rs) (filtres `default` et `length`, lignes ~570-589) | |
| 89 | +- Verifier la compatibilite du trait `askama::Values` (devrait rester identique) | |
| 90 | +- Les 23+ templates `#[derive(Template)]` n'ont pas besoin de changement. | |
| 91 | + | |
| 92 | +### 2.2 jsonwebtoken 9.3 -> 10.3 | |
| 93 | + | |
| 94 | +- Modifier dans [crates/rustwarden-core/Cargo.toml](crates/rustwarden-core/Cargo.toml) : | |
| 95 | + | |
| 96 | +```toml | |
| 97 | + jsonwebtoken = { version = "10", features = ["rust_crypto"] } | |
| 98 | + | |
| 99 | + | |
| 100 | +``` | |
| 101 | + | |
| 102 | + Le feature `rust_crypto` fournit HMAC/SHA (utilise pour HS256 dans le projet). | |
| 103 | + | |
| 104 | +- Adapter [crates/rustwarden-core/src/auth/jwt.rs](crates/rustwarden-core/src/auth/jwt.rs) : | |
| 105 | + - `encode()` et `decode()` pourraient avoir une signature legerement differente (crypto provider) | |
| 106 | + - `Header::default()` et `Validation::default()` a verifier | |
| 107 | + - `jsonwebtoken::errors::ErrorKind::ExpiredSignature` a verifier | |
| 108 | + | |
| 109 | +### 2.3 rand 0.8 -> 0.9 | |
| 110 | + | |
| 111 | +- Modifier `"0.8"` -> `"0.9"` dans `rustwarden-core/Cargo.toml` et `rustwarden-cli/Cargo.toml` | |
| 112 | +- 4 fichiers a adapter (remplacement API renommee) : | |
| 113 | + - `core/src/services/password_service.rs` : `thread_rng()` -> `rng()`, `gen_range()` -> `random_range()` | |
| 114 | + - `core/src/services/refresh_token_service.rs` : `thread_rng()` -> `rng()` | |
| 115 | + - `core/src/middleware/csrf.rs` : `thread_rng()` -> `rng()` | |
| 116 | + - `tools/rustwarden-cli/src/start_project.rs` : `thread_rng()` -> `rng()`, `gen_range()` -> `random_range()` | |
| 117 | +- Note : les anciennes methodes restent disponibles (deprecated) mais on appliquera les nouveaux noms pour etre propre. | |
| 118 | + | |
| 119 | +### 2.4 axum-extra 0.9 -> 0.12 | |
| 120 | + | |
| 121 | +- Modifier `"0.9"` -> `"0.12"` dans `rustwarden-core/Cargo.toml` et `rustwarden-ui/Cargo.toml` | |
| 122 | +- Le feature `cookie` est toujours disponible. | |
| 123 | +- Verifier la compatibilite de `Cookie::build()` et `SameSite` avec la nouvelle dep cookie 0.18. | |
| 124 | +- 8 fichiers utilisent `CookieJar` / `Cookie::build` -- adapter si l'API a change. | |
| 125 | + | |
| 126 | +--- | |
| 127 | + | |
| 128 | +## Phase 3 -- Verification | |
| 129 | + | |
| 130 | +- `cargo check` sur tout le workspace | |
| 131 | +- `cargo test` pour valider les 7 tests email + les autres | |
| 132 | +- Corriger les eventuelles erreurs de compilation | |
| 133 | + | |
| 134 | +--- | |
| 135 | + | |
| 136 | +## Non traite (bloque) | |
| 137 | + | |
| 138 | +- `tower-sessions 0.14 -> 0.15` et `tower-sessions-redis-store 0.14 -> 0.16` : **impossible** tant que `axum-login` ne publie pas une version compatible avec `tower-sessions 0.15`. Le crate `tower-sessions-seaorm-store` n'est de toute facon pas utilise (commentaire dans le code). A surveiller pour une future mise a jour. | |
| 139 | +- `cookie-factory`, `generic-array`, `matchit` : versions mineures/patch, mises a jour automatiquement par Cargo lors du `cargo update`. | |
| 140 | + |
A
.cursor/plans/oauth2_framework_integration_90234caf.plan.md
+467
-0
@@ -0,0 +1,467 @@
| 1 | +--- | |
| 2 | +name: OAuth2 framework integration | |
| 3 | +overview: Securiser le framework existant (audit Phase 0) puis ajouter le support OAuth2/OIDC (Google, GitHub, Discord, Microsoft) activable par fournisseur via admin settings. | |
| 4 | +todos: | |
| 5 | + - id: sec-jwt-blacklist | |
| 6 | + content: "C1 CRITICAL : Ajouter verification blacklist JWT dans AuthUser::from_request_parts" | |
| 7 | + status: completed | |
| 8 | + - id: sec-rate-limit | |
| 9 | + content: "C2 CRITICAL : Appliquer RateLimitLayer sur login, register, refresh, forgot-password, OAuth callback" | |
| 10 | + status: completed | |
| 11 | + - id: sec-csrf-fix | |
| 12 | + content: "C3 CRITICAL : Rejeter les requetes POST/PUT/DELETE sans token CSRF (sauf routes API Bearer)" | |
| 13 | + status: completed | |
| 14 | + - id: sec-password-complexity | |
| 15 | + content: "H1 HIGH : Appeler validate_password_complexity dans register() et reset_password()" | |
| 16 | + status: completed | |
| 17 | + - id: sec-log-password | |
| 18 | + content: "H2 HIGH : Supprimer le log du mot de passe par defaut dans user_handler.rs" | |
| 19 | + status: completed | |
| 20 | + - id: sec-users-api-admin | |
| 21 | + content: "H3 HIGH : Proteger /api/v1/users avec AdminClaims au lieu de AuthUser" | |
| 22 | + status: completed | |
| 23 | + - id: sec-csrf-timing | |
| 24 | + content: "H4 HIGH : Utiliser comparaison constant-time pour les tokens CSRF" | |
| 25 | + status: completed | |
| 26 | + - id: sec-session-fixation | |
| 27 | + content: "M1 MEDIUM : Appeler session.cycle() apres login reussi" | |
| 28 | + status: completed | |
| 29 | + - id: sec-cookie-secure | |
| 30 | + content: "M3 MEDIUM : Forcer COOKIE_SECURE=true si APP_DEBUG=false" | |
| 31 | + status: completed | |
| 32 | + - id: sec-refresh-hash | |
| 33 | + content: "L1 LOW : Hasher les refresh tokens avec SHA-256 avant stockage" | |
| 34 | + status: completed | |
| 35 | + - id: sec-open-redirect | |
| 36 | + content: "L2 LOW : Valider le parametre redirect au login (doit commencer par /)" | |
| 37 | + status: completed | |
| 38 | + - id: sec-verify | |
| 39 | + content: "Phase 0 : cargo check + cargo test apres corrections securite" | |
| 40 | + status: completed | |
| 41 | + - id: oauth-dep | |
| 42 | + content: Ajouter openidconnect + reqwest en dependances optionnelles avec feature flag 'oauth' | |
| 43 | + status: completed | |
| 44 | + - id: oauth-migration | |
| 45 | + content: Migration SeaORM pour la table oauth_accounts | |
| 46 | + status: completed | |
| 47 | + - id: oauth-model | |
| 48 | + content: Modele SeaORM oauth_account.rs + declaration dans mod.rs | |
| 49 | + status: completed | |
| 50 | + - id: oauth-trait | |
| 51 | + content: Trait OAuthProvider + struct ProviderUserInfo dans services/oauth/provider_trait.rs | |
| 52 | + status: completed | |
| 53 | + - id: oauth-providers | |
| 54 | + content: Implementer GoogleProvider, GitHubProvider, DiscordProvider, MicrosoftProvider + registry | |
| 55 | + status: completed | |
| 56 | + - id: oauth-config | |
| 57 | + content: "config/oauth.rs : OAuthConfig charge depuis env vars avec pattern OAUTH_{NAME}_*" | |
| 58 | + status: completed | |
| 59 | + - id: oauth-settings-db | |
| 60 | + content: Ajouter les cles oauth_* dans initialize_default_settings | |
| 61 | + status: completed | |
| 62 | + - id: oauth-service | |
| 63 | + content: "OAuthService : utilise le trait OAuthProvider pour build_client, auth_url, exchange, userinfo, find_or_create_user" | |
| 64 | + status: completed | |
| 65 | + - id: oauth-handlers | |
| 66 | + content: "auth/oauth_handlers.rs : oauth_redirect + oauth_callback avec PKCE et state CSRF" | |
| 67 | + status: completed | |
| 68 | + - id: oauth-hook | |
| 69 | + content: Ajouter on_oauth_login dans RustwardenHooks | |
| 70 | + status: completed | |
| 71 | + - id: oauth-login-ui | |
| 72 | + content: Boutons OAuth conditionnels sur la page login + icones SVG | |
| 73 | + status: in_progress | |
| 74 | + - id: oauth-admin-ui | |
| 75 | + content: Section OAuth dans la page admin settings (toggles par provider) | |
| 76 | + status: pending | |
| 77 | + - id: oauth-profile-ui | |
| 78 | + content: Section comptes lies dans la page profil utilisateur | |
| 79 | + status: pending | |
| 80 | + - id: oauth-routes | |
| 81 | + content: Enregistrer les routes OAuth dans le routeur (conditionne par feature) | |
| 82 | + status: completed | |
| 83 | + - id: oauth-docs | |
| 84 | + content: Documenter OAuth dans DEVELOPER.md + mettre a jour le wizard CLI | |
| 85 | + status: pending | |
| 86 | + - id: oauth-verify | |
| 87 | + content: "Phase finale : cargo check + cargo test + revue securite OAuth" | |
| 88 | + status: pending | |
| 89 | +isProject: false | |
| 90 | +--- | |
| 91 | + | |
| 92 | +# Securisation + Integration OAuth2/OIDC dans Rustwarden | |
| 93 | + | |
| 94 | +--- | |
| 95 | + | |
| 96 | +## Phase 0 -- Securisation du framework (audit) | |
| 97 | + | |
| 98 | +Corrections des vulnerabilites identifiees par l'audit de securite, classees par severite. | |
| 99 | + | |
| 100 | +### 0.1 [CRITICAL] C1 -- Blacklist JWT dans `AuthUser` extracteur | |
| 101 | + | |
| 102 | +**Fichier** : [crates/rustwarden-core/src/auth/extractors.rs](crates/rustwarden-core/src/auth/extractors.rs) | |
| 103 | + | |
| 104 | +**Probleme** : `AuthUser::from_request_parts` valide le JWT (signature, expiration, issuer) mais ne verifie jamais si le token est blackliste. Un token revoque au logout reste valide jusqu'a expiration (15 min). | |
| 105 | + | |
| 106 | +**Correction** : L'extracteur n'a pas acces a la DB (trait `FromRequestParts<S>` sans contrainte sur S). Deux approches possibles : | |
| 107 | + | |
| 108 | +- **Option A** (recommandee) : Changer `AuthUser` pour extraire la DB depuis `State<DatabaseConnection>` en ajoutant la contrainte `S: FromRef<DatabaseConnection>`, puis appeler `JwtBlacklistService::is_blacklisted()`. | |
| 109 | +- **Option B** : Ajouter `/settings/`* et `/api/v1/auth/me` dans les routes protegees par `AuthService` middleware (qui verifie deja la blacklist). | |
| 110 | + | |
| 111 | +On choisit **Option B** car moins invasive : etendre la liste `protected_routes` dans `auth_service.rs` pour couvrir toutes les routes authentifiees. Adopter un pattern "proteger tout sauf les routes publiques" : | |
| 112 | + | |
| 113 | +```rust | |
| 114 | +let public_routes = ["/login", "/health", "/static/", "/api/v1/auth/login", | |
| 115 | + "/api/v1/auth/register", "/api/v1/auth/forgot-password", | |
| 116 | + "/api/v1/auth/reset-password", "/api/v1/auth/verify-email", | |
| 117 | + "/api/v1/auth/resend-verification", "/auth/oauth/"]; | |
| 118 | +let is_public = public_routes.iter().any(|r| path.starts_with(r)); | |
| 119 | +// Si PAS public -> verifier JWT + blacklist | |
| 120 | +``` | |
| 121 | + | |
| 122 | +### 0.2 [CRITICAL] C2 -- Rate limiting sur les endpoints d'authentification | |
| 123 | + | |
| 124 | +**Fichiers** : [crates/rustwarden-core/src/app.rs](crates/rustwarden-core/src/app.rs) + [crates/rustwarden-ui/src/routes.rs](crates/rustwarden-ui/src/routes.rs) | |
| 125 | + | |
| 126 | +**Probleme** : Les valeurs de rate limiting sont chargees en config (`RATE_LIMIT_LOGIN_PER_MINUTE=5`) mais jamais appliquees. Brute-force illimite possible. | |
| 127 | + | |
| 128 | +**Correction** : Appliquer `tower::limit::RateLimitLayer` sur les routes sensibles. Creer un helper dans `middleware/rate_limit.rs` : | |
| 129 | + | |
| 130 | +```rust | |
| 131 | +pub fn auth_rate_limit() -> RateLimitLayer { | |
| 132 | + let config = AuthConfig::from_env().unwrap_or_default(); | |
| 133 | + RateLimitLayer::new(config.rate_limit_login_per_minute as u64, Duration::from_secs(60)) | |
| 134 | +} | |
| 135 | +``` | |
| 136 | + | |
| 137 | +Appliquer sur : `/api/v1/auth/login`, `/api/v1/auth/register`, `/api/v1/auth/refresh`, `/api/v1/auth/forgot-password`, et les callbacks OAuth. | |
| 138 | + | |
| 139 | +### 0.3 [CRITICAL] C3 -- CSRF : rejeter les requetes sans token | |
| 140 | + | |
| 141 | +**Fichier** : [crates/rustwarden-core/src/middleware/csrf.rs](crates/rustwarden-core/src/middleware/csrf.rs) | |
| 142 | + | |
| 143 | +**Probleme** : Le middleware laisse passer les requetes POST/PUT/DELETE sans token CSRF (`None => Ok(next.run(request).await)`). Les handlers n'appellent jamais `validate_csrf_token()`. | |
| 144 | + | |
| 145 | +**Correction** en 2 etapes : | |
| 146 | + | |
| 147 | +1. **Middleware CSRF** : rejeter les requetes sans token, SAUF si elles portent un header `Authorization: Bearer` (les API tokens sont immunisees contre CSRF) ou si la route est dans une liste d'exclusion (`/api/v1/auth/login`, `/api/v1/auth/register`, etc.) : | |
| 148 | + | |
| 149 | +```rust | |
| 150 | +None => { | |
| 151 | + // Bearer tokens are CSRF-immune | |
| 152 | + if request.headers().contains_key("authorization") { | |
| 153 | + return Ok(next.run(request).await); | |
| 154 | + } | |
| 155 | + // API JSON endpoints with no cookie auth are also safe | |
| 156 | + if path.starts_with("/api/") && !has_jwt_cookie { | |
| 157 | + return Ok(next.run(request).await); | |
| 158 | + } | |
| 159 | + Ok((StatusCode::FORBIDDEN, "Missing CSRF token").into_response()) | |
| 160 | +} | |
| 161 | +``` | |
| 162 | + | |
| 163 | +1. **Monter le middleware** dans le `ServiceBuilder` de `app.rs`. | |
| 164 | + | |
| 165 | +### 0.4 [HIGH] H1 -- Complexite mot de passe a l'inscription et au reset | |
| 166 | + | |
| 167 | +**Fichiers** : [crates/rustwarden-core/src/auth/handlers.rs](crates/rustwarden-core/src/auth/handlers.rs) (register), [crates/rustwarden-core/src/services/password_reset_service.rs](crates/rustwarden-core/src/services/password_reset_service.rs) | |
| 168 | + | |
| 169 | +**Correction** : Ajouter avant le hash dans `register()` et `reset_password()` : | |
| 170 | + | |
| 171 | +```rust | |
| 172 | +let rules = PasswordRules::from_db(&db).await; | |
| 173 | +if let Err(errors) = rules.validate(&password) { | |
| 174 | + return Err(AppError::Validation(errors.join(", "))); | |
| 175 | +} | |
| 176 | +``` | |
| 177 | + | |
| 178 | +### 0.5 [HIGH] H2 -- Supprimer le log du mot de passe | |
| 179 | + | |
| 180 | +**Fichier** : [crates/rustwarden-ui/src/handlers/user_handler.rs](crates/rustwarden-ui/src/handlers/user_handler.rs) ligne 93 | |
| 181 | + | |
| 182 | +**Correction** : Remplacer `tracing::info!("Generated default password for user '{}': {}", ...)` par `tracing::info!("Generated default password for user '{}'", payload.username)` -- ne jamais logger le mot de passe. | |
| 183 | + | |
| 184 | +### 0.6 [HIGH] H3 -- Proteger `/api/v1/users` avec verification admin | |
| 185 | + | |
| 186 | +**Fichier** : [crates/rustwarden-ui/src/handlers/user_handler.rs](crates/rustwarden-ui/src/handlers/user_handler.rs) | |
| 187 | + | |
| 188 | +**Correction** : Remplacer `_auth_user: AuthUser` par `_admin: AdminClaims` dans `create_user()` et `list_users()`, ou ajouter une verification de permission explicite. | |
| 189 | + | |
| 190 | +### 0.7 [HIGH] H4 -- Comparaison CSRF constant-time | |
| 191 | + | |
| 192 | +**Fichier** : [crates/rustwarden-core/src/middleware/csrf.rs](crates/rustwarden-core/src/middleware/csrf.rs) | |
| 193 | + | |
| 194 | +**Correction** : Ajouter `subtle = "2"` dans les dependances et remplacer : | |
| 195 | + | |
| 196 | +```rust | |
| 197 | +// Avant | |
| 198 | +if token != session_token { | |
| 199 | +// Apres | |
| 200 | +use subtle::ConstantTimeEq; | |
| 201 | +if token.as_bytes().ct_eq(session_token.as_bytes()).unwrap_u8() != 1 { | |
| 202 | +``` | |
| 203 | + | |
| 204 | +### 0.8 [MEDIUM] M1 -- Protection session fixation | |
| 205 | + | |
| 206 | +**Fichier** : [crates/rustwarden-core/src/auth/handlers.rs](crates/rustwarden-core/src/auth/handlers.rs) (login) | |
| 207 | + | |
| 208 | +**Correction** : Apres l'authentification reussie et avant de generer le JWT, appeler `session.cycle().await` pour regenerer l'ID de session. Meme chose dans le callback OAuth. | |
| 209 | + | |
| 210 | +### 0.9 [MEDIUM] M3 -- COOKIE_SECURE en production | |
| 211 | + | |
| 212 | +**Fichier** : [crates/rustwarden-core/src/config/auth.rs](crates/rustwarden-core/src/config/auth.rs) | |
| 213 | + | |
| 214 | +**Correction** : Si `APP_DEBUG` est absent ou `false`, forcer `cookie_secure = true` et emettre un `tracing::error!` si l'operateur tente de le mettre a `false` hors mode debug. | |
| 215 | + | |
| 216 | +### 0.10 [LOW] L1 -- Hasher les refresh tokens | |
| 217 | + | |
| 218 | +**Fichier** : [crates/rustwarden-core/src/services/refresh_token_service.rs](crates/rustwarden-core/src/services/refresh_token_service.rs) | |
| 219 | + | |
| 220 | +**Correction** : Stocker `SHA-256(token)` en base, retourner le token brut au client. Lors de la validation, comparer `SHA-256(token_recu)` avec la valeur en base. Identique au pattern deja utilise pour les password reset tokens. | |
| 221 | + | |
| 222 | +### 0.11 [LOW] L2 -- Validation du parametre redirect | |
| 223 | + | |
| 224 | +**Fichier** : [crates/rustwarden-core/src/middleware/auth_required.rs](crates/rustwarden-core/src/middleware/auth_required.rs) + login handler JS | |
| 225 | + | |
| 226 | +**Correction** : Valider que `redirect` commence par `/` et ne contient pas `//` ni de schema (`http://`, `javascript:`). | |
| 227 | + | |
| 228 | +### 0.12 Verification Phase 0 | |
| 229 | + | |
| 230 | +`cargo check` + `cargo test` pour valider que toutes les corrections compilent et ne cassent pas les tests existants. | |
| 231 | + | |
| 232 | +--- | |
| 233 | + | |
| 234 | +## Phase 1 -- Dependances et modele de donnees OAuth | |
| 235 | + | |
| 236 | +### 1.1 Dependances | |
| 237 | + | |
| 238 | +```toml | |
| 239 | +# crates/rustwarden-core/Cargo.toml | |
| 240 | +openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"], optional = true } | |
| 241 | +reqwest = { version = "0.13", features = ["json"], optional = true } | |
| 242 | + | |
| 243 | +[features] | |
| 244 | +oauth = ["openidconnect", "reqwest"] | |
| 245 | +``` | |
| 246 | + | |
| 247 | +### 1.2 Migration : table `oauth_accounts` | |
| 248 | + | |
| 249 | +Nouvelle migration `m20260310_000005_create_oauth_accounts_table.rs` : | |
| 250 | + | |
| 251 | +- `**oauth_accounts**` : lien entre un user et ses comptes OAuth | |
| 252 | + - `id` UUID PK | |
| 253 | + - `user_id` UUID FK -> users (ON DELETE CASCADE) | |
| 254 | + - `provider` String (google, github, discord, microsoft) | |
| 255 | + - `provider_user_id` String (ID unique chez le fournisseur) | |
| 256 | + - `provider_email` String | |
| 257 | + - `provider_username` Option String | |
| 258 | + - `avatar_url` Option String | |
| 259 | + - `created_at` DateTime | |
| 260 | + - `updated_at` DateTime | |
| 261 | + - Index unique sur (`provider`, `provider_user_id`) -- empeche les doublons | |
| 262 | + - Index unique sur (`provider`, `user_id`) -- un seul compte par provider par user | |
| 263 | + - Index sur `user_id` | |
| 264 | + | |
| 265 | +Note securite : pas de stockage des access/refresh tokens OAuth. Si besoin futur, ils devront etre chiffres (AES-256-GCM) avec une cle dediee. | |
| 266 | + | |
| 267 | +### 1.3 Modele SeaORM `oauth_account.rs` | |
| 268 | + | |
| 269 | +Nouveau fichier dans [crates/rustwarden-core/src/models/](crates/rustwarden-core/src/models/). | |
| 270 | + | |
| 271 | +### 1.4 Settings admin en DB | |
| 272 | + | |
| 273 | +Nouvelles cles dans `app_settings` (initialisees via `initialize_default_settings`) : | |
| 274 | + | |
| 275 | +- `oauth_enabled` : `"false"` (toggle global) | |
| 276 | +- `oauth_google_enabled` : `"false"` | |
| 277 | +- `oauth_github_enabled` : `"false"` | |
| 278 | +- `oauth_discord_enabled` : `"false"` | |
| 279 | +- `oauth_microsoft_enabled` : `"false"` | |
| 280 | +- `oauth_auto_register` : `"true"` (creer un compte si l'email n'existe pas) | |
| 281 | +- `oauth_link_existing_account` : `"true"` (lier automatiquement si meme email verifie) | |
| 282 | + | |
| 283 | +--- | |
| 284 | + | |
| 285 | +## Phase 2 -- Architecture modulaire providers | |
| 286 | + | |
| 287 | +### 2.1 Trait `OAuthProvider` et structure | |
| 288 | + | |
| 289 | +``` | |
| 290 | +services/oauth/ | |
| 291 | + mod.rs -- re-exports + OAuthService | |
| 292 | + provider_trait.rs -- trait OAuthProvider + ProviderUserInfo | |
| 293 | + providers/ | |
| 294 | + mod.rs -- registry des providers built-in | |
| 295 | + google.rs -- impl OAuthProvider for GoogleProvider (OIDC) | |
| 296 | + github.rs -- impl OAuthProvider for GitHubProvider (OAuth2 pur) | |
| 297 | + discord.rs -- impl OAuthProvider for DiscordProvider (OAuth2 pur) | |
| 298 | + microsoft.rs -- impl OAuthProvider for MicrosoftProvider (OIDC) | |
| 299 | +``` | |
| 300 | + | |
| 301 | +**Trait** : | |
| 302 | + | |
| 303 | +```rust | |
| 304 | +pub struct ProviderUserInfo { | |
| 305 | + pub provider_user_id: String, | |
| 306 | + pub email: String, | |
| 307 | + pub email_verified: bool, // SECURITE : le fournisseur confirme-t-il l'email ? | |
| 308 | + pub username: Option<String>, | |
| 309 | + pub display_name: Option<String>, | |
| 310 | + pub avatar_url: Option<String>, | |
| 311 | +} | |
| 312 | + | |
| 313 | +#[async_trait] | |
| 314 | +pub trait OAuthProvider: Send + Sync { | |
| 315 | + fn name(&self) -> &str; | |
| 316 | + fn display_name(&self) -> &str; | |
| 317 | + fn icon_path(&self) -> &str; | |
| 318 | + fn scopes(&self) -> Vec<String>; | |
| 319 | + fn supports_oidc(&self) -> bool; | |
| 320 | + fn discovery_url(&self) -> Option<String>; | |
| 321 | + fn auth_url(&self) -> String; | |
| 322 | + fn token_url(&self) -> String; | |
| 323 | + fn userinfo_url(&self) -> String; | |
| 324 | + async fn parse_user_info(&self, json: serde_json::Value) -> Result<ProviderUserInfo, AppError>; | |
| 325 | +} | |
| 326 | +``` | |
| 327 | + | |
| 328 | +### 2.2 Configuration (`config/oauth.rs`) | |
| 329 | + | |
| 330 | +```rust | |
| 331 | +pub struct OAuthProviderConfig { | |
| 332 | + pub client_id: String, | |
| 333 | + pub client_secret: String, | |
| 334 | +} | |
| 335 | + | |
| 336 | +pub struct OAuthConfig { | |
| 337 | + pub providers: HashMap<String, OAuthProviderConfig>, | |
| 338 | +} | |
| 339 | +``` | |
| 340 | + | |
| 341 | +Pattern env vars : `OAUTH_{NAME}_CLIENT_ID` / `OAUTH_{NAME}_CLIENT_SECRET`. | |
| 342 | + | |
| 343 | +--- | |
| 344 | + | |
| 345 | +## Phase 3 -- Service OAuth et handlers | |
| 346 | + | |
| 347 | +### 3.1 `OAuthService` | |
| 348 | + | |
| 349 | +- `get_enabled_providers(db)` -> liste des providers actifs (DB toggle + env vars presents) | |
| 350 | +- `build_oidc_client(provider)` -> discovery OIDC pour Google/Microsoft | |
| 351 | +- `build_oauth2_client(provider)` -> OAuth2 pur pour GitHub/Discord | |
| 352 | +- `generate_auth_url(provider, session)` -> PKCE + state + nonce OIDC, stockes en session | |
| 353 | +- `exchange_code(provider, code, pkce_verifier)` -> echange code, valide ID token si OIDC | |
| 354 | +- `fetch_user_info(provider, access_token)` -> appel userinfo | |
| 355 | +- `find_or_create_user(db, provider, user_info)` -> logique de liaison/creation (voir securite ci-dessous) | |
| 356 | +- `link_oauth_account(db, user_id, provider, info)` -> insert dans `oauth_accounts` | |
| 357 | + | |
| 358 | +### 3.2 Handlers OAuth (`auth/oauth_handlers.rs`) | |
| 359 | + | |
| 360 | +- `GET /auth/oauth/{provider}` -- initie le flux | |
| 361 | +- `GET /auth/oauth/{provider}/callback` -- callback | |
| 362 | + | |
| 363 | +### 3.3 Hook OAuth | |
| 364 | + | |
| 365 | +```rust | |
| 366 | +async fn on_oauth_login(&self, _db, _user_id, _provider, _is_new_account) -> anyhow::Result<()> { | |
| 367 | + Ok(()) | |
| 368 | +} | |
| 369 | +``` | |
| 370 | + | |
| 371 | +--- | |
| 372 | + | |
| 373 | +## Phase 4 -- UI | |
| 374 | + | |
| 375 | +### 4.1 Boutons OAuth sur la page login | |
| 376 | + | |
| 377 | +Boutons conditionnels generes dynamiquement depuis la liste des providers actifs. Le handler `login_page` charge les providers et les passe au template. | |
| 378 | + | |
| 379 | +### 4.2 Section OAuth dans admin settings | |
| 380 | + | |
| 381 | +4eme carte : toggle global + toggles par fournisseur + statut (configure/non configure) + auto-register + link existing. | |
| 382 | + | |
| 383 | +### 4.3 Section comptes lies dans le profil | |
| 384 | + | |
| 385 | +Liste des comptes OAuth associes, possibilite de dissocier (seulement si l'utilisateur a un mot de passe local defini). | |
| 386 | + | |
| 387 | +### 4.4 Icones SVG | |
| 388 | + | |
| 389 | +`google.svg`, `github.svg`, `discord.svg`, `microsoft.svg` dans `static/icons/`. | |
| 390 | + | |
| 391 | +--- | |
| 392 | + | |
| 393 | +## Phase 5 -- Routes, documentation, wizard | |
| 394 | + | |
| 395 | +### 5.1 Routes (conditionnees par feature `oauth`) | |
| 396 | + | |
| 397 | +```rust | |
| 398 | +.route("/auth/oauth/:provider", get(oauth_handlers::oauth_redirect)) | |
| 399 | +.route("/auth/oauth/:provider/callback", get(oauth_handlers::oauth_callback)) | |
| 400 | +``` | |
| 401 | + | |
| 402 | +Rate limiting applique sur le callback (reutilise le rate limiter de Phase 0). | |
| 403 | + | |
| 404 | +### 5.2 Documentation DEVELOPER.md | |
| 405 | + | |
| 406 | +Chapitre OAuth2 : configuration, activation admin, flux technique, ajout de fournisseur custom. | |
| 407 | + | |
| 408 | +### 5.3 Wizard CLI | |
| 409 | + | |
| 410 | +Demander si l'OAuth est souhaite, generer les variables `.env` correspondantes. | |
| 411 | + | |
| 412 | +### 5.4 Verification finale | |
| 413 | + | |
| 414 | +`cargo check` + `cargo test` + revue securite specifique OAuth. | |
| 415 | + | |
| 416 | +--- | |
| 417 | + | |
| 418 | +## Securite OAuth -- Revue "secure by design" | |
| 419 | + | |
| 420 | +Chaque point de securite est adresse explicitement dans le plan : | |
| 421 | + | |
| 422 | +### Protections integrees | |
| 423 | + | |
| 424 | + | |
| 425 | +| Menace | Protection | Ou | | |
| 426 | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | | |
| 427 | +| **CSRF sur le callback** | Parametre `state` aleatoire stocke en session, valide au callback | `oauth_handlers.rs` | | |
| 428 | +| **Interception du code** | PKCE (RFC 7636) obligatoire pour tous les fournisseurs | `OAuthService::generate_auth_url` | | |
| 429 | +| **Replay d'ID token** | Nonce OIDC stocke en session, valide a la reception | `OAuthService::exchange_code` | | |
| 430 | +| **Usurpation d'email** | `email_verified` du fournisseur verifie ; si `false`, le compte n'est PAS lie automatiquement | `find_or_create_user` | | |
| 431 | +| **Account takeover par email** | Le lien automatique par email ne fonctionne que si `oauth_link_existing_account=true` ET `ProviderUserInfo.email_verified=true` | `find_or_create_user` | | |
| 432 | +| **Brute force sur callback** | Rate limiting applique (Phase 0 C2) | `routes.rs` | | |
| 433 | +| **Token OAuth stocke en clair** | Pas de stockage des tokens OAuth du fournisseur | Migration Phase 1 | | |
| 434 | +| **Open redirect post-OAuth** | Redirect fixe vers `/home` apres callback (pas de parametre `redirect` depuis le fournisseur) | `oauth_handlers.rs` | | |
| 435 | +| **Session fixation** | `session.cycle()` apres login OAuth (Phase 0 M1) | `oauth_handlers.rs` | | |
| 436 | +| **Enumeration de comptes** | Le callback OAuth ne revele pas si un compte existe ; il cree ou lie silencieusement | `find_or_create_user` | | |
| 437 | +| **Provider desactive** | Double verification : toggle DB + credentials env vars presents | `get_enabled_providers` | | |
| 438 | + | |
| 439 | + | |
| 440 | +### Flux `find_or_create_user` securise | |
| 441 | + | |
| 442 | +``` | |
| 443 | +1. Chercher oauth_accounts par (provider, provider_user_id) | |
| 444 | + -> Trouve : login avec le user lie | |
| 445 | + -> Non trouve : | |
| 446 | +2. Si email_verified == false dans ProviderUserInfo : | |
| 447 | + -> REJETER (ne pas lier un email non verifie) | |
| 448 | +3. Si oauth_link_existing_account == true : | |
| 449 | + a. Chercher user par email | |
| 450 | + -> Trouve : creer l'entree oauth_accounts, login | |
| 451 | + -> Non trouve : continuer | |
| 452 | +4. Si oauth_auto_register == true : | |
| 453 | + a. Creer un user (username derive de l'email, password aleatoire, email_verified=true) | |
| 454 | + b. Creer l'entree oauth_accounts | |
| 455 | + c. Appeler hooks.on_user_registered() + hooks.on_oauth_login(is_new=true) | |
| 456 | + -> Login | |
| 457 | +5. Si oauth_auto_register == false : | |
| 458 | + -> REJETER avec message "Contactez l'administrateur" | |
| 459 | +``` | |
| 460 | + | |
| 461 | +### Dissociation de compte OAuth | |
| 462 | + | |
| 463 | +Un utilisateur ne peut dissocier un compte OAuth que si : | |
| 464 | + | |
| 465 | +- Il a un mot de passe local defini (sinon il serait bloque hors de son compte) | |
| 466 | +- OU il a au moins un autre compte OAuth lie | |
| 467 | + |
A
.cursor/plans/oauth_docs_and_fixes_16c13ed7.plan.md
+127
-0
@@ -0,0 +1,127 @@
| 1 | +--- | |
| 2 | +name: OAuth docs and fixes | |
| 3 | +overview: Corriger le bug du hook on_oauth_login, compléter les éléments UI manquants (admin settings, profil, locales), puis documenter OAuth dans DEVELOPER.md, README.md, docs/configuration-flows.md, et mettre à jour le wizard CLI. | |
| 4 | +todos: | |
| 5 | + - id: fix-oauth-hook-call | |
| 6 | + content: "Corriger le bug : appeler on_oauth_login() dans oauth_callback (oauth_handlers.rs)" | |
| 7 | + status: completed | |
| 8 | + - id: ui-admin-settings-oauth | |
| 9 | + content: Ajouter la section OAuth dans templates/admin/settings.html (toggles par provider) | |
| 10 | + status: completed | |
| 11 | + - id: ui-profile-linked-accounts | |
| 12 | + content: Ajouter la section comptes liés dans templates/profile.html + endpoints API list/unlink | |
| 13 | + status: completed | |
| 14 | + - id: locales-oauth-keys | |
| 15 | + content: Ajouter les clés de traduction OAuth dans les 4 fichiers locales/*.yml | |
| 16 | + status: completed | |
| 17 | + - id: doc-developer-md | |
| 18 | + content: Mettre à jour DEVELOPER.md avec la section OAuth complète | |
| 19 | + status: completed | |
| 20 | + - id: doc-readme | |
| 21 | + content: Mettre à jour README.md (fonctionnalités, routes, config, hooks) | |
| 22 | + status: completed | |
| 23 | + - id: doc-config-flows | |
| 24 | + content: Ajouter le diagramme Mermaid OAuth dans docs/configuration-flows.md | |
| 25 | + status: completed | |
| 26 | + - id: cli-wizard-oauth | |
| 27 | + content: Mettre à jour le wizard CLI (questions OAuth + templates .env) | |
| 28 | + status: completed | |
| 29 | + - id: final-cargo-check | |
| 30 | + content: "Vérification finale : cargo check --features oauth + cargo check" | |
| 31 | + status: completed | |
| 32 | +isProject: false | |
| 33 | +--- | |
| 34 | + | |
| 35 | +# Phase finale OAuth : corrections, UI restante et documentation | |
| 36 | + | |
| 37 | +## 1. Correction du bug hook `on_oauth_login` | |
| 38 | + | |
| 39 | +Dans `[crates/rustwarden-core/src/auth/oauth_handlers.rs](crates/rustwarden-core/src/auth/oauth_handlers.rs)`, ajouter l'appel au hook `on_oauth_login(provider, is_new_user)` dans la fonction `oauth_callback`, juste après l'appel existant à `on_user_login()` (vers la ligne 242). | |
| 40 | + | |
| 41 | +## 2. Compléter la Phase 4 UI | |
| 42 | + | |
| 43 | +### 2a. Section OAuth dans admin settings | |
| 44 | + | |
| 45 | +Dans `[templates/admin/settings.html](templates/admin/settings.html)`, ajouter une 4e carte "OAuth / SSO" entre "Audit Logging" et les boutons d'action. Contenu : | |
| 46 | + | |
| 47 | +- Toggle global `oauth_enabled` | |
| 48 | +- Toggle par provider : `oauth_google_enabled`, `oauth_github_enabled`, `oauth_discord_enabled`, `oauth_microsoft_enabled` | |
| 49 | +- Champs info (lecture seule) rappelant que les client_id/secret se configurent via variables d'env | |
| 50 | + | |
| 51 | +Note : les client_id/client_secret restent dans les variables d'env (pas en DB) pour des raisons de sécurité. | |
| 52 | + | |
| 53 | +### 2b. Section comptes liés dans le profil | |
| 54 | + | |
| 55 | +Dans `[templates/profile.html](templates/profile.html)`, ajouter une carte "Comptes liés" entre "Informations supplémentaires" et "Quick Links". Contenu : | |
| 56 | + | |
| 57 | +- Liste des comptes OAuth liés (provider + email) chargés dynamiquement | |
| 58 | +- Bouton "Délier" par compte (avec confirmation) | |
| 59 | +- Protection : impossible de délier si c'est le dernier moyen d'authentification | |
| 60 | + | |
| 61 | +Cela nécessite un nouvel endpoint API : `GET /api/v1/auth/oauth/accounts` et `DELETE /api/v1/auth/oauth/accounts/{id}` (à ajouter dans `oauth_handlers.rs` et les routes dans `app.rs`). | |
| 62 | + | |
| 63 | +### 2c. Clés de traduction OAuth | |
| 64 | + | |
| 65 | +Ajouter les clés dans les 4 fichiers de locale (racine) : | |
| 66 | + | |
| 67 | +- `[locales/fr.yml](locales/fr.yml)`, `[locales/en.yml](locales/en.yml)`, `[locales/de.yml](locales/de.yml)`, `[locales/es.yml](locales/es.yml)` | |
| 68 | + | |
| 69 | +Clés nécessaires : | |
| 70 | + | |
| 71 | +- `login.oauth_divider` ("ou" / "or") | |
| 72 | +- `admin.settings.oauth_title`, `admin.settings.oauth_enabled`, `admin.settings.oauth_provider_*` | |
| 73 | +- `profile.linked_accounts`, `profile.unlink`, `profile.unlink_confirm`, `profile.no_linked_accounts` | |
| 74 | +- `profile.cannot_unlink_last` | |
| 75 | + | |
| 76 | +## 3. Documentation | |
| 77 | + | |
| 78 | +### 3a. DEVELOPER.md | |
| 79 | + | |
| 80 | +Fichier : `[DEVELOPER.md](DEVELOPER.md)` (~1200 lignes) | |
| 81 | + | |
| 82 | +Mises à jour : | |
| 83 | + | |
| 84 | +- **Table modules auth** : ajouter `oauth_handlers.rs` | |
| 85 | +- **Table modules config** : ajouter `config/oauth.rs` | |
| 86 | +- **Table modules services** : ajouter `services/oauth/` (provider_trait, providers, service) | |
| 87 | +- **Table modules models** : ajouter `OAuthAccount` | |
| 88 | +- **Nouvelle section "Authentification OAuth / SSO"** (après la section "Authentification et autorisation") : | |
| 89 | + - Variables d'env `OAUTH`_* | |
| 90 | + - Flux OAuth (redirect -> callback -> find_or_create_user -> JWT) | |
| 91 | + - Ajouter un provider custom (implémenter `OAuthProvider`) | |
| 92 | + - Sécurité (PKCE, state CSRF, nonce OIDC, email vérifié requis) | |
| 93 | +- **Section Hooks** : ajouter `on_oauth_login(db, user_id, provider, is_new_user)` | |
| 94 | +- **Section Production** : ajouter checklist OAuth (redirect URLs, HTTPS, secrets) | |
| 95 | + | |
| 96 | +### 3b. README.md | |
| 97 | + | |
| 98 | +Fichier : `[README.md](README.md)` | |
| 99 | + | |
| 100 | +Mises à jour : | |
| 101 | + | |
| 102 | +- **Fonctionnalités** : ajouter "OAuth2 / OpenID Connect (Google, GitHub, Discord, Microsoft)" | |
| 103 | +- **Routes disponibles** : ajouter les 3 routes OAuth | |
| 104 | +- **Configuration (.env)** : ajouter section "OAuth" avec les variables `OAUTH`_* | |
| 105 | +- **Hooks** : ajouter `on_oauth_login` | |
| 106 | + | |
| 107 | +### 3c. docs/configuration-flows.md | |
| 108 | + | |
| 109 | +Fichier : `[docs/configuration-flows.md](docs/configuration-flows.md)` | |
| 110 | + | |
| 111 | +Ajouter un diagramme de séquence Mermaid montrant le flux OAuth complet : | |
| 112 | + | |
| 113 | +- User -> Login page -> Provider redirect -> Provider auth -> Callback -> State validation -> Code exchange -> find_or_create_user -> JWT/cookies -> Home | |
| 114 | + | |
| 115 | +### 3d. Wizard CLI | |
| 116 | + | |
| 117 | +Fichiers : | |
| 118 | + | |
| 119 | +- `[tools/rustwarden-cli/src/start_project.rs](tools/rustwarden-cli/src/start_project.rs)` : ajouter questions OAuth dans le wizard | |
| 120 | +- `[tools/rustwarden-cli/src/templates.rs](tools/rustwarden-cli/src/templates.rs)` : ajouter champs OAuth dans `ProjectConfig`, variables dans `.env` et `.env.example` | |
| 121 | + | |
| 122 | +## 4. Vérification finale | |
| 123 | + | |
| 124 | +- `cargo check --features oauth` sans erreur | |
| 125 | +- `cargo check` (sans feature) sans erreur | |
| 126 | +- Vérifier les lints sur les fichiers modifiés | |
| 127 | + |
A
.cursor/plans/pasterman_rust_htmx_refonte_90c064a6.plan.md
+120
-0
@@ -0,0 +1,120 @@
| 1 | +--- | |
| 2 | +name: PasterMan Rust HTMX Refonte | |
| 3 | +overview: "Refonte complète de PasterMan : backend Rust/Axum (inspire du projet rustcloud), frontend HTMX + Tailwind/DaisyUI, securisation contre XSS/SSRF/injections, puis ajout de comptes utilisateurs et visibilite publique/privee des pastes." | |
| 4 | +todos: | |
| 5 | + - id: cargo-init | |
| 6 | + content: Initialiser le projet Cargo (Cargo.toml avec axum, tokio, serde, askama, reqwest, tower-http) | |
| 7 | + status: completed | |
| 8 | + - id: rust-models | |
| 9 | + content: "Creer models.rs : struct Paste, PasteStore (lecture/ecriture data/pastes.json)" | |
| 10 | + status: completed | |
| 11 | + - id: rust-preview | |
| 12 | + content: "Creer preview.rs : fetch OG preview avec reqwest + regex (port du code Node actuel)" | |
| 13 | + status: completed | |
| 14 | + - id: rust-security | |
| 15 | + content: "Creer security.rs : validation URL anti-SSRF, sanitisation, rate limiting" | |
| 16 | + status: completed | |
| 17 | + - id: askama-templates | |
| 18 | + content: Creer les templates Askama (base.html Tailwind+DaisyUI+HTMX, index.html, partials cards/liste) | |
| 19 | + status: completed | |
| 20 | + - id: rust-handlers | |
| 21 | + content: "Creer handlers.rs : GET / (page), POST /paste (HTMX fragment), DELETE /paste/:id (HTMX)" | |
| 22 | + status: completed | |
| 23 | + - id: rust-main | |
| 24 | + content: "Creer main.rs + routes.rs + config.rs : serveur Axum, middleware securite, fichiers statiques" | |
| 25 | + status: completed | |
| 26 | + - id: update-scripts | |
| 27 | + content: Mettre a jour pasterman.sh, .gitignore et README.md pour le projet Rust | |
| 28 | + status: completed | |
| 29 | +isProject: false | |
| 30 | +--- | |
| 31 | + | |
| 32 | +# Refonte PasterMan : Rust + HTMX + Securite + Comptes | |
| 33 | + | |
| 34 | +## Phase 1 : Backend Rust/Axum + HTMX + Tailwind/DaisyUI | |
| 35 | + | |
| 36 | +Remplacement du `server.js` Node.js par un serveur Rust avec Axum, inspire de la structure de [rustcloud](file:///home/monsieurb/Documents/devel/specKit/rustcloud/src/main.rs). | |
| 37 | + | |
| 38 | +### Structure cible | |
| 39 | + | |
| 40 | +``` | |
| 41 | +PasterMan/ | |
| 42 | +├── Cargo.toml | |
| 43 | +├── .env | |
| 44 | +├── pasterman.sh (conserve) | |
| 45 | +├── src/ | |
| 46 | +│ ├── main.rs ← point d'entree, config Axum | |
| 47 | +│ ├── config.rs ← lecture .env (port, host) | |
| 48 | +│ ├── routes.rs ← definition des routes | |
| 49 | +│ ├── handlers.rs ← handlers GET /, POST /paste, DELETE /paste/:id | |
| 50 | +│ ├── models.rs ← struct Paste, PasteStore (fichier JSON) | |
| 51 | +│ ├── preview.rs ← fetch OG preview (reqwest + regex) | |
| 52 | +│ ├── security.rs ← sanitisation, validation URL, anti-SSRF | |
| 53 | +│ └── templates.rs ← templates HTML (askama) | |
| 54 | +├── templates/ | |
| 55 | +│ ├── base.html ← layout Tailwind + DaisyUI (CDN pour simplifier) | |
| 56 | +│ ├── index.html ← page principale | |
| 57 | +│ └── partials/ | |
| 58 | +│ ├── paste_card.html ← fragment HTMX pour une card | |
| 59 | +│ ├── paste_list.html ← fragment HTMX pour la liste | |
| 60 | +│ └── paste_zone.html ← zone de collage | |
| 61 | +├── public/ | |
| 62 | +│ └── css/ | |
| 63 | +│ └── style.css ← overrides CSS custom (.deletePaste, etc.) | |
| 64 | +└── data/ | |
| 65 | + └── pastes.json ← stockage fichier plat (conserve) | |
| 66 | +``` | |
| 67 | + | |
| 68 | +### Dependencies Cargo (minimales) | |
| 69 | + | |
| 70 | +- **axum** 0.8+ : framework web (comme rustcloud) | |
| 71 | +- **tokio** 1.x : runtime async | |
| 72 | +- **serde** / **serde_json** : serialisation | |
| 73 | +- **askama** : templates HTML (compile-time) | |
| 74 | +- **reqwest** : HTTP client pour fetch des previews OG | |
| 75 | +- **tower-http** : ServeDir (fichiers statiques), headers de securite | |
| 76 | + | |
| 77 | +### Frontend HTMX + Tailwind + DaisyUI | |
| 78 | + | |
| 79 | +- Tailwind CSS + DaisyUI charges via CDN dans `base.html` (pas de build CSS) | |
| 80 | +- **HTMX** pour les interactions dynamiques (pas de JS custom sauf l'ecoute du paste) : | |
| 81 | + - `hx-post="/paste"` pour soumettre un collage | |
| 82 | + - `hx-delete="/paste/{id}"` pour effacer (la croix) | |
| 83 | + - `hx-target="#pastes-list"` + `hx-swap="innerHTML"` pour rafraichir la liste | |
| 84 | + - Le serveur renvoie des **fragments HTML** (pas du JSON) via les templates partiels | |
| 85 | +- Un petit `<script>` pour l'ecoute de l'evenement `paste` sur le textarea, qui declenche le POST via HTMX | |
| 86 | + | |
| 87 | +## Phase 2 : Securisation | |
| 88 | + | |
| 89 | +### Cote serveur ([`security.rs`]) | |
| 90 | + | |
| 91 | +- **Sanitisation du contenu** : echapper le HTML dans les pastes (XSS stored) ; utiliser `askama` qui echappe par defaut | |
| 92 | +- **Validation des URLs** pour le fetch preview : | |
| 93 | + - Bloquer les IP privees/localhost (anti-SSRF) : `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, `fc00::/7` | |
| 94 | + - Timeout strict (5s) | |
| 95 | + - Limiter la taille de la reponse fetched (1 Mo max) | |
| 96 | + - Valider le Content-Type (text/html uniquement) | |
| 97 | +- **Taille du body** : limiter POST a 100 Ko (`tower-http` RequestBodyLimitLayer) | |
| 98 | +- **Rate limiting** simple : limiter les POST a ~30 req/min par IP (in-memory HashMap + cleanup) | |
| 99 | +- **Headers HTTP de securite** (middleware tower-http) : | |
| 100 | + - `Content-Security-Policy` : restreindre les sources | |
| 101 | + - `X-Content-Type-Options: nosniff` | |
| 102 | + - `X-Frame-Options: DENY` | |
| 103 | + - `Referrer-Policy: no-referrer` | |
| 104 | + | |
| 105 | +### Cote client | |
| 106 | + | |
| 107 | +- Askama echappe par defaut tout le contenu dans les templates (anti-XSS) | |
| 108 | +- Les URLs dans les previews : attribut `rel="noopener noreferrer"` deja present | |
| 109 | +- Valider cote client que le contenu colle n'est pas vide | |
| 110 | + | |
| 111 | +## Phase 3 : Comptes utilisateurs + visibilite (ulterieure) | |
| 112 | + | |
| 113 | +Cette phase viendra apres validation de la Phase 1+2. Grandes lignes : | |
| 114 | + | |
| 115 | +- **Base de donnees** : migration de `pastes.json` vers SQLite (ou PostgreSQL si souhaite comme rustcloud) | |
| 116 | +- **Crates supplementaires** : `argon2` (hash mots de passe), `jsonwebtoken` (JWT), `sea-orm` ou `sqlx` | |
| 117 | +- **Modele utilisateur** : inscription, login, session cookie | |
| 118 | +- **Paste avec proprietaire** : chaque paste est lie a un user (ou anonyme) | |
| 119 | +- **Visibilite** : champ `visibility` (public/private) sur chaque paste ; les privees ne sont visibles que par leur auteur | |
| 120 | +- **Interface** : page login/register, filtre "mes pastes" vs "publics", toggle visibilite sur chaque card |
A
.cursor/plans/rbac_permissions_and_roles_crud_1cf67621.plan.md
+189
-0
@@ -0,0 +1,189 @@
| 1 | +--- | |
| 2 | +name: RBAC permissions and roles CRUD | |
| 3 | +overview: Ajouter un systeme de permissions granulaires (table permissions + role_permissions) et un CRUD complet pour les roles dans l'interface admin, en suivant les patterns existants du CRUD utilisateurs. | |
| 4 | +todos: | |
| 5 | + - id: migration | |
| 6 | + content: "Creer la migration : tables permissions + role_permissions + seed permissions par defaut + assignation au role admin" | |
| 7 | + status: completed | |
| 8 | + - id: models | |
| 9 | + content: Creer les modeles SeaORM permission.rs et role_permission.rs, les enregistrer dans models/mod.rs | |
| 10 | + status: completed | |
| 11 | + - id: permission-service | |
| 12 | + content: Creer permission_service.rs (CRUD permissions + get_permissions_for_user + initialize_default_permissions) | |
| 13 | + status: completed | |
| 14 | + - id: role-service-update | |
| 15 | + content: Enrichir role_service.rs avec update_role, delete_role, get_role_by_id, get_role_with_permissions | |
| 16 | + status: completed | |
| 17 | + - id: jwt-permissions | |
| 18 | + content: Ajouter permissions dans Claims (jwt.rs) et AuthUser (extractors.rs), charger au login | |
| 19 | + status: completed | |
| 20 | + - id: roles-crud-handlers | |
| 21 | + content: Implementer les 6 handlers CRUD roles dans admin_handler.rs + routes dans routes.rs | |
| 22 | + status: completed | |
| 23 | + - id: roles-templates | |
| 24 | + content: Creer roles_list.html et role_form.html (avec checkboxes permissions groupees par categorie) | |
| 25 | + status: completed | |
| 26 | + - id: dashboard-card | |
| 27 | + content: Ajouter la carte Roles dans le dashboard admin | |
| 28 | + status: completed | |
| 29 | + - id: i18n | |
| 30 | + content: Ajouter les traductions admin.roles_list.* et admin.role_form.* dans les 4 locales | |
| 31 | + status: completed | |
| 32 | + - id: verify | |
| 33 | + content: Verifier la compilation cargo check + tester le flux complet | |
| 34 | + status: completed | |
| 35 | +isProject: false | |
| 36 | +--- | |
| 37 | + | |
| 38 | +# Permissions granulaires et CRUD Roles | |
| 39 | + | |
| 40 | +## Architecture des permissions | |
| 41 | + | |
| 42 | +Ajout de 2 tables : | |
| 43 | + | |
| 44 | +- `permissions` (id, name, description, category, created_at) -- ex: "users.create", "users.delete", "roles.manage", "settings.edit" | |
| 45 | +- `role_permissions` (role_id, permission_id) -- table de liaison many-to-many | |
| 46 | + | |
| 47 | +```mermaid | |
| 48 | +erDiagram | |
| 49 | + users ||--o{ user_roles : has | |
| 50 | + roles ||--o{ user_roles : has | |
| 51 | + roles ||--o{ role_permissions : has | |
| 52 | + permissions ||--o{ role_permissions : has | |
| 53 | + | |
| 54 | + permissions { | |
| 55 | + uuid id PK | |
| 56 | + string name UK | |
| 57 | + string description | |
| 58 | + string category | |
| 59 | + timestamp created_at | |
| 60 | + } | |
| 61 | + role_permissions { | |
| 62 | + uuid role_id FK | |
| 63 | + uuid permission_id FK | |
| 64 | + } | |
| 65 | +``` | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | +Le role `admin` a toutes les permissions (wildcard). Les autres roles n'ont que les permissions explicitement assignees. | |
| 70 | + | |
| 71 | +### Permissions predefinies | |
| 72 | + | |
| 73 | +Categories et permissions : | |
| 74 | + | |
| 75 | +- **users** : `users.list`, `users.create`, `users.edit`, `users.delete`, `users.reset_password` | |
| 76 | +- **roles** : `roles.list`, `roles.create`, `roles.edit`, `roles.delete` | |
| 77 | +- **settings** : `settings.view`, `settings.edit` | |
| 78 | +- **audit** : `audit.view` | |
| 79 | + | |
| 80 | +### Verification des permissions | |
| 81 | + | |
| 82 | +Ajouter a `AuthUser` (dans [src/auth/extractors.rs](src/auth/extractors.rs)) : | |
| 83 | + | |
| 84 | +```rust | |
| 85 | +pub fn has_permission(&self, permission: &str) -> bool { | |
| 86 | + if self.has_role("admin") { return true; } // admin = superuser | |
| 87 | + self.permissions.iter().any(|p| p == permission) | |
| 88 | +} | |
| 89 | +``` | |
| 90 | + | |
| 91 | +Les permissions sont chargees depuis la DB au login et embarquees dans le JWT (champ `permissions: Vec<String>` dans `Claims`). | |
| 92 | + | |
| 93 | +## Fichiers a creer | |
| 94 | + | |
| 95 | + | |
| 96 | +| Fichier | Description | | |
| 97 | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------- | | |
| 98 | +| `migrations/src/m20260309_000003_create_permissions_tables.rs` | Migration : tables permissions + role_permissions + seed permissions par defaut | | |
| 99 | +| `src/models/permission.rs` | Entite SeaORM permission | | |
| 100 | +| `src/models/role_permission.rs` | Entite SeaORM role_permission | | |
| 101 | +| `src/services/permission_service.rs` | Service CRUD permissions + chargement permissions user | | |
| 102 | +| `templates/admin/roles_list.html` | Page liste des roles | | |
| 103 | +| `templates/admin/role_form.html` | Formulaire creation/edition role avec checkboxes permissions | | |
| 104 | + | |
| 105 | + | |
| 106 | +## Fichiers a modifier | |
| 107 | + | |
| 108 | + | |
| 109 | +| Fichier | Modifications | | |
| 110 | +| ----------------------------------- | ---------------------------------------------------------------------------------------- | | |
| 111 | +| `migrations/src/lib.rs` | Enregistrer la nouvelle migration | | |
| 112 | +| `src/models/mod.rs` | Ajouter `pub mod permission;` et `pub mod role_permission;` | | |
| 113 | +| `src/services/mod.rs` | Ajouter `pub mod permission_service;` | | |
| 114 | +| `src/services/role_service.rs` | Ajouter `update_role()`, `delete_role()`, `get_role_by_id()` | | |
| 115 | +| `src/auth/jwt.rs` | Ajouter champ `permissions: Vec<String>` dans `Claims`, le remplir au login | | |
| 116 | +| `src/auth/extractors.rs` | Ajouter `permissions: Vec<String>` a `AuthUser` + methode `has_permission()` | | |
| 117 | +| `src/api/handlers/admin_handler.rs` | Ajouter les 6 handlers CRUD roles (list, create_form, create, edit_form, update, delete) | | |
| 118 | +| `src/api/routes.rs` | Ajouter les 6 routes `/admin/roles/*` | | |
| 119 | +| `templates/admin/dashboard.html` | Ajouter une carte "Roles" avec compteur | | |
| 120 | +| `locales/fr.yml` | Ajouter les traductions `admin.roles_list.*` et `admin.role_form.*` | | |
| 121 | +| `locales/en.yml` | Idem en anglais | | |
| 122 | +| `locales/de.yml` | Idem en allemand | | |
| 123 | +| `locales/es.yml` | Idem en espagnol | | |
| 124 | + | |
| 125 | + | |
| 126 | +## Details d'implementation | |
| 127 | + | |
| 128 | +### 1. Migration | |
| 129 | + | |
| 130 | +Table `permissions` avec seed des permissions par defaut (users.*, roles.*, settings.*, audit.*). | |
| 131 | +Table `role_permissions` avec seed : role `admin` recoit toutes les permissions, role `user` recoit aucune permission specifique (acces de base gere par le middleware auth). | |
| 132 | + | |
| 133 | +### 2. CRUD Roles (admin) | |
| 134 | + | |
| 135 | +Suivre exactement les patterns du CRUD users : | |
| 136 | + | |
| 137 | +**Routes** : | |
| 138 | + | |
| 139 | +- `GET /admin/roles` -- liste des roles (tableau avec nom, description, nb users, nb permissions, actions) | |
| 140 | +- `GET /admin/roles/create` -- formulaire creation | |
| 141 | +- `POST /admin/roles` -- creation (HTMX) | |
| 142 | +- `GET /admin/roles/{id}` -- formulaire edition | |
| 143 | +- `PUT /admin/roles/{id}` -- mise a jour (HTMX) | |
| 144 | +- `DELETE /admin/roles/{id}` -- suppression (avec protection des roles "admin" et "user") | |
| 145 | + | |
| 146 | +**Template `roles_list.html*`* : | |
| 147 | + | |
| 148 | +- Breadcrumb : Home > Admin > Roles | |
| 149 | +- Tableau : nom, description, nombre d'utilisateurs, nombre de permissions, boutons edit/delete | |
| 150 | +- Modal de confirmation de suppression | |
| 151 | +- Bouton "Creer un role" | |
| 152 | + | |
| 153 | +**Template `role_form.html`** : | |
| 154 | + | |
| 155 | +- Champs : nom, description | |
| 156 | +- Section permissions : checkboxes groupees par categorie (users, roles, settings, audit) | |
| 157 | +- Mode create/edit conditionnel | |
| 158 | +- Protection : roles "admin" et "user" ne peuvent pas etre supprimes (nom readonly en edition) | |
| 159 | + | |
| 160 | +### 3. Integration dans le JWT | |
| 161 | + | |
| 162 | +Dans [src/auth/jwt.rs](src/auth/jwt.rs), `Claims` : | |
| 163 | + | |
| 164 | +```rust | |
| 165 | +pub struct Claims { | |
| 166 | + pub sub: Uuid, | |
| 167 | + pub username: String, | |
| 168 | + pub roles: Vec<String>, | |
| 169 | + pub permissions: Vec<String>, // NEW | |
| 170 | + pub exp: i64, | |
| 171 | + pub iat: i64, | |
| 172 | +} | |
| 173 | +``` | |
| 174 | + | |
| 175 | +Au login, charger les permissions via `PermissionService::get_permissions_for_user(db, user_id)` qui joint user_roles + role_permissions + permissions. | |
| 176 | + | |
| 177 | +### 4. Middleware de verification (optionnel, a terme) | |
| 178 | + | |
| 179 | +Pour l'instant, la verification se fait dans les handlers via `claims.has_permission("xxx")` ou `AuthUser.has_permission("xxx")`. Un middleware `require_permission("users.delete")` pourra etre ajoute plus tard. | |
| 180 | + | |
| 181 | +### 5. Dashboard | |
| 182 | + | |
| 183 | +Ajouter une carte dans le dashboard admin : | |
| 184 | + | |
| 185 | +- Icone : shield/key | |
| 186 | +- Titre : "Roles" | |
| 187 | +- Valeur : nombre total de roles | |
| 188 | +- Bouton : "Gerer" -> `/admin/roles` | |
| 189 | + |
A
.cursor/plans/remediation_securite_rustwarden_398ec47c.plan.md
+478
-0
@@ -0,0 +1,478 @@
| 1 | +--- | |
| 2 | +name: Remediation securite Rustwarden | |
| 3 | +overview: Corriger les 4 vulnerabilites critiques, 9 hautes, 10 moyennes et 8 basses identifiees dans l'audit de securite, en procedant par ordre de priorite decroissante. Chaque phase est independante et compilable. | |
| 4 | +todos: | |
| 5 | + - id: phase1-c1 | |
| 6 | + content: "C1 - Timing attack : ajouter dummy bcrypt hash dans verify_credentials (auth_service.rs)" | |
| 7 | + status: completed | |
| 8 | + - id: phase1-c2 | |
| 9 | + content: "C2 - Enumeration inscription : messages d'erreur generiques (handlers.rs)" | |
| 10 | + status: completed | |
| 11 | + - id: phase1-c3 | |
| 12 | + content: "C3+H4 - CSRF bypass /api/ : supprimer exception, valider Bearer, retirer TRACE (csrf.rs)" | |
| 13 | + status: completed | |
| 14 | + - id: phase1-c4 | |
| 15 | + content: "C4 - Open redirect : URL-encoder redirect dans sanitize_redirect (auth_service.rs middleware)" | |
| 16 | + status: completed | |
| 17 | + - id: phase2-h1 | |
| 18 | + content: "H1 - Security Headers HTTP : ajouter middleware dans la stack (app.rs)" | |
| 19 | + status: completed | |
| 20 | + - id: phase2-h2 | |
| 21 | + content: "H2 - Open redirect JS : valider redirect cote client (login.html)" | |
| 22 | + status: completed | |
| 23 | + - id: phase2-h3 | |
| 24 | + content: "H3 - IP Spoofing : TRUSTED_PROXY_HOPS + ConnectInfo fallback (rate_limit.rs)" | |
| 25 | + status: completed | |
| 26 | + - id: phase2-h5 | |
| 27 | + content: "H5 - CSRF formulaires admin : verifier tokens dans templates (admin_handler, settings_handler)" | |
| 28 | + status: completed | |
| 29 | + - id: phase2-h6 | |
| 30 | + content: "H6 - PKCE GitHub/Discord : ajouter code_challenge S256 (github.rs, discord.rs)" | |
| 31 | + status: completed | |
| 32 | + - id: phase2-h7 | |
| 33 | + content: "H7 - redirect_uri encode : urlencoding dans tous les providers OAuth" | |
| 34 | + status: completed | |
| 35 | + - id: phase2-h8 | |
| 36 | + content: "H8 - Nonce vide OIDC : rejeter si absent (google.rs, microsoft.rs)" | |
| 37 | + status: completed | |
| 38 | + - id: phase2-h9 | |
| 39 | + content: "H9 - optional_auth : propager claims dans extensions (auth_service.rs middleware)" | |
| 40 | + status: completed | |
| 41 | + - id: phase3-m1 | |
| 42 | + content: "M1 - AuthConfig singleton : charger une fois, passer via Extension (app.rs, extractors.rs)" | |
| 43 | + status: completed | |
| 44 | + - id: phase3-m2 | |
| 45 | + content: "M2 - Rate limiter borne : taille max HashMap + purge periodique (rate_limit.rs)" | |
| 46 | + status: completed | |
| 47 | + - id: phase3-m3 | |
| 48 | + content: "M3 - Max longueur mot de passe : 128 chars avant bcrypt (password_complexity.rs)" | |
| 49 | + status: completed | |
| 50 | + - id: phase3-m4 | |
| 51 | + content: "M4 - Limite refresh tokens : MAX_ACTIVE_TOKENS_PER_USER (refresh_token_service.rs)" | |
| 52 | + status: completed | |
| 53 | + - id: phase3-m5 | |
| 54 | + content: "M5 - Bcrypt cost configurable : BCRYPT_COST env var (password_service.rs)" | |
| 55 | + status: completed | |
| 56 | + - id: phase3-m7 | |
| 57 | + content: "M7 - Invalidation JWT au changement mdp : blacklist ou claim password_changed_at" | |
| 58 | + status: completed | |
| 59 | + - id: phase3-m9 | |
| 60 | + content: M9 - COOKIE_SECURE force en production (config/auth.rs) | |
| 61 | + status: completed | |
| 62 | + - id: phase3-m10 | |
| 63 | + content: M10 - Implementer ou supprimer middlewares no-op (error_handler.rs, password_complexity.rs) | |
| 64 | + status: completed | |
| 65 | + - id: phase4-low | |
| 66 | + content: "L1-L8 - Corrections basses : JWT_SECRET check, jti/aud, default password, complexite, logs, cookie name, email validation, SameSite" | |
| 67 | + status: completed | |
| 68 | +isProject: false | |
| 69 | +--- | |
| 70 | + | |
| 71 | +# Remediation des vulnerabilites de securite -- Rustwarden | |
| 72 | + | |
| 73 | +## Phase 1 : CRITIQUES (C1-C4) | |
| 74 | + | |
| 75 | +### C1 -- Timing attack sur `verify_credentials` | |
| 76 | + | |
| 77 | +**Fichier** : [crates/rustwarden-core/src/services/auth_service.rs](crates/rustwarden-core/src/services/auth_service.rs) (lignes 28-55) | |
| 78 | + | |
| 79 | +**Probleme** : Quand l'utilisateur n'existe pas, la fonction retourne immediatement (~~0ms) au lieu d'executer bcrypt (~~200ms), permettant l'enumeration de comptes par mesure du temps de reponse. | |
| 80 | + | |
| 81 | +**Correction** : Generer un hash bcrypt factice ("dummy hash") au demarrage du service et l'utiliser pour `verify` meme quand l'utilisateur n'existe pas, rendant le temps de reponse constant. | |
| 82 | + | |
| 83 | +```rust | |
| 84 | +lazy_static! { | |
| 85 | + static ref DUMMY_HASH: String = hash("dummy_password_for_timing", DEFAULT_COST).unwrap(); | |
| 86 | +} | |
| 87 | + | |
| 88 | +// Dans verify_credentials, branche None : | |
| 89 | +None => { | |
| 90 | + let _ = verify("invalid", &DUMMY_HASH); | |
| 91 | + tracing::warn!("Login attempt for unknown user"); | |
| 92 | + return Err(AppError::InvalidCredentials); | |
| 93 | +} | |
| 94 | +``` | |
| 95 | + | |
| 96 | +**Fichier additionnel** : Ajouter `once_cell` ou `lazy_static` a [crates/rustwarden-core/Cargo.toml](crates/rustwarden-core/Cargo.toml) (ou utiliser `std::sync::LazyLock` disponible depuis Rust 1.80). | |
| 97 | + | |
| 98 | +### C2 -- Enumeration via inscription | |
| 99 | + | |
| 100 | +**Fichier** : [crates/rustwarden-core/src/auth/handlers.rs](crates/rustwarden-core/src/auth/handlers.rs) (lignes 1168-1188) | |
| 101 | + | |
| 102 | +**Probleme** : Les messages d'erreur revelent si un email ou username est deja utilise. | |
| 103 | + | |
| 104 | +**Correction** : Remplacer les deux messages par un message generique identique : | |
| 105 | + | |
| 106 | +```rust | |
| 107 | +return Err(AppError::Validation( | |
| 108 | + "Inscription impossible. Verifiez vos informations.".to_string() | |
| 109 | +)); | |
| 110 | +``` | |
| 111 | + | |
| 112 | +### C3 -- Bypass CSRF pour toutes les routes `/api/*` | |
| 113 | + | |
| 114 | +**Fichier** : [crates/rustwarden-core/src/middleware/csrf.rs](crates/rustwarden-core/src/middleware/csrf.rs) (lignes 128-182) | |
| 115 | + | |
| 116 | +**Probleme** : Deux failles : | |
| 117 | + | |
| 118 | +- Les routes `/api/`* sans token CSRF passent librement (lignes 169-175) | |
| 119 | +- Le check Bearer (ligne 139) verifie seulement la presence du header, pas la validite du token | |
| 120 | + | |
| 121 | +**Correction** : | |
| 122 | + | |
| 123 | +- Supprimer l'exception `/api/` : si pas de token CSRF, bloquer (403) sauf si un Bearer **valide** est present | |
| 124 | +- Pour verifier le Bearer, faire un `JwtService::validate_token()` dans le middleware (necessaire : passer `AuthConfig` via `Extension`) | |
| 125 | +- Retirer `Method::TRACE` de la liste des methodes exemptees (corrige aussi H4) | |
| 126 | + | |
| 127 | +```rust | |
| 128 | +// Remplacer le bloc None => { ... } par : | |
| 129 | +None => { | |
| 130 | + tracing::warn!("CSRF token missing for {} {}", method, uri); | |
| 131 | + Ok((StatusCode::FORBIDDEN, "CSRF token required").into_response()) | |
| 132 | +} | |
| 133 | +``` | |
| 134 | + | |
| 135 | +Pour le Bearer, valider reellement le JWT : | |
| 136 | + | |
| 137 | +```rust | |
| 138 | +fn is_valid_bearer_api_request(request: &Request) -> bool { | |
| 139 | + if let Some(auth_header) = request.headers().get("authorization") { | |
| 140 | + if let Ok(value) = auth_header.to_str() { | |
| 141 | + if let Some(token) = value.strip_prefix("Bearer ") { | |
| 142 | + if let Some(config) = request.extensions().get::<AuthConfig>() { | |
| 143 | + return JwtService::validate_token(config, token).is_ok(); | |
| 144 | + } | |
| 145 | + } | |
| 146 | + } | |
| 147 | + } | |
| 148 | + false | |
| 149 | +} | |
| 150 | +``` | |
| 151 | + | |
| 152 | +### C4 -- Open Redirect dans `sanitize_redirect` | |
| 153 | + | |
| 154 | +**Fichier** : [crates/rustwarden-core/src/middleware/auth_service.rs](crates/rustwarden-core/src/middleware/auth_service.rs) (lignes 19-26) | |
| 155 | + | |
| 156 | +**Probleme** : Le chemin est injecte dans l'URL sans encodage, permettant l'injection de parametres. | |
| 157 | + | |
| 158 | +**Correction** : URL-encoder le parametre redirect et ajouter une validation stricte : | |
| 159 | + | |
| 160 | +```rust | |
| 161 | +use urlencoding::encode; | |
| 162 | + | |
| 163 | +pub(crate) fn sanitize_redirect(uri: &str) -> String { | |
| 164 | + let path = uri.to_string(); | |
| 165 | + // N'accepter que les chemins relatifs commencant par / (pas //) | |
| 166 | + // et ne contenant pas de caracteres dangereux | |
| 167 | + if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') { | |
| 168 | + format!("/login?redirect={}", encode(&path)) | |
| 169 | + } else { | |
| 170 | + "/login".to_string() | |
| 171 | + } | |
| 172 | +} | |
| 173 | +``` | |
| 174 | + | |
| 175 | +**Dependance** : Ajouter `urlencoding` a [crates/rustwarden-core/Cargo.toml](crates/rustwarden-core/Cargo.toml). | |
| 176 | + | |
| 177 | +--- | |
| 178 | + | |
| 179 | +## Phase 2 : HAUTES (H1-H9) | |
| 180 | + | |
| 181 | +### H1 -- Ajout des Security Headers HTTP | |
| 182 | + | |
| 183 | +**Fichier** : [crates/rustwarden-core/src/app.rs](crates/rustwarden-core/src/app.rs) (lignes 375-392, middleware stack) | |
| 184 | + | |
| 185 | +**Correction** : Ajouter un middleware de security headers en tant que layer dans la stack : | |
| 186 | + | |
| 187 | +```rust | |
| 188 | +async fn security_headers(request: Request, next: Next) -> Response { | |
| 189 | + let mut response = next.run(request).await; | |
| 190 | + let headers = response.headers_mut(); | |
| 191 | + headers.insert("x-content-type-options", "nosniff".parse().unwrap()); | |
| 192 | + headers.insert("x-frame-options", "DENY".parse().unwrap()); | |
| 193 | + headers.insert("x-xss-protection", "0".parse().unwrap()); | |
| 194 | + headers.insert("referrer-policy", "strict-origin-when-cross-origin".parse().unwrap()); | |
| 195 | + headers.insert("permissions-policy", "camera=(), microphone=(), geolocation=()".parse().unwrap()); | |
| 196 | + // HSTS uniquement si COOKIE_SECURE=true (production) | |
| 197 | + if std::env::var("COOKIE_SECURE").unwrap_or_default() == "true" { | |
| 198 | + headers.insert("strict-transport-security", "max-age=31536000; includeSubDomains".parse().unwrap()); | |
| 199 | + } | |
| 200 | + response | |
| 201 | +} | |
| 202 | +``` | |
| 203 | + | |
| 204 | +Placer dans la stack avec `.layer(axum::middleware::from_fn(security_headers))`. | |
| 205 | + | |
| 206 | +### H2 -- Open Redirect cote client (login.html) | |
| 207 | + | |
| 208 | +**Fichier** : [crates/rustwarden-ui/templates/login.html](crates/rustwarden-ui/templates/login.html) (lignes 340, 426) | |
| 209 | + | |
| 210 | +**Correction** : Valider le parametre `redirect` en JavaScript avant utilisation (2 occurrences) : | |
| 211 | + | |
| 212 | +```javascript | |
| 213 | +function getSafeRedirect() { | |
| 214 | + const urlParams = new URLSearchParams(window.location.search); | |
| 215 | + const redirect = urlParams.get('redirect') || '/home'; | |
| 216 | + try { | |
| 217 | + const url = new URL(redirect, window.location.origin); | |
| 218 | + if (url.origin === window.location.origin && url.pathname.startsWith('/')) { | |
| 219 | + return url.pathname + url.search + url.hash; | |
| 220 | + } | |
| 221 | + } catch (e) {} | |
| 222 | + return '/home'; | |
| 223 | +} | |
| 224 | +``` | |
| 225 | + | |
| 226 | +Remplacer les deux `const redirect = urlParams.get('redirect') || '/home';` par un appel a `getSafeRedirect()`. | |
| 227 | + | |
| 228 | +### H3 -- IP Spoofing dans le rate limiter | |
| 229 | + | |
| 230 | +**Fichier** : [crates/rustwarden-core/src/middleware/rate_limit.rs](crates/rustwarden-core/src/middleware/rate_limit.rs) (lignes 179-189) | |
| 231 | + | |
| 232 | +**Correction** : Ajouter une variable d'environnement `TRUSTED_PROXY_HOPS` (defaut: 0) et n'utiliser `X-Forwarded-For` que si un reverse proxy est configure. Sinon, utiliser `ConnectInfo` d'Axum pour l'IP reelle : | |
| 233 | + | |
| 234 | +```rust | |
| 235 | +pub fn extract_client_ip(request: &Request) -> String { | |
| 236 | + let trusted_hops: usize = std::env::var("TRUSTED_PROXY_HOPS") | |
| 237 | + .ok().and_then(|v| v.parse().ok()).unwrap_or(0); | |
| 238 | + | |
| 239 | + if trusted_hops > 0 { | |
| 240 | + if let Some(xff) = request.headers().get("x-forwarded-for") | |
| 241 | + .and_then(|h| h.to_str().ok()) | |
| 242 | + { | |
| 243 | + let ips: Vec<&str> = xff.split(',').map(|s| s.trim()).collect(); | |
| 244 | + // Prendre la Nieme IP depuis la droite (la plus fiable) | |
| 245 | + if ips.len() >= trusted_hops { | |
| 246 | + return ips[ips.len() - trusted_hops].to_string(); | |
| 247 | + } | |
| 248 | + } | |
| 249 | + } | |
| 250 | + // Fallback : IP de connexion directe | |
| 251 | + request.extensions().get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() | |
| 252 | + .map(|ci| ci.0.ip().to_string()) | |
| 253 | + .unwrap_or_else(|| "unknown".to_string()) | |
| 254 | +} | |
| 255 | +``` | |
| 256 | + | |
| 257 | +### H4 -- Methode TRACE autorisee (CSRF) | |
| 258 | + | |
| 259 | +**Fichier** : [crates/rustwarden-core/src/middleware/csrf.rs](crates/rustwarden-core/src/middleware/csrf.rs) (ligne 133) | |
| 260 | + | |
| 261 | +**Correction** : Retirer `Method::TRACE` de la liste des methodes exemptees. Traite dans la correction C3. | |
| 262 | + | |
| 263 | +### H5 -- CSRF non valide dans les handlers formulaire | |
| 264 | + | |
| 265 | +**Fichiers** : | |
| 266 | + | |
| 267 | +- [crates/rustwarden-ui/src/handlers/admin_handler.rs](crates/rustwarden-ui/src/handlers/admin_handler.rs) | |
| 268 | +- [crates/rustwarden-ui/src/handlers/settings_handler.rs](crates/rustwarden-ui/src/handlers/settings_handler.rs) | |
| 269 | + | |
| 270 | +**Correction** : Ces handlers utilisent des formulaires HTML (POST). Le middleware CSRF global (une fois corrige en C3) protegera automatiquement ces routes puisqu'elles ne sont pas sous `/api/` et n'utilisent pas de Bearer. Verifier que les templates incluent bien le champ `csrf_token` dans chaque formulaire. | |
| 271 | + | |
| 272 | +### H6 -- Pas de PKCE pour GitHub et Discord OAuth | |
| 273 | + | |
| 274 | +**Fichiers** : | |
| 275 | + | |
| 276 | +- [crates/rustwarden-core/src/services/oauth/providers/github.rs](crates/rustwarden-core/src/services/oauth/providers/github.rs) | |
| 277 | +- [crates/rustwarden-core/src/services/oauth/providers/discord.rs](crates/rustwarden-core/src/services/oauth/providers/discord.rs) | |
| 278 | + | |
| 279 | +**Correction** : Ajouter la generation PKCE (S256) identique a Google/Microsoft : | |
| 280 | + | |
| 281 | +```rust | |
| 282 | +use openidconnect::{PkceCodeChallenge, PkceCodeVerifier}; | |
| 283 | + | |
| 284 | +let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); | |
| 285 | +// Ajouter &code_challenge=...&code_challenge_method=S256 a l'URL | |
| 286 | +// Retourner pkce_verifier: Some(pkce_verifier.secret().to_string()) | |
| 287 | +``` | |
| 288 | + | |
| 289 | +Note : GitHub supporte PKCE depuis 2021, Discord depuis 2023. | |
| 290 | + | |
| 291 | +### H7 -- redirect_uri non encode dans les URLs OAuth | |
| 292 | + | |
| 293 | +**Fichiers** : Tous les providers (github.rs, discord.rs, google.rs, microsoft.rs) | |
| 294 | + | |
| 295 | +**Correction** : URL-encoder `redirect_uri` dans tous les `format!()` : | |
| 296 | + | |
| 297 | +```rust | |
| 298 | +use urlencoding::encode; | |
| 299 | +// Remplacer redirect_uri={} par redirect_uri={} | |
| 300 | +// avec encode(redirect_uri) | |
| 301 | +``` | |
| 302 | + | |
| 303 | +### H8 -- Nonce vide accepte pour OIDC | |
| 304 | + | |
| 305 | +**Fichiers** : | |
| 306 | + | |
| 307 | +- [crates/rustwarden-core/src/services/oauth/providers/google.rs](crates/rustwarden-core/src/services/oauth/providers/google.rs) (ligne 110) | |
| 308 | +- [crates/rustwarden-core/src/services/oauth/providers/microsoft.rs](crates/rustwarden-core/src/services/oauth/providers/microsoft.rs) (ligne 118) | |
| 309 | + | |
| 310 | +**Correction** : Retourner une erreur si le nonce est absent au lieu de passer une chaine vide : | |
| 311 | + | |
| 312 | +```rust | |
| 313 | +let nonce_str = nonce_str.ok_or_else(|| { | |
| 314 | + AppError::Internal("OIDC nonce missing from session - possible replay attack".to_string()) | |
| 315 | +})?; | |
| 316 | +let nonce = Nonce::new(nonce_str.to_string()); | |
| 317 | +``` | |
| 318 | + | |
| 319 | +### H9 -- `optional_auth` ne propage pas les claims | |
| 320 | + | |
| 321 | +**Fichier** : [crates/rustwarden-core/src/middleware/auth_service.rs](crates/rustwarden-core/src/middleware/auth_service.rs) (lignes 180-183) | |
| 322 | + | |
| 323 | +**Correction** : Dans la branche ou le JWT est present et valide dans `optional_auth`, inserer les claims dans les extensions de la requete comme le fait `auth_required`. Si le JWT est absent ou invalide, laisser passer sans claims (comportement attendu d'un middleware optionnel). | |
| 324 | + | |
| 325 | +--- | |
| 326 | + | |
| 327 | +## Phase 3 : MOYENNES (M1-M10) | |
| 328 | + | |
| 329 | +### M1 -- `AuthConfig::from_env()` relu a chaque requete | |
| 330 | + | |
| 331 | +**Fichiers** : | |
| 332 | + | |
| 333 | +- [crates/rustwarden-core/src/auth/extractors.rs](crates/rustwarden-core/src/auth/extractors.rs) (ligne 194) | |
| 334 | +- [crates/rustwarden-core/src/app.rs](crates/rustwarden-core/src/app.rs) | |
| 335 | + | |
| 336 | +**Correction** : Charger `AuthConfig` une seule fois dans `app.rs` et l'injecter via `Extension<Arc<AuthConfig>>`. Modifier l'extracteur `AuthUser` pour lire depuis l'extension au lieu de `from_env()`. | |
| 337 | + | |
| 338 | +### M2 -- Rate limiter en memoire sans borne | |
| 339 | + | |
| 340 | +**Fichier** : [crates/rustwarden-core/src/middleware/rate_limit.rs](crates/rustwarden-core/src/middleware/rate_limit.rs) | |
| 341 | + | |
| 342 | +**Correction** : | |
| 343 | + | |
| 344 | +- Ajouter une taille maximale a la HashMap (ex: 100 000 entrees). Si la limite est atteinte, purger les entrees expirees ; si toujours plein, rejeter. | |
| 345 | +- Ajouter une purge periodique (toutes les 5 min) via un `tokio::spawn` au demarrage. | |
| 346 | + | |
| 347 | +### M3 -- Pas de longueur max mot de passe | |
| 348 | + | |
| 349 | +**Fichier** : [crates/rustwarden-core/src/middleware/password_complexity.rs](crates/rustwarden-core/src/middleware/password_complexity.rs) | |
| 350 | + | |
| 351 | +**Correction** : Ajouter `max_length: usize` a `PasswordRules` (defaut: 128). Valider avant l'appel bcrypt pour eviter le DoS (bcrypt tronque silencieusement a 72 octets). | |
| 352 | + | |
| 353 | +### M4 -- Pas de limite de refresh tokens actifs | |
| 354 | + | |
| 355 | +**Fichier** : [crates/rustwarden-core/src/services/refresh_token_service.rs](crates/rustwarden-core/src/services/refresh_token_service.rs) | |
| 356 | + | |
| 357 | +**Correction** : Ajouter une constante `MAX_ACTIVE_TOKENS_PER_USER` (defaut: 10). Dans `create_refresh_token`, compter les tokens actifs (non revoques, non expires) et revoquer les plus anciens si la limite est depassee. | |
| 358 | + | |
| 359 | +### M5 -- Bcrypt cost non configurable | |
| 360 | + | |
| 361 | +**Fichier** : [crates/rustwarden-core/src/services/password_service.rs](crates/rustwarden-core/src/services/password_service.rs) | |
| 362 | + | |
| 363 | +**Correction** : Lire `BCRYPT_COST` depuis les variables d'environnement (defaut: 12, min: 10, max: 20). | |
| 364 | + | |
| 365 | +### M6 -- JWT dans body ET cookie | |
| 366 | + | |
| 367 | +**Fichier** : [crates/rustwarden-core/src/auth/handlers.rs](crates/rustwarden-core/src/auth/handlers.rs) (lignes 310-357) | |
| 368 | + | |
| 369 | +**Correction** : Documenter le choix architectural (necessaire pour les SPA qui font des appels API Bearer). Pas de changement de comportement, mais ajouter un commentaire de securite expliquant le trade-off et recommandant CSP strict pour mitiger XSS. | |
| 370 | + | |
| 371 | +### M7 -- Pas d'invalidation JWT au changement de mot de passe | |
| 372 | + | |
| 373 | +**Approche** : Ajouter un champ `password_changed_at` dans les claims JWT. Lors de la validation, comparer avec le `updated_at` de l'utilisateur en base. Si le mot de passe a change apres l'emission du token, rejeter. Cela implique un hit DB supplementaire a chaque requete authentifiee (acceptable car deja fait dans `optional_auth`/`auth_required` pour certaines routes). | |
| 374 | + | |
| 375 | +**Alternative plus legere** : Lors du changement de mot de passe, ajouter tous les JWT actifs de l'utilisateur a la blacklist via `jwt_blacklist_service`. | |
| 376 | + | |
| 377 | +### M8 -- OAuth secrets non dechiffres dans `from_env()` | |
| 378 | + | |
| 379 | +**Fichier** : [crates/rustwarden-core/src/config/oauth.rs](crates/rustwarden-core/src/config/oauth.rs) (lignes 156-172) | |
| 380 | + | |
| 381 | +**Correction** : Le fallback `from_env()` lit directement les variables d'environnement en clair, ce qui est correct (les secrets dans les env vars ne sont pas chiffres). Pas de changement necessaire -- c'est un faux positif. | |
| 382 | + | |
| 383 | +### M9 -- `COOKIE_SECURE=false` accepte en production | |
| 384 | + | |
| 385 | +**Fichier** : [crates/rustwarden-core/src/config/auth.rs](crates/rustwarden-core/src/config/auth.rs) (ligne 110) | |
| 386 | + | |
| 387 | +**Correction** : Si `APP_DEBUG=false` (production), forcer `cookie_secure = true` et emettre un warning si `COOKIE_SECURE=false` est explicitement defini. | |
| 388 | + | |
| 389 | +### M10 -- Middlewares no-op | |
| 390 | + | |
| 391 | +**Fichiers** : | |
| 392 | + | |
| 393 | +- [crates/rustwarden-core/src/middleware/error_handler.rs](crates/rustwarden-core/src/middleware/error_handler.rs) | |
| 394 | +- [crates/rustwarden-core/src/middleware/password_complexity.rs](crates/rustwarden-core/src/middleware/password_complexity.rs) | |
| 395 | + | |
| 396 | +**Correction** : Implementer `password_complexity_validation` (extraire le body, valider le mot de passe, rejeter si non conforme). Pour `error_handler`, soit implementer les error codes, soit supprimer le middleware mort. | |
| 397 | + | |
| 398 | +--- | |
| 399 | + | |
| 400 | +## Phase 4 : BASSES (L1-L8) | |
| 401 | + | |
| 402 | +### L1 -- JWT_SECRET d'exemple non rejete | |
| 403 | + | |
| 404 | +**Fichier** : [crates/rustwarden-core/src/config/auth.rs](crates/rustwarden-core/src/config/auth.rs) | |
| 405 | + | |
| 406 | +**Correction** : Au demarrage, comparer le `JWT_SECRET` a une liste de valeurs d'exemple connues et paniquer si trouvee. | |
| 407 | + | |
| 408 | +### L2 -- Ajout de `jti` et `aud` dans les claims JWT | |
| 409 | + | |
| 410 | +**Fichier** : [crates/rustwarden-core/src/auth/jwt.rs](crates/rustwarden-core/src/auth/jwt.rs) | |
| 411 | + | |
| 412 | +**Correction** : Ajouter `jti: Uuid::new_v4().to_string()` et `aud: config.jwt_audience` (nouvelle variable d'env, defaut: nom de l'app). Valider `aud` lors de la verification. | |
| 413 | + | |
| 414 | +### L3 -- Mot de passe par defaut trop previsible | |
| 415 | + | |
| 416 | +**Fichier** : [crates/rustwarden-core/src/services/password_service.rs](crates/rustwarden-core/src/services/password_service.rs) (lignes 73-77) | |
| 417 | + | |
| 418 | +**Correction** : Generer un mot de passe avec plus d'entropie (au moins 128 bits) en utilisant un alphabet alphanumerique + special, 24 caracteres minimum. | |
| 419 | + | |
| 420 | +### L4 -- Regles de complexite toutes desactivees | |
| 421 | + | |
| 422 | +**Fichier** : [crates/rustwarden-core/src/middleware/password_complexity.rs](crates/rustwarden-core/src/middleware/password_complexity.rs) | |
| 423 | + | |
| 424 | +**Correction** : Activer par defaut `require_uppercase`, `require_lowercase`, `require_digits`. Laisser `require_special` en option. | |
| 425 | + | |
| 426 | +### L5 -- Usernames logges en clair | |
| 427 | + | |
| 428 | +**Fichier** : [crates/rustwarden-core/src/services/auth_service.rs](crates/rustwarden-core/src/services/auth_service.rs) | |
| 429 | + | |
| 430 | +**Correction** : Masquer partiellement les usernames dans les logs (`adm`*** au lieu de `admin`). | |
| 431 | + | |
| 432 | +### L6 -- Nom du cookie de session generique | |
| 433 | + | |
| 434 | +**Fichier** : [crates/rustwarden-core/src/auth/session.rs](crates/rustwarden-core/src/auth/session.rs) | |
| 435 | + | |
| 436 | +**Correction** : Ajouter `.with_name("__Host-sid")` (ou configurable via `SESSION_COOKIE_NAME`). | |
| 437 | + | |
| 438 | +### L7 -- Validation email trop simpliste | |
| 439 | + | |
| 440 | +**Fichier** : [crates/rustwarden-core/src/services/email_sanitization.rs](crates/rustwarden-core/src/services/email_sanitization.rs) | |
| 441 | + | |
| 442 | +**Correction** : Utiliser `validator::validate_email()` (le crate `validator` est deja en dependance) au lieu de la regex manuelle. | |
| 443 | + | |
| 444 | +### L8 -- SameSite contradictoire (Lax vs Strict) | |
| 445 | + | |
| 446 | +**Fichier** : [crates/rustwarden-core/src/config/auth.rs](crates/rustwarden-core/src/config/auth.rs) (ligne 115) | |
| 447 | + | |
| 448 | +**Correction** : Changer la valeur par defaut de `Lax` a `Strict` pour etre coherent avec les commentaires securite, ou mettre a jour les commentaires dans handlers.rs. | |
| 449 | + | |
| 450 | +--- | |
| 451 | + | |
| 452 | +## Recapitulatif par fichier modifie | |
| 453 | + | |
| 454 | +- `auth_service.rs` (service) : C1, L5 | |
| 455 | +- `handlers.rs` (auth) : C2, M6 | |
| 456 | +- `csrf.rs` : C3, H4 | |
| 457 | +- `auth_service.rs` (middleware) : C4, H9 | |
| 458 | +- `admin_service.rs` (middleware) : C4 (utilise sanitize_redirect) | |
| 459 | +- `app.rs` : H1, M1 | |
| 460 | +- `login.html` : H2 | |
| 461 | +- `rate_limit.rs` : H3, M2 | |
| 462 | +- `github.rs` : H6, H7 | |
| 463 | +- `discord.rs` : H6, H7 | |
| 464 | +- `google.rs` : H7, H8 | |
| 465 | +- `microsoft.rs` : H7, H8 | |
| 466 | +- `admin_handler.rs` : H5 | |
| 467 | +- `settings_handler.rs` : H5 | |
| 468 | +- `extractors.rs` : M1 | |
| 469 | +- `password_complexity.rs` : M3, M10, L4 | |
| 470 | +- `refresh_token_service.rs` : M4 | |
| 471 | +- `password_service.rs` : M5, L3 | |
| 472 | +- `auth.rs` (config) : M9, L1, L8 | |
| 473 | +- `jwt.rs` : L2 | |
| 474 | +- `session.rs` : L6 | |
| 475 | +- `email_sanitization.rs` : L7 | |
| 476 | +- `error_handler.rs` : M10 | |
| 477 | +- `Cargo.toml` (core) : urlencoding (C4) | |
| 478 | + |
A
.cursor/plans/rustwarden_framework_generique_3ba43592.plan.md
+321
-0
@@ -0,0 +1,321 @@
| 1 | +--- | |
| 2 | +name: Rustwarden Framework Generique | |
| 3 | +overview: Transformer Rustwarden d'une application monolithique en un framework reutilisable compose d'une library core (auth, RBAC, services), d'un module UI optionnel, d'un builder API pour bootstrapper une app en quelques lignes, et d'un template cargo-generate pour demarrer rapidement. | |
| 4 | +todos: | |
| 5 | + - id: phase0-cleanup | |
| 6 | + content: "Phase 0 : Nettoyer le code mort (organisation_dto, share_dto, sharing_helpers)" | |
| 7 | + status: completed | |
| 8 | + - id: phase1-workspace | |
| 9 | + content: "Phase 1.1 : Convertir Cargo.toml en workspace manifest + creer les 3 crates" | |
| 10 | + status: completed | |
| 11 | + - id: phase1-core | |
| 12 | + content: "Phase 1.2 : Deplacer auth, models, services, middleware, config, i18n, tasks, error dans rustwarden-core" | |
| 13 | + status: completed | |
| 14 | + - id: phase1-ui | |
| 15 | + content: "Phase 1.3 : Deplacer handlers, dto, routes, templates, static, locales dans rustwarden-ui" | |
| 16 | + status: completed | |
| 17 | + - id: phase1-app | |
| 18 | + content: "Phase 1.4 : Creer le binaire app/ de reference" | |
| 19 | + status: completed | |
| 20 | + - id: phase1-imports | |
| 21 | + content: "Phase 1.5 : Adapter tous les imports entre crates + verifier compilation" | |
| 22 | + status: completed | |
| 23 | + - id: phase2-builder | |
| 24 | + content: "Phase 2 : Implementer RustwardenApp builder (encapsuler le bootstrap de main.rs)" | |
| 25 | + status: completed | |
| 26 | + - id: phase3-hooks | |
| 27 | + content: "Phase 3 : Creer le trait RustwardenHooks et l'integrer dans les handlers d'auth" | |
| 28 | + status: completed | |
| 29 | + - id: phase4-migrations | |
| 30 | + content: "Phase 4 : Exposer les migrations core + option auto-migrate" | |
| 31 | + status: completed | |
| 32 | + - id: phase5-template | |
| 33 | + content: "Phase 5 : Creer le template cargo-generate" | |
| 34 | + status: completed | |
| 35 | + - id: phase6-docs | |
| 36 | + content: "Phase 6 : Documentation (README, doc-comments, examples/)" | |
| 37 | + status: completed | |
| 38 | +isProject: false | |
| 39 | +--- | |
| 40 | + | |
| 41 | +# Rendre Rustwarden generique et reutilisable | |
| 42 | + | |
| 43 | +## Etat actuel | |
| 44 | + | |
| 45 | +Le projet est une **application monolithique** (~108 fichiers source) ou tout est couple : | |
| 46 | + | |
| 47 | +- Le bootstrap de 240 lignes dans `main.rs` melange config, DB, email, i18n, tasks, middleware | |
| 48 | +- Les handlers HTML referencent directement les services et les traductions i18n | |
| 49 | +- Les templates Askama sont compiles a build-time (non substituables a runtime) | |
| 50 | +- Du code mort existe : stubs Organisation/Sharing (Feature 024) | |
| 51 | +- Aucune API publique formelle pour les consommateurs externes | |
| 52 | + | |
| 53 | +## Architecture cible | |
| 54 | + | |
| 55 | +```mermaid | |
| 56 | +graph TB | |
| 57 | + subgraph workspace [Workspace Cargo] | |
| 58 | + subgraph core [rustwarden-core] | |
| 59 | + Auth[auth: JWT, extractors, session] | |
| 60 | + Models[models: SeaORM entities] | |
| 61 | + Services[services: User, Role, Permission, Email...] | |
| 62 | + Middleware[middleware: auth, admin, CSRF, rate_limit] | |
| 63 | + Config[config: server, db, auth, email] | |
| 64 | + I18n[i18n: service, loader, middleware] | |
| 65 | + Tasks[tasks: email queue, cleanup] | |
| 66 | + Builder[RustwardenApp builder] | |
| 67 | + Hooks[RustwardenHooks trait] | |
| 68 | + end | |
| 69 | + subgraph ui [rustwarden-ui -- optionnel] | |
| 70 | + Handlers[handlers: login, profile, admin, home] | |
| 71 | + Templates[templates HTML Askama] | |
| 72 | + DTOs[dto: navigation, breadcrumb, user_profile] | |
| 73 | + UIRouter["ui_routes() -> Router"] | |
| 74 | + end | |
| 75 | + subgraph app [mon-application -- binaire] | |
| 76 | + Main["main.rs ~20 lignes"] | |
| 77 | + CustomRoutes[Routes metier custom] | |
| 78 | + CustomHooks[Impl RustwardenHooks] | |
| 79 | + end | |
| 80 | + end | |
| 81 | + app --> core | |
| 82 | + app --> ui | |
| 83 | + ui --> core | |
| 84 | +``` | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | +## Phase 0 : Nettoyage du code mort | |
| 89 | + | |
| 90 | +Supprimer les artefacts inutilises avant la restructuration : | |
| 91 | + | |
| 92 | +- [src/api/dto/organisation_dto.rs](src/api/dto/organisation_dto.rs) -- stub vide | |
| 93 | +- [src/api/dto/share_dto.rs](src/api/dto/share_dto.rs) -- stub vide | |
| 94 | +- [src/templates/helpers/sharing_helpers.rs](src/templates/helpers/sharing_helpers.rs) -- 380 lignes de code mort | |
| 95 | +- References dans les `mod.rs` correspondants | |
| 96 | + | |
| 97 | +## Phase 1 : Workspace Cargo + crate core | |
| 98 | + | |
| 99 | +### 1.1 Convertir en workspace | |
| 100 | + | |
| 101 | +Transformer le `Cargo.toml` racine en workspace manifest : | |
| 102 | + | |
| 103 | +```toml | |
| 104 | +[workspace] | |
| 105 | +members = ["crates/rustwarden-core", "crates/rustwarden-ui", "app"] | |
| 106 | +resolver = "2" | |
| 107 | +``` | |
| 108 | + | |
| 109 | +### 1.2 Creer `crates/rustwarden-core/` | |
| 110 | + | |
| 111 | +Deplacer dans ce crate : | |
| 112 | + | |
| 113 | +- `src/auth/` -> `crates/rustwarden-core/src/auth/` | |
| 114 | +- `src/config/` -> `crates/rustwarden-core/src/config/` | |
| 115 | +- `src/error.rs` -> `crates/rustwarden-core/src/error.rs` | |
| 116 | +- `src/i18n/` -> `crates/rustwarden-core/src/i18n/` | |
| 117 | +- `src/models/` -> `crates/rustwarden-core/src/models/` | |
| 118 | +- `src/services/` -> `crates/rustwarden-core/src/services/` | |
| 119 | +- `src/tasks/` -> `crates/rustwarden-core/src/tasks/` | |
| 120 | +- `src/api/middleware/` -> `crates/rustwarden-core/src/middleware/` | |
| 121 | +- `src/templates/mod.rs` (macro `impl_template_response!` + `LayoutContext`) -> `crates/rustwarden-core/src/templates.rs` | |
| 122 | + | |
| 123 | +Le `Cargo.toml` de core herite de toutes les deps actuelles sauf `askama`. | |
| 124 | + | |
| 125 | +Le `lib.rs` de core expose un **prelude** : | |
| 126 | + | |
| 127 | +```rust | |
| 128 | +pub mod prelude { | |
| 129 | + pub use crate::auth::extractors::AuthUser; | |
| 130 | + pub use crate::auth::jwt::{Claims, JwtService}; | |
| 131 | + pub use crate::error::AppError; | |
| 132 | + pub use crate::services::user_service::UserService; | |
| 133 | + pub use crate::services::role_service::RoleService; | |
| 134 | + pub use crate::services::permission_service::PermissionService; | |
| 135 | + pub use crate::middleware::auth_required::auth_required; | |
| 136 | + pub use crate::middleware::admin_required::admin_required; | |
| 137 | + pub use crate::app::RustwardenApp; | |
| 138 | +} | |
| 139 | +``` | |
| 140 | + | |
| 141 | +### 1.3 Creer `crates/rustwarden-ui/` | |
| 142 | + | |
| 143 | +Deplacer dans ce crate : | |
| 144 | + | |
| 145 | +- `src/api/handlers/` -> `crates/rustwarden-ui/src/handlers/` | |
| 146 | +- `src/api/dto/` -> `crates/rustwarden-ui/src/dto/` | |
| 147 | +- `src/api/routes.rs` -> `crates/rustwarden-ui/src/routes.rs` | |
| 148 | +- `templates/` -> `crates/rustwarden-ui/templates/` | |
| 149 | +- `static/` -> `crates/rustwarden-ui/static/` | |
| 150 | +- `locales/` -> `crates/rustwarden-ui/locales/` | |
| 151 | + | |
| 152 | +Expose une fonction `pub fn default_routes() -> Router<DatabaseConnection>` et `pub fn default_static_service()`. | |
| 153 | + | |
| 154 | +Le `Cargo.toml` depend de `rustwarden-core` + `askama`. | |
| 155 | + | |
| 156 | +### 1.4 Creer `app/` | |
| 157 | + | |
| 158 | +Le binaire de reference (~20 lignes) : | |
| 159 | + | |
| 160 | +```rust | |
| 161 | +use rustwarden_core::prelude::*; | |
| 162 | +use rustwarden_ui; | |
| 163 | + | |
| 164 | +#[tokio::main] | |
| 165 | +async fn main() -> anyhow::Result<()> { | |
| 166 | + RustwardenApp::builder() | |
| 167 | + .from_env() | |
| 168 | + .with_default_ui() // ajoute les routes UI + static + locales | |
| 169 | + .build() | |
| 170 | + .await? | |
| 171 | + .run() | |
| 172 | + .await | |
| 173 | +} | |
| 174 | +``` | |
| 175 | + | |
| 176 | +### 1.5 Adapter les imports | |
| 177 | + | |
| 178 | +Tous les `use crate::` dans les fichiers deplaces deviennent `use rustwarden_core::` ou intra-crate. C'est la partie la plus volumineuse mais mecanique. | |
| 179 | + | |
| 180 | +## Phase 2 : Builder API (`RustwardenApp`) | |
| 181 | + | |
| 182 | +Creer dans `crates/rustwarden-core/src/app.rs` -- encapsule toute la logique actuellement dans `main.rs` : | |
| 183 | + | |
| 184 | +```rust | |
| 185 | +pub struct RustwardenApp { /* router, listener, tasks handles */ } | |
| 186 | + | |
| 187 | +pub struct RustwardenBuilder { | |
| 188 | + database_url: Option<String>, | |
| 189 | + db: Option<DatabaseConnection>, | |
| 190 | + jwt_secret: Option<String>, | |
| 191 | + custom_routes: Vec<Router<DatabaseConnection>>, | |
| 192 | + hooks: Option<Arc<dyn RustwardenHooks>>, | |
| 193 | + enable_email: bool, | |
| 194 | + enable_background_tasks: bool, | |
| 195 | + locale_dirs: Vec<PathBuf>, | |
| 196 | + extra_layers: Vec</* tower layers */>, | |
| 197 | +} | |
| 198 | + | |
| 199 | +impl RustwardenBuilder { | |
| 200 | + pub fn from_env(mut self) -> Self { /* charge .env */ } | |
| 201 | + pub fn database_url(mut self, url: &str) -> Self { ... } | |
| 202 | + pub fn database(mut self, db: DatabaseConnection) -> Self { ... } | |
| 203 | + pub fn merge_routes(mut self, router: Router<DatabaseConnection>) -> Self { ... } | |
| 204 | + pub fn hooks(mut self, h: impl RustwardenHooks + 'static) -> Self { ... } | |
| 205 | + pub fn with_default_ui(mut self) -> Self { ... } | |
| 206 | + pub fn headless(mut self) -> Self { ... } | |
| 207 | + pub fn locale_dir(mut self, path: impl Into<PathBuf>) -> Self { ... } | |
| 208 | + pub async fn build(self) -> Result<RustwardenApp> { ... } | |
| 209 | +} | |
| 210 | + | |
| 211 | +impl RustwardenApp { | |
| 212 | + pub fn builder() -> RustwardenBuilder { ... } | |
| 213 | + pub fn router(self) -> Router { ... } // pour les usages avances | |
| 214 | + pub async fn run(self) -> Result<()> { ... } | |
| 215 | +} | |
| 216 | +``` | |
| 217 | + | |
| 218 | +Le `.build()` effectue : | |
| 219 | + | |
| 220 | +1. Connexion DB (ou reutilise celle fournie) | |
| 221 | +2. Ensure default admin + settings | |
| 222 | +3. Configuration session/JWT | |
| 223 | +4. Demarrage des background tasks (optionnel) | |
| 224 | +5. Chargement i18n | |
| 225 | +6. Assemblage du router avec les middlewares | |
| 226 | + | |
| 227 | +## Phase 3 : Trait RustwardenHooks | |
| 228 | + | |
| 229 | +Dans `crates/rustwarden-core/src/hooks.rs` : | |
| 230 | + | |
| 231 | +```rust | |
| 232 | +#[async_trait] | |
| 233 | +pub trait RustwardenHooks: Send + Sync { | |
| 234 | + async fn on_user_registered(&self, db: &DatabaseConnection, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 235 | + async fn on_user_login(&self, db: &DatabaseConnection, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 236 | + async fn on_user_logout(&self, db: &DatabaseConnection, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 237 | + async fn on_password_changed(&self, db: &DatabaseConnection, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 238 | + async fn on_email_verified(&self, db: &DatabaseConnection, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 239 | + async fn on_role_changed(&self, db: &DatabaseConnection, user_id: Uuid, roles: &[String]) -> Result<()> { Ok(()) } | |
| 240 | +} | |
| 241 | +``` | |
| 242 | + | |
| 243 | +Les handlers d'auth ([src/auth/handlers.rs](src/auth/handlers.rs)) appellent ces hooks aux moments cles. L'implementation par defaut est un no-op. | |
| 244 | + | |
| 245 | +Exemple d'usage dans une app metier : | |
| 246 | + | |
| 247 | +```rust | |
| 248 | +struct MyHooks; | |
| 249 | + | |
| 250 | +#[async_trait] | |
| 251 | +impl RustwardenHooks for MyHooks { | |
| 252 | + async fn on_user_registered(&self, db: &DatabaseConnection, user_id: Uuid) -> Result<()> { | |
| 253 | + // Creer un workspace par defaut pour le nouvel utilisateur | |
| 254 | + MyWorkspaceService::create_default(db, user_id).await | |
| 255 | + } | |
| 256 | +} | |
| 257 | +``` | |
| 258 | + | |
| 259 | +## Phase 4 : Migrations exposees | |
| 260 | + | |
| 261 | +Dans `crates/rustwarden-core/src/migrations.rs`, exposer les migrations core pour que les apps consumer puissent les combiner : | |
| 262 | + | |
| 263 | +```rust | |
| 264 | +pub fn core_migrations() -> Vec<Box<dyn MigrationTrait>> { | |
| 265 | + vec![ | |
| 266 | + Box::new(m20260305_000001_initial_schema::Migration), | |
| 267 | + Box::new(m20260306_000002_create_app_settings_table::Migration), | |
| 268 | + Box::new(m20260309_000003_create_permissions_tables::Migration), | |
| 269 | + ] | |
| 270 | +} | |
| 271 | +``` | |
| 272 | + | |
| 273 | +Option d'auto-migration au demarrage via le builder : `.auto_migrate(true)`. | |
| 274 | + | |
| 275 | +## Phase 5 : Template cargo-generate | |
| 276 | + | |
| 277 | +Creer un dossier `template/` a la racine pour `cargo generate` : | |
| 278 | + | |
| 279 | +``` | |
| 280 | +template/ | |
| 281 | + cargo-generate.toml # metadata (name, description, placeholders) | |
| 282 | + Cargo.toml.liquid # {{ project-name }}, dep sur rustwarden-core + ui | |
| 283 | + src/main.rs.liquid # builder minimal avec placeholder pour routes custom | |
| 284 | + .env.sample # variables d'env documentees | |
| 285 | + README.md.liquid # getting started | |
| 286 | +``` | |
| 287 | + | |
| 288 | +Usage : | |
| 289 | + | |
| 290 | +```bash | |
| 291 | +cargo generate --path ./template --name mon-app | |
| 292 | +``` | |
| 293 | + | |
| 294 | +## Phase 6 : Documentation | |
| 295 | + | |
| 296 | +- README.md complet : architecture, getting started, API reference | |
| 297 | +- Doc-comments Rust sur toute l'API publique du core | |
| 298 | +- Dossier `examples/` avec : | |
| 299 | + - `examples/minimal.rs` -- app minimale headless (API only) | |
| 300 | + - `examples/with_ui.rs` -- app avec UI par defaut | |
| 301 | + - `examples/custom_hooks.rs` -- app avec hooks metier | |
| 302 | + | |
| 303 | +## Estimation de l'effort | |
| 304 | + | |
| 305 | +- **Phase 0** (nettoyage) : ~30 min | |
| 306 | +- **Phase 1** (workspace + split) : ~4-6h -- le plus gros du travail, mecanique mais volumineux | |
| 307 | +- **Phase 2** (builder) : ~2-3h | |
| 308 | +- **Phase 3** (hooks) : ~1-2h | |
| 309 | +- **Phase 4** (migrations) : ~30 min | |
| 310 | +- **Phase 5** (cargo-generate) : ~1h | |
| 311 | +- **Phase 6** (documentation) : ~2h | |
| 312 | + | |
| 313 | +**Total estime : 10-15h de travail** | |
| 314 | + | |
| 315 | +## Ordre de priorite recommande | |
| 316 | + | |
| 317 | +1. Phase 0 + Phase 1 en premier (fondation) | |
| 318 | +2. Phase 2 (builder) immediatement apres (c'est ce qui rend le framework utilisable) | |
| 319 | +3. Phase 3 (hooks) pour l'extensibilite | |
| 320 | +4. Phases 4-6 en finition | |
| 321 | + |
A
.cursor/plans/rustwarden_generic_framework_a6e13a43.plan.md
+164
-0
@@ -0,0 +1,164 @@
| 1 | +--- | |
| 2 | +name: Rustwarden generic framework | |
| 3 | +overview: Transformer Rustwarden en une bibliotheque Rust (crate) reutilisable que l'on ajoute en dependance Cargo.toml, avec un builder pattern pour l'initialisation, des points d'extension (routes, navigation), et toutes les valeurs de branding configurables via app_settings. | |
| 4 | +todos: | |
| 5 | + - id: cleanup | |
| 6 | + content: "Chantier 5 : Supprimer organisation_dto, share_dto, sharing_helpers, liens/variables organisations dans dashboard" | |
| 7 | + status: pending | |
| 8 | + - id: branding | |
| 9 | + content: "Chantier 4 : Remplacer ~80 occurrences hardcodees RustWarden/RustCloud par des valeurs app_settings dynamiques" | |
| 10 | + status: pending | |
| 11 | + - id: routes-config | |
| 12 | + content: "Chantier 2 : Rendre protected_routes et admin_routes configurables dans AuthService/AdminService" | |
| 13 | + status: pending | |
| 14 | + - id: nav-extensible | |
| 15 | + content: "Chantier 3 : Rendre NavigationDTO::for_user() extensible avec des items supplementaires" | |
| 16 | + status: pending | |
| 17 | + - id: builder | |
| 18 | + content: "Chantier 1 : Creer RustwardenApp builder encapsulant toute l'initialisation, transformer main.rs en consommateur" | |
| 19 | + status: pending | |
| 20 | + - id: locales | |
| 21 | + content: "Chantier 7 : Rendre les locales extensibles (chemin configurable, merge de cles, liste langues dynamique)" | |
| 22 | + status: pending | |
| 23 | + - id: docs | |
| 24 | + content: "Chantier 6 : .env.example, exemples, doc-comments API publique, mise a jour README" | |
| 25 | + status: pending | |
| 26 | +isProject: false | |
| 27 | +--- | |
| 28 | + | |
| 29 | +# Rendre Rustwarden generique : plan de transformation en crate reutilisable | |
| 30 | + | |
| 31 | +## Etat actuel | |
| 32 | + | |
| 33 | +Le projet est deja structure en `lib.rs` + `main.rs` (binaire). ~95% du code est generique (user management), ~5% est specifique (stubs organisations/shares, branding hardcode). La base est saine. | |
| 34 | + | |
| 35 | +## Architecture cible | |
| 36 | + | |
| 37 | +```mermaid | |
| 38 | +graph TD | |
| 39 | + ConsumerApp["Mon Application (main.rs)"] -->|"depend de"| RustwardenCrate["rustwarden (crate lib)"] | |
| 40 | + ConsumerApp -->|"ajoute ses propres"| CustomRoutes["Routes metier"] | |
| 41 | + ConsumerApp -->|"configure via"| AppSettings["app_settings (DB)"] | |
| 42 | + ConsumerApp -->|"etend"| CustomNav["Nav items supplementaires"] | |
| 43 | + | |
| 44 | + RustwardenCrate --> Auth["Auth (JWT, sessions)"] | |
| 45 | + RustwardenCrate --> UserMgmt["User Management"] | |
| 46 | + RustwardenCrate --> AdminPanel["Admin Panel"] | |
| 47 | + RustwardenCrate --> Templates["Templates Askama"] | |
| 48 | + RustwardenCrate --> I18n["I18n (locales)"] | |
| 49 | + RustwardenCrate --> EmailSvc["Email Service"] | |
| 50 | +``` | |
| 51 | + | |
| 52 | +L'application consommatrice fait : | |
| 53 | + | |
| 54 | +```rust | |
| 55 | +use rustwarden::RustwardenApp; | |
| 56 | + | |
| 57 | +#[tokio::main] | |
| 58 | +async fn main() { | |
| 59 | + let app = RustwardenApp::builder() | |
| 60 | + .with_extra_routes(my_routes()) | |
| 61 | + .with_extra_nav_items(vec![ | |
| 62 | + NavItem::new("dashboard", "Dashboard", "chart", "/dashboard"), | |
| 63 | + ]) | |
| 64 | + .with_extra_protected_paths(&["/dashboard", "/my-feature"]) | |
| 65 | + .build() | |
| 66 | + .await | |
| 67 | + .expect("Failed to build app"); | |
| 68 | + | |
| 69 | + app.serve().await; | |
| 70 | +} | |
| 71 | +``` | |
| 72 | + | |
| 73 | +## Chantiers a realiser | |
| 74 | + | |
| 75 | +### Chantier 1 : Builder pattern (`RustwardenApp`) | |
| 76 | + | |
| 77 | +Creer un struct `RustwardenApp` dans [src/lib.rs](src/lib.rs) avec un builder qui encapsule toute la logique d'initialisation actuellement dans [src/main.rs](src/main.rs) : | |
| 78 | + | |
| 79 | +- Connexion DB, creation admin, init app_settings | |
| 80 | +- Configuration auth, sessions, email | |
| 81 | +- Demarrage des taches de fond (email queue, JWT cleanup, IMAP bounce) | |
| 82 | +- Chargement i18n | |
| 83 | +- Construction du Router Axum avec middleware stack | |
| 84 | +- Methode `.serve()` pour lancer le serveur | |
| 85 | + | |
| 86 | +Points d'extension du builder : | |
| 87 | +- `.with_extra_routes(Router)` : merge des routes custom | |
| 88 | +- `.with_extra_nav_items(Vec<NavigationItemDTO>)` : items de navigation supplementaires | |
| 89 | +- `.with_extra_protected_paths(&[&str])` : routes protegees additionnelles | |
| 90 | +- `.with_extra_admin_paths(&[&str])` : routes admin additionnelles | |
| 91 | +- `.with_locale_directory(&str)` : chemin vers les fichiers de locale | |
| 92 | +- `.with_static_directory(&str)` : chemin vers les fichiers statiques | |
| 93 | + | |
| 94 | +Le `main.rs` actuel devient un consommateur minimal du builder (exemple de reference). | |
| 95 | + | |
| 96 | +### Chantier 2 : Routes protegees configurables | |
| 97 | + | |
| 98 | +Dans [src/api/middleware/auth_service.rs](src/api/middleware/auth_service.rs), les routes protegees sont hardcodees : | |
| 99 | + | |
| 100 | +```rust | |
| 101 | +let protected_routes = ["/home", "/settings", "/test-base", "/profil"]; | |
| 102 | +``` | |
| 103 | + | |
| 104 | +Passer a une configuration injectable via le builder : | |
| 105 | +- `AuthService::new(inner, db, protected_paths)` au lieu de hardcoder | |
| 106 | +- Idem pour `AdminService` dans [src/api/middleware/admin_service.rs](src/api/middleware/admin_service.rs) (actuellement hardcode `/admin` et `/api/v1/admin`) | |
| 107 | +- Les chemins par defaut restent les memes, le builder permet d'en ajouter | |
| 108 | + | |
| 109 | +### Chantier 3 : Navigation extensible | |
| 110 | + | |
| 111 | +Dans [src/api/dto/navigation_dto.rs](src/api/dto/navigation_dto.rs), la methode `NavigationDTO::for_user()` construit un menu statique (Home, Settings, Admin). | |
| 112 | + | |
| 113 | +Modifier pour accepter des items supplementaires : | |
| 114 | +- Ajouter un parametre `extra_items: &[NavigationItemDTO]` a `for_user()` | |
| 115 | +- Stocker les extra items dans un `Arc<Vec<NavigationItemDTO>>` partage via `Extension` | |
| 116 | +- Le builder injecte ces items au demarrage | |
| 117 | + | |
| 118 | +### Chantier 4 : Eliminer le branding hardcode | |
| 119 | + | |
| 120 | +**~80 occurrences** de "RustWarden"/"RustCloud" en dur dans le code. Il faut les remplacer par des valeurs lues depuis `app_settings` : | |
| 121 | + | |
| 122 | +- **Fichiers Rust** (fallbacks) : [src/templates/mod.rs](src/templates/mod.rs) (`LayoutContext::new` hardcode "RustWarden"), [src/templates/helpers.rs](src/templates/helpers.rs) (fallback `@rustwarden.local`), [src/auth/jwt.rs](src/auth/jwt.rs) (issuer "rustwarden"), [src/services/password_service.rs](src/services/password_service.rs) (format "RustWarden-{uuid}"), [src/templates/email_templates.rs](src/templates/email_templates.rs) (fallback "RustWarden" dans tous les emails) | |
| 123 | +- **Templates HTML** : [templates/login.html](templates/login.html), [templates/components/navbar.html](templates/components/navbar.html), [templates/errors/*.html](templates/errors/), [templates/base.html](templates/base.html) -- la plupart utilisent deja `{{ app_name }}`, mais quelques-uns sont hardcodes | |
| 124 | +- **Locales** : les page_title contiennent "RustCloud" dans les 4 fichiers yml -- remplacer par des references a `{app_name}` | |
| 125 | +- **CSS** : [static/css/tailwind.src.css](static/css/tailwind.src.css) theme "rustwarden", couleurs `#0082c9`/`#ce422b` -- rendre configurable via CSS variables injectees depuis app_settings | |
| 126 | + | |
| 127 | +Strategie : passer `app_name` et `app_domain` depuis `app_settings` dans tous les contextes de template (deja fait pour la plupart des handlers via `build_base_context`). Corriger les quelques endroits restants. | |
| 128 | + | |
| 129 | +### Chantier 5 : Nettoyage des elements specifiques | |
| 130 | + | |
| 131 | +Supprimer les stubs et remnants de Rustcloud : | |
| 132 | +- [src/api/dto/organisation_dto.rs](src/api/dto/organisation_dto.rs) -- stub organisations | |
| 133 | +- [src/api/dto/share_dto.rs](src/api/dto/share_dto.rs) -- stub partages | |
| 134 | +- [src/templates/helpers/sharing_helpers.rs](src/templates/helpers/sharing_helpers.rs) -- helpers organisations/partages | |
| 135 | +- Liens `/admin/apps` et `/admin/organisations` dans [templates/admin/dashboard.html](templates/admin/dashboard.html) | |
| 136 | +- Variables `total_organisations`, `total_organisation_members` dans [src/api/handlers/admin_handler.rs](src/api/handlers/admin_handler.rs) | |
| 137 | + | |
| 138 | +### Chantier 6 : Documentation et exemple | |
| 139 | + | |
| 140 | +- Creer un `.env.example` complet avec toutes les variables et commentaires | |
| 141 | +- Transformer le `main.rs` actuel en exemple reference dans un dossier `examples/` | |
| 142 | +- Documenter l'API publique du builder dans `lib.rs` avec des doc-comments | |
| 143 | +- Mettre a jour le `README.md` avec : installation, configuration, usage minimal, points d'extension | |
| 144 | + | |
| 145 | +### Chantier 7 : Locales extensibles | |
| 146 | + | |
| 147 | +Actuellement les locales sont hardcodees ("fr", "en", "de", "es") dans : | |
| 148 | +- [src/main.rs](src/main.rs) lignes 20-21 (locale_middleware) | |
| 149 | +- Le service I18n charge depuis un dossier fixe `locales/` | |
| 150 | + | |
| 151 | +Modifier pour : | |
| 152 | +- Accepter un chemin de locales configurable | |
| 153 | +- Permettre au consommateur d'ajouter ses propres cles de traduction (merge avec celles de rustwarden) | |
| 154 | +- Rendre la liste des langues supportees configurable | |
| 155 | + | |
| 156 | +## Ordre d'execution recommande | |
| 157 | + | |
| 158 | +1. **Chantier 5** (nettoyage) -- rapide, reduit le bruit | |
| 159 | +2. **Chantier 4** (branding) -- elimine les valeurs hardcodees | |
| 160 | +3. **Chantier 2** (routes configurables) -- prerequis pour le builder | |
| 161 | +4. **Chantier 3** (navigation extensible) -- prerequis pour le builder | |
| 162 | +5. **Chantier 1** (builder) -- le coeur de la transformation | |
| 163 | +6. **Chantier 7** (locales extensibles) -- amelioration | |
| 164 | +7. **Chantier 6** (documentation) -- finalisation |
A
.cursor/plans/wizard_env_configuration_356fe268.plan.md
+70
-0
@@ -0,0 +1,70 @@
| 1 | +--- | |
| 2 | +name: Wizard env configuration | |
| 3 | +overview: Ajouter des questions au wizard pour configurer les identifiants de la base de donnees, l'admin, et le JWT secret, puis generer des fichiers .env fonctionnels (app + database) au lieu de templates avec des valeurs par defaut. | |
| 4 | +todos: | |
| 5 | + - id: add-rand-dep | |
| 6 | + content: Ajouter rand dans Cargo.toml du CLI | |
| 7 | + status: completed | |
| 8 | + - id: update-templates | |
| 9 | + content: Enrichir ProjectConfig et ajouter dot_env(), dot_env_example(), database_dot_env() | |
| 10 | + status: completed | |
| 11 | + - id: update-wizard | |
| 12 | + content: Ajouter les questions database + admin + generation JWT/passwords dans start_project.rs | |
| 13 | + status: completed | |
| 14 | + - id: test-cli | |
| 15 | + content: Compiler et verifier le CLI | |
| 16 | + status: completed | |
| 17 | +isProject: false | |
| 18 | +--- | |
| 19 | + | |
| 20 | +# Wizard : configuration des .env | |
| 21 | + | |
| 22 | +## Nouvelles questions du wizard | |
| 23 | + | |
| 24 | +Apres les questions existantes (nom, description, dossier, mode, migration, port), ajouter : | |
| 25 | + | |
| 26 | +### Bloc "Base de donnees" | |
| 27 | + | |
| 28 | +- **Nom de la base** (Input, defaut: nom du projet) | |
| 29 | +- **Utilisateur PostgreSQL** (Input, defaut: `<nom>_user`) | |
| 30 | +- **Mot de passe PostgreSQL** (Input, defaut: genere aleatoirement 16 chars) | |
| 31 | + | |
| 32 | +### Bloc "Administrateur" | |
| 33 | + | |
| 34 | +- **Username admin** (Input, defaut: `admin`) | |
| 35 | +- **Email admin** (Input, defaut: `admin@localhost`) | |
| 36 | +- **Mot de passe admin** (Input, defaut: genere aleatoirement 12 chars) | |
| 37 | + | |
| 38 | +### JWT Secret | |
| 39 | + | |
| 40 | +Genere automatiquement (32 chars aleatoires hex) -- pas de question, juste une generation. | |
| 41 | + | |
| 42 | +## Fichiers modifies | |
| 43 | + | |
| 44 | +### [tools/rustwarden-cli/Cargo.toml](tools/rustwarden-cli/Cargo.toml) | |
| 45 | + | |
| 46 | +- Ajouter `rand = "0.8"` pour la generation de mots de passe et JWT secret | |
| 47 | + | |
| 48 | +### [tools/rustwarden-cli/src/templates.rs](tools/rustwarden-cli/src/templates.rs) | |
| 49 | + | |
| 50 | +- Enrichir `ProjectConfig` avec les champs : `db_name`, `db_user`, `db_password`, `admin_username`, `admin_email`, `admin_password`, `jwt_secret` | |
| 51 | +- `env_example()` -> renommer en `dot_env()` : genere `.env` avec les **vraies valeurs** | |
| 52 | +- Ajouter `dot_env_example()` : version commentee/documentee avec des placeholders (pour reference) | |
| 53 | +- Ajouter `database_dot_env()` : genere `database/.env` avec les credentials alignes | |
| 54 | + | |
| 55 | +### [tools/rustwarden-cli/src/start_project.rs](tools/rustwarden-cli/src/start_project.rs) | |
| 56 | + | |
| 57 | +- Ajouter `ask_database_config()` et `ask_admin_config()` | |
| 58 | +- Ajouter `generate_random_password(len)` et `generate_jwt_secret()` | |
| 59 | +- Dans `generate_files()` : generer `.env`, `.env.example`, et `database/.env` | |
| 60 | +- Apres la copie de `database/`, ecraser `database/.env` avec les valeurs saisies | |
| 61 | + | |
| 62 | +## Resultat | |
| 63 | + | |
| 64 | +Le projet genere contient : | |
| 65 | + | |
| 66 | +- `.env` : pret a l'emploi avec les vrais identifiants | |
| 67 | +- `.env.example` : documentation des variables disponibles | |
| 68 | +- `database/.env` : credentials PostgreSQL alignes avec le `.env` principal | |
| 69 | +- Plus aucun credential de developpement par defaut | |
| 70 | + |
A
.cursor/plans/wizard_start_project_cli_8798cf97.plan.md
+163
-0
@@ -0,0 +1,163 @@
| 1 | +--- | |
| 2 | +name: Wizard start_project CLI | |
| 3 | +overview: Creer un binaire CLI independant `rustwarden-cli` dans le workspace qui pose des questions interactives et genere un dossier projet complet (Cargo.toml, main.rs, .env, docker-compose.yml, README) pret a compiler et lancer. | |
| 4 | +todos: | |
| 5 | + - id: cli-crate | |
| 6 | + content: Creer tools/rustwarden-cli/ avec Cargo.toml, ajouter au workspace | |
| 7 | + status: pending | |
| 8 | + - id: cli-main | |
| 9 | + content: Implementer main.rs avec clap (sous-commande start-project) | |
| 10 | + status: pending | |
| 11 | + - id: cli-templates | |
| 12 | + content: Ecrire les templates de fichiers generes dans templates.rs | |
| 13 | + status: pending | |
| 14 | + - id: cli-wizard | |
| 15 | + content: Implementer le wizard interactif avec dialoguer dans start_project.rs | |
| 16 | + status: pending | |
| 17 | + - id: cli-generation | |
| 18 | + content: Implementer la generation des fichiers et le resume final | |
| 19 | + status: pending | |
| 20 | + - id: cli-test | |
| 21 | + content: Tester le CLI avec cargo run -p rustwarden-cli -- start-project | |
| 22 | + status: pending | |
| 23 | +isProject: false | |
| 24 | +--- | |
| 25 | + | |
| 26 | +# Wizard `start_project` -- CLI interactive | |
| 27 | + | |
| 28 | +## Objectif | |
| 29 | + | |
| 30 | +Creer un crate binaire `tools/rustwarden-cli` dans le workspace, contenant un sous-commande `start-project` qui genere interactivement un dossier application pret a l'emploi. | |
| 31 | + | |
| 32 | +## Architecture | |
| 33 | + | |
| 34 | +```mermaid | |
| 35 | +graph LR | |
| 36 | + User -->|"rustwarden-cli start-project"| CLI | |
| 37 | + CLI -->|questions interactives| Wizard | |
| 38 | + Wizard -->|genere| Output["mon-app/"] | |
| 39 | + Output --> CargoToml["Cargo.toml"] | |
| 40 | + Output --> MainRs["src/main.rs"] | |
| 41 | + Output --> DotEnv[".env"] | |
| 42 | + Output --> Docker["database/docker-compose.yml"] | |
| 43 | + Output --> Readme["README.md"] | |
| 44 | + Output --> Gitignore[".gitignore"] | |
| 45 | +``` | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | +## Structure du crate | |
| 50 | + | |
| 51 | +Nouveau dossier `tools/rustwarden-cli/` dans le workspace : | |
| 52 | + | |
| 53 | +``` | |
| 54 | +tools/rustwarden-cli/ | |
| 55 | + Cargo.toml | |
| 56 | + src/ | |
| 57 | + main.rs # point d'entree CLI (clap) | |
| 58 | + commands/ | |
| 59 | + mod.rs | |
| 60 | + start_project.rs # logique du wizard | |
| 61 | + templates.rs # contenus des fichiers generes (const &str) | |
| 62 | +``` | |
| 63 | + | |
| 64 | +Le [Cargo.toml](Cargo.toml) workspace sera mis a jour pour ajouter `"tools/rustwarden-cli"` dans `members`. | |
| 65 | + | |
| 66 | +## Dependances du CLI | |
| 67 | + | |
| 68 | +- `clap` (avec feature `derive`) -- parsing des arguments et sous-commandes | |
| 69 | +- `dialoguer` -- questions interactives (Input, Select, Confirm) avec couleurs | |
| 70 | +- `rand` -- generation du JWT_SECRET aleatoire | |
| 71 | +- `console` -- affichage stylise dans le terminal | |
| 72 | + | |
| 73 | +Pas de dependance sur `rustwarden-core` ni `rustwarden-ui` : le CLI est autonome (separation des responsabilites). | |
| 74 | + | |
| 75 | +## Questions du wizard | |
| 76 | + | |
| 77 | +Le wizard `start-project` posera ces questions dans l'ordre : | |
| 78 | + | |
| 79 | +1. **Nom du projet** (Input, defaut : nom du dossier courant) | |
| 80 | +2. **Description** (Input, optionnel) | |
| 81 | +3. **Mode UI** (Select : "UI complete (HTML + admin)" / "Headless API-only") | |
| 82 | +4. **Port du serveur** (Input, defaut : 3000) | |
| 83 | +5. **Base de donnees** : | |
| 84 | + - Host (defaut : localhost) | |
| 85 | + - Port (defaut : 5432) | |
| 86 | + - Nom de la DB (defaut : nom du projet) | |
| 87 | + - Utilisateur (defaut : nom du projet) | |
| 88 | + - Mot de passe (Input masque, genere aleatoirement si vide) | |
| 89 | +6. **Admin par defaut** : | |
| 90 | + - Username (defaut : admin) | |
| 91 | + - Email (Input, requis) | |
| 92 | + - Mot de passe (Input masque, defaut genere) | |
| 93 | +7. **Email SMTP** (Confirm : "Configurer l'envoi d'emails ?") | |
| 94 | + - Si oui : SMTP host, port, user, password, from | |
| 95 | +8. **Auto-migrate** (Confirm, defaut : oui) | |
| 96 | +9. **Generer docker-compose.yml ?** (Confirm, defaut : oui) | |
| 97 | + | |
| 98 | +## Fichiers generes | |
| 99 | + | |
| 100 | +Pour chaque fichier, le contenu sera stocke comme template `&str` dans `templates.rs` avec des placeholders `{name}`, `{port}`, etc. remplaces via `str::replace()`. | |
| 101 | + | |
| 102 | +### Fichiers toujours generes | |
| 103 | + | |
| 104 | +- `**Cargo.toml`** -- deps vers rustwarden-core + rustwarden-ui (conditionnel au mode) | |
| 105 | +- `**src/main.rs**` -- builder Rustwarden avec les options choisies | |
| 106 | +- `**.env**` -- toutes les variables configurees, avec commentaires explicatifs | |
| 107 | +- `**.gitignore**` -- target/, .env, database/data/ | |
| 108 | +- `**README.md**` -- instructions de demarrage specifiques au projet | |
| 109 | + | |
| 110 | +### Fichiers conditionnels | |
| 111 | + | |
| 112 | +- `**database/docker-compose.yml**` + `**database/.env**` -- si docker-compose demande | |
| 113 | +- `**locales/**` -- si mode UI, copie des fichiers locales de reference | |
| 114 | + | |
| 115 | +## Logique du `main.rs` genere | |
| 116 | + | |
| 117 | +```rust | |
| 118 | +// Mode UI | |
| 119 | +RustwardenApp::builder() | |
| 120 | + .from_env() | |
| 121 | + .with_ui(rustwarden_ui::default_routes) | |
| 122 | + .auto_migrate(true) // si choisi | |
| 123 | + .merge_routes(app_routes()) | |
| 124 | + .build().await?.run().await | |
| 125 | + | |
| 126 | +// Mode headless | |
| 127 | +RustwardenApp::builder() | |
| 128 | + .from_env() | |
| 129 | + .headless() | |
| 130 | + .auto_migrate(true) | |
| 131 | + .merge_routes(app_routes()) | |
| 132 | + .build().await?.run().await | |
| 133 | +``` | |
| 134 | + | |
| 135 | +## Usage | |
| 136 | + | |
| 137 | +```bash | |
| 138 | +# Depuis n'importe ou (apres cargo install) | |
| 139 | +rustwarden-cli start-project | |
| 140 | + | |
| 141 | +# Ou avec le nom du projet en argument | |
| 142 | +rustwarden-cli start-project mon-app | |
| 143 | + | |
| 144 | +# Resultat | |
| 145 | +mon-app/ | |
| 146 | + Cargo.toml | |
| 147 | + src/main.rs | |
| 148 | + .env | |
| 149 | + .gitignore | |
| 150 | + README.md | |
| 151 | + database/ | |
| 152 | + docker-compose.yml | |
| 153 | + .env | |
| 154 | +``` | |
| 155 | + | |
| 156 | +## Points de conception | |
| 157 | + | |
| 158 | +- Le JWT_SECRET est genere aleatoirement (64 chars alphanumeriques) par le wizard, jamais un placeholder | |
| 159 | +- Les mots de passe generes respectent les regles de complexite (majuscule, minuscule, chiffre, special, 16+ chars) | |
| 160 | +- Le CLI affiche un resume avant de generer, avec confirmation | |
| 161 | +- Le chemin vers rustwarden-core/ui dans le Cargo.toml genere utilise `path = "..."` relatif (pour dev local) avec un commentaire expliquant comment passer en `git = "..."` pour la production | |
| 162 | +- Le CLI est extensible : d'autres sous-commandes pourront etre ajoutees plus tard (ex: `add-migration`, `generate-model`) | |
| 163 | + |
A
CLAUDE.md
+179
-0
@@ -0,0 +1,179 @@
| 1 | +# CLAUDE.md | |
| 2 | + | |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. | |
| 4 | + | |
| 5 | +## Project Overview | |
| 6 | + | |
| 7 | +Rustwarden est un **framework réutilisable** qui fournit la couche authentification, gestion utilisateurs, RBAC, OAuth/SSO, email et admin UI à d'autres applications Rust. Il sert de fondation à plusieurs projets (ex: PasterMan). Le CLI wizard (`rustwarden-cli start-project`) génère des projets autonomes qui consomment le framework. | |
| 8 | + | |
| 9 | +**Principe fondamental** : toute modification doit préserver la réutilisabilité du framework. Les changements dans `rustwarden-core` et `rustwarden-ui` impactent tous les projets consommateurs. | |
| 10 | + | |
| 11 | +## Workspace Structure | |
| 12 | + | |
| 13 | +- **`crates/rustwarden-core/`** — Coeur du framework : auth, services, middleware, models (SeaORM), migrations, i18n, background tasks, hooks | |
| 14 | +- **`crates/rustwarden-ui/`** — UI par défaut : templates Askama, handlers, routes, DTOs, static assets | |
| 15 | +- **`app/`** — Application de référence (12 lignes, sert de démo/test du builder) | |
| 16 | +- **`tools/rustwarden-cli/`** — CLI wizard interactif pour scaffolder de nouveaux projets consommateurs | |
| 17 | + | |
| 18 | +### Comment les apps consomment le framework | |
| 19 | + | |
| 20 | +```rust | |
| 21 | +// app/src/main.rs — usage minimal | |
| 22 | +RustwardenApp::builder() | |
| 23 | + .from_env() | |
| 24 | + .with_ui(rustwarden_ui::default_routes) | |
| 25 | + .build().await? | |
| 26 | + .run().await | |
| 27 | + | |
| 28 | +// Usage avancé (projet consommateur type) | |
| 29 | +let db = rustwarden_core::config::database::create_connection().await?; | |
| 30 | +my_app::migrations::run_migrations(&db).await?; // migrations métier d'abord | |
| 31 | +RustwardenApp::builder() | |
| 32 | + .from_env() | |
| 33 | + .database(db) | |
| 34 | + .auto_migrate(false) // DB déjà migrée | |
| 35 | + .with_ui(rustwarden_ui::default_routes) | |
| 36 | + .merge_routes(my_app_routes) // routes métier ajoutées | |
| 37 | + .hooks(MyAppHooks) // hooks lifecycle | |
| 38 | + .build().await? | |
| 39 | + .run().await | |
| 40 | +``` | |
| 41 | + | |
| 42 | +Les apps consommatrices peuvent **surcharger** les routes framework (les routes custom mergées après prennent priorité) et **surcharger** les templates en créant leurs propres templates + handlers. | |
| 43 | + | |
| 44 | +## Build & Development Commands | |
| 45 | + | |
| 46 | +```bash | |
| 47 | +# Build | |
| 48 | +cargo build # Full workspace | |
| 49 | +cargo build -p rustwarden-core # Single crate | |
| 50 | +cargo check # Quick compilation check (no codegen) | |
| 51 | + | |
| 52 | +# Run | |
| 53 | +cargo run -p rustwarden-app # Reference app (port 3000) | |
| 54 | +cargo run -p rustwarden-cli -- start-project # Project scaffolder | |
| 55 | + | |
| 56 | +# Tests | |
| 57 | +cargo test # All tests | |
| 58 | +cargo test -p rustwarden-core # Single crate | |
| 59 | +cargo test test_name # Single test | |
| 60 | + | |
| 61 | +# Quality gates (must all pass) | |
| 62 | +cargo clippy -- -D warnings | |
| 63 | +cargo fmt --check | |
| 64 | +cargo doc # Must generate without warnings | |
| 65 | + | |
| 66 | +# Database binaries (in rustwarden-core) | |
| 67 | +cargo run --bin migrate | |
| 68 | +cargo run --bin seed # Create/update default admin | |
| 69 | +cargo run --bin hash_password | |
| 70 | +cargo run --bin change_password -- admin "NewPassword123!" | |
| 71 | + | |
| 72 | +# Database (Docker) | |
| 73 | +cd database && docker compose up -d # PostgreSQL 18 | |
| 74 | + | |
| 75 | +# CSS (Tailwind + DaisyUI) | |
| 76 | +npm install | |
| 77 | +npm run build:css # One-shot build | |
| 78 | +npm run build:css:watch # Watch mode | |
| 79 | +npm run build:css:min # Minified | |
| 80 | +``` | |
| 81 | + | |
| 82 | +## Tech Stack | |
| 83 | + | |
| 84 | +- **Rust 1.91.1+** (Edition 2024), **Axum 0.8**, **Tokio 1.x**, **SeaORM 2.0.0-rc.20** | |
| 85 | +- **PostgreSQL 18+** (via Docker in `database/`) | |
| 86 | +- **Templates:** Askama 0.15 — **Frontend:** Alpine.js + HTMX (CDN) — **CSS:** Tailwind 4 + DaisyUI 5 | |
| 87 | +- **Auth:** JWT (jsonwebtoken), bcrypt, tower-sessions (memory/redis) | |
| 88 | +- **OAuth** (feature-gated): `openidconnect` — Google, GitHub, Discord, Microsoft | |
| 89 | +- **Email:** lettre (SMTP), imap (bounce detection) | |
| 90 | +- **i18n:** YAML files in `/locales/` (fr, en, de, es). Default locale: fr | |
| 91 | + | |
| 92 | +## Architecture Key Points | |
| 93 | + | |
| 94 | +- **Builder pattern:** `RustwardenApp::builder()` — configure DB, routes, hooks, middleware, i18n, background tasks | |
| 95 | +- **Service layer:** Business logic in `services/`, handlers call services, services call SeaORM | |
| 96 | +- **Middleware stack:** auth validation → CSRF → rate limiting → admin check | |
| 97 | +- **Hooks system:** `RustwardenHooks` trait — lifecycle events que les apps consommatrices implémentent (on_user_registered, on_user_login, on_password_changed, on_oauth_login, on_resource_shared...) | |
| 98 | +- **Feature flags:** `oauth` (OpenID Connect), `redis-session` (Redis session backend) | |
| 99 | +- **Background tasks:** Email queue, JWT cleanup, password change cleanup, IMAP bounce — démarrés automatiquement au build | |
| 100 | +- **19 SeaORM entities** in `models/` with 5 migrations | |
| 101 | +- **Config:** Variables d'environnement (voir `.env.sample`). OAuth secrets chiffrés AES-256-GCM en DB | |
| 102 | +- **Extensibilité:** Les apps consommatrices étendent via `merge_routes()`, `hooks()`, et surcharge de templates | |
| 103 | + | |
| 104 | +## Framework Constraints — Impact sur les consommateurs | |
| 105 | + | |
| 106 | +Avant toute modification, considérer : | |
| 107 | + | |
| 108 | +1. **API publique** : les types exportés via `prelude` (`RustwardenApp`, `AuthUser`, `AppError`, `Claims`, etc.) et les traits (`RustwardenHooks`, `OAuthProvider`) sont l'interface du framework. Tout changement de signature est un breaking change pour les consommateurs. | |
| 109 | + | |
| 110 | +2. **Migrations** : les apps consommatrices appellent `core_migrations()` depuis `src/migrations.rs`, **pas** le `Migrator` de `migrations/src/lib.rs`. Les deux fichiers doivent rester synchronisés (voir "Adding Features"). | |
| 111 | + | |
| 112 | +3. **Templates** : les apps consommatrices peuvent étendre `base.html` et inclure les composants (`navbar.html`, `sidebar.html`). Les variables de `LayoutContext` (`page_title`, `app_name`, `app_version`, `csrf_token`) sont un contrat. | |
| 113 | + | |
| 114 | +4. **CLI wizard** : `tools/rustwarden-cli/src/templates.rs` génère le code des nouveaux projets. Si l'API du builder ou les imports changent, mettre à jour les templates du wizard. | |
| 115 | + | |
| 116 | +## Handler Patterns | |
| 117 | + | |
| 118 | +Each handler file in `rustwarden-ui/src/handlers/` follows this structure: | |
| 119 | + | |
| 120 | +- Template struct with `#[derive(Template)]` and `#[template(path = "...")]` | |
| 121 | +- Required base variables (contrat `base.html`): `page_title`, `app_name`, `app_version`, `csrf_token` | |
| 122 | +- Authenticated templates add: `username`, `email`, `initials`, `avatar_color`, `roles_text`, `nav_items`, `app_domain`, `app_description` | |
| 123 | +- Auth extractors: `Extension(claims): Extension<Claims>` (preferred) or `AuthUser` | |
| 124 | +- Services take `&DatabaseConnection` as first parameter, return `Result<T, AppError>` | |
| 125 | + | |
| 126 | +## Frontend Stack — Tailwind CSS v4 + DaisyUI v5 | |
| 127 | + | |
| 128 | +Couleurs CSS variables au format DaisyUI v5 : | |
| 129 | + | |
| 130 | +- `var(--color-primary)`, `var(--color-base-100)`, `var(--color-base-content)`, etc. | |
| 131 | +- Ne PAS utiliser l'ancienne syntaxe DaisyUI v3/v4 : ~~`oklch(var(--p))`~~, ~~`oklch(var(--bc))`~~ | |
| 132 | +- Pour les opacités/mélanges : `color-mix(in oklab, var(--color-primary) 35%, transparent)` | |
| 133 | + | |
| 134 | +## HTMX — Principes | |
| 135 | + | |
| 136 | +Documentation : https://htmx.fr/ | |
| 137 | + | |
| 138 | +**Philosophie** : privilégier les attributs HTMX au JavaScript pour toute interaction serveur renvoyant du HTML. Réserver le JS (ou Alpine.js) uniquement aux cas où HTMX ne suffit pas : API JSON, Clipboard API, manipulation DOM purement client-side. | |
| 139 | + | |
| 140 | +| Cas | Utiliser | | |
| 141 | +|-----|----------| | |
| 142 | +| Formulaire → réponse HTML | HTMX (`hx-post` + `hx-target`) | | |
| 143 | +| Confirmation avant action | HTMX (`hx-confirm`) | | |
| 144 | +| Suppression avec feedback | HTMX (`hx-delete` + `hx-swap="outerHTML"`) | | |
| 145 | +| Pagination / chargement | HTMX (`hx-get` + `hx-swap`) | | |
| 146 | +| API JSON (login, register) | Alpine.js / JS (`fetch`) | | |
| 147 | +| Clipboard, drag & drop | JS natif | | |
| 148 | +| Toggle CSS / état UI local | Alpine.js (`x-data`, `@click`) | | |
| 149 | + | |
| 150 | +## Adding Features | |
| 151 | + | |
| 152 | +- **New service**: service in `rustwarden-core/src/services/` → declare in `mod.rs` | |
| 153 | +- **New entity**: migration → SeaORM model in `models/` → service → declare in `mod.rs` files | |
| 154 | +- **New page (UI)**: Askama template in `rustwarden-ui/templates/` → Template struct in handler → route in `routes.rs` | |
| 155 | +- **New middleware**: implement in `rustwarden-core/src/middleware/` → wire into app builder | |
| 156 | +- **New migration**: `m{YYYYMMDD}_{NNNNNN}_{description}.rs` — must be declared in **both** `migrations/src/lib.rs` **and** `src/migrations.rs` (`core_migrations()`) to be executed by consumer apps. Missing one breaks either `sea-orm-cli` or production consumer apps. | |
| 157 | +- **New builder option / public type**: update `prelude` re-exports in `lib.rs` + update CLI wizard templates in `tools/rustwarden-cli/src/templates.rs` | |
| 158 | + | |
| 159 | +## Code Conventions (from constitution.md) | |
| 160 | + | |
| 161 | +- **All public functions** must have `///` doc comments | |
| 162 | +- **No `unwrap()`/`expect()`** in production code (only in tests). Use `Result<T, AppError>` with `?` | |
| 163 | +- **No raw SQL** — all DB access via SeaORM. Consult SeaORM docs for patterns | |
| 164 | +- **No `unsafe`** without `// SAFETY:` justification | |
| 165 | +- **No stub handlers** — every route must have a complete, functional implementation | |
| 166 | +- **Zero clippy warnings:** `cargo clippy -- -D warnings` | |
| 167 | +- **`cargo fmt`** before every commit | |
| 168 | +- **Test coverage target:** >80% overall, >90% for auth/security/core services | |
| 169 | +- **ANSSI compliance:** See `docs/ANSSI_COMPLIANCE_REPORT.md` | |
| 170 | + | |
| 171 | +## Documentation | |
| 172 | + | |
| 173 | +- **`DEVELOPER.md`** — Guide de référence complet (70KB+) : architecture, ajout de routes/models/services, personnalisation templates, auth, RBAC, OAuth, hooks, i18n, tâches de fond, tests, production | |
| 174 | +- **`constitution.md`** — Règles du projet, principes de sécurité, contraintes techniques | |
| 175 | +- **`docs/ANSSI_COMPLIANCE_REPORT.md`** — Rapport de conformité sécurité | |
| 176 | + | |
| 177 | +## Language | |
| 178 | + | |
| 179 | +La documentation du projet (constitution, guide développeur, commentaires) est en **français**. Les identifiants de code et les chemins API sont en anglais. |
M
README.md
+2
-0
@@ -550,6 +550,8 @@ La locale est detectee automatiquement dans cet ordre :
| 550 | 550 | | `cargo test` | Lancer les tests | |
| 551 | 551 | | `cargo build --release` | Build optimise pour la production | |
| 552 | 552 | |
| 553 | + cargo run --bin change_password -- admin "Admin123!" | |
| 554 | + | |
| 553 | 555 | --- |
| 554 | 556 | |
| 555 | 557 | ## Documentation complementaire |
M
crates/rustwarden-core/migrations/src/lib.rs
+2
-0
@@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::*;
| 3 | 3 | pub mod m20260305_000001_initial_schema; |
| 4 | 4 | pub mod m20260306_000002_create_app_settings_table; |
| 5 | 5 | pub mod m20260309_000003_create_permissions_tables; |
| 6 | +pub mod m20260309_000004_create_resources_tables; | |
| 6 | 7 | pub mod m20260310_000005_create_oauth_accounts_table; |
| 7 | 8 | |
| 8 | 9 | pub struct Migrator; |
@@ -14,6 +15,7 @@ impl MigratorTrait for Migrator {
| 14 | 15 | Box::new(m20260305_000001_initial_schema::Migration), |
| 15 | 16 | Box::new(m20260306_000002_create_app_settings_table::Migration), |
| 16 | 17 | Box::new(m20260309_000003_create_permissions_tables::Migration), |
| 18 | + Box::new(m20260309_000004_create_resources_tables::Migration), | |
| 17 | 19 | Box::new(m20260310_000005_create_oauth_accounts_table::Migration), |
| 18 | 20 | ] |
| 19 | 21 | } |
M
crates/rustwarden-core/src/app.rs
+16
-8
@@ -18,7 +18,7 @@
| 18 | 18 | //! } |
| 19 | 19 | //! ``` |
| 20 | 20 | |
| 21 | -use axum::{extract::Request, http::header::HeaderName, middleware::Next, response::Response, Router}; | |
| 21 | +use axum::{extract::Request, http::{header::HeaderName, HeaderValue}, middleware::Next, response::Response, Router}; | |
| 22 | 22 | use sea_orm::DatabaseConnection; |
| 23 | 23 | use std::path::PathBuf; |
| 24 | 24 | use std::sync::Arc; |
@@ -500,32 +500,40 @@ async fn security_headers_middleware(request: Request, next: Next) -> Response {
| 500 | 500 | let headers = response.headers_mut(); |
| 501 | 501 | headers.insert( |
| 502 | 502 | HeaderName::from_static("x-content-type-options"), |
| 503 | - "nosniff".parse().unwrap(), | |
| 503 | + HeaderValue::from_static("nosniff"), | |
| 504 | 504 | ); |
| 505 | 505 | headers.insert( |
| 506 | 506 | HeaderName::from_static("x-frame-options"), |
| 507 | - "DENY".parse().unwrap(), | |
| 507 | + HeaderValue::from_static("DENY"), | |
| 508 | 508 | ); |
| 509 | 509 | // Disable legacy XSS auditor (can introduce vulnerabilities) |
| 510 | 510 | headers.insert( |
| 511 | 511 | HeaderName::from_static("x-xss-protection"), |
| 512 | - "0".parse().unwrap(), | |
| 512 | + HeaderValue::from_static("0"), | |
| 513 | 513 | ); |
| 514 | 514 | headers.insert( |
| 515 | 515 | HeaderName::from_static("referrer-policy"), |
| 516 | - "strict-origin-when-cross-origin".parse().unwrap(), | |
| 516 | + HeaderValue::from_static("strict-origin-when-cross-origin"), | |
| 517 | 517 | ); |
| 518 | 518 | headers.insert( |
| 519 | 519 | HeaderName::from_static("permissions-policy"), |
| 520 | - "camera=(), microphone=(), geolocation=()".parse().unwrap(), | |
| 520 | + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), | |
| 521 | 521 | ); |
| 522 | - if std::env::var("COOKIE_SECURE") | |
| 522 | + // SEC-FIX: CSP header to mitigate XSS attacks | |
| 523 | + headers.insert( | |
| 524 | + HeaderName::from_static("content-security-policy"), | |
| 525 | + HeaderValue::from_static( | |
| 526 | + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" | |
| 527 | + ), | |
| 528 | + ); | |
| 529 | + // SEC-FIX: HSTS conditioned on APP_DEBUG=false (production mode) instead of COOKIE_SECURE | |
| 530 | + if !std::env::var("APP_DEBUG") | |
| 523 | 531 | .unwrap_or_default() |
| 524 | 532 | .eq_ignore_ascii_case("true") |
| 525 | 533 | { |
| 526 | 534 | headers.insert( |
| 527 | 535 | HeaderName::from_static("strict-transport-security"), |
| 528 | - "max-age=31536000; includeSubDomains".parse().unwrap(), | |
| 536 | + HeaderValue::from_static("max-age=31536000; includeSubDomains"), | |
| 529 | 537 | ); |
| 530 | 538 | } |
| 531 | 539 | response |
M
crates/rustwarden-core/src/auth/extractors.rs
+1
-1
@@ -222,7 +222,7 @@ where
| 222 | 222 | } |
| 223 | 223 | |
| 224 | 224 | // Validate token and extract claims |
| 225 | - let claims = JwtService::validate_token(&auth_config, &token).map_err(|e| { | |
| 225 | + let claims = JwtService::validate_token(auth_config, &token).map_err(|e| { | |
| 226 | 226 | let (status, message) = match e { |
| 227 | 227 | AppError::TokenExpired => { |
| 228 | 228 | tracing::debug!("Token expired"); |
M
crates/rustwarden-core/src/auth/handlers.rs
+1
-1
@@ -1133,7 +1133,7 @@ pub async fn register(
| 1133 | 1133 | Json(payload): Json<RegisterRequest>, |
| 1134 | 1134 | ) -> Result<Json<RegisterResponse>, AppError> { |
| 1135 | 1135 | // SEC-C2: Per-IP rate limiting |
| 1136 | - if let Err(_) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) { | |
| 1136 | + if crate::middleware::rate_limit::check_auth_rate_limit(&headers).is_err() { | |
| 1137 | 1137 | return Err(AppError::Validation("Too many requests. Please try again later.".to_string())); |
| 1138 | 1138 | } |
| 1139 | 1139 |
M
crates/rustwarden-core/src/auth/jwt.rs
+2
-0
@@ -43,6 +43,8 @@ use crate::error::AppError;
| 43 | 43 | /// |
| 44 | 44 | /// let claims = Claims { |
| 45 | 45 | /// sub: Uuid::new_v4(), |
| 46 | +/// jti: Uuid::new_v4().to_string(), | |
| 47 | +/// aud: "rustwarden".to_string(), | |
| 46 | 48 | /// username: "john_doe".to_string(), |
| 47 | 49 | /// roles: vec!["user".to_string()], |
| 48 | 50 | /// permissions: vec!["users.list".to_string()], |
M
crates/rustwarden-core/src/auth/oauth_handlers.rs
+1
-1
@@ -89,7 +89,7 @@ pub async fn oauth_callback(
| 89 | 89 | Query(params): Query<OAuthCallbackQuery>, |
| 90 | 90 | ) -> Result<Response, AppError> { |
| 91 | 91 | // SEC-C2: Rate limiting |
| 92 | - if let Err(_) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) { | |
| 92 | + if crate::middleware::rate_limit::check_auth_rate_limit(&headers).is_err() { | |
| 93 | 93 | return Err(AppError::Validation("Too many requests".to_string())); |
| 94 | 94 | } |
| 95 | 95 |
M
crates/rustwarden-core/src/auth/session.rs
+8
-2
@@ -122,8 +122,14 @@ impl SessionConfig {
| 122 | 122 | }; |
| 123 | 123 | |
| 124 | 124 | // SEC-L6: Custom session cookie name (hides technology stack) |
| 125 | - let session_cookie_name = std::env::var("SESSION_COOKIE_NAME") | |
| 126 | - .unwrap_or_else(|_| "__Host-sid".to_string()); | |
| 125 | + // __Host- prefix requires Secure flag (HTTPS); fall back to plain name in dev | |
| 126 | + let session_cookie_name = std::env::var("SESSION_COOKIE_NAME").unwrap_or_else(|_| { | |
| 127 | + if config.cookie_secure { | |
| 128 | + "__Host-sid".to_string() | |
| 129 | + } else { | |
| 130 | + "sid".to_string() | |
| 131 | + } | |
| 132 | + }); | |
| 127 | 133 | |
| 128 | 134 | let session_layer = SessionManagerLayer::new(session_store) |
| 129 | 135 | .with_name(session_cookie_name.clone()) |
M
crates/rustwarden-core/src/config/auth.rs
+9
-3
@@ -14,8 +14,10 @@ pub enum SessionBackend {
| 14 | 14 | Redis, |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | -impl SessionBackend { | |
| 18 | - pub fn from_str(s: &str) -> Result<Self, String> { | |
| 17 | +impl std::str::FromStr for SessionBackend { | |
| 18 | + type Err = String; | |
| 19 | + | |
| 20 | + fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 19 | 21 | match s.to_lowercase().as_str() { |
| 20 | 22 | "memory" => Ok(Self::Memory), |
| 21 | 23 | "seaorm" => Ok(Self::SeaOrm), |
@@ -92,7 +94,7 @@ impl AuthConfig {
| 92 | 94 | |
| 93 | 95 | let session_backend_str = |
| 94 | 96 | env::var("SESSION_BACKEND").unwrap_or_else(|_| "seaorm".to_string()); |
| 95 | - let session_backend = SessionBackend::from_str(&session_backend_str)?; | |
| 97 | + let session_backend = session_backend_str.parse::<SessionBackend>()?; | |
| 96 | 98 | |
| 97 | 99 | let redis_url = env::var("REDIS_URL").ok(); |
| 98 | 100 | if session_backend == SessionBackend::Redis && redis_url.is_none() { |
@@ -134,6 +136,10 @@ impl AuthConfig {
| 134 | 136 | } |
| 135 | 137 | true |
| 136 | 138 | } else { |
| 139 | + tracing::warn!( | |
| 140 | + "⚠ APP_DEBUG=true — running in DEVELOPMENT mode. Secure cookies disabled, \ | |
| 141 | + relaxed cookie prefixes. DO NOT use in production." | |
| 142 | + ); | |
| 137 | 143 | cookie_secure_env.unwrap_or(false) |
| 138 | 144 | }; |
| 139 | 145 |
M
crates/rustwarden-core/src/config/email.rs
+12
-4
@@ -130,8 +130,16 @@ impl EmailConfig {
| 130 | 130 | let smtp_from = env::var("SMTP_FROM") |
| 131 | 131 | .map_err(|_| "SMTP_FROM environment variable is required".to_string())?; |
| 132 | 132 | |
| 133 | - let email_base_url = env::var("EMAIL_BASE_URL") | |
| 134 | - .map_err(|_| "EMAIL_BASE_URL environment variable is required".to_string())?; | |
| 133 | + let email_base_url = env::var("EMAIL_BASE_URL").unwrap_or_else(|_| { | |
| 134 | + let host = env::var("SERVER_HOST").unwrap_or_else(|_| "localhost".to_string()); | |
| 135 | + let port = env::var("SERVER_PORT").unwrap_or_else(|_| "3000".to_string()); | |
| 136 | + let default_url = format!("http://{}:{}", host, port); | |
| 137 | + tracing::info!( | |
| 138 | + "EMAIL_BASE_URL not set, defaulting to {} (from SERVER_HOST/SERVER_PORT)", | |
| 139 | + default_url | |
| 140 | + ); | |
| 141 | + default_url | |
| 142 | + }); | |
| 135 | 143 | |
| 136 | 144 | // Optional variables with defaults |
| 137 | 145 | let smtp_port = env::var("SMTP_PORT") |
@@ -256,10 +264,10 @@ impl EmailConfig {
| 256 | 264 | tracing::info!("IMAP custom CA certificate configured: {}", path); |
| 257 | 265 | } |
| 258 | 266 | |
| 259 | - if imap_host.is_some() { | |
| 267 | + if let Some(ref host) = imap_host { | |
| 260 | 268 | tracing::info!( |
| 261 | 269 | "IMAP bounce processing enabled: host={}, mailbox={}, poll interval={}s", |
| 262 | - imap_host.as_ref().unwrap(), | |
| 270 | + host, | |
| 263 | 271 | imap_mailbox, |
| 264 | 272 | imap_poll_interval_seconds |
| 265 | 273 | ); |
M
crates/rustwarden-core/src/middleware/csrf.rs
+1
-0
@@ -102,6 +102,7 @@ fn extract_csrf_token_from_request(request: &Request) -> Option<String> {
| 102 | 102 | /// |
| 103 | 103 | /// * `200 OK` if valid or safe method (passes to handler) |
| 104 | 104 | /// * `403 Forbidden` if CSRF token missing or invalid |
| 105 | +/// | |
| 105 | 106 | /// SEC-C3: Check if the request uses Bearer authentication (API client) |
| 106 | 107 | /// API clients using Authorization: Bearer are not vulnerable to CSRF |
| 107 | 108 | /// because the token is not sent automatically by the browser. |
M
crates/rustwarden-core/src/middleware/password_complexity.rs
+4
-5
@@ -29,7 +29,7 @@ impl Default for PasswordRules {
| 29 | 29 | fn default() -> Self { |
| 30 | 30 | Self { |
| 31 | 31 | min_length: 8, |
| 32 | - max_length: 128, | |
| 32 | + max_length: 72, | |
| 33 | 33 | // SEC-L4: Sensible defaults — require at least mixed case and digits |
| 34 | 34 | require_uppercase: true, |
| 35 | 35 | require_lowercase: true, |
@@ -44,7 +44,6 @@ impl PasswordRules {
| 44 | 44 | /// Falls back to defaults for any missing key. |
| 45 | 45 | pub async fn from_db(db: &DatabaseConnection) -> Self { |
| 46 | 46 | let get_bool = |key: &'static str, default: bool| { |
| 47 | - let db = db; | |
| 48 | 47 | async move { |
| 49 | 48 | AppSettingsService::get_bool(db, key, default) |
| 50 | 49 | .await |
@@ -58,8 +57,8 @@ impl PasswordRules {
| 58 | 57 | }; |
| 59 | 58 | |
| 60 | 59 | let max_length = match AppSettingsService::get_setting(db, "password_max_length").await { |
| 61 | - Ok(Some(s)) => s.value.parse::<usize>().unwrap_or(128), | |
| 62 | - _ => 128, | |
| 60 | + Ok(Some(s)) => s.value.parse::<usize>().unwrap_or(72), | |
| 61 | + _ => 72, | |
| 63 | 62 | }; |
| 64 | 63 | |
| 65 | 64 | Self { |
@@ -155,7 +154,7 @@ mod tests {
| 155 | 154 | fn test_password_rules_validation() { |
| 156 | 155 | let rules = PasswordRules { |
| 157 | 156 | min_length: 8, |
| 158 | - max_length: 128, | |
| 157 | + max_length: 72, | |
| 159 | 158 | require_uppercase: true, |
| 160 | 159 | require_lowercase: true, |
| 161 | 160 | require_digits: true, |
M
crates/rustwarden-core/src/middleware/rate_limit.rs
+10
-5
@@ -100,6 +100,12 @@ pub struct PublicShareRateLimiter {
| 100 | 100 | window_seconds: u64, |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | +impl Default for PublicShareRateLimiter { | |
| 104 | + fn default() -> Self { | |
| 105 | + Self::new(30, 60) | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 103 | 109 | impl PublicShareRateLimiter { |
| 104 | 110 | pub fn new(max_requests: u32, window_seconds: u64) -> Self { |
| 105 | 111 | Self { |
@@ -109,10 +115,6 @@ impl PublicShareRateLimiter {
| 109 | 115 | } |
| 110 | 116 | } |
| 111 | 117 | |
| 112 | - pub fn default() -> Self { | |
| 113 | - Self::new(30, 60) | |
| 114 | - } | |
| 115 | - | |
| 116 | 118 | pub fn check_rate_limit(&self, ip: &str) -> Result<(), StatusCode> { |
| 117 | 119 | let mut requests = self |
| 118 | 120 | .requests |
@@ -217,7 +219,7 @@ static PUBLIC_SHARE_RATE_LIMITER: std::sync::OnceLock<PublicShareRateLimiter> =
| 217 | 219 | /// Get or initialize the global public share rate limiter |
| 218 | 220 | /// Feature 041: Files Sync & Sharing (T074) |
| 219 | 221 | pub fn get_public_share_rate_limiter() -> &'static PublicShareRateLimiter { |
| 220 | - PUBLIC_SHARE_RATE_LIMITER.get_or_init(|| PublicShareRateLimiter::default()) | |
| 222 | + PUBLIC_SHARE_RATE_LIMITER.get_or_init(PublicShareRateLimiter::default) | |
| 221 | 223 | } |
| 222 | 224 | |
| 223 | 225 | /// Check rate limit for a public share endpoint request |
@@ -234,6 +236,7 @@ pub fn get_public_share_rate_limiter() -> &'static PublicShareRateLimiter {
| 234 | 236 | /// |
| 235 | 237 | /// * `Ok(())` if within limits |
| 236 | 238 | /// * `Err(Response)` with 429 status if limit exceeded |
| 239 | +#[allow(clippy::result_large_err)] | |
| 237 | 240 | pub fn check_public_share_rate_limit( |
| 238 | 241 | request: &Request, |
| 239 | 242 | ) -> Result<(), axum::response::Response> { |
@@ -275,6 +278,7 @@ pub fn check_public_share_rate_limit(
| 275 | 278 | /// |
| 276 | 279 | /// * `Ok(())` if within limits |
| 277 | 280 | /// * `Err(Response)` with 429 status if limit exceeded |
| 281 | +#[allow(clippy::result_large_err)] | |
| 278 | 282 | pub fn check_public_share_rate_limit_from_headers( |
| 279 | 283 | headers: &axum::http::HeaderMap, |
| 280 | 284 | ) -> Result<(), axum::response::Response> { |
@@ -374,6 +378,7 @@ pub fn get_auth_rate_limiter() -> &'static AuthRateLimiter {
| 374 | 378 | } |
| 375 | 379 | |
| 376 | 380 | /// Check auth rate limit for a request and return an error response if exceeded. |
| 381 | +#[allow(clippy::result_large_err)] | |
| 377 | 382 | pub fn check_auth_rate_limit( |
| 378 | 383 | headers: &axum::http::HeaderMap, |
| 379 | 384 | ) -> Result<(), axum::response::Response> { |
M
crates/rustwarden-core/src/migrations.rs
+4
-0
@@ -33,6 +33,9 @@ pub mod m20260309_000003_create_permissions_tables;
| 33 | 33 | #[path = "../migrations/src/m20260309_000004_create_resources_tables.rs"] |
| 34 | 34 | pub mod m20260309_000004_create_resources_tables; |
| 35 | 35 | |
| 36 | +#[path = "../migrations/src/m20260310_000005_create_oauth_accounts_table.rs"] | |
| 37 | +pub mod m20260310_000005_create_oauth_accounts_table; | |
| 38 | + | |
| 36 | 39 | use sea_orm_migration::prelude::*; |
| 37 | 40 | |
| 38 | 41 | /// Returns all core Rustwarden migrations in order. |
@@ -42,6 +45,7 @@ pub fn core_migrations() -> Vec<Box<dyn MigrationTrait>> {
| 42 | 45 | Box::new(m20260306_000002_create_app_settings_table::Migration), |
| 43 | 46 | Box::new(m20260309_000003_create_permissions_tables::Migration), |
| 44 | 47 | Box::new(m20260309_000004_create_resources_tables::Migration), |
| 48 | + Box::new(m20260310_000005_create_oauth_accounts_table::Migration), | |
| 45 | 49 | ] |
| 46 | 50 | } |
| 47 | 51 |
M
crates/rustwarden-core/src/services/auth_service.rs
+12
-3
@@ -7,14 +7,23 @@
| 7 | 7 | use crate::error::AppError; |
| 8 | 8 | use crate::models::user; |
| 9 | 9 | use crate::services::password_service::PasswordService; |
| 10 | -use bcrypt::{hash, verify, DEFAULT_COST}; | |
| 10 | +use bcrypt::{hash, verify}; | |
| 11 | 11 | use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; |
| 12 | 12 | use std::sync::LazyLock; |
| 13 | 13 | |
| 14 | 14 | /// SEC-C1: Pre-computed dummy bcrypt hash used when a user is not found, |
| 15 | 15 | /// ensuring constant response time regardless of user existence. |
| 16 | -static DUMMY_HASH: LazyLock<String> = | |
| 17 | - LazyLock::new(|| hash("__dummy_timing_pad__", DEFAULT_COST).unwrap()); | |
| 16 | +// Note: This static initialization uses expect() intentionally — if bcrypt | |
| 17 | +// fails to hash a constant string at startup, the application cannot function | |
| 18 | +// securely and must fail immediately. | |
| 19 | +// SEC-FIX: Use the same configurable cost as real passwords to prevent timing attacks. | |
| 20 | +static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| { | |
| 21 | + let cost = std::env::var("BCRYPT_COST") | |
| 22 | + .ok() | |
| 23 | + .and_then(|v| v.parse::<u32>().ok()) | |
| 24 | + .unwrap_or(bcrypt::DEFAULT_COST); | |
| 25 | + hash("__dummy_timing_pad__", cost.clamp(12, 20)).expect("bcrypt init failed") | |
| 26 | +}); | |
| 18 | 27 | |
| 19 | 28 | fn mask_username(username: &str) -> String { |
| 20 | 29 | if username.len() <= 2 { |
M
crates/rustwarden-core/src/services/email_queue_service.rs
+2
-1
@@ -232,7 +232,8 @@ impl EmailQueueService {
| 232 | 232 | .ok_or_else(|| AppError::NotFound(format!("Email queue entry {} not found", id)))?; |
| 233 | 233 | |
| 234 | 234 | let mut email_active: email_queue::ActiveModel = email.into(); |
| 235 | - email_active.attempts = Set(email_active.attempts.unwrap() + 1); | |
| 235 | + let current_attempts = email_active.attempts.try_as_ref().copied().unwrap_or(0); | |
| 236 | + email_active.attempts = Set(current_attempts + 1); | |
| 236 | 237 | email_active.last_attempt_at = Set(Some(Utc::now())); |
| 237 | 238 | email_active.updated_at = Set(Utc::now()); |
| 238 | 239 |
M
crates/rustwarden-core/src/services/email_validation_service.rs
+10
-6
@@ -104,7 +104,7 @@ impl EmailValidationService {
| 104 | 104 | self.email_config |
| 105 | 105 | .smtp_from_name |
| 106 | 106 | .clone() |
| 107 | - .unwrap_or_else(|| crate::config::app::default_app_name()), | |
| 107 | + .unwrap_or_else(crate::config::app::default_app_name), | |
| 108 | 108 | ), |
| 109 | 109 | ); |
| 110 | 110 |
@@ -224,7 +224,7 @@ impl EmailValidationService {
| 224 | 224 | self.email_config |
| 225 | 225 | .smtp_from_name |
| 226 | 226 | .clone() |
| 227 | - .unwrap_or_else(|| crate::config::app::default_app_name()), | |
| 227 | + .unwrap_or_else(crate::config::app::default_app_name), | |
| 228 | 228 | ), |
| 229 | 229 | ); |
| 230 | 230 |
@@ -358,13 +358,17 @@ impl EmailValidationService {
| 358 | 358 | // Clean up old entries (older than 1 hour) |
| 359 | 359 | let one_hour_ago = SystemTime::now() |
| 360 | 360 | .duration_since(UNIX_EPOCH) |
| 361 | - .unwrap() | |
| 361 | + .unwrap_or_default() | |
| 362 | 362 | .as_secs() |
| 363 | - - 3600; | |
| 363 | + .saturating_sub(3600); | |
| 364 | 364 | |
| 365 | 365 | if let Some(requests) = store.get_mut(email) { |
| 366 | - requests | |
| 367 | - .retain(|&time| time.duration_since(UNIX_EPOCH).unwrap().as_secs() > one_hour_ago); | |
| 366 | + requests.retain(|&time| { | |
| 367 | + time.duration_since(UNIX_EPOCH) | |
| 368 | + .unwrap_or_default() | |
| 369 | + .as_secs() | |
| 370 | + > one_hour_ago | |
| 371 | + }); | |
| 368 | 372 | } |
| 369 | 373 | |
| 370 | 374 | // Check current requests |
M
crates/rustwarden-core/src/services/oauth/service.rs
+1
-1
@@ -214,6 +214,6 @@ impl OAuthService {
| 214 | 214 | } |
| 215 | 215 | } |
| 216 | 216 | |
| 217 | - Ok(format!("{}_{}", sanitized, Uuid::new_v4().to_string()[..8].to_string())) | |
| 217 | + Ok(format!("{}_{}", sanitized, &Uuid::new_v4().to_string()[..8])) | |
| 218 | 218 | } |
| 219 | 219 | } |
M
crates/rustwarden-core/src/services/password_change_service.rs
+7
-3
@@ -489,16 +489,20 @@ impl PasswordChangeService {
| 489 | 489 | // Get current time |
| 490 | 490 | let now = SystemTime::now() |
| 491 | 491 | .duration_since(UNIX_EPOCH) |
| 492 | - .unwrap() | |
| 492 | + .unwrap_or_default() | |
| 493 | 493 | .as_secs(); |
| 494 | 494 | |
| 495 | 495 | // Get user's request history |
| 496 | 496 | let requests = store.entry(user_id).or_insert_with(Vec::new); |
| 497 | 497 | |
| 498 | 498 | let window_secs = crate::config::tasks::password_change_rate_limit_window_secs(); |
| 499 | - let cutoff = now - window_secs; | |
| 499 | + let cutoff = now.saturating_sub(window_secs); | |
| 500 | 500 | requests.retain(|×tamp| { |
| 501 | - timestamp.duration_since(UNIX_EPOCH).unwrap().as_secs() > cutoff | |
| 501 | + timestamp | |
| 502 | + .duration_since(UNIX_EPOCH) | |
| 503 | + .unwrap_or_default() | |
| 504 | + .as_secs() | |
| 505 | + > cutoff | |
| 502 | 506 | }); |
| 503 | 507 | |
| 504 | 508 | let max_requests = crate::config::tasks::password_change_rate_limit_max(); |
M
crates/rustwarden-core/src/services/password_reset_service.rs
+9
-5
@@ -114,7 +114,7 @@ impl PasswordResetService {
| 114 | 114 | self.email_config |
| 115 | 115 | .smtp_from_name |
| 116 | 116 | .clone() |
| 117 | - .unwrap_or_else(|| crate::config::app::default_app_name()), | |
| 117 | + .unwrap_or_else(crate::config::app::default_app_name), | |
| 118 | 118 | ), |
| 119 | 119 | ); |
| 120 | 120 |
@@ -306,13 +306,17 @@ impl PasswordResetService {
| 306 | 306 | // Clean up old entries (older than 1 hour) |
| 307 | 307 | let one_hour_ago = SystemTime::now() |
| 308 | 308 | .duration_since(UNIX_EPOCH) |
| 309 | - .unwrap() | |
| 309 | + .unwrap_or_default() | |
| 310 | 310 | .as_secs() |
| 311 | - - 3600; | |
| 311 | + .saturating_sub(3600); | |
| 312 | 312 | |
| 313 | 313 | if let Some(requests) = store.get_mut(email) { |
| 314 | - requests | |
| 315 | - .retain(|&time| time.duration_since(UNIX_EPOCH).unwrap().as_secs() > one_hour_ago); | |
| 314 | + requests.retain(|&time| { | |
| 315 | + time.duration_since(UNIX_EPOCH) | |
| 316 | + .unwrap_or_default() | |
| 317 | + .as_secs() | |
| 318 | + > one_hour_ago | |
| 319 | + }); | |
| 316 | 320 | } |
| 317 | 321 | |
| 318 | 322 | // Check current requests |
M
crates/rustwarden-core/src/services/password_service.rs
+1
-1
@@ -10,7 +10,7 @@ static BCRYPT_COST: LazyLock<u32> = LazyLock::new(|| {
| 10 | 10 | .ok() |
| 11 | 11 | .and_then(|v| v.parse::<u32>().ok()) |
| 12 | 12 | .unwrap_or(DEFAULT_COST); |
| 13 | - cost.clamp(10, 20) | |
| 13 | + cost.clamp(12, 20) | |
| 14 | 14 | }); |
| 15 | 15 | |
| 16 | 16 | /// Service for password operations (hashing, verification, generation) |
M
crates/rustwarden-core/src/services/refresh_token_service.rs
+1
-1
@@ -107,7 +107,7 @@ impl RefreshTokenService {
| 107 | 107 | /// let token = RefreshTokenService::create_refresh_token(&db, user_id, 7) |
| 108 | 108 | /// .await |
| 109 | 109 | /// .unwrap(); |
| 110 | - /// println!("Token: {}", token.token); | |
| 110 | + /// println!("Token: {}", token.plain_token); | |
| 111 | 111 | /// } |
| 112 | 112 | /// ``` |
| 113 | 113 | /// SEC-M4: Maximum active (non-revoked, non-expired) refresh tokens per user. |
M
crates/rustwarden-core/src/tasks/password_change_email_retry.rs
+1
-1
@@ -199,7 +199,7 @@ impl PasswordChangeEmailRetryProcessor {
| 199 | 199 | self.email_config |
| 200 | 200 | .smtp_from_name |
| 201 | 201 | .clone() |
| 202 | - .unwrap_or_else(|| crate::config::app::default_app_name()), | |
| 202 | + .unwrap_or_else(crate::config::app::default_app_name), | |
| 203 | 203 | ), |
| 204 | 204 | ); |
| 205 | 205 |
M
crates/rustwarden-ui/src/extractors.rs
+1
-1
@@ -93,7 +93,7 @@ fn parse_urlencoded_with_duplicates(input: &str) -> Result<HashMap<String, serde
| 93 | 93 | (key, String::new()) |
| 94 | 94 | }; |
| 95 | 95 | |
| 96 | - map.entry(key).or_insert_with(Vec::new).push(value); | |
| 96 | + map.entry(key).or_default().push(value); | |
| 97 | 97 | } |
| 98 | 98 | |
| 99 | 99 | // Convert to HashMap<String, Value> where single values are strings and multiple values are arrays |
M
crates/rustwarden-ui/src/handlers/admin_handler.rs
+3
-3
@@ -548,7 +548,7 @@ pub async fn list_users(
| 548 | 548 | } |
| 549 | 549 | |
| 550 | 550 | let total = all_users.len() as u64; |
| 551 | - let total_pages = if total == 0 { 1 } else { (total + per_page - 1) / per_page }; | |
| 551 | + let total_pages = if total == 0 { 1 } else { total.div_ceil(per_page) }; | |
| 552 | 552 | let start = ((page - 1) * per_page) as usize; |
| 553 | 553 | let page_users: Vec<_> = all_users.into_iter().skip(start).take(per_page as usize).collect(); |
| 554 | 554 |
@@ -638,8 +638,8 @@ pub async fn list_users(
| 638 | 638 | total_pages, |
| 639 | 639 | current_search: search.clone().unwrap_or_default(), |
| 640 | 640 | current_role_filter: role_filter.clone().unwrap_or_default(), |
| 641 | - has_search: search.is_some() && !search.as_ref().unwrap().is_empty(), | |
| 642 | - has_role_filter: role_filter.is_some() && !role_filter.as_ref().unwrap().is_empty(), | |
| 641 | + has_search: search.as_ref().is_some_and(|s| !s.is_empty()), | |
| 642 | + has_role_filter: role_filter.as_ref().is_some_and(|r| !r.is_empty()), | |
| 643 | 643 | is_admin_filter: role_filter.as_ref().map(|r| r == "admin").unwrap_or(false), |
| 644 | 644 | is_user_filter: role_filter.as_ref().map(|r| r == "user").unwrap_or(false), |
| 645 | 645 | title: i18n_service.translate("admin.users_list.title", locale_str).await, |
M
crates/rustwarden-ui/src/handlers/login_handler.rs
+4
-0
@@ -68,6 +68,7 @@ struct ResendVerificationTemplate {
| 68 | 68 | app_domain: String, |
| 69 | 69 | app_description: String, |
| 70 | 70 | page_title: String, |
| 71 | + csrf_token: String, | |
| 71 | 72 | title: String, |
| 72 | 73 | subtitle: String, |
| 73 | 74 | email_label: String, |
@@ -89,6 +90,7 @@ struct ResetPasswordTemplate {
| 89 | 90 | app_domain: String, |
| 90 | 91 | app_description: String, |
| 91 | 92 | page_title: String, |
| 93 | + csrf_token: String, | |
| 92 | 94 | reset_password_title: String, |
| 93 | 95 | subtitle: String, |
| 94 | 96 | new_password_label: String, |
@@ -342,6 +344,7 @@ pub async fn resend_verification_page(
| 342 | 344 | app_domain, |
| 343 | 345 | app_description, |
| 344 | 346 | page_title, |
| 347 | + csrf_token: String::new(), | |
| 345 | 348 | title, |
| 346 | 349 | subtitle, |
| 347 | 350 | email_label, |
@@ -461,6 +464,7 @@ pub async fn reset_password_page(
| 461 | 464 | app_domain, |
| 462 | 465 | app_description, |
| 463 | 466 | page_title, |
| 467 | + csrf_token: String::new(), | |
| 464 | 468 | reset_password_title, |
| 465 | 469 | subtitle, |
| 466 | 470 | new_password_label, |
M
crates/rustwarden-ui/src/handlers/settings_handler.rs
+3
-5
@@ -137,7 +137,7 @@ pub async fn update_profile(
| 137 | 137 | let app_name = email_config |
| 138 | 138 | .smtp_from_name |
| 139 | 139 | .clone() |
| 140 | - .unwrap_or_else(|| rustwarden_core::config::app::default_app_name()); | |
| 140 | + .unwrap_or_else(rustwarden_core::config::app::default_app_name); | |
| 141 | 141 | |
| 142 | 142 | let mut changes = Vec::new(); |
| 143 | 143 | if new_username.is_some() { |
@@ -187,15 +187,13 @@ pub async fn update_profile(
| 187 | 187 | ).await { |
| 188 | 188 | tracing::warn!("Failed to enqueue account modified email to old address: {}", e); |
| 189 | 189 | } |
| 190 | - } else { | |
| 191 | - if let Err(e) = EmailQueueService::enqueue( | |
| 190 | + } else if let Err(e) = EmailQueueService::enqueue( | |
| 192 | 191 | &db, updated_user.email.clone(), Some(updated_user.username.clone()), |
| 193 | 192 | email_config.smtp_from.clone(), email_config.smtp_from_name.clone(), |
| 194 | 193 | subject, body_html, body_text, |
| 195 | 194 | "account_modified".to_string(), |
| 196 | 195 | ).await { |
| 197 | 196 | tracing::warn!("Failed to enqueue account modified notification: {}", e); |
| 198 | - } | |
| 199 | 197 | } |
| 200 | 198 | } |
| 201 | 199 | } |
@@ -267,7 +265,7 @@ pub async fn change_password(
| 267 | 265 | email_config |
| 268 | 266 | .smtp_from_name |
| 269 | 267 | .clone() |
| 270 | - .unwrap_or_else(|| rustwarden_core::config::app::default_app_name()), | |
| 268 | + .unwrap_or_else(rustwarden_core::config::app::default_app_name), | |
| 271 | 269 | ), |
| 272 | 270 | ); |
| 273 | 271 |
M
crates/rustwarden-ui/src/handlers/user_handler.rs
+1
-1
@@ -172,7 +172,7 @@ pub async fn create_user(
| 172 | 172 | let app_name = email_config |
| 173 | 173 | .smtp_from_name |
| 174 | 174 | .clone() |
| 175 | - .unwrap_or_else(|| rustwarden_core::config::app::default_app_name()); | |
| 175 | + .unwrap_or_else(rustwarden_core::config::app::default_app_name); | |
| 176 | 176 | |
| 177 | 177 | let template = rustwarden_core::templates::email_templates::AdminNewUserTemplate::new( |
| 178 | 178 | Some("fr".to_string()), |
M
crates/rustwarden-ui/src/routes.rs
+1
-5
@@ -125,11 +125,7 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 125 | 125 | .route("/admin/errors", get(admin_handler::admin_errors)); |
| 126 | 126 | |
| 127 | 127 | let static_dir = std::env::var("RUSTWARDEN_STATIC_DIR").unwrap_or_else(|_| { |
| 128 | - if std::path::Path::new("static").exists() { | |
| 129 | - "static".to_string() | |
| 130 | - } else { | |
| 131 | - "static".to_string() | |
| 132 | - } | |
| 128 | + "static".to_string() | |
| 133 | 129 | }); |
| 134 | 130 | |
| 135 | 131 | tracing::info!("Serving static files from: {}", static_dir); |
M
crates/rustwarden-ui/src/template_helpers.rs
+1
-1
@@ -108,7 +108,7 @@ pub async fn build_base_context_from_claims(
| 108 | 108 | let app_name = AppSettingsService::get_setting(db, "app_name") |
| 109 | 109 | .await? |
| 110 | 110 | .map(|s| s.value) |
| 111 | - .unwrap_or_else(|| rustwarden_core::config::app::default_app_name()); | |
| 111 | + .unwrap_or_else(rustwarden_core::config::app::default_app_name); | |
| 112 | 112 | |
| 113 | 113 | let app_domain = AppSettingsService::get_setting(db, "app_domain") |
| 114 | 114 | .await? |
M
crates/rustwarden-ui/templates/admin/dashboard.html
+1
-1
@@ -141,7 +141,7 @@
| 141 | 141 | |
| 142 | 142 | {% block scripts %} |
| 143 | 143 | {% if has_audit_logs %} |
| 144 | -<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> | |
| 144 | +<script src="/static/js/chart.umd.min.js"></script> | |
| 145 | 145 | <script> |
| 146 | 146 | document.addEventListener('DOMContentLoaded', function() { |
| 147 | 147 | const ctx = document.getElementById('audit-logs-chart'); |
M
crates/rustwarden-ui/templates/admin/roles_list.html
+11
-7
@@ -74,8 +74,9 @@
| 74 | 74 | {{ edit }} |
| 75 | 75 | </a> |
| 76 | 76 | {% if !role.is_protected %} |
| 77 | - <button class="btn btn-ghost btn-sm text-error" | |
| 78 | - onclick="confirmDeleteRole('{{ role.id }}', '{{ role.name }}')"> | |
| 77 | + <button class="btn btn-ghost btn-sm text-error btn-delete-role" | |
| 78 | + data-role-id="{{ role.id }}" | |
| 79 | + data-role-name="{{ role.name }}"> | |
| 79 | 80 | <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| 80 | 81 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> |
| 81 | 82 | </svg> |
@@ -114,11 +115,14 @@
| 114 | 115 | <script> |
| 115 | 116 | let deleteRoleId = null; |
| 116 | 117 | |
| 117 | -function confirmDeleteRole(id, name) { | |
| 118 | - deleteRoleId = id; | |
| 119 | - document.getElementById('deleteRoleName').textContent = name; | |
| 120 | - document.getElementById('deleteRoleModal').showModal(); | |
| 121 | -} | |
| 118 | +// SEC-FIX: Use data-* attributes instead of inline onclick to prevent XSS | |
| 119 | +document.querySelectorAll('.btn-delete-role').forEach(function(btn) { | |
| 120 | + btn.addEventListener('click', function() { | |
| 121 | + deleteRoleId = this.dataset.roleId; | |
| 122 | + document.getElementById('deleteRoleName').textContent = this.dataset.roleName; | |
| 123 | + document.getElementById('deleteRoleModal').showModal(); | |
| 124 | + }); | |
| 125 | +}); | |
| 122 | 126 | |
| 123 | 127 | document.getElementById('confirmDeleteBtn').addEventListener('click', function() { |
| 124 | 128 | if (!deleteRoleId) return; |
M
crates/rustwarden-ui/templates/admin/settings.html
+1
-0
@@ -676,6 +676,7 @@ document.getElementById('settingsForm').addEventListener('submit', async functio
| 676 | 676 | method: 'PUT', |
| 677 | 677 | headers: { |
| 678 | 678 | 'Content-Type': 'application/x-www-form-urlencoded', |
| 679 | + 'X-CSRF-Token': '{{ csrf_token }}', | |
| 679 | 680 | }, |
| 680 | 681 | body: new URLSearchParams(formData) |
| 681 | 682 | }); |
M
crates/rustwarden-ui/templates/admin/users_list.html
+61
-41
@@ -146,10 +146,11 @@
| 146 | 146 | <a href="/admin/users/{{ user.id }}" class="btn btn-sm btn-ghost"> |
| 147 | 147 | {{ edit }} |
| 148 | 148 | </a> |
| 149 | - <button | |
| 149 | + <button | |
| 150 | 150 | type="button" |
| 151 | - class="btn btn-sm btn-warning" | |
| 152 | - onclick="openResetPasswordModal('{{ user.id }}', '{{ user.username }}')" | |
| 151 | + class="btn btn-sm btn-warning btn-reset-password" | |
| 152 | + data-user-id="{{ user.id }}" | |
| 153 | + data-username="{{ user.username }}" | |
| 153 | 154 | title="{{ reset_password_title }}" |
| 154 | 155 | > |
| 155 | 156 | <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
@@ -157,10 +158,12 @@
| 157 | 158 | </svg> |
| 158 | 159 | {{ reset_password }} |
| 159 | 160 | </button> |
| 160 | - <button | |
| 161 | + <button | |
| 161 | 162 | type="button" |
| 162 | - class="btn btn-sm btn-error" | |
| 163 | - onclick="openDeleteUserModal('{{ user.id }}', '{{ user.username }}', '{{ user.email }}')" | |
| 163 | + class="btn btn-sm btn-error btn-delete-user" | |
| 164 | + data-user-id="{{ user.id }}" | |
| 165 | + data-username="{{ user.username }}" | |
| 166 | + data-email="{{ user.email }}" | |
| 164 | 167 | > |
| 165 | 168 | <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| 166 | 169 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> |
@@ -336,17 +339,32 @@ document.querySelectorAll('input[name="reset_type"]').forEach(radio => {
| 336 | 339 | }); |
| 337 | 340 | }); |
| 338 | 341 | |
| 339 | -// Open Reset Password Modal | |
| 340 | -function openResetPasswordModal(userId, username) { | |
| 341 | - document.getElementById('resetUserId').value = userId; | |
| 342 | - document.getElementById('resetPasswordForm').action = `/admin/users/${userId}/reset-password`; | |
| 343 | - // Reset form state | |
| 344 | - document.querySelector('input[name="reset_type"][value="generate"]').checked = true; | |
| 345 | - document.getElementById('customPasswordField').classList.add('hidden'); | |
| 346 | - document.getElementById('newPassword').value = ''; | |
| 347 | - document.getElementById('newPassword').removeAttribute('required'); | |
| 348 | - document.getElementById('resetPasswordModal').showModal(); | |
| 349 | -} | |
| 342 | +// SEC-FIX: Use data-* attributes instead of inline onclick to prevent XSS | |
| 343 | +document.querySelectorAll('.btn-reset-password').forEach(function(btn) { | |
| 344 | + btn.addEventListener('click', function() { | |
| 345 | + var userId = this.dataset.userId; | |
| 346 | + document.getElementById('resetUserId').value = userId; | |
| 347 | + document.getElementById('resetPasswordForm').action = '/admin/users/' + encodeURIComponent(userId) + '/reset-password'; | |
| 348 | + document.querySelector('input[name="reset_type"][value="generate"]').checked = true; | |
| 349 | + document.getElementById('customPasswordField').classList.add('hidden'); | |
| 350 | + document.getElementById('newPassword').value = ''; | |
| 351 | + document.getElementById('newPassword').removeAttribute('required'); | |
| 352 | + document.getElementById('resetPasswordModal').showModal(); | |
| 353 | + }); | |
| 354 | +}); | |
| 355 | + | |
| 356 | +document.querySelectorAll('.btn-delete-user').forEach(function(btn) { | |
| 357 | + btn.addEventListener('click', function() { | |
| 358 | + var userId = this.dataset.userId; | |
| 359 | + var username = this.dataset.username; | |
| 360 | + var email = this.dataset.email; | |
| 361 | + document.getElementById('deleteUserId').value = userId; | |
| 362 | + document.getElementById('deleteUsername').textContent = username; | |
| 363 | + document.getElementById('deleteEmail').textContent = email; | |
| 364 | + document.getElementById('deleteUserForm').action = '/admin/users/' + encodeURIComponent(userId); | |
| 365 | + document.getElementById('deleteUserModal').showModal(); | |
| 366 | + }); | |
| 367 | +}); | |
| 350 | 368 | |
| 351 | 369 | // Handle Reset Password Form Submission |
| 352 | 370 | document.getElementById('resetPasswordForm').addEventListener('submit', async function(e) { |
@@ -369,7 +387,8 @@ document.getElementById('resetPasswordForm').addEventListener('submit', async fu
| 369 | 387 | response = await fetch(this.action, { |
| 370 | 388 | method: 'POST', |
| 371 | 389 | headers: { |
| 372 | - 'Accept': 'text/html', // Expect HTML fragment response | |
| 390 | + 'Accept': 'text/html', | |
| 391 | + 'X-CSRF-Token': '{{ csrf_token }}', | |
| 373 | 392 | }, |
| 374 | 393 | credentials: 'include', |
| 375 | 394 | }); |
@@ -383,7 +402,8 @@ document.getElementById('resetPasswordForm').addEventListener('submit', async fu
| 383 | 402 | method: 'POST', |
| 384 | 403 | headers: { |
| 385 | 404 | 'Content-Type': 'application/json', |
| 386 | - 'Accept': 'application/json' | |
| 405 | + 'Accept': 'application/json', | |
| 406 | + 'X-CSRF-Token': '{{ csrf_token }}', | |
| 387 | 407 | }, |
| 388 | 408 | credentials: 'include', |
| 389 | 409 | body: JSON.stringify(requestBody) |
@@ -397,21 +417,23 @@ document.getElementById('resetPasswordForm').addEventListener('submit', async fu
| 397 | 417 | submitBtn.innerHTML = originalBtnText; |
| 398 | 418 | |
| 399 | 419 | if (resetType === 'email') { |
| 400 | - var htmlFragment = await response.text(); | |
| 401 | - var messageDiv = document.createElement('div'); | |
| 402 | - messageDiv.innerHTML = htmlFragment; | |
| 403 | - var message = messageDiv.querySelector('.alert') || messageDiv; | |
| 404 | - | |
| 420 | + var responseText = await response.text(); | |
| 421 | + // SEC-FIX: Use textContent instead of innerHTML to prevent XSS | |
| 405 | 422 | var successModal = document.createElement('div'); |
| 406 | 423 | successModal.className = 'modal modal-open'; |
| 407 | 424 | successModal.innerHTML = '<div class="modal-box">' |
| 408 | 425 | + '<h3 class="font-bold text-lg mb-4">{{ reset_by_email_title }}</h3>' |
| 409 | - + '<div class="mb-4">' + message.innerHTML + '</div>' | |
| 426 | + + '<div class="mb-4 alert alert-success"></div>' | |
| 410 | 427 | + '<div class="modal-action">' |
| 411 | - + '<button type="button" class="btn btn-primary" onclick="this.closest(\'.modal\').remove();">{{ close }}</button>' | |
| 428 | + + '<button type="button" class="btn btn-primary">{{ close }}</button>' | |
| 412 | 429 | + '</div></div>' |
| 413 | 430 | + '<form method="dialog" class="modal-backdrop"><button>close</button></form>'; |
| 414 | 431 | document.body.appendChild(successModal); |
| 432 | + // Set text safely via textContent | |
| 433 | + successModal.querySelector('.alert').textContent = responseText.replace(/<[^>]*>/g, ''); | |
| 434 | + successModal.querySelector('.btn-primary').addEventListener('click', function() { | |
| 435 | + successModal.remove(); | |
| 436 | + }); | |
| 415 | 437 | return; |
| 416 | 438 | } |
| 417 | 439 |
@@ -421,6 +443,7 @@ document.getElementById('resetPasswordForm').addEventListener('submit', async fu
| 421 | 443 | alert('{{ password_set_success }}'); |
| 422 | 444 | window.location.reload(); |
| 423 | 445 | } else if (result.password) { |
| 446 | + // SEC-FIX: Set password value via DOM property, not innerHTML | |
| 424 | 447 | var passwordDisplay = document.createElement('div'); |
| 425 | 448 | passwordDisplay.className = 'modal modal-open'; |
| 426 | 449 | passwordDisplay.innerHTML = '<div class="modal-box">' |
@@ -429,14 +452,19 @@ document.getElementById('resetPasswordForm').addEventListener('submit', async fu
| 429 | 452 | + '<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>' |
| 430 | 453 | + '<span>{{ password_warning_once }}</span></div>' |
| 431 | 454 | + '<div class="form-control mb-4"><label class="label"><span class="label-text font-semibold">{{ new_password_label }}</span></label>' |
| 432 | - + '<div class="join"><input type="text" id="displayPassword" value="' + result.password.replace(/"/g, '"') + '" class="input input-bordered join-item w-full font-mono text-lg" readonly>' | |
| 433 | - + '<button type="button" class="btn btn-primary join-item" onclick="copyPassword()"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {{ copy }}</button>' | |
| 455 | + + '<div class="join"><input type="text" id="displayPassword" class="input input-bordered join-item w-full font-mono text-lg" readonly>' | |
| 456 | + + '<button type="button" class="btn btn-primary join-item" id="copyPasswordBtn"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {{ copy }}</button>' | |
| 434 | 457 | + '</div></div>' |
| 435 | - + '<div class="modal-action"><button type="button" class="btn btn-primary" onclick="this.closest(\'.modal\').remove(); window.location.reload();">{{ close }}</button></div>' | |
| 458 | + + '<div class="modal-action"><button type="button" class="btn btn-primary" id="closePasswordModal">{{ close }}</button></div>' | |
| 436 | 459 | + '</div>'; |
| 437 | 460 | document.body.appendChild(passwordDisplay); |
| 438 | - | |
| 439 | - window.copyPassword = function() { | |
| 461 | + // Set password via DOM property (safe, no HTML parsing) | |
| 462 | + document.getElementById('displayPassword').value = result.password; | |
| 463 | + document.getElementById('closePasswordModal').addEventListener('click', function() { | |
| 464 | + passwordDisplay.remove(); | |
| 465 | + window.location.reload(); | |
| 466 | + }); | |
| 467 | + document.getElementById('copyPasswordBtn').addEventListener('click', function() { | |
| 440 | 468 | var passwordInput = document.getElementById('displayPassword'); |
| 441 | 469 | navigator.clipboard.writeText(passwordInput.value).then(function() { |
| 442 | 470 | alert('{{ password_copied }}'); |
@@ -445,7 +473,7 @@ document.getElementById('resetPasswordForm').addEventListener('submit', async fu
| 445 | 473 | document.execCommand('copy'); |
| 446 | 474 | alert('{{ password_copied }}'); |
| 447 | 475 | }); |
| 448 | - }; | |
| 476 | + }); | |
| 449 | 477 | } else { |
| 450 | 478 | alert('{{ password_set_success }}'); |
| 451 | 479 | window.location.reload(); |
@@ -474,15 +502,6 @@ document.getElementById('resetPasswordForm').addEventListener('submit', async fu
| 474 | 502 | } |
| 475 | 503 | }); |
| 476 | 504 | |
| 477 | -// Open Delete User Modal | |
| 478 | -function openDeleteUserModal(userId, username, email) { | |
| 479 | - document.getElementById('deleteUserId').value = userId; | |
| 480 | - document.getElementById('deleteUsername').textContent = username; | |
| 481 | - document.getElementById('deleteEmail').textContent = email; | |
| 482 | - document.getElementById('deleteUserForm').action = `/admin/users/${userId}`; | |
| 483 | - document.getElementById('deleteUserModal').showModal(); | |
| 484 | -} | |
| 485 | - | |
| 486 | 505 | // Handle form submissions |
| 487 | 506 | document.getElementById('deleteUserForm').addEventListener('submit', async function(e) { |
| 488 | 507 | e.preventDefault(); |
@@ -494,6 +513,7 @@ document.getElementById('deleteUserForm').addEventListener('submit', async funct
| 494 | 513 | method: 'DELETE', |
| 495 | 514 | headers: { |
| 496 | 515 | 'Content-Type': 'application/x-www-form-urlencoded', |
| 516 | + 'X-CSRF-Token': '{{ csrf_token }}', | |
| 497 | 517 | }, |
| 498 | 518 | body: new URLSearchParams(formData) |
| 499 | 519 | }); |
M
crates/rustwarden-ui/templates/base.html
+1
-1
@@ -154,7 +154,7 @@
| 154 | 154 | |
| 155 | 155 | fetch('/settings/preferences', { |
| 156 | 156 | method: 'PUT', |
| 157 | - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 157 | + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': '{{ csrf_token }}' }, | |
| 158 | 158 | credentials: 'include', |
| 159 | 159 | body: 'language=' + encodeURIComponent(locale) |
| 160 | 160 | }).then(function() { |
M
crates/rustwarden-ui/templates/fragments/user_menu_dropdown.html
+3
-2
@@ -54,9 +54,10 @@ function handleLogout() {
| 54 | 54 | method: 'POST', |
| 55 | 55 | headers: { |
| 56 | 56 | 'Content-Type': 'application/json', |
| 57 | - 'Accept': 'text/html,application/json' | |
| 57 | + 'Accept': 'text/html,application/json', | |
| 58 | + 'X-CSRF-Token': '{{ csrf_token }}', | |
| 58 | 59 | }, |
| 59 | - credentials: 'include' // Include cookies | |
| 60 | + credentials: 'include' | |
| 60 | 61 | }) |
| 61 | 62 | .then(response => { |
| 62 | 63 | if (response.ok) { |
M
crates/rustwarden-ui/templates/profile.html
+1
-0
@@ -147,6 +147,7 @@ document.getElementById('profileForm').addEventListener('submit', async function
| 147 | 147 | method: 'PUT', |
| 148 | 148 | headers: { |
| 149 | 149 | 'Content-Type': 'application/x-www-form-urlencoded', |
| 150 | + 'X-CSRF-Token': '{{ csrf_token }}', | |
| 150 | 151 | }, |
| 151 | 152 | body: new URLSearchParams(formData) |
| 152 | 153 | }); |
M
crates/rustwarden-ui/templates/settings/password.html
+4
-2
@@ -362,9 +362,10 @@ document.getElementById('passwordForm').addEventListener('submit', async functio
| 362 | 362 | method: 'PUT', |
| 363 | 363 | headers: { |
| 364 | 364 | 'Content-Type': 'application/json', |
| 365 | - 'Accept': 'application/json' | |
| 365 | + 'Accept': 'application/json', | |
| 366 | + 'X-CSRF-Token': '{{ csrf_token }}', | |
| 366 | 367 | }, |
| 367 | - credentials: 'include', // Important: inclure les cookies (JWT token) | |
| 368 | + credentials: 'include', | |
| 368 | 369 | body: jsonBody |
| 369 | 370 | }); |
| 370 | 371 |
@@ -466,6 +467,7 @@ async function cancelPendingRequest() {
| 466 | 467 | method: 'DELETE', |
| 467 | 468 | headers: { |
| 468 | 469 | 'Content-Type': 'application/json', |
| 470 | + 'X-CSRF-Token': '{{ csrf_token }}', | |
| 469 | 471 | }, |
| 470 | 472 | credentials: 'same-origin' |
| 471 | 473 | }); |
M
crates/rustwarden-ui/templates/settings/preferences.html
+1
-1
@@ -301,7 +301,7 @@ document.getElementById('preferencesForm').addEventListener('submit', async func
| 301 | 301 | |
| 302 | 302 | var response = await fetch('/settings/preferences', { |
| 303 | 303 | method: 'PUT', |
| 304 | - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 304 | + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': '{{ csrf_token }}' }, | |
| 305 | 305 | credentials: 'include', |
| 306 | 306 | body: params.toString() |
| 307 | 307 | }); |
M
crates/rustwarden-ui/templates/settings/sessions.html
+44
-41
@@ -76,9 +76,10 @@
| 76 | 76 | <!-- Revoke Button --> |
| 77 | 77 | <div class="card-actions"> |
| 78 | 78 | {% if !session.is_current %} |
| 79 | - <button | |
| 80 | - class="btn btn-error btn-sm" | |
| 81 | - onclick="revokeSession('{{ session.id }}', '{{ session.device_info }}')" | |
| 79 | + <button | |
| 80 | + class="btn btn-error btn-sm btn-revoke-session" | |
| 81 | + data-session-id="{{ session.id }}" | |
| 82 | + data-device-info="{{ session.device_info }}" | |
| 82 | 83 | > |
| 83 | 84 | <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg> |
| 84 | 85 | {{ revoke }} |
@@ -150,32 +151,37 @@
| 150 | 151 | </div> |
| 151 | 152 | |
| 152 | 153 | <script> |
| 153 | -// Revoke a specific session | |
| 154 | -async function revokeSession(sessionId, deviceInfo) { | |
| 155 | - if (!confirm(`Êtes-vous sûr de vouloir révoquer la session pour "${deviceInfo}" ?\n\nCet appareil sera déconnecté immédiatement.`)) { | |
| 156 | - return; | |
| 157 | - } | |
| 158 | - | |
| 159 | - try { | |
| 160 | - const response = await fetch(`/settings/sessions/${sessionId}`, { | |
| 161 | - method: 'DELETE', | |
| 162 | - headers: { | |
| 163 | - 'Content-Type': 'application/json', | |
| 154 | +// SEC-FIX: Use data-* attributes instead of inline onclick to prevent XSS | |
| 155 | +document.querySelectorAll('.btn-revoke-session').forEach(function(btn) { | |
| 156 | + btn.addEventListener('click', async function() { | |
| 157 | + var sessionId = this.dataset.sessionId; | |
| 158 | + var deviceInfo = this.dataset.deviceInfo; | |
| 159 | + if (!confirm('Êtes-vous sûr de vouloir révoquer la session pour "' + deviceInfo + '" ?\n\nCet appareil sera déconnecté immédiatement.')) { | |
| 160 | + return; | |
| 161 | + } | |
| 162 | + | |
| 163 | + try { | |
| 164 | + const response = await fetch('/settings/sessions/' + encodeURIComponent(sessionId), { | |
| 165 | + method: 'DELETE', | |
| 166 | + headers: { | |
| 167 | + 'Content-Type': 'application/json', | |
| 168 | + 'X-CSRF-Token': '{{ csrf_token }}', | |
| 169 | + } | |
| 170 | + }); | |
| 171 | + | |
| 172 | + if (response.ok) { | |
| 173 | + alert('Session révoquée avec succès !'); | |
| 174 | + window.location.reload(); | |
| 175 | + } else { | |
| 176 | + const result = await response.json(); | |
| 177 | + alert('Erreur : ' + (result.error || result.message || 'Échec de la révocation')); | |
| 164 | 178 | } |
| 165 | - }); | |
| 166 | - | |
| 167 | - if (response.ok) { | |
| 168 | - alert('✅ Session révoquée avec succès !'); | |
| 169 | - window.location.reload(); | |
| 170 | - } else { | |
| 171 | - const result = await response.json(); | |
| 172 | - alert('❌ Erreur : ' + (result.error || result.message || 'Échec de la révocation')); | |
| 179 | + } catch (error) { | |
| 180 | + console.error('Error:', error); | |
| 181 | + alert('Erreur réseau. Veuillez réessayer.'); | |
| 173 | 182 | } |
| 174 | - } catch (error) { | |
| 175 | - console.error('Error:', error); | |
| 176 | - alert('❌ Erreur réseau. Veuillez réessayer.'); | |
| 177 | - } | |
| 178 | -} | |
| 183 | + }); | |
| 184 | +}); | |
| 179 | 185 | |
| 180 | 186 | // Revoke all other sessions |
| 181 | 187 | const revokeAllConfirmMsg = "{{ revoke_all_confirm }}"; |
@@ -192,21 +198,18 @@ async function revokeAllSessions() {
| 192 | 198 | const promises = []; |
| 193 | 199 | |
| 194 | 200 | sessionCards.forEach(card => { |
| 195 | - const revokeBtn = card.querySelector('button.btn-error'); | |
| 201 | + const revokeBtn = card.querySelector('.btn-revoke-session'); | |
| 196 | 202 | if (revokeBtn) { |
| 197 | - const onclick = revokeBtn.getAttribute('onclick'); | |
| 198 | - const match = onclick.match(/revokeSession\('([^']+)'/); | |
| 199 | - if (match) { | |
| 200 | - const sessionId = match[1]; | |
| 201 | - promises.push( | |
| 202 | - fetch(`/settings/sessions/${sessionId}`, { | |
| 203 | - method: 'DELETE', | |
| 204 | - headers: { | |
| 205 | - 'Content-Type': 'application/json', | |
| 206 | - } | |
| 207 | - }) | |
| 208 | - ); | |
| 209 | - } | |
| 203 | + const sessionId = revokeBtn.dataset.sessionId; | |
| 204 | + promises.push( | |
| 205 | + fetch('/settings/sessions/' + encodeURIComponent(sessionId), { | |
| 206 | + method: 'DELETE', | |
| 207 | + headers: { | |
| 208 | + 'Content-Type': 'application/json', | |
| 209 | + 'X-CSRF-Token': '{{ csrf_token }}', | |
| 210 | + } | |
| 211 | + }) | |
| 212 | + ); | |
| 210 | 213 | } |
| 211 | 214 | }); |
| 212 | 215 |
A
docs/admin_oauth_settings_ui_7ca3f1fd.plan.html
+40
-0
@@ -0,0 +1,40 @@
| 1 | +<!DOCTYPE html><html><head> | |
| 2 | + <title>admin_oauth_settings_ui_7ca3f1fd.plan</title> | |
| 3 | + <meta charset="utf-8"> | |
| 4 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 5 | + | |
| 6 | + <link rel="stylesheet" href="file:////home/monsieurb/.cursor/extensions/shd101wyy.markdown-preview-enhanced-0.8.20-universal/crossnote/dependencies/katex/katex.min.css"> | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + <style> | |
| 13 | + code[class*=language-],pre[class*=language-]{color:#333;background:0 0;font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.4;-moz-tab-size:8;-o-tab-size:8;tab-size:8;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:.8em;overflow:auto;border-radius:3px;background:#f5f5f5}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal;background:#f5f5f5}.token.blockquote,.token.comment{color:#969896}.token.cdata{color:#183691}.token.doctype,.token.macro.property,.token.punctuation,.token.variable{color:#333}.token.builtin,.token.important,.token.keyword,.token.operator,.token.rule{color:#a71d5d}.token.attr-value,.token.regex,.token.string,.token.url{color:#183691}.token.atrule,.token.boolean,.token.code,.token.command,.token.constant,.token.entity,.token.number,.token.property,.token.symbol{color:#0086b3}.token.prolog,.token.selector,.token.tag{color:#63a35c}.token.attr-name,.token.class,.token.class-name,.token.function,.token.id,.token.namespace,.token.pseudo-class,.token.pseudo-element,.token.url-reference .token.variable{color:#795da3}.token.entity{cursor:help}.token.title,.token.title .token.punctuation{font-weight:700;color:#1d3e81}.token.list{color:#ed6a43}.token.inserted{background-color:#eaffea;color:#55a532}.token.deleted{background-color:#ffecec;color:#bd2c00}.token.bold{font-weight:700}.token.italic{font-style:italic}.language-json .token.property{color:#183691}.language-markup .token.tag .token.punctuation{color:#333}.language-css .token.function,code.language-css{color:#0086b3}.language-yaml .token.atrule{color:#63a35c}code.language-yaml{color:#183691}.language-ruby .token.function{color:#333}.language-markdown .token.url{color:#795da3}.language-makefile .token.symbol{color:#795da3}.language-makefile .token.variable{color:#183691}.language-makefile .token.builtin{color:#0086b3}.language-bash .token.keyword{color:#0086b3}pre[data-line]{position:relative;padding:1em 0 1em 3em}pre[data-line] .line-highlight-wrapper{position:absolute;top:0;left:0;background-color:transparent;display:block;width:100%}pre[data-line] .line-highlight{position:absolute;left:0;right:0;padding:inherit 0;margin-top:1em;background:hsla(24,20%,50%,.08);background:linear-gradient(to right,hsla(24,20%,50%,.1) 70%,hsla(24,20%,50%,0));pointer-events:none;line-height:inherit;white-space:pre}pre[data-line] .line-highlight:before,pre[data-line] .line-highlight[data-end]:after{content:attr(data-start);position:absolute;top:.4em;left:.6em;min-width:1em;padding:0 .5em;background-color:hsla(24,20%,50%,.4);color:#f4f1ef;font:bold 65%/1.5 sans-serif;text-align:center;vertical-align:.3em;border-radius:999px;text-shadow:none;box-shadow:0 1px #fff}pre[data-line] .line-highlight[data-end]:after{content:attr(data-end);top:auto;bottom:.4em}html body{font-family:'Helvetica Neue',Helvetica,'Segoe UI',Arial,freesans,sans-serif;font-size:16px;line-height:1.6;color:#333;background-color:#fff;overflow:initial;box-sizing:border-box;word-wrap:break-word}html body>:first-child{margin-top:0}html body h1,html body h2,html body h3,html body h4,html body h5,html body h6{line-height:1.2;margin-top:1em;margin-bottom:16px;color:#000}html body h1{font-size:2.25em;font-weight:300;padding-bottom:.3em}html body h2{font-size:1.75em;font-weight:400;padding-bottom:.3em}html body h3{font-size:1.5em;font-weight:500}html body h4{font-size:1.25em;font-weight:600}html body h5{font-size:1.1em;font-weight:600}html body h6{font-size:1em;font-weight:600}html body h1,html body h2,html body h3,html body h4,html body h5{font-weight:600}html body h5{font-size:1em}html body h6{color:#5c5c5c}html body strong{color:#000}html body del{color:#5c5c5c}html body a:not([href]){color:inherit;text-decoration:none}html body a{color:#08c;text-decoration:none}html body a:hover{color:#00a3f5;text-decoration:none}html body img{max-width:100%}html body>p{margin-top:0;margin-bottom:16px;word-wrap:break-word}html body>ol,html body>ul{margin-bottom:16px}html body ol,html body ul{padding-left:2em}html body ol.no-list,html body ul.no-list{padding:0;list-style-type:none}html body ol ol,html body ol ul,html body ul ol,html body ul ul{margin-top:0;margin-bottom:0}html body li{margin-bottom:0}html body li.task-list-item{list-style:none}html body li>p{margin-top:0;margin-bottom:0}html body .task-list-item-checkbox{margin:0 .2em .25em -1.8em;vertical-align:middle}html body .task-list-item-checkbox:hover{cursor:pointer}html body blockquote{margin:16px 0;font-size:inherit;padding:0 15px;color:#5c5c5c;background-color:#f0f0f0;border-left:4px solid #d6d6d6}html body blockquote>:first-child{margin-top:0}html body blockquote>:last-child{margin-bottom:0}html body hr{height:4px;margin:32px 0;background-color:#d6d6d6;border:0 none}html body table{margin:10px 0 15px 0;border-collapse:collapse;border-spacing:0;display:block;width:100%;overflow:auto;word-break:normal;word-break:keep-all}html body table th{font-weight:700;color:#000}html body table td,html body table th{border:1px solid #d6d6d6;padding:6px 13px}html body dl{padding:0}html body dl dt{padding:0;margin-top:16px;font-size:1em;font-style:italic;font-weight:700}html body dl dd{padding:0 16px;margin-bottom:16px}html body code{font-family:Menlo,Monaco,Consolas,'Courier New',monospace;font-size:.85em;color:#000;background-color:#f0f0f0;border-radius:3px;padding:.2em 0}html body code::after,html body code::before{letter-spacing:-.2em;content:'\00a0'}html body pre>code{padding:0;margin:0;word-break:normal;white-space:pre;background:0 0;border:0}html body .highlight{margin-bottom:16px}html body .highlight pre,html body pre{padding:1em;overflow:auto;line-height:1.45;border:#d6d6d6;border-radius:3px}html body .highlight pre{margin-bottom:0;word-break:normal}html body pre code,html body pre tt{display:inline;max-width:initial;padding:0;margin:0;overflow:initial;line-height:inherit;word-wrap:normal;background-color:transparent;border:0}html body pre code:after,html body pre code:before,html body pre tt:after,html body pre tt:before{content:normal}html body blockquote,html body dl,html body ol,html body p,html body pre,html body ul{margin-top:0;margin-bottom:16px}html body kbd{color:#000;border:1px solid #d6d6d6;border-bottom:2px solid #c7c7c7;padding:2px 4px;background-color:#f0f0f0;border-radius:3px}@media print{html body{background-color:#fff}html body h1,html body h2,html body h3,html body h4,html body h5,html body h6{color:#000;page-break-after:avoid}html body blockquote{color:#5c5c5c}html body pre{page-break-inside:avoid}html body table{display:table}html body img{display:block;max-width:100%;max-height:100%}html body code,html body pre{word-wrap:break-word;white-space:pre}}.markdown-preview{width:100%;height:100%;box-sizing:border-box}.markdown-preview ul{list-style:disc}.markdown-preview ul ul{list-style:circle}.markdown-preview ul ul ul{list-style:square}.markdown-preview ol{list-style:decimal}.markdown-preview ol ol,.markdown-preview ul ol{list-style-type:lower-roman}.markdown-preview ol ol ol,.markdown-preview ol ul ol,.markdown-preview ul ol ol,.markdown-preview ul ul ol{list-style-type:lower-alpha}.markdown-preview .newpage,.markdown-preview .pagebreak{page-break-before:always}.markdown-preview pre.line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}.markdown-preview pre.line-numbers>code{position:relative}.markdown-preview pre.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:1em;font-size:100%;left:0;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.markdown-preview pre.line-numbers .line-numbers-rows>span{pointer-events:none;display:block;counter-increment:linenumber}.markdown-preview pre.line-numbers .line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right}.markdown-preview .mathjax-exps .MathJax_Display{text-align:center!important}.markdown-preview:not([data-for=preview]) .code-chunk .code-chunk-btn-group{display:none}.markdown-preview:not([data-for=preview]) .code-chunk .status{display:none}.markdown-preview:not([data-for=preview]) .code-chunk .output-div{margin-bottom:16px}.markdown-preview .md-toc{padding:0}.markdown-preview .md-toc .md-toc-link-wrapper .md-toc-link{display:inline;padding:.25rem 0}.markdown-preview .md-toc .md-toc-link-wrapper .md-toc-link div,.markdown-preview .md-toc .md-toc-link-wrapper .md-toc-link p{display:inline}.markdown-preview .md-toc .md-toc-link-wrapper.highlighted .md-toc-link{font-weight:800}.scrollbar-style::-webkit-scrollbar{width:8px}.scrollbar-style::-webkit-scrollbar-track{border-radius:10px;background-color:transparent}.scrollbar-style::-webkit-scrollbar-thumb{border-radius:5px;background-color:rgba(150,150,150,.66);border:4px solid rgba(150,150,150,.66);background-clip:content-box}html body[for=html-export]:not([data-presentation-mode]){position:relative;width:100%;height:100%;top:0;left:0;margin:0;padding:0;overflow:auto}html body[for=html-export]:not([data-presentation-mode]) .markdown-preview{position:relative;top:0;min-height:100vh}@media screen and (min-width:914px){html body[for=html-export]:not([data-presentation-mode]) .markdown-preview{padding:2em calc(50% - 457px + 2em)}}@media screen and (max-width:914px){html body[for=html-export]:not([data-presentation-mode]) .markdown-preview{padding:2em}}@media screen and (max-width:450px){html body[for=html-export]:not([data-presentation-mode]) .markdown-preview{font-size:14px!important;padding:1em}}@media print{html body[for=html-export]:not([data-presentation-mode]) #sidebar-toc-btn{display:none}}html body[for=html-export]:not([data-presentation-mode]) #sidebar-toc-btn{position:fixed;bottom:8px;left:8px;font-size:28px;cursor:pointer;color:inherit;z-index:99;width:32px;text-align:center;opacity:.4}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] #sidebar-toc-btn{opacity:1}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc{position:fixed;top:0;left:0;width:300px;height:100%;padding:32px 0 48px 0;font-size:14px;box-shadow:0 0 4px rgba(150,150,150,.33);box-sizing:border-box;overflow:auto;background-color:inherit}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc::-webkit-scrollbar{width:8px}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc::-webkit-scrollbar-track{border-radius:10px;background-color:transparent}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc::-webkit-scrollbar-thumb{border-radius:5px;background-color:rgba(150,150,150,.66);border:4px solid rgba(150,150,150,.66);background-clip:content-box}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc a{text-decoration:none}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc .md-toc{padding:0 16px}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc .md-toc .md-toc-link-wrapper .md-toc-link{display:inline;padding:.25rem 0}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc .md-toc .md-toc-link-wrapper .md-toc-link div,html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc .md-toc .md-toc-link-wrapper .md-toc-link p{display:inline}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .md-sidebar-toc .md-toc .md-toc-link-wrapper.highlighted .md-toc-link{font-weight:800}html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .markdown-preview{left:300px;width:calc(100% - 300px);padding:2em calc(50% - 457px - 300px / 2);margin:0;box-sizing:border-box}@media screen and (max-width:1274px){html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .markdown-preview{padding:2em}}@media screen and (max-width:450px){html body[for=html-export]:not([data-presentation-mode])[html-show-sidebar-toc] .markdown-preview{width:100%}}html body[for=html-export]:not([data-presentation-mode]):not([html-show-sidebar-toc]) .markdown-preview{left:50%;transform:translateX(-50%)}html body[for=html-export]:not([data-presentation-mode]):not([html-show-sidebar-toc]) .md-sidebar-toc{display:none} | |
| 14 | +/* Please visit the URL below for more information: */ | |
| 15 | +/* https://shd101wyy.github.io/markdown-preview-enhanced/#/customize-css */ | |
| 16 | + | |
| 17 | + </style> | |
| 18 | + <!-- The content below will be included at the end of the <head> element. --><script type="text/javascript"> | |
| 19 | + document.addEventListener("DOMContentLoaded", function () { | |
| 20 | + // your code here | |
| 21 | + }); | |
| 22 | +</script></head><body for="html-export"> | |
| 23 | + | |
| 24 | + | |
| 25 | + <div class="crossnote markdown-preview "> | |
| 26 | + | |
| 27 | +<h1 id="refonte-admin-oauth-settings---configuration-complete-via-ui">Refonte Admin OAuth Settings -- Configuration complete via UI </h1> | |
| 28 | +<h2></h2> | |
| 29 | + | |
| 30 | + </div> | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + </body></html> | |
| 40 | < \ No newline at end of file |
D
docs/configuration-flows.md
+0
-688
@@ -1,688 +0,0 @@
| 1 | -# RustWarden — Variables d'environnement & Diagrammes de séquence | |
| 2 | - | |
| 3 | -Ce document décrit l'ensemble des variables d'environnement de RustWarden et les diagrammes de séquence montrant où chacune intervient dans les flux applicatifs. | |
| 4 | - | |
| 5 | ---- | |
| 6 | - | |
| 7 | -## Table des matières | |
| 8 | - | |
| 9 | -1. [Démarrage du serveur](#1-démarrage-du-serveur) | |
| 10 | -2. [Authentification (Login)](#2-authentification-login) | |
| 11 | -3. [Rendu des pages (i18n, thème, navigation)](#3-rendu-des-pages) | |
| 12 | -4. [Inscription & Validation email](#4-inscription--validation-email) | |
| 13 | -5. [Réinitialisation de mot de passe (Forgot Password)](#5-réinitialisation-de-mot-de-passe) | |
| 14 | -6. [Changement de mot de passe (avec confirmation email)](#6-changement-de-mot-de-passe) | |
| 15 | -7. [Traitement de la queue email (Background)](#7-traitement-de-la-queue-email) | |
| 16 | -8. [Détection des bounces IMAP (Background)](#8-détection-des-bounces-imap) | |
| 17 | -9. [Nettoyage périodique (Background Tasks)](#9-nettoyage-périodique) | |
| 18 | -10. [Administration — Liste & Pagination](#10-administration--pagination) | |
| 19 | -11. [Préférences utilisateur (Thème & Langue)](#11-préférences-utilisateur) | |
| 20 | -12. [Authentification OAuth / SSO](#12-authentification-oauth--sso) | |
| 21 | -13. [Référence complète des variables](#13-référence-complète) | |
| 22 | - | |
| 23 | ---- | |
| 24 | - | |
| 25 | -## 1. Démarrage du serveur | |
| 26 | - | |
| 27 | -Variables impliquées : `SERVER_HOST`, `SERVER_PORT`, `DATABASE_URL`, `RUST_LOG`, `DEFAULT_LOCALE`, `CREATE_DEFAULT_ADMIN`, `ADMIN_*` | |
| 28 | - | |
| 29 | -```mermaid | |
| 30 | -sequenceDiagram | |
| 31 | - participant Env as .env | |
| 32 | - participant Main as main.rs | |
| 33 | - participant DB as PostgreSQL | |
| 34 | - participant I18n as I18nService | |
| 35 | - participant Tasks as Background Tasks | |
| 36 | - | |
| 37 | - Main->>Env: SERVER_HOST (défaut: 0.0.0.0) | |
| 38 | - Main->>Env: SERVER_PORT (défaut: 3000) | |
| 39 | - Main->>Env: RUST_LOG | |
| 40 | - Main->>Env: DATABASE_URL | |
| 41 | - Main->>DB: Connexion SeaORM | |
| 42 | - Main->>DB: Migrations (sea-orm-migration) | |
| 43 | - | |
| 44 | - Main->>Env: CREATE_DEFAULT_ADMIN | |
| 45 | - alt CREATE_DEFAULT_ADMIN = true | |
| 46 | - Main->>Env: ADMIN_USERNAME, ADMIN_EMAIL, ADMIN_PASSWORD | |
| 47 | - Main->>DB: Créer compte admin par défaut | |
| 48 | - end | |
| 49 | - | |
| 50 | - Main->>Env: DEFAULT_LOCALE (défaut: "fr") | |
| 51 | - Main->>I18n: Charger locales/*.yml | |
| 52 | - | |
| 53 | - Main->>Env: EMAIL_QUEUE_INTERVAL_SECS (défaut: 30) | |
| 54 | - Main->>Env: PASSWORD_CHANGE_RETRY_INTERVAL_SECS (défaut: 600) | |
| 55 | - Main->>Env: PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS (défaut: 86400) | |
| 56 | - Main->>Env: JWT_CLEANUP_INTERVAL_SECS (défaut: 3600) | |
| 57 | - Main->>Tasks: Lancer 4 tâches de fond | |
| 58 | - Main->>Main: bind(SERVER_HOST:SERVER_PORT) | |
| 59 | -``` | |
| 60 | - | |
| 61 | ---- | |
| 62 | - | |
| 63 | -## 2. Authentification (Login) | |
| 64 | - | |
| 65 | -Variables impliquées : `JWT_SECRET`, `JWT_EXPIRATION_MINUTES`, `JWT_ISSUER`, `COOKIE_SECURE`, `COOKIE_SAME_SITE`, `REFRESH_TOKEN_EXPIRATION_DAYS`, `EMAIL_VALIDATION_REQUIRED`, `RATE_LIMIT_LOGIN_PER_MINUTE`, `COOKIE_PREFERENCE_MAX_AGE_SECS` | |
| 66 | - | |
| 67 | -```mermaid | |
| 68 | -sequenceDiagram | |
| 69 | - participant User as Navigateur | |
| 70 | - participant Server as POST /api/v1/auth/login | |
| 71 | - participant Auth as AuthService | |
| 72 | - participant JWT as JwtService | |
| 73 | - participant DB as PostgreSQL | |
| 74 | - participant Env as .env | |
| 75 | - | |
| 76 | - User->>Server: { username, password } | |
| 77 | - | |
| 78 | - Server->>Env: RATE_LIMIT_LOGIN_PER_MINUTE (défaut: 5) | |
| 79 | - Server->>Server: Vérifier rate limit | |
| 80 | - | |
| 81 | - Server->>Auth: authenticate_user() | |
| 82 | - Auth->>DB: SELECT user WHERE username | |
| 83 | - Auth->>Auth: bcrypt::verify(password) | |
| 84 | - | |
| 85 | - Server->>Env: EMAIL_VALIDATION_REQUIRED (défaut: true) | |
| 86 | - alt EMAIL_VALIDATION_REQUIRED && !email_verified | |
| 87 | - Server-->>User: 403 "Email non vérifié" | |
| 88 | - end | |
| 89 | - | |
| 90 | - Server->>Env: JWT_SECRET (obligatoire, min 32 chars) | |
| 91 | - Server->>Env: JWT_EXPIRATION_MINUTES (défaut: 15) | |
| 92 | - Server->>Env: JWT_ISSUER (défaut: "rustwarden") | |
| 93 | - Server->>JWT: generate_token(user_id, roles) | |
| 94 | - | |
| 95 | - Server->>Env: REFRESH_TOKEN_EXPIRATION_DAYS (défaut: 7) | |
| 96 | - Server->>DB: INSERT refresh_token | |
| 97 | - | |
| 98 | - Server->>Env: COOKIE_SECURE (défaut: false) | |
| 99 | - Server->>Env: COOKIE_SAME_SITE (défaut: "Strict") | |
| 100 | - Server->>User: Set-Cookie: jwt_token (HttpOnly) | |
| 101 | - | |
| 102 | - Server->>DB: SELECT user_preferences | |
| 103 | - Server->>Env: COOKIE_PREFERENCE_MAX_AGE_SECS (défaut: 31536000) | |
| 104 | - Server->>User: Set-Cookie: theme={pref.theme} | |
| 105 | - Server->>User: Set-Cookie: locale={pref.language} | |
| 106 | - | |
| 107 | - Server-->>User: 200 { access_token, refresh_token, user } | |
| 108 | -``` | |
| 109 | - | |
| 110 | ---- | |
| 111 | - | |
| 112 | -## 3. Rendu des pages | |
| 113 | - | |
| 114 | -Variables impliquées : `DEFAULT_LOCALE`, `RUSTWARDEN_DEBUG_I18N`, `APP_DOMAIN` | |
| 115 | - | |
| 116 | -```mermaid | |
| 117 | -sequenceDiagram | |
| 118 | - participant User as Navigateur | |
| 119 | - participant MW as Middleware (locale) | |
| 120 | - participant Handler as Page Handler | |
| 121 | - participant I18n as I18nService | |
| 122 | - participant DB as app_settings | |
| 123 | - participant Env as .env | |
| 124 | - | |
| 125 | - User->>MW: GET /home (Cookie: locale=de, theme=dark) | |
| 126 | - MW->>MW: Lire cookie "locale" | |
| 127 | - alt cookie "locale" présent et valide | |
| 128 | - MW->>MW: locale = cookie value | |
| 129 | - else Accept-Language header | |
| 130 | - MW->>MW: Extraire langue du header | |
| 131 | - else aucun | |
| 132 | - MW->>Env: DEFAULT_LOCALE (défaut: "fr") | |
| 133 | - end | |
| 134 | - MW->>Handler: Extension(Locale("de")) | |
| 135 | - | |
| 136 | - Handler->>I18n: translate("home.title", "de") | |
| 137 | - Note over I18n,Env: Si clé manquante et<br/>RUSTWARDEN_DEBUG_I18N=1<br/>→ log warning détaillé | |
| 138 | - I18n-->>Handler: "Startseite" | |
| 139 | - | |
| 140 | - Handler->>DB: app_settings("app_domain") | |
| 141 | - alt setting trouvé | |
| 142 | - Handler->>Handler: domain = setting.value | |
| 143 | - else | |
| 144 | - Handler->>Env: APP_DOMAIN (fallback "localhost") | |
| 145 | - end | |
| 146 | - | |
| 147 | - Handler-->>User: HTML rendu (data-theme="dark") | |
| 148 | - Note over User: Le script base.html lit<br/>Cookie "theme" au chargement<br/>et applique data-theme | |
| 149 | -``` | |
| 150 | - | |
| 151 | ---- | |
| 152 | - | |
| 153 | -## 4. Inscription & Validation email | |
| 154 | - | |
| 155 | -Variables impliquées : `ALLOW_REGISTRATION`, `EMAIL_VALIDATION_EXPIRATION_HOURS`, `SMTP_*`, `EMAIL_BASE_URL`, `EMAIL_WELCOME_ENABLED`, `SMTP_FROM_NAME` | |
| 156 | - | |
| 157 | -```mermaid | |
| 158 | -sequenceDiagram | |
| 159 | - participant User as Navigateur | |
| 160 | - participant Server as POST /api/v1/auth/register | |
| 161 | - participant ValSvc as EmailValidationService | |
| 162 | - participant Queue as email_queue (DB) | |
| 163 | - participant Env as .env | |
| 164 | - | |
| 165 | - User->>Server: { username, email, password } | |
| 166 | - | |
| 167 | - Server->>Env: ALLOW_REGISTRATION (défaut: true) | |
| 168 | - alt ALLOW_REGISTRATION = false | |
| 169 | - Server-->>User: 403 Forbidden | |
| 170 | - end | |
| 171 | - | |
| 172 | - Server->>Server: Valider & créer utilisateur | |
| 173 | - | |
| 174 | - Server->>ValSvc: send_validation_email(user_id) | |
| 175 | - ValSvc->>Env: EMAIL_VALIDATION_EXPIRATION_HOURS (défaut: 24) | |
| 176 | - ValSvc->>ValSvc: expires_at = now + 24h | |
| 177 | - ValSvc->>ValSvc: token = UUID v4, hash = SHA-256 | |
| 178 | - | |
| 179 | - ValSvc->>Env: EMAIL_BASE_URL | |
| 180 | - ValSvc->>ValSvc: url = EMAIL_BASE_URL/verify-email/{token} | |
| 181 | - | |
| 182 | - ValSvc->>Env: SMTP_FROM, SMTP_FROM_NAME (fallback "RustWarden") | |
| 183 | - ValSvc->>Queue: INSERT email_queue (type: "email_validation") | |
| 184 | - Note over Queue: Sujet traduit selon locale :<br/>fr: "Vérification de votre adresse email"<br/>en: "Verify Your Email Address"<br/>de: "Bestätigung Ihrer E-Mail-Adresse"<br/>es: "Verifique su dirección de correo" | |
| 185 | - | |
| 186 | - Server-->>User: 201 Created | |
| 187 | - | |
| 188 | - Note over User,Queue: --- Clic sur le lien reçu par email --- | |
| 189 | - | |
| 190 | - User->>Server: GET /api/v1/auth/verify-email/{token} | |
| 191 | - Server->>ValSvc: verify_email(token) | |
| 192 | - ValSvc->>ValSvc: Vérifier hash + expiration | |
| 193 | - | |
| 194 | - alt EMAIL_WELCOME_ENABLED = true | |
| 195 | - ValSvc->>Env: EMAIL_WELCOME_ENABLED | |
| 196 | - ValSvc->>Queue: INSERT email_queue (type: "welcome") | |
| 197 | - Note over Queue: Sujet: "Bienvenue sur RustWarden !" | |
| 198 | - end | |
| 199 | - | |
| 200 | - Server-->>User: ✅ (message i18n selon locale) | |
| 201 | -``` | |
| 202 | - | |
| 203 | ---- | |
| 204 | - | |
| 205 | -## 5. Réinitialisation de mot de passe | |
| 206 | - | |
| 207 | -Variables impliquées : `PASSWORD_RESET_EXPIRATION_HOURS`, `SMTP_*`, `EMAIL_BASE_URL`, `RATE_LIMIT_GENERAL_PER_MINUTE` | |
| 208 | - | |
| 209 | -```mermaid | |
| 210 | -sequenceDiagram | |
| 211 | - participant User as Navigateur | |
| 212 | - participant Server as auth_handlers | |
| 213 | - participant ResetSvc as PasswordResetService | |
| 214 | - participant Queue as email_queue (DB) | |
| 215 | - participant DB as PostgreSQL | |
| 216 | - participant Env as .env | |
| 217 | - | |
| 218 | - User->>Server: POST /api/v1/auth/forgot-password { email } | |
| 219 | - | |
| 220 | - Server->>ResetSvc: request_reset(email) | |
| 221 | - ResetSvc->>DB: SELECT user WHERE email | |
| 222 | - ResetSvc->>DB: Invalider anciens tokens | |
| 223 | - | |
| 224 | - ResetSvc->>Env: PASSWORD_RESET_EXPIRATION_HOURS (défaut: 1) | |
| 225 | - ResetSvc->>ResetSvc: expires_at = now + 1h | |
| 226 | - ResetSvc->>DB: INSERT password_reset_token | |
| 227 | - | |
| 228 | - ResetSvc->>Env: EMAIL_BASE_URL | |
| 229 | - ResetSvc->>ResetSvc: url = EMAIL_BASE_URL/reset-password?token={token} | |
| 230 | - | |
| 231 | - ResetSvc->>Env: SMTP_FROM, SMTP_FROM_NAME (fallback "RustWarden") | |
| 232 | - ResetSvc->>Queue: INSERT email_queue | |
| 233 | - Note over Queue: Sujet traduit (4 langues) | |
| 234 | - | |
| 235 | - Server-->>User: 200 OK (toujours, pour sécurité) | |
| 236 | - | |
| 237 | - Note over User,Queue: --- Email envoyé via queue processor --- | |
| 238 | - | |
| 239 | - User->>Server: POST /api/v1/auth/reset-password { token, new_password } | |
| 240 | - Server->>ResetSvc: reset_password(token, new_password) | |
| 241 | - ResetSvc->>DB: Vérifier token hash + expiration | |
| 242 | - ResetSvc->>DB: UPDATE user.password_hash | |
| 243 | - | |
| 244 | - Server-->>User: 200 OK | |
| 245 | -``` | |
| 246 | - | |
| 247 | ---- | |
| 248 | - | |
| 249 | -## 6. Changement de mot de passe | |
| 250 | - | |
| 251 | -Variables impliquées : `PASSWORD_CHANGE_EXPIRATION_HOURS`, `PASSWORD_CHANGE_RATE_LIMIT_MAX`, `PASSWORD_CHANGE_RATE_LIMIT_WINDOW_SECS`, `PASSWORD_CHANGE_MAX_RETRIES`, `PASSWORD_CHANGE_RETRY_GAP_MINUTES` | |
| 252 | - | |
| 253 | -```mermaid | |
| 254 | -sequenceDiagram | |
| 255 | - participant User as Navigateur | |
| 256 | - participant Server as settings_handler | |
| 257 | - participant PwSvc as PasswordChangeService | |
| 258 | - participant Queue as email_queue (DB) | |
| 259 | - participant Retry as RetryProcessor (bg) | |
| 260 | - participant Env as .env | |
| 261 | - | |
| 262 | - User->>Server: POST /settings/password { old, new } | |
| 263 | - | |
| 264 | - Server->>PwSvc: create_change_request(user_id, new_password) | |
| 265 | - | |
| 266 | - PwSvc->>Env: PASSWORD_CHANGE_RATE_LIMIT_WINDOW_SECS (défaut: 3600) | |
| 267 | - PwSvc->>Env: PASSWORD_CHANGE_RATE_LIMIT_MAX (défaut: 3) | |
| 268 | - PwSvc->>PwSvc: Vérifier: < 3 demandes dans la dernière heure ? | |
| 269 | - alt rate limit dépassé | |
| 270 | - PwSvc-->>Server: 422 "Too many requests" | |
| 271 | - end | |
| 272 | - | |
| 273 | - PwSvc->>Env: PASSWORD_CHANGE_EXPIRATION_HOURS (défaut: 24) | |
| 274 | - PwSvc->>PwSvc: expires_at = now + 24h | |
| 275 | - PwSvc->>Queue: INSERT email_queue (confirmation) | |
| 276 | - | |
| 277 | - Server-->>User: 200 "Email de confirmation envoyé" | |
| 278 | - | |
| 279 | - Note over Retry: Tâche de fond — retry processor | |
| 280 | - | |
| 281 | - loop Toutes les PASSWORD_CHANGE_RETRY_INTERVAL_SECS (600s) | |
| 282 | - Retry->>Env: PASSWORD_CHANGE_RETRY_INTERVAL_SECS | |
| 283 | - Retry->>Env: PASSWORD_CHANGE_MAX_RETRIES (défaut: 3) | |
| 284 | - Retry->>Env: PASSWORD_CHANGE_RETRY_GAP_MINUTES (défaut: 20) | |
| 285 | - Retry->>Retry: SELECT demandes WHERE retry_count < 3<br/>AND last_sent > 20 min ago | |
| 286 | - alt demande en attente | |
| 287 | - Retry->>Queue: INSERT email_queue (retry) | |
| 288 | - Retry->>Retry: retry_count++ | |
| 289 | - end | |
| 290 | - end | |
| 291 | - | |
| 292 | - User->>Server: GET /confirm-password-change?token={token} | |
| 293 | - Server->>PwSvc: confirm(token) | |
| 294 | - PwSvc->>PwSvc: UPDATE user.password_hash | |
| 295 | - Server-->>User: ✅ Mot de passe changé | |
| 296 | -``` | |
| 297 | - | |
| 298 | ---- | |
| 299 | - | |
| 300 | -## 7. Traitement de la queue email | |
| 301 | - | |
| 302 | -Variables impliquées : `EMAIL_QUEUE_INTERVAL_SECS`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_USE_TLS`, `SMTP_USE_STARTTLS`, `SMTP_ACCEPT_INVALID_CERTS`, `SMTP_CA_CERT_PATH`, `EMAIL_REQUEST_DSN` | |
| 303 | - | |
| 304 | -```mermaid | |
| 305 | -sequenceDiagram | |
| 306 | - participant Timer as Tokio Timer | |
| 307 | - participant Proc as EmailQueueProcessor | |
| 308 | - participant DB as email_queue (DB) | |
| 309 | - participant SMTP as Serveur SMTP | |
| 310 | - participant Env as .env | |
| 311 | - | |
| 312 | - Proc->>Env: EMAIL_QUEUE_INTERVAL_SECS (défaut: 30) | |
| 313 | - | |
| 314 | - loop Toutes les 30 secondes | |
| 315 | - Timer->>Proc: tick() | |
| 316 | - Proc->>DB: SELECT * FROM email_queue WHERE status='pending' | |
| 317 | - | |
| 318 | - loop Pour chaque email | |
| 319 | - Proc->>Proc: attempt_count++ | |
| 320 | - | |
| 321 | - Proc->>Env: SMTP_HOST, SMTP_PORT (défaut: 587) | |
| 322 | - Proc->>Env: SMTP_USER, SMTP_PASSWORD | |
| 323 | - Proc->>Env: SMTP_USE_TLS, SMTP_USE_STARTTLS | |
| 324 | - Proc->>Env: SMTP_ACCEPT_INVALID_CERTS | |
| 325 | - Proc->>Env: SMTP_CA_CERT_PATH (optionnel) | |
| 326 | - Proc->>Env: EMAIL_REQUEST_DSN (défaut: true) | |
| 327 | - | |
| 328 | - Proc->>SMTP: Envoyer email (lettre) | |
| 329 | - | |
| 330 | - alt Succès | |
| 331 | - Proc->>DB: UPDATE status='sent', smtp_code, smtp_response | |
| 332 | - else Échec | |
| 333 | - alt Max attempts atteint | |
| 334 | - Proc->>DB: UPDATE status='failed' | |
| 335 | - else Tentatives restantes | |
| 336 | - Proc->>DB: UPDATE attempt_count (retry au prochain tick) | |
| 337 | - end | |
| 338 | - end | |
| 339 | - end | |
| 340 | - end | |
| 341 | -``` | |
| 342 | - | |
| 343 | ---- | |
| 344 | - | |
| 345 | -## 8. Détection des bounces IMAP | |
| 346 | - | |
| 347 | -Variables impliquées : `IMAP_HOST`, `IMAP_PORT`, `IMAP_USE_TLS`, `IMAP_USE_STARTTLS`, `IMAP_MAILBOX`, `IMAP_POLL_INTERVAL_SECONDS`, `IMAP_ACCEPT_INVALID_CERTS`, `IMAP_CA_CERT_PATH`, `IMAP_SOCKET_TIMEOUT_SECS` | |
| 348 | - | |
| 349 | -```mermaid | |
| 350 | -sequenceDiagram | |
| 351 | - participant Timer as Tokio Timer | |
| 352 | - participant IMAP as ImapBounceService | |
| 353 | - participant Mail as Serveur IMAP | |
| 354 | - participant DB as email_delivery_status | |
| 355 | - participant Env as .env | |
| 356 | - | |
| 357 | - IMAP->>Env: IMAP_POLL_INTERVAL_SECONDS (défaut: 300) | |
| 358 | - | |
| 359 | - loop Toutes les 300 secondes | |
| 360 | - Timer->>IMAP: tick() | |
| 361 | - | |
| 362 | - IMAP->>Env: IMAP_HOST, IMAP_PORT (défaut: 993) | |
| 363 | - IMAP->>Env: IMAP_SOCKET_TIMEOUT_SECS (défaut: 30) | |
| 364 | - IMAP->>IMAP: set_read_timeout(30s) | |
| 365 | - IMAP->>IMAP: set_write_timeout(30s) | |
| 366 | - | |
| 367 | - IMAP->>Env: IMAP_USE_TLS, IMAP_USE_STARTTLS | |
| 368 | - IMAP->>Env: IMAP_ACCEPT_INVALID_CERTS | |
| 369 | - IMAP->>Env: IMAP_CA_CERT_PATH (optionnel) | |
| 370 | - IMAP->>Mail: Connexion TLS / STARTTLS | |
| 371 | - | |
| 372 | - IMAP->>Env: IMAP_MAILBOX (défaut: "INBOX") | |
| 373 | - IMAP->>Mail: SELECT INBOX | |
| 374 | - IMAP->>Mail: SEARCH UNSEEN | |
| 375 | - | |
| 376 | - loop Pour chaque message non lu | |
| 377 | - IMAP->>Mail: FETCH message | |
| 378 | - IMAP->>IMAP: Analyser DSN / bounces | |
| 379 | - alt Bounce détecté | |
| 380 | - IMAP->>DB: INSERT email_delivery_status (bounce) | |
| 381 | - IMAP->>DB: UPDATE email_queue.status = 'bounced' | |
| 382 | - end | |
| 383 | - IMAP->>Mail: STORE +FLAGS (\Seen) | |
| 384 | - end | |
| 385 | - end | |
| 386 | -``` | |
| 387 | - | |
| 388 | ---- | |
| 389 | - | |
| 390 | -## 9. Nettoyage périodique | |
| 391 | - | |
| 392 | -Variables impliquées : `JWT_CLEANUP_INTERVAL_SECS`, `PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS` | |
| 393 | - | |
| 394 | -```mermaid | |
| 395 | -sequenceDiagram | |
| 396 | - participant Timer as Tokio Timer | |
| 397 | - participant JWTClean as JWT Blacklist Cleanup | |
| 398 | - participant PWClean as Password Change Cleanup | |
| 399 | - participant DB as PostgreSQL | |
| 400 | - participant Env as .env | |
| 401 | - | |
| 402 | - Note over JWTClean: Tâche 1 : Nettoyage blacklist JWT | |
| 403 | - | |
| 404 | - JWTClean->>Env: JWT_CLEANUP_INTERVAL_SECS (défaut: 3600) | |
| 405 | - loop Toutes les heures | |
| 406 | - Timer->>JWTClean: tick() | |
| 407 | - JWTClean->>DB: DELETE FROM jwt_blacklist<br/>WHERE expires_at < now() | |
| 408 | - JWTClean->>JWTClean: log "{n} tokens nettoyés" | |
| 409 | - end | |
| 410 | - | |
| 411 | - Note over PWClean: Tâche 2 : Nettoyage demandes mot de passe | |
| 412 | - | |
| 413 | - PWClean->>Env: PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS (défaut: 86400) | |
| 414 | - loop Toutes les 24 heures | |
| 415 | - Timer->>PWClean: tick() | |
| 416 | - PWClean->>DB: DELETE FROM password_change_request<br/>WHERE expires_at < now()<br/>OR confirmed_at IS NOT NULL | |
| 417 | - PWClean->>PWClean: log "{n} demandes supprimées" | |
| 418 | - end | |
| 419 | -``` | |
| 420 | - | |
| 421 | ---- | |
| 422 | - | |
| 423 | -## 10. Administration — Pagination | |
| 424 | - | |
| 425 | -Variables impliquées : `PAGINATION_DEFAULT_PER_PAGE`, `PAGINATION_MAX_PER_PAGE`, `RATE_LIMIT_*` | |
| 426 | - | |
| 427 | -```mermaid | |
| 428 | -sequenceDiagram | |
| 429 | - participant Admin as Navigateur (Admin) | |
| 430 | - participant MW as AdminService Middleware | |
| 431 | - participant Handler as admin_handler | |
| 432 | - participant DB as PostgreSQL | |
| 433 | - participant Env as .env | |
| 434 | - | |
| 435 | - Admin->>MW: GET /admin/users?page=2&per_page=25 | |
| 436 | - MW->>MW: Vérifier JWT + rôle "admin" | |
| 437 | - | |
| 438 | - MW->>Handler: Extension(Claims) | |
| 439 | - | |
| 440 | - Handler->>Env: PAGINATION_DEFAULT_PER_PAGE (défaut: 50) | |
| 441 | - Handler->>Env: PAGINATION_MAX_PER_PAGE (défaut: 100) | |
| 442 | - Handler->>Handler: per_page = min(request.per_page, 100) | |
| 443 | - Handler->>Handler: si absent → per_page = 50 | |
| 444 | - | |
| 445 | - Handler->>DB: SELECT users LIMIT 25 OFFSET 25 | |
| 446 | - Handler->>DB: SELECT COUNT(*) FROM users | |
| 447 | - | |
| 448 | - Handler-->>Admin: HTML (table paginée) | |
| 449 | -``` | |
| 450 | - | |
| 451 | ---- | |
| 452 | - | |
| 453 | -## 11. Préférences utilisateur | |
| 454 | - | |
| 455 | -Variables impliquées : `COOKIE_PREFERENCE_MAX_AGE_SECS` | |
| 456 | - | |
| 457 | -```mermaid | |
| 458 | -sequenceDiagram | |
| 459 | - participant User as Navigateur | |
| 460 | - participant JS as JavaScript (preferences.html) | |
| 461 | - participant Server as PUT /settings/preferences | |
| 462 | - participant DB as user_preferences | |
| 463 | - participant Env as .env | |
| 464 | - | |
| 465 | - User->>JS: Sélectionne thème "auto" + langue "de" | |
| 466 | - JS->>Server: fetch({ theme: "auto", language: "de" }) | |
| 467 | - | |
| 468 | - Server->>DB: UPDATE user_preferences SET theme, language | |
| 469 | - Server->>Env: COOKIE_PREFERENCE_MAX_AGE_SECS (défaut: 31536000) | |
| 470 | - | |
| 471 | - Server-->>User: Set-Cookie: theme=auto; max-age=31536000 | |
| 472 | - Server-->>User: Set-Cookie: locale=de; max-age=31536000 | |
| 473 | - Server-->>User: 200 { reload_required: true } | |
| 474 | - | |
| 475 | - JS->>JS: document.cookie = "theme=auto" | |
| 476 | - JS->>User: window.location.reload() | |
| 477 | - | |
| 478 | - Note over User: Au rechargement (base.html) :<br/>1. Script inline lit cookie "theme"<br/>2. theme="auto" → prefers-color-scheme<br/>3. Applique data-theme="dark" ou "light"<br/>4. Middleware lit cookie "locale"<br/>5. Page rendue en allemand | |
| 479 | -``` | |
| 480 | - | |
| 481 | ---- | |
| 482 | - | |
| 483 | -## 12. Authentification OAuth / SSO | |
| 484 | - | |
| 485 | -*(Feature flag `oauth` -- compile conditionnellement)* | |
| 486 | - | |
| 487 | -### Flux OAuth complet | |
| 488 | - | |
| 489 | -```mermaid | |
| 490 | -sequenceDiagram | |
| 491 | - participant U as Utilisateur | |
| 492 | - participant L as Page Login | |
| 493 | - participant R as oauth_redirect | |
| 494 | - participant S as Session | |
| 495 | - participant P as Provider (Google/GitHub/...) | |
| 496 | - participant C as oauth_callback | |
| 497 | - participant OS as OAuthService | |
| 498 | - participant DB as Base de données | |
| 499 | - participant JWT as JwtService | |
| 500 | - | |
| 501 | - U->>L: Clique "Se connecter avec Google" | |
| 502 | - L->>R: GET /api/v1/auth/oauth/google/redirect | |
| 503 | - R->>R: Lit OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET | |
| 504 | - R->>R: Génère state CSRF + nonce OIDC + PKCE verifier | |
| 505 | - R->>S: Stocke state, nonce, pkce_verifier, provider | |
| 506 | - R->>U: 302 Redirect vers Google | |
| 507 | - | |
| 508 | - U->>P: Authentification chez le provider | |
| 509 | - P->>C: GET /api/v1/auth/oauth/google/callback?code=XXX&state=YYY | |
| 510 | - C->>C: check_auth_rate_limit (RATE_LIMIT_AUTH_PER_MINUTE) | |
| 511 | - C->>S: Récupère state, nonce, pkce_verifier | |
| 512 | - C->>C: Valide state (constant-time via subtle) | |
| 513 | - C->>P: Échange code + PKCE verifier contre tokens | |
| 514 | - P-->>C: ID Token + Access Token | |
| 515 | - C->>C: Vérifie nonce dans l'ID Token | |
| 516 | - C->>C: Extrait ProviderUserInfo (email, name, avatar) | |
| 517 | - | |
| 518 | - C->>OS: find_or_create_user(provider, user_info) | |
| 519 | - OS->>DB: Cherche oauth_account par (provider, provider_user_id) | |
| 520 | - alt Compte OAuth lié existant | |
| 521 | - OS->>DB: Récupère l'utilisateur associé | |
| 522 | - else Email vérifié + utilisateur existant | |
| 523 | - OS->>DB: Lie le compte OAuth à l'utilisateur | |
| 524 | - else Nouvel utilisateur | |
| 525 | - OS->>DB: Crée user (sans password) + role "user" | |
| 526 | - OS->>DB: Crée oauth_account lié | |
| 527 | - end | |
| 528 | - OS-->>C: (user, is_new_user) | |
| 529 | - | |
| 530 | - C->>JWT: Génère JWT (JWT_SECRET, JWT_EXPIRATION_MINUTES) | |
| 531 | - C->>DB: Crée refresh_token (hash SHA-256) | |
| 532 | - C->>S: session.cycle_id() (anti-fixation) | |
| 533 | - C->>U: Set-Cookie: jwt_token (HttpOnly, Secure) | |
| 534 | - C->>U: 302 Redirect vers /home | |
| 535 | -``` | |
| 536 | - | |
| 537 | -### Variables d'environnement OAuth | |
| 538 | - | |
| 539 | -| Variable | Défaut | Description | | |
| 540 | -|----------|--------|-------------| | |
| 541 | -| `OAUTH_ENCRYPTION_KEY` | — | Clé AES-256-GCM (hex 64 chars) pour chiffrer les secrets OAuth en DB. Générer avec `openssl rand -hex 32` | | |
| 542 | -| `OAUTH_ENABLED` | `false` | Active globalement OAuth | | |
| 543 | -| `OAUTH_REDIRECT_BASE_URL` | `http://localhost:3000` | Base URL pour les callbacks | | |
| 544 | -| `OAUTH_GOOGLE_CLIENT_ID` | — | Google Client ID | | |
| 545 | -| `OAUTH_GOOGLE_CLIENT_SECRET` | — | Google Client Secret | | |
| 546 | -| `OAUTH_GOOGLE_ENABLED` | `false` | Active Google | | |
| 547 | -| `OAUTH_GITHUB_CLIENT_ID` | — | GitHub Client ID | | |
| 548 | -| `OAUTH_GITHUB_CLIENT_SECRET` | — | GitHub Client Secret | | |
| 549 | -| `OAUTH_GITHUB_ENABLED` | `false` | Active GitHub | | |
| 550 | -| `OAUTH_DISCORD_CLIENT_ID` | — | Discord Client ID | | |
| 551 | -| `OAUTH_DISCORD_CLIENT_SECRET` | — | Discord Client Secret | | |
| 552 | -| `OAUTH_DISCORD_ENABLED` | `false` | Active Discord | | |
| 553 | -| `OAUTH_MICROSOFT_CLIENT_ID` | — | Microsoft Client ID | | |
| 554 | -| `OAUTH_MICROSOFT_CLIENT_SECRET` | — | Microsoft Client Secret | | |
| 555 | -| `OAUTH_MICROSOFT_ENABLED` | `false` | Active Microsoft | | |
| 556 | -| `OAUTH_MICROSOFT_TENANT` | `common` | Azure AD tenant | | |
| 557 | - | |
| 558 | -> **Note** : Les variables d'environnement ci-dessus servent de fallback. La configuration | |
| 559 | -> principale peut se faire via **Admin > Settings > OAuth / SSO** dans l'interface web. | |
| 560 | -> Les secrets sont chiffrés (AES-256-GCM) en base de données. | |
| 561 | - | |
| 562 | ---- | |
| 563 | - | |
| 564 | -## 13. Référence complète | |
| 565 | - | |
| 566 | -### Serveur & Base de données | |
| 567 | - | |
| 568 | -| Variable | Défaut | Description | | |
| 569 | -|----------|--------|-------------| | |
| 570 | -| `SERVER_HOST` | `0.0.0.0` | Adresse d'écoute | | |
| 571 | -| `SERVER_PORT` | `3000` | Port d'écoute | | |
| 572 | -| `DATABASE_URL` | — (obligatoire) | URI PostgreSQL | | |
| 573 | -| `RUST_LOG` | `rustwarden=debug` | Niveau de log | | |
| 574 | - | |
| 575 | -### Authentification & JWT | |
| 576 | - | |
| 577 | -| Variable | Défaut | Description | | |
| 578 | -|----------|--------|-------------| | |
| 579 | -| `JWT_SECRET` | — (obligatoire, ≥32 chars) | Clé secrète HMAC | | |
| 580 | -| `JWT_EXPIRATION_MINUTES` | `15` | Durée de vie du JWT | | |
| 581 | -| `JWT_ISSUER` | `rustwarden` | Champ `iss` du JWT | | |
| 582 | -| `REFRESH_TOKEN_EXPIRATION_DAYS` | `7` | Durée refresh token | | |
| 583 | -| `SESSION_TIMEOUT_MINUTES` | `30` | Timeout session serveur | | |
| 584 | -| `SESSION_BACKEND` | `seaorm` | Backend: `seaorm` / `memory` / `redis` | | |
| 585 | -| `REDIS_URL` | — | URL Redis (si backend=redis) | | |
| 586 | -| `COOKIE_SECURE` | `false` | `true` en production (HTTPS) | | |
| 587 | -| `COOKIE_SAME_SITE` | `Lax` | `Strict` / `Lax` / `None` | | |
| 588 | - | |
| 589 | -### Rate Limits (Auth) | |
| 590 | - | |
| 591 | -| Variable | Défaut | Description | | |
| 592 | -|----------|--------|-------------| | |
| 593 | -| `RATE_LIMIT_LOGIN_PER_MINUTE` | `5` | Tentatives login/min | | |
| 594 | -| `RATE_LIMIT_REFRESH_PER_MINUTE` | `10` | Refresh token/min | | |
| 595 | -| `RATE_LIMIT_GENERAL_PER_MINUTE` | `100` | Requêtes API/min | | |
| 596 | - | |
| 597 | -### Email (SMTP) | |
| 598 | - | |
| 599 | -| Variable | Défaut | Description | | |
| 600 | -|----------|--------|-------------| | |
| 601 | -| `SMTP_HOST` | — | Hôte SMTP | | |
| 602 | -| `SMTP_PORT` | `587` | Port SMTP | | |
| 603 | -| `SMTP_USER` | — | Utilisateur SMTP | | |
| 604 | -| `SMTP_PASSWORD` | — | Mot de passe SMTP | | |
| 605 | -| `SMTP_FROM` | — | Adresse expéditeur | | |
| 606 | -| `SMTP_FROM_NAME` | `RustWarden` | Nom expéditeur | | |
| 607 | -| `SMTP_USE_TLS` | `false` | TLS direct | | |
| 608 | -| `SMTP_USE_STARTTLS` | `false` | STARTTLS | | |
| 609 | -| `SMTP_ACCEPT_INVALID_CERTS` | `false` | Ignorer erreurs cert | | |
| 610 | -| `SMTP_CA_CERT_PATH` | — | Certificat CA custom | | |
| 611 | -| `EMAIL_REQUEST_DSN` | `true` | Demander accusés DSN | | |
| 612 | -| `EMAIL_BASE_URL` | — | URL de base pour les liens | | |
| 613 | -| `EMAIL_VALIDATION_REQUIRED` | `true` | Exiger validation email | | |
| 614 | -| `EMAIL_WELCOME_ENABLED` | `true` | Email de bienvenue | | |
| 615 | -| `EMAIL_ADMIN_NOTIFICATIONS_ENABLED` | `false` | Notifier admins | | |
| 616 | - | |
| 617 | -### Email (IMAP / Bounces) | |
| 618 | - | |
| 619 | -| Variable | Défaut | Description | | |
| 620 | -|----------|--------|-------------| | |
| 621 | -| `IMAP_HOST` | — | Hôte IMAP | | |
| 622 | -| `IMAP_PORT` | `993` | Port IMAP | | |
| 623 | -| `IMAP_USE_TLS` | `false` | TLS direct | | |
| 624 | -| `IMAP_USE_STARTTLS` | `false` | STARTTLS | | |
| 625 | -| `IMAP_MAILBOX` | `INBOX` | Boîte à surveiller | | |
| 626 | -| `IMAP_POLL_INTERVAL_SECONDS` | `300` | Intervalle de poll | | |
| 627 | -| `IMAP_ACCEPT_INVALID_CERTS` | `false` | Ignorer erreurs cert | | |
| 628 | -| `IMAP_CA_CERT_PATH` | — | Certificat CA custom | | |
| 629 | -| `IMAP_SOCKET_TIMEOUT_SECS` | `30` | Timeout r/w socket | | |
| 630 | - | |
| 631 | -### Tâches de fond & Timeouts | |
| 632 | - | |
| 633 | -| Variable | Défaut | Description | | |
| 634 | -|----------|--------|-------------| | |
| 635 | -| `EMAIL_QUEUE_INTERVAL_SECS` | `30` | Intervalle traitement queue | | |
| 636 | -| `PASSWORD_CHANGE_RETRY_INTERVAL_SECS` | `600` | Intervalle retry (10 min) | | |
| 637 | -| `PASSWORD_CHANGE_RETRY_GAP_MINUTES` | `20` | Gap entre 2 retries | | |
| 638 | -| `PASSWORD_CHANGE_MAX_RETRIES` | `3` | Max tentatives email | | |
| 639 | -| `PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS` | `86400` | Nettoyage quotidien | | |
| 640 | -| `JWT_CLEANUP_INTERVAL_SECS` | `3600` | Nettoyage blacklist JWT | | |
| 641 | - | |
| 642 | -### Expiration des tokens | |
| 643 | - | |
| 644 | -| Variable | Défaut | Description | | |
| 645 | -|----------|--------|-------------| | |
| 646 | -| `PASSWORD_CHANGE_EXPIRATION_HOURS` | `24` | Expiration demande changement | | |
| 647 | -| `PASSWORD_RESET_EXPIRATION_HOURS` | `1` | Expiration token reset | | |
| 648 | -| `EMAIL_VALIDATION_EXPIRATION_HOURS` | `24` | Expiration token validation | | |
| 649 | - | |
| 650 | -### Rate Limits (Password Change) | |
| 651 | - | |
| 652 | -| Variable | Défaut | Description | | |
| 653 | -|----------|--------|-------------| | |
| 654 | -| `PASSWORD_CHANGE_RATE_LIMIT_MAX` | `3` | Max requêtes/fenêtre | | |
| 655 | -| `PASSWORD_CHANGE_RATE_LIMIT_WINDOW_SECS` | `3600` | Fenêtre (1 heure) | | |
| 656 | - | |
| 657 | -### Application & UI | |
| 658 | - | |
| 659 | -| Variable | Défaut | Description | | |
| 660 | -|----------|--------|-------------| | |
| 661 | -| `APP_THEME` | `light` | Thème par défaut | | |
| 662 | -| `APP_DEBUG` | `false` | Mode debug | | |
| 663 | -| `APP_DOMAIN` | `localhost` | Domaine de l'app | | |
| 664 | -| `DEFAULT_LOCALE` | `fr` | Locale par défaut | | |
| 665 | -| `RUSTWARDEN_DEBUG_I18N` | — | Log clés i18n manquantes | | |
| 666 | -| `COOKIE_PREFERENCE_MAX_AGE_SECS` | `31536000` | Max-age cookies pref (1 an) | | |
| 667 | - | |
| 668 | -### Pagination | |
| 669 | - | |
| 670 | -| Variable | Défaut | Description | | |
| 671 | -|----------|--------|-------------| | |
| 672 | -| `PAGINATION_DEFAULT_PER_PAGE` | `50` | Taille page par défaut | | |
| 673 | -| `PAGINATION_MAX_PER_PAGE` | `100` | Taille page max | | |
| 674 | - | |
| 675 | -### Admin par défaut | |
| 676 | - | |
| 677 | -| Variable | Défaut | Description | | |
| 678 | -|----------|--------|-------------| | |
| 679 | -| `CREATE_DEFAULT_ADMIN` | `true` | Créer admin au 1er démarrage | | |
| 680 | -| `ADMIN_USERNAME` | `admin` | Nom utilisateur admin | | |
| 681 | -| `ADMIN_EMAIL` | `admin@localhost` | Email admin | | |
| 682 | -| `ADMIN_PASSWORD` | — (généré si absent) | Mot de passe admin | | |
| 683 | - | |
| 684 | -### Fichiers statiques | |
| 685 | - | |
| 686 | -| Variable | Défaut | Description | | |
| 687 | -|----------|--------|-------------| | |
| 688 | -| `RUSTWARDEN_STATIC_DIR` | `static` | Répertoire fichiers statiques | |
A
static/js/chart.umd.min.js
+20
-0
@@ -0,0 +1,20 @@
| 1 | +/** | |
| 2 | + * Skipped minification because the original files appears to be already minified. | |
| 3 | + * Original file: /npm/chart.js@4.4.0/dist/chart.umd.js | |
| 4 | + * | |
| 5 | + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files | |
| 6 | + */ | |
| 7 | +/*! | |
| 8 | + * Chart.js v4.4.0 | |
| 9 | + * https://www.chartjs.org | |
| 10 | + * (c) 2023 Chart.js Contributors | |
| 11 | + * Released under the MIT License | |
| 12 | + */ | |
| 13 | +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(o(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function f(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function g(t){if(n(t))return t.map(g);if(o(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=g(t[i[n]]);return e}return t}function p(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function m(t,e,i,s){if(!p(t))return;const n=e[t],a=i[t];o(n)&&o(a)?b(n,a,s):e[t]=g(a)}function b(t,e,i){const s=n(e)?e:[e],a=s.length;if(!o(t))return t;const r=(i=i||{}).merger||m;let l;for(let e=0;e<a;++e){if(l=s[e],!o(l))continue;const n=Object.keys(l);for(let e=0,s=n.length;e<s;++e)r(n[e],t,l,i)}return t}function x(t,e){return b(t,e,{merger:_})}function _(t,e,i){if(!p(t))return;const s=e[t],n=i[t];o(s)&&o(n)?x(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=g(n))}const y={"":t=>t,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)<i}function B(t){const e=Math.round(t);t=V(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(z(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function W(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function $(t){return t*(C/180)}function Y(t){return t*(180/C)}function U(t){if(!a(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function X(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*C&&(o+=O),{angle:o,distance:n}}function q(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function K(t,e){return(t-e+A)%O-C}function G(t){return(t%O+O)%O}function Z(t,e,i,s){const n=G(t),o=G(e),a=G(i),r=G(o-n),l=G(a-n),h=G(n-o),c=G(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function J(t,e,i){return Math.max(e,Math.min(i,t))}function Q(t){return J(t,-32768,32767)}function tt(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),st=(t,e,i)=>et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const ot=["push","pop","shift","splice","unshift"];function at(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),ot.forEach((e=>{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=J(Math.min(it(r,l,h).lo,i?s:it(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?J(Math.max(it(r,a.axis,c,!0).hi+1,i?0:it(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class bt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var xt=new bt; | |
| 14 | +/*! | |
| 15 | + * @kurkle/color v0.3.2 | |
| 16 | + * https://github.com/kurkle/color#readme | |
| 17 | + * (c) 2023 Jukka Kurkela | |
| 18 | + * Released under the MIT License | |
| 19 | + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function zt(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(Mt)}function Ft(t,e,i){return zt(Lt,t,e,i)}function Vt(t){return(t%360+360)%360}function Bt(t){const e=Tt.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?vt(+e[5]):Mt(+e[5]));const n=Vt(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return zt(Rt,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return zt(Et,t,e,i)}(n,o,a):Ft(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Wt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Ht;function jt(t){Ht||(Ht=function(){const t={},e=Object.keys(Nt),i=Object.keys(Wt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Wt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;e<s;++e){const s=i[e];t=t[s]||(t[s]=Object.create(null))}return t}function ce(t,e,i){return"string"==typeof e?b(he(t,e),i):b(he(t,""),e)}class de{constructor(t,e){this.animation=void 0,this.backgroundColor="rgba(0,0,0,0.1)",this.borderColor="rgba(0,0,0,0.1)",this.color="#666",this.datasets={},this.devicePixelRatio=t=>t.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function be(t,e){return me(t).getPropertyValue(e)}const xe=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=xe[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};window.addEventListener("test",null,e),window.removeEventListener("test",null,e)}catch(t){}return t}();function Pe(t,e){const i=be(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;h<l;h++)if(u=i[h],null==u||n(u)){if(n(u))for(c=0,d=u.length;c<d;c++)f=u[c],null==f||n(f)||(r=Ce(t,o,a,r,f))}else r=Ce(t,o,a,r,u);t.restore();const g=a.length/2;if(g>i.length){for(h=0;h<g;h++)delete o[a[h]];a.splice(0,g)}return r}function Ae(t,e,i){const s=t.currentDevicePixelRatio,n=0!==i?Math.max(i/2,.5):0;return Math.round((e-n)*s)/s+n}function Te(t,e){(e=e||t.getContext("2d")).save(),e.resetTransform(),e.clearRect(0,0,t.width,t.height),e.restore()}function Le(t,e,i,s){Ee(t,e,i,s,null)}function Ee(t,e,i,s,n){let o,a,r,l,h,c,d,u;const f=e.pointStyle,g=e.rotation,p=e.radius;let m=(g||0)*L;if(f&&"object"==typeof f&&(o=f.toString(),"[object HTMLImageElement]"===o||"[object HTMLCanvasElement]"===o))return t.save(),t.translate(i,s),t.rotate(m),t.drawImage(f,-f.width/2,-f.height/2,f.width,f.height),void t.restore();if(!(isNaN(p)||p<=0)){switch(t.beginPath(),f){default:n?t.ellipse(i,s,n/2,p,0,0,O):t.arc(i,s,p,0,O),t.closePath();break;case"triangle":c=n?n/2:p,t.moveTo(i+Math.sin(m)*c,s-Math.cos(m)*p),m+=I,t.lineTo(i+Math.sin(m)*c,s-Math.cos(m)*p),m+=I,t.lineTo(i+Math.sin(m)*c,s-Math.cos(m)*p),t.closePath();break;case"rectRounded":h=.516*p,l=p-h,a=Math.cos(m+R)*l,d=Math.cos(m+R)*(n?n/2-h:l),r=Math.sin(m+R)*l,u=Math.sin(m+R)*(n?n/2-h:l),t.arc(i-d,s-r,h,m-C,m-E),t.arc(i+u,s-a,h,m-E,m),t.arc(i+d,s+r,h,m,m+E),t.arc(i-u,s+a,h,m+E,m+C),t.closePath();break;case"rect":if(!g){l=Math.SQRT1_2*p,c=n?n/2:l,t.rect(i-c,s-l,2*c,2*l);break}m+=R;case"rectRot":d=Math.cos(m)*(n?n/2:p),a=Math.cos(m)*p,r=Math.sin(m)*p,u=Math.sin(m)*(n?n/2:p),t.moveTo(i-d,s-r),t.lineTo(i+u,s-a),t.lineTo(i+d,s+r),t.lineTo(i-u,s+a),t.closePath();break;case"crossRot":m+=R;case"cross":d=Math.cos(m)*(n?n/2:p),a=Math.cos(m)*p,r=Math.sin(m)*p,u=Math.sin(m)*(n?n/2:p),t.moveTo(i-d,s-r),t.lineTo(i+d,s+r),t.moveTo(i+u,s-a),t.lineTo(i-u,s+a);break;case"star":d=Math.cos(m)*(n?n/2:p),a=Math.cos(m)*p,r=Math.sin(m)*p,u=Math.sin(m)*(n?n/2:p),t.moveTo(i-d,s-r),t.lineTo(i+d,s+r),t.moveTo(i+u,s-a),t.lineTo(i-u,s+a),m+=R,d=Math.cos(m)*(n?n/2:p),a=Math.cos(m)*p,r=Math.sin(m)*p,u=Math.sin(m)*(n?n/2:p),t.moveTo(i-d,s-r),t.lineTo(i+d,s+r),t.moveTo(i+u,s-a),t.lineTo(i-u,s+a);break;case"line":a=n?n/2:Math.cos(m)*p,r=Math.sin(m)*p,t.moveTo(i-a,s-r),t.lineTo(i+a,s+r);break;case"dash":t.moveTo(i,s),t.lineTo(i+Math.cos(m)*(n?n/2:p),s+Math.sin(m)*p);break;case!1:t.closePath()}t.fill(),e.borderWidth>0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.x<e.right+i&&t.y>e.top-i&&t.y<e.bottom+i}function Ie(t,e){t.save(),t.beginPath(),t.rect(e.left,e.top,e.right-e.left,e.bottom-e.top),t.clip()}function ze(t){t.restore()}function Fe(t,e,i,s,n){if(!e)return t.lineTo(i.x,i.y);if("middle"===n){const s=(e.x+i.x)/2;t.lineTo(s,e.y),t.lineTo(s,i.y)}else"after"===n!=!!s?t.lineTo(e.x,i.y):t.lineTo(i.x,e.y);t.lineTo(i.x,i.y)}function Ve(t,e,i,s){if(!e)return t.lineTo(i.x,i.y);t.bezierCurveTo(s?e.cp1x:e.cp2x,s?e.cp1y:e.cp2y,s?i.cp2x:i.cp1x,s?i.cp2y:i.cp1y,i.x,i.y)}function Be(t,e,i,s,n){if(n.strikethrough||n.underline){const o=t.measureText(s),a=e-o.actualBoundingBoxLeft,r=e+o.actualBoundingBoxRight,l=i-o.actualBoundingBoxAscent,h=i+o.actualBoundingBoxDescent,c=n.strikethrough?(l+h)/2:h;t.strokeStyle=t.fillStyle,t.beginPath(),t.lineWidth=n.decorationWidth||2,t.moveTo(a,c),t.lineTo(r,c),t.stroke()}}function We(t,e){const i=t.fillStyle;t.fillStyle=e.color,t.fillRect(e.left,e.top,e.width,e.height),t.fillStyle=i}function Ne(t,e,i,o,a,r={}){const l=n(e)?e:[e],h=r.strokeWidth>0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;c<l.length;++c)d=l[c],r.backdrop&&We(t,r.backdrop),h&&(r.strokeColor&&(t.strokeStyle=r.strokeColor),s(r.strokeWidth)||(t.lineWidth=r.strokeWidth),t.strokeText(d,i,o,r.maxWidth)),t.fillText(d,i,o,r.maxWidth),Be(t,i,o,d,r),o+=Number(a.lineHeight);t.restore()}function He(t,e){const{x:i,y:s,w:n,h:o,radius:a}=e;t.arc(i+a.topLeft,s+a.topLeft,a.topLeft,1.5*C,C,!0),t.lineTo(i,s+o-a.bottomLeft),t.arc(i+a.bottomLeft,s+o-a.bottomLeft,a.bottomLeft,C,E,!0),t.lineTo(i+n-a.bottomRight,s+o),t.arc(i+n-a.bottomRight,s+o-a.bottomRight,a.bottomRight,E,0,!0),t.lineTo(i+n,s+a.topRight),t.arc(i+n-a.topRight,s+a.topRight,a.topRight,0,-E,!0),t.lineTo(i+a.topLeft,s)}function je(t,e=[""],i,s,n=(()=>t[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;r<l;++r)h=r+i,c=e[h],a[r]={r:n.parse(M(c,o),h)};return a}const si=Number.EPSILON||1e-14,ni=(t,e)=>e<t.length&&!t[e].skip&&t[e],oi=t=>"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a<s;++a)if(r=l,l=h,h=ni(t,a+1),l){if(h){const t=h[e]-l[e];n[a]=0!==t?(h[i]-l[i])/t:0}o[a]=r?h?F(n[a-1])!==F(n[a])?0:(n[a-1]+n[a])/2:n[a-1]:n[a]}!function(t,e,i){const s=t.length;let n,o,a,r,l,h=ni(t,0);for(let c=0;c<s-1;++c)l=h,h=ni(t,c+1),l&&h&&(V(e[c],0,si)?i[c]=i[c+1]=0:(n=i[c]/e[c],o=i[c+1]/e[c],r=Math.pow(n,2)+Math.pow(o,2),r<=9||(a=3/Math.sqrt(r),i[c]=n*a*e[c],i[c+1]=o*a*e[c])))}(t,n,o),function(t,e,i="x"){const s=oi(i),n=t.length;let o,a,r,l=ni(t,0);for(let h=0;h<n;++h){if(a=r,r=l,l=ni(t,h+1),!r)continue;const n=r[i],c=r[s];a&&(o=(n-a[i])/3,r[`cp1${i}`]=n-o,r[`cp1${s}`]=c-o*e[h]),l&&(o=(l[i]-n)/3,r[`cp2${i}`]=n+o,r[`cp2${s}`]=c+o*e[h])}}(t,o,e)}function li(t,e,i){return Math.max(Math.min(t,i),e)}function hi(t,e,i,s,n){let o,a,r,l;if(e.spanGaps&&(t=t.filter((t=>!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o<a;++o)r=t[o],l=ai(i,r,t[Math.min(o+1,a-(s?0:1))%a],e.tension),r.cp1x=l.previous.x,r.cp1y=l.previous.y,r.cp2x=l.next.x,r.cp2y=l.next.y,i=r}e.capBezierPoints&&function(t,e){let i,s,n,o,a,r=Re(t[0],e);for(i=0,s=t.length;i<s;++i)a=o,o=r,r=i<s-1&&Re(t[i+1],e),o&&(n=t[i],a&&(n.cp1x=li(n.cp1x,e.left,e.right),n.cp1y=li(n.cp1y,e.top,e.bottom)),r&&(n.cp2x=li(n.cp2x,e.left,e.right),n.cp2y=li(n.cp2y,e.top,e.bottom)))}(t,i)}const ci=t=>0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const bi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,xi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(bi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;o<a;++o)if(r=t[o],void 0!==r&&(void 0!==e&&"function"==typeof r&&(r=r(e),l=!1),void 0!==i&&n(r)&&(r=r[i%r.length],l=!1),void 0!==r))return s&&!l&&(s.cacheable=!1),r}function Di(t,e,i){const{min:s,max:n}=t,o=c(e,(n-s)/2),a=(t,e)=>i&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;h<c&&a(r(e[d%l][s]),n,o);++h)d--,u--;d%=l,u%=l}return u<d&&(u+=l),{start:d,end:u,loop:f,style:t.style}}(t,e,i),g=[];let p,m,b,x=!1,_=null;const y=()=>x||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;n<s.length;n++){const o=Ri(s[n],t.points,e);o.length&&i.push(...o)}return i}function zi(t,e){const i=t.points,s=t.options.spanGaps,n=i.length;if(!n)return[];const o=!!t._loop,{start:a,end:r}=function(t,e,i,s){let n=0,o=e-1;if(i&&!s)for(;n<e&&!t[n].skip;)n++;for(;n<e&&t[n].skip;)n++;for(n%=e,i&&(o+=n);o>n&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r<a?r+n:r,!!t._fullLoop&&0===a&&r===n-1),i,e)}function Fi(t,e,i,s){return s&&s.setContext&&i?function(t,e,i,s){const n=t._chart.getContext(),o=Vi(t.options),{_datasetIndex:a,options:{spanGaps:r}}=t,l=i.length,h=[];let c=o,d=e[0].start,u=d;function f(t,e,s,n){const o=r?-1:1;if(t!==e){for(t+=l;i[t%l].skip;)t-=o;for(;i[e%l].skip;)e+=o;t%l!=e%l&&(h.push({start:t%l,end:e%l,loop:s,style:n}),c=n,d=e%l)}}for(const t of e){d=r?d:t.start;let e,o=i[d%l];for(u=d+1;u<=t.end;u++){const r=i[u%l];e=Vi(s.setContext(Ci(n,{type:"segment",p0:o,p1:r,p0DataIndex:(u-1)%l,p1DataIndex:u%l,datasetIndex:a}))),Bi(e,c)&&f(d,u-1,t.loop,c),o=r,c=e}d<u-1&&f(d,u-1,t.loop,c)}return h}(t,e,i,s):e}function Vi(t){return{backgroundColor:t.backgroundColor,borderCapStyle:t.borderCapStyle,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderJoinStyle:t.borderJoinStyle,borderWidth:t.borderWidth,borderColor:t.borderColor}}function Bi(t,e){if(!e)return!1;const i=[],s=function(t,e){return Jt(e)?(i.includes(e)||i.push(e),i.indexOf(e)):e};return JSON.stringify(t,s)!==JSON.stringify(e,s)}var Wi=Object.freeze({__proto__:null,HALF_PI:E,INFINITY:T,PI:C,PITAU:A,QUARTER_PI:R,RAD_PER_DEG:L,TAU:O,TWO_THIRDS_PI:I,_addGrace:Di,_alignPixel:Ae,_alignStartEnd:ft,_angleBetween:Z,_angleDiff:K,_arrayUnique:lt,_attachContext:$e,_bezierCurveTo:Ve,_bezierInterpolation:mi,_boundSegment:Ri,_boundSegments:Ii,_capitalize:w,_computeSegments:zi,_createResolver:je,_decimalPlaces:U,_deprecated:function(t,e,i,s){void 0!==e&&console.warn(t+': "'+i+'" is deprecated. Please use "'+s+'" instead')},_descriptors:Ye,_elementsEqual:f,_factorize:W,_filterBetween:nt,_getParentNode:ge,_getStartAndCountOfVisiblePoints:pt,_int16Range:Q,_isBetween:tt,_isClickEvent:D,_isDomSupported:fe,_isPointInArea:Re,_limitValue:J,_longestText:Oe,_lookup:et,_lookupByKey:it,_measureText:Ce,_merger:m,_mergerIf:_,_normalizeAngle:G,_parseObjectDataRadialScale:ii,_pointInLine:gi,_readValueToProps:vi,_rlookupByKey:st,_scaleRangesChanged:mt,_setMinAndMaxByKey:j,_splitKey:v,_steppedInterpolation:pi,_steppedLineTo:Fe,_textX:gt,_toLeftRightCenter:ut,_updateBezierControlPoints:hi,addRoundedRectPath:He,almostEquals:V,almostWhole:H,callback:d,clearCanvas:Te,clipArea:Ie,clone:g,color:Qt,createContext:Ci,debounce:dt,defined:k,distanceBetweenPoints:q,drawPoint:Le,drawPointLegend:Ee,each:u,easingEffects:fi,finiteOrDefault:r,fontString:function(t,e,i){return e+" "+t+"px "+i},formatNumber:ne,getAngleFromPoint:X,getHoverColor:te,getMaximumSize:we,getRelativePosition:ve,getRtlAdapter:Oi,getStyle:be,isArray:n,isFinite:a,isFunction:S,isNullOrUndef:s,isNumber:N,isObject:o,isPatternOrGradient:Jt,listenArrayEvents:at,log10:z,merge:b,mergeIf:x,niceNum:B,noop:e,overrideTextDirection:Ai,readUsedSize:Pe,renderText:Ne,requestAnimFrame:ht,resolve:Pi,resolveObjectKey:M,restoreTextDirection:Ti,retinaScale:ke,setsEqual:P,sign:F,splineCurve:ai,splineCurveMonotone:ri,supportsEventListenerOptions:Se,throttled:ct,toDegrees:Y,toDimension:c,toFont:Si,toFontString:De,toLineHeight:_i,toPadding:ki,toPercentage:h,toRadians:$,toTRBL:Mi,toTRBLCorners:wi,uid:i,unclipArea:ze,unlistenArrayEvents:rt,valueOrDefault:l});function Ni(t,e,i,s){const{controller:n,data:o,_sorted:a}=t,r=n._cachedMeta.iScale;if(r&&e===r.axis&&"r"!==e&&a&&o.length){const t=r._reversePixels?st:it;if(!s)return t(o,e,i);if(n._sharedOptions){const s=o[0],n="function"==typeof s.getRange&&s.getRange(e);if(n){const s=t(o,e,i-n),a=t(o,e,i+n);return{lo:s.lo,hi:a.hi}}}}return{lo:0,hi:o.length-1}}function Hi(t,e,i,s,n){const o=t.getSortedVisibleDatasetMetas(),a=i[e];for(let t=0,i=o.length;t<i;++t){const{index:i,data:r}=o[t],{lo:l,hi:h}=Ni(o[t],e,a,n);for(let t=l;t<=h;++t){const e=r[t];e.skip||s(e,i,t)}}}function ji(t,e,i,s,n){const o=[];if(!n&&!t.isPointInArea(e))return o;return Hi(t,i,e,(function(i,a,r){(n||Re(i,t.chartArea,0))&&i.inRange(e.x,e.y,s)&&o.push({element:i,datasetIndex:a,index:r})}),!0),o}function $i(t,e,i,s,n,o){let a=[];const r=function(t){const e=-1!==t.indexOf("x"),i=-1!==t.indexOf("y");return function(t,s){const n=e?Math.abs(t.x-s.x):0,o=i?Math.abs(t.y-s.y):0;return Math.sqrt(Math.pow(n,2)+Math.pow(o,2))}}(i);let l=Number.POSITIVE_INFINITY;return Hi(t,i,e,(function(i,h,c){const d=i.inRange(e.x,e.y,n);if(s&&!d)return;const u=i.getCenterPoint(n);if(!(!!o||t.isPointInArea(u))&&!d)return;const f=r(e,u);f<l?(a=[{element:i,datasetIndex:h,index:c}],l=f):f===l&&a.push({element:i,datasetIndex:h,index:c})})),a}function Yi(t,e,i,s,n,o){return o||t.isPointInArea(e)?"r"!==i||s?$i(t,e,i,s,n,o):function(t,e,i,s){let n=[];return Hi(t,i,e,(function(t,i,o){const{startAngle:a,endAngle:r}=t.getProps(["startAngle","endAngle"],s),{angle:l}=X(t,{x:e.x,y:e.y});Z(l,a,r)&&n.push({element:t,datasetIndex:i,index:o})})),n}(t,e,i,n):[]}function Ui(t,e,i,s,n){const o=[],a="x"===i?"inXRange":"inYRange";let r=!1;return Hi(t,i,e,((t,s,l)=>{t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;t<i.length;++t)r.push({element:i[t],datasetIndex:e,index:t})}return r},point:(t,e,i,s)=>ji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o<a;++o){r=t[o];const{fullSize:a}=r.box,l=i[r.stack],h=l&&r.stackWeight/l.weight;r.horizontal?(r.width=h?h*s:a&&e.availableWidth,r.height=n):(r.width=s,r.height=h?h*n:a&&e.availableHeight)}return i}function Qi(t,e,i,s){return Math.max(t[i],e[i])+Math.max(t[s],e[s])}function ts(t,e){t.top=Math.max(t.top,e.top),t.left=Math.max(t.left,e.left),t.bottom=Math.max(t.bottom,e.bottom),t.right=Math.max(t.right,e.right)}function es(t,e,i,s){const{pos:n,box:a}=i,r=t.maxPadding;if(!o(n)){i.size&&(t[n]-=i.size);const e=s[i.stack]||{size:0,count:1};e.size=Math.max(e.size,i.horizontal?a.height:a.width),i.size=e.size/e.count,t[n]+=i.size}a.getPadding&&ts(r,a.getPadding());const l=Math.max(0,e.outerWidth-Qi(r,t,"left","right")),h=Math.max(0,e.outerHeight-Qi(r,t,"top","bottom")),c=l!==t.w,d=h!==t.h;return t.w=l,t.h=h,i.horizontal?{same:c,other:d}:{same:d,other:c}}function is(t,e){const i=e.maxPadding;function s(t){const s={left:0,top:0,right:0,bottom:0};return t.forEach((t=>{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;o<a;++o){r=t[o],l=r.box,l.update(r.width||e.w,r.height||e.h,is(r.horizontal,e));const{same:a,other:d}=es(e,i,r,s);h|=a&&n.length,c=c||d,l.fullSize||n.push(r)}return h&&ss(n,e,i,s)||c}function ns(t,e,i,s,n){t.top=i,t.left=e,t.right=e+s,t.bottom=i+n,t.width=s,t.height=n}function os(t,e,i,s){const n=i.padding;let{x:o,y:a}=e;for(const r of t){const t=r.box,l=s[r.stack]||{count:1,placed:0,weight:1},h=r.stackWeight/l.weight||1;if(r.horizontal){const s=e.w*h,o=l.size||t.height;k(l.start)&&(a=l.start),t.fullSize?ns(t,n.left,a,i.outerWidth-n.right-n.left,o):ns(t,e.left+l.placed,a,s,o),l.start=a,l.placed+=s,a=t.bottom}else{const s=e.h*h,a=l.size||t.width;k(l.start)&&(o=l.start),t.fullSize?ns(t,o,n.top,a,i.outerHeight-n.bottom-n.top):ns(t,o,e.top+l.placed,a,s),l.start=o,l.placed+=s,o=t.right}}e.x=o,e.y=a}var as={addBox(t,e){t.boxes||(t.boxes=[]),e.fullSize=e.fullSize||!1,e.position=e.position||"top",e.weight=e.weight||0,e._layers=e._layers||function(){return[{z:0,draw(t){e.draw(t)}}]},t.boxes.push(e)},removeBox(t,e){const i=t.boxes?t.boxes.indexOf(e):-1;-1!==i&&t.boxes.splice(i,1)},configure(t,e,i){e.fullSize=i.fullSize,e.position=i.position,e.weight=i.weight},update(t,e,i,s){if(!t)return;const n=ki(t.options.layout.padding),o=Math.max(e-n.width,0),a=Math.max(i-n.height,0),r=function(t){const e=function(t){const e=[];let i,s,n,o,a,r;for(i=0,s=(t||[]).length;i<s;++i)n=t[i],({position:o,options:{stack:a,stackWeight:r=1}}=n),e.push({index:i,box:n,pos:o,horizontal:n.isHorizontal(),weight:n.weight,stack:a&&o+a,stackWeight:r});return e}(t),i=Zi(e.filter((t=>t.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const bs=new Map;let xs=0;function _s(){const t=window.devicePixelRatio;t!==xs&&(xs=t,bs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s<n.clientWidth&&i()}),window),a=new ResizeObserver((t=>{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){bs.size||window.addEventListener("resize",_s),bs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){bs.delete(t),bs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e<i),!this._active)return this._target[s]=a,void this._notify(!0);e<0?this._target[s]=n:(r=e/i%2,r=o&&r>1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t<i.length;t++)i[t][e]()}}class Os{constructor(t,e){this._chart=t,this._properties=new Map,this.configure(e)}configure(t){if(!o(t))return;const e=Object.keys(ue.animation),i=this._properties;Object.getOwnPropertyNames(t).forEach((s=>{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e<s.length;e++){const n=t[s[e]];n&&n.active()&&i.push(n.wait())}return Promise.all(i)}(t.options.$animations,i).then((()=>{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(xt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n<o;++n)i.push(s[n].index);return i}function Ls(t,e,i,s={}){const n=t.keys,o="single"===s.mode;let r,l,h,c;if(null!==e){for(r=0,l=n.length;r<l;++r){if(h=+n[r],h===i){if(s.all)continue;break}c=t.values[h],a(c)&&(o||0===e||F(e)===F(c))&&(e+=c)}return e}}function Es(t,e){const i=t&&t.options.stacked;return i||void 0===i&&void 0!==e.stack}function Rs(t,e,i){const s=t[e]||(t[e]={});return s[i]||(s[i]={})}function Is(t,e,i,s){for(const n of e.getMatchingVisibleMetas(s).reverse()){const e=t[n.index];if(i&&e>0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;t<d;++t){const i=e[t],{[l]:o,[h]:d}=i;u=(i._stacks||(i._stacks={}))[h]=Rs(n,c,o),u[r]=d,u._top=Is(u,a,!0,s.type),u._bottom=Is(u,a,!1,s.type);(u._visualValues||(u._visualValues={}))[r]=d}}function Fs(t,e){const i=t.scales;return Object.keys(i).filter((t=>i[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,n,o;for(s=0,n=e.length;s<n;++s)o=e[s],i[s]={x:o,y:t[o]};return i}(e);else if(i!==e){if(i){rt(i,this);const t=this._cachedMeta;Vs(t),t._parsed=[]}e&&Object.isExtensible(e)&&at(e,this),this._syncList=[],this._data=e}}addElements(){const t=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(t.dataset=new this.datasetElementType)}buildOrUpdateElements(t){const e=this._cachedMeta,i=this.getDataset();let s=!1;this._dataCheck();const n=e._stacked;e._stacked=Es(e.vScale,e),e.stack!==i.stack&&(s=!0,Vs(e),e.stack=i.stack),this._resyncElements(t),(s||n!==e._stacked)&&zs(this,e._parsed)}configure(){const t=this.chart.config,e=t.datasetScopeKeys(this._type),i=t.getOptionScopes(this.getDataset(),e,!0);this.options=t.createResolver(i,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(t,e){const{_cachedMeta:i,_data:s}=this,{iScale:a,_stacked:r}=i,l=a.axis;let h,c,d,u=0===t&&e===s.length||i._sorted,f=t>0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]<f[l];for(h=0;h<e;++h)i._parsed[h+t]=c=d[h],u&&(a()&&(u=!1),f=c);i._sorted=u}r&&zs(this,d)}parsePrimitiveData(t,e,i,s){const{iScale:n,vScale:o}=t,a=n.axis,r=o.axis,l=n.getLabels(),h=n===o,c=new Array(s);let d,u,f;for(d=0,u=s;d<u;++d)f=d+i,c[d]={[a]:h||n.parse(l[f],f),[r]:o.parse(e[f],f)};return c}parseArrayData(t,e,i,s){const{xScale:n,yScale:o}=t,a=new Array(s);let r,l,h,c;for(r=0,l=s;r<l;++r)h=r+i,c=e[h],a[r]={x:n.parse(c[0],h),y:o.parse(c[1],h)};return a}parseObjectData(t,e,i,s){const{xScale:n,yScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l=new Array(s);let h,c,d,u;for(h=0,c=s;h<c;++h)d=h+i,u=e[d],l[h]={x:n.parse(M(u,a),d),y:o.parse(M(u,r),d)};return l}getParsed(t){return this._cachedMeta._parsed[t]}getDataElement(t){return this._cachedMeta.data[t]}applyStack(t,e,i){const s=this.chart,n=this._cachedMeta,o=e[t.axis];return Ls({keys:Ts(s,!0),values:e._stacks[t.axis]._visualValues},o,n.index,{mode:i})}updateRangeFromParsed(t,e,i,s){const n=i[e.axis];let o=null===n?NaN:n;const a=s&&i._stacks[e.axis];s&&a&&(s.values=a,o=Ls(s,n,this._cachedMeta.index)),t.min=Math.min(t.min,o),t.max=Math.max(t.max,o)}getMinMax(t,e){const i=this._cachedMeta,s=i._parsed,n=i._sorted&&t===i.iScale,o=s.length,r=this._getOtherScale(t),l=((t,e,i)=>t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d<e}for(u=0;u<o&&(g()||(this.updateRangeFromParsed(h,t,f,l),!n));++u);if(n)for(u=o-1;u>=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s<n;++s)o=e[s][t.axis],a(o)&&i.push(o);return i}getMaxOverflow(){return!1}getLabelAndValue(t){const e=this._cachedMeta,i=e.iScale,s=e.vScale,n=this.getParsed(t);return{label:i?""+i.getLabelForValue(n[i.axis]):"",value:s?""+s.getLabelForValue(n[s.axis]):""}}_update(t){const e=this._cachedMeta;this.update(t||"default"),e._clip=function(t){let e,i,s,n;return o(t)?(e=t.top,i=t.right,s=t.bottom,n=t.left):e=i=s=n=t,{top:e,right:i,bottom:s,left:n,disabled:!1===t}}(l(this.options.clip,function(t,e,i){if(!1===i)return!1;const s=As(t,i),n=As(e,i);return{top:n.end,right:s.end,bottom:n.start,left:s.start}}(e.xScale,e.yScale,this.getMaxOverflow())))}update(t){}draw(){const t=this._ctx,e=this.chart,i=this._cachedMeta,s=i.data||[],n=e.chartArea,o=[],a=this._drawStart||0,r=this._drawCount||s.length-a,l=this.options.drawActiveElementsOnTop;let h;for(i.dataset&&i.dataset.draw(t,n,a,r),h=a;h<a+r;++h){const e=s[h];e.hidden||(e.active&&l?o.push(e):e.draw(t,n))}for(h=0;h<o.length;++h)o[h].draw(t,n)}getStyle(t,e){const i=e?"active":"default";return void 0===t&&this._cachedMeta.dataset?this.resolveDatasetElementOptions(i):this.resolveDataElementOptions(t||0,i)}getContext(t,e,i){const s=this.getDataset();let n;if(t>=0&&t<this._cachedMeta.data.length){const e=this._cachedMeta.data[t];n=e.$context||(e.$context=function(t,e,i){return Ci(t,{active:!1,dataIndex:e,parsed:void 0,raw:void 0,element:i,index:e,mode:"default",type:"data"})}(this.getContext(),t,e)),n.parsed=this.getParsed(t),n.raw=s.data[t],n.index=n.dataIndex=t}else n=this.$context||(this.$context=function(t,e){return Ci(t,{active:!1,dataset:void 0,datasetIndex:e,index:e,mode:"default",type:"dataset"})}(this.chart.getContext(),this.index)),n.dataset=s,n.index=n.datasetIndex=this.index;return n.active=!!e,n.mode=i,n}resolveDatasetElementOptions(t){return this._resolveElementOptions(this.datasetElementType.id,t)}resolveDataElementOptions(t,e){return this._resolveElementOptions(this.dataElementType.id,e,t)}_resolveElementOptions(t,e="default",i){const s="active"===e,n=this._cachedDataOpts,o=t+"-"+e,a=n[o],r=this.enableOptionSharing&&k(i);if(a)return Ws(a,r);const l=this.chart.config,h=l.datasetElementScopeKeys(this._type,t),c=s?[`${t}Hover`,"hover",t,""]:[t,""],d=l.getOptionScopes(this.getDataset(),h),u=Object.keys(ue.elements[t]),f=l.resolveNamedOptions(d,u,(()=>this.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n<s&&this._removeElements(n,s-n)}_insertElements(t,e,i=!0){const s=this._cachedMeta,n=s.data,o=t+e;let a;const r=t=>{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a<o;++a)n[a]=new this.dataElementType;this._parsing&&r(s._parsed),this.parse(t,e),i&&this.updateElements(n,t,e,"reset")}updateElements(t,e,i,s){}_removeElements(t,e){const i=this._cachedMeta;if(this._parsing){const s=i._parsed.splice(t,e);i._stacked&&Vs(i,s)}i.data.splice(t,e)}_sync(t){if(this._parsing)this._syncList.push(t);else{const[e,i,s]=t;this[e](i,s)}this.chart._dataChanges.push([this.index,...t])}_onDataPush(){const t=arguments.length;this._sync(["_insertElements",this.getDataset().data.length-t,t])}_onDataPop(){this._sync(["_removeElements",this._cachedMeta.data.length-1,1])}_onDataShift(){this._sync(["_removeElements",0,1])}_onDataSplice(t,e){e&&this._sync(["_removeElements",t,e]);const i=arguments.length-2;i&&this._sync(["_insertElements",t,i])}_onDataUnshift(){this._sync(["_insertElements",0,arguments.length])}}class Hs{static defaults={};static defaultRoutes=void 0;x;y;active=!1;options;$animations;tooltipPosition(t){const{x:e,y:i}=this.getProps(["x","y"],t);return{x:e,y:i}}hasValue(){return N(this.x)&&N(this.y)}getProps(t,e){const i=this.$animations;if(!e||!i)return this;const s={};return t.forEach((t=>{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;i<s;i++)t[i].major&&e.push(i);return e}(e):[],r=a.length,l=a[0],h=a[r-1],c=[];if(r>o)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;n<t.length;n++)n===a&&(e.push(t[n]),o++,a=i[o*s])}(e,c,a,r/o),c;const d=function(t,e,i){const s=function(t){const e=t.length;let i,s;if(e<2)return!1;for(s=t[0],i=1;i<e;++i)if(t[i]-t[i-1]!==s)return!1;return s}(t),n=e.length/i;if(!s)return Math.max(n,1);const o=W(s);for(let t=0,e=o.length-1;t<e;t++){const e=o[t];if(e>n)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t<i;t++)$s(e,c,d,a[t],a[t+1]);return $s(e,c,d,h,s(n)?e.length:h+n),c}return $s(e,c,d),c}function $s(t,e,i,s,n){const o=l(s,0),a=Math.min(l(n,t.length),t.length);let r,h,c,d=0;for(i=Math.ceil(i),n&&(r=n-s,i=r/Math.floor(r/i)),c=o;c<0;)d++,c=Math.round(o+d*i);for(h=Math.max(o,0);h<a;h++)h===c&&(e.push(t[h]),d++,c=Math.round(o+d*i))}const Ys=(t,e,i)=>"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;o<n;o+=s)i.push(t[Math.floor(o)]);return i}function qs(t,e,i){const s=t.ticks.length,n=Math.min(e,s-1),o=t._startPixel,a=t._endPixel,r=1e-6;let l,h=t.getPixelForTick(n);if(!(i&&(l=1===s?Math.max(h-o,a-h):0===e?(t.getPixelForTick(1)-h)/2:(h-t.getPixelForTick(n-1))/2,h+=n<e?l:-l,h<o-r||h>a+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;r<l;++r)e=a[r].controller.getMinMax(this,t),n||(i=Math.min(i,e.min)),o||(s=Math.max(s,e.max));return i=o&&i>s?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a<this.ticks.length;this._convertTicksToLabels(r?Xs(this.ticks,a):this.ticks),this.configure(),this.beforeCalculateLabelRotation(),this.calculateLabelRotation(),this.afterCalculateLabelRotation(),o.display&&(o.autoSkip||"auto"===o.source)&&(this.ticks=js(this,this.ticks),this._labelSizes=null,this.afterAutoSkip()),r&&this._convertTicksToLabels(this.ticks),this.beforeFit(),this.fit(),this.afterFit(),this.afterUpdate()}configure(){let t,e,i=this.options.reverse;this.isHorizontal()?(t=this.left,e=this.right):(t=this.top,e=this.bottom,i=!i),this._startPixel=t,this._endPixel=e,this._reversePixels=i,this._length=e-t,this._alignToPixels=this.options.alignToPixels}afterUpdate(){d(this.options.afterUpdate,[this])}beforeSetDimensions(){d(this.options.beforeSetDimensions,[this])}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=0,this.right=this.width):(this.height=this.maxHeight,this.top=0,this.bottom=this.height),this.paddingLeft=0,this.paddingTop=0,this.paddingRight=0,this.paddingBottom=0}afterSetDimensions(){d(this.options.afterSetDimensions,[this])}_callHooks(t){this.chart.notifyPlugins(t,this.getContext()),d(this.options[t],[this])}beforeDataLimits(){this._callHooks("beforeDataLimits")}determineDataLimits(){}afterDataLimits(){this._callHooks("afterDataLimits")}beforeBuildTicks(){this._callHooks("beforeBuildTicks")}buildTicks(){return[]}afterBuildTicks(){this._callHooks("afterBuildTicks")}beforeTickToLabelConversion(){d(this.options.beforeTickToLabelConversion,[this])}generateTickLabels(t){const e=this.options.ticks;let i,s,n;for(i=0,s=t.length;i<s;i++)n=t[i],n.label=d(e.callback,[n.value,i,t],this)}afterTickToLabelConversion(){d(this.options.afterTickToLabelConversion,[this])}beforeCalculateLabelRotation(){d(this.options.beforeCalculateLabelRotation,[this])}calculateLabelRotation(){const t=this.options,e=t.ticks,i=Us(this.ticks.length,t.ticks.maxTicksLimit),s=e.minRotation||0,n=e.maxRotation;let o,a,r,l=s;if(!this._isVisible()||!e.display||s>=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e<i;e++)s(t[e].label)&&(t.splice(e,1),i--,e--);this.afterTickToLabelConversion()}_getLabelSizes(){let t=this._labelSizes;if(!t){const e=this.options.ticks.sampleSize;let i=this.ticks;e<i.length&&(i=Xs(i,e)),this._labelSizes=t=this._computeLabelSizes(i,i.length,this.options.ticks.maxTicksLimit)}return t}_computeLabelSizes(t,e,i){const{ctx:o,_longestTextCache:a}=this,r=[],l=[],h=Math.floor(e/Us(e,i));let c,d,f,g,p,m,b,x,_,y,v,M=0,w=0;for(c=0;c<e;c+=h){if(g=t[c].label,p=this._resolveTickFontOptions(c),o.font=m=p.string,b=a[m]=a[m]||{data:{},gc:[]},x=p.lineHeight,_=y=0,s(g)||n(g)){if(n(g))for(d=0,f=g.length;d<f;++d)v=g[d],s(v)||n(v)||(_=Ce(o,b.data,b.gc,_,v),y+=x)}else _=Ce(o,b.data,b.gc,_,g),y=x;r.push(_),l.push(y),M=Math.max(_,M),w=Math.max(y,w)}!function(t,e){u(t,(t=>{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n<s;++n)delete t.data[i[n]];i.splice(0,s)}}))}(a,e);const k=r.indexOf(M),S=l.indexOf(w),P=t=>({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&t<e.length){const i=e[t];return i.$context||(i.$context=function(t,e,i){return Ci(t,{tick:i,index:e,type:"tick"})}(this.getContext(),t,i))}return this.$context||(this.$context=Ci(this.chart.getContext(),{scale:this,type:"scale"}))}_tickSize(){const t=this.options.ticks,e=$(this.labelRotation),i=Math.abs(Math.cos(e)),s=Math.abs(Math.sin(e)),n=this._getLabelSizes(),o=t.autoSkipPadding||0,a=n?n.widest.width+o:0,r=n?n.highest.height+o:0;return this.isHorizontal()?r*i>a*s?a/i:r/s:r*s<a*i?r/i:a/s}_isVisible(){const t=this.options.display;return"auto"!==t?!!t:this.getMatchingVisibleMetas().length>0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,b=function(t){return Ae(i,t,p)};let x,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)x=b(this.bottom),w=this.bottom-u,S=x-m,D=b(t.top)+m,O=t.bottom;else if("bottom"===a)x=b(this.top),D=t.top,O=b(t.bottom)-m,w=x+m,S=this.top+u;else if("left"===a)x=b(this.right),M=this.right-u,k=x-m,P=b(t.left)+m,C=t.right;else if("right"===a)x=b(this.left),P=t.left,C=b(t.right)-m,M=x+m,k=this.left+u;else if("x"===e){if("center"===a)x=b((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=x+m,S=w+u}else if("y"===e){if("center"===a)x=b((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}M=x-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_<d;_+=T){const t=this.getContext(_),e=n.setContext(t),s=r.setContext(t),o=e.lineWidth,a=e.color,l=s.dash||[],d=s.dashOffset,u=e.tickWidth,g=e.tickColor,p=e.tickBorderDash||[],m=e.tickBorderDashOffset;y=qs(this,_,h),void 0!==y&&(v=Ae(i,y,o),c?M=k=P=C=v:w=S=D=O=v,f.push({tx1:M,ty1:w,tx2:k,ty2:S,x1:P,y1:D,x2:C,y2:O,width:o,color:a,borderDash:l,borderDashOffset:d,tickWidth:u,tickColor:g,tickBorderDash:p,tickBorderDashOffset:m}))}return this._ticksLength=d,this._borderValue=x,f}_computeLabelItems(t){const e=this.axis,i=this.options,{position:s,ticks:a}=i,r=this.isHorizontal(),l=this.ticks,{align:h,crossAlign:c,padding:d,mirror:u}=a,f=Ks(i.grid),g=f+d,p=u?-d:g,m=-$(this.labelRotation),b=[];let x,_,y,v,M,w,k,S,P,D,C,O,A="middle";if("top"===s)w=this.bottom-p,k=this._getXAxisLabelAlignment();else if("bottom"===s)w=this.top+p,k=this._getXAxisLabelAlignment();else if("left"===s){const t=this._getYAxisLabelAlignment(f);k=t.textAlign,M=t.x}else if("right"===s){const t=this._getYAxisLabelAlignment(f);k=t.textAlign,M=t.x}else if("x"===e){if("center"===s)w=(t.top+t.bottom)/2+g;else if(o(s)){const t=Object.keys(s)[0],e=s[t];w=this.chart.scales[t].getPixelForValue(e)+g}k=this._getXAxisLabelAlignment()}else if("y"===e){if("center"===s)M=(t.left+t.right)/2-g;else if(o(s)){const t=Object.keys(s)[0],e=s[t];M=this.chart.scales[t].getPixelForValue(e)}k=this._getYAxisLabelAlignment(f).textAlign}"y"===e&&("start"===h?A="top":"end"===h&&(A="bottom"));const T=this._getLabelSizes();for(x=0,_=l.length;x<_;++x){y=l[x],v=y.label;const t=a.setContext(this.getContext(x));S=this.getPixelForTick(x)+a.labelOffset,P=this._resolveTickFontOptions(x),D=P.lineHeight,C=n(v)?v.length:1;const e=C/2,i=t.color,o=t.textStrokeColor,h=t.textStrokeWidth;let d,f=k;if(r?(M=S,"inner"===k&&(f=x===_-1?this.options.reverse?"left":"right":0===x?this.options.reverse?"right":"left":"center"),O="top"===s?"near"===c||0!==m?-C*D+D/2:"center"===c?-T.highest.height/2-e*D+D:-T.highest.height+D/2:"near"===c||0!==m?D/2:"center"===c?T.highest.height/2-e*D:T.highest.height-C*D,u&&(O*=-1),0===m||t.showLabelBackdrop||(M+=D/2*Math.sin(m))):(w=S,O=(1-C)*D/2),t.showLabelBackdrop){const e=ki(t.backdropPadding),i=T.heights[x],s=T.widths[x];let n=O-e.top,o=0-e.left;switch(A){case"middle":n-=i/2;break;case"bottom":n-=i}switch(k){case"center":o-=s/2;break;case"right":o-=s}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}b.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return b}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n<o;++n){const t=s[n];e.drawOnChartArea&&a({x:t.x1,y:t.y1},{x:t.x2,y:t.y2},t),e.drawTicks&&a({x:t.tx1,y:t.ty1},{x:t.tx2,y:t.ty2},{color:t.tickColor,width:t.tickWidth,borderDash:t.tickBorderDash,borderDashOffset:t.tickBorderDashOffset})}}drawBorder(){const{chart:t,ctx:e,options:{border:i,grid:s}}=this,n=i.setContext(this.getContext()),o=i.display?n.width:0;if(!o)return;const a=s.setContext(this.getContext(0)).lineWidth,r=this._borderValue;let l,h,c,d;this.isHorizontal()?(l=Ae(t,this.left,o)-o/2,h=Ae(t,this.right,a)+a/2,c=d=r):(c=Ae(t,this.top,o)-o/2,d=Ae(t,this.bottom,a)+a/2,l=h=r),e.save(),e.lineWidth=n.width,e.strokeStyle=n.color,e.beginPath(),e.moveTo(l,c),e.lineTo(h,d),e.stroke(),e.restore()}drawLabels(t){if(!this.options.ticks.display)return;const e=this.ctx,i=this._computeLabelArea();i&&Ie(e,i);const s=this.getLabelItems(t);for(const t of s){const i=t.options,s=t.font;Ne(e,t.label,0,t.textOffset,s,i)}i&&ze(e)}drawTitle(){const{ctx:t,options:{position:e,title:i,reverse:s}}=this;if(!i.display)return;const a=Si(i.font),r=ki(i.padding),l=i.align;let h=a.lineHeight/2;"bottom"===e||"center"===e||o(e)?(h+=r.bottom,n(i.text)&&(h+=a.lineHeight*(i.text.length-1))):h+=r.top;const{titleX:c,titleY:d,maxWidth:u,rotation:f}=function(t,e,i,s){const{top:n,left:a,bottom:r,right:l,chart:h}=t,{chartArea:c,scales:d}=h;let u,f,g,p=0;const m=r-n,b=l-a;if(t.isHorizontal()){if(f=ft(s,a,l),o(i)){const t=Object.keys(i)[0],s=i[t];g=d[t].getPixelForValue(s)+m-e}else g="center"===i?(c.bottom+c.top)/2+m-e:Ys(t,i,e);u=l-a}else{if(o(i)){const t=Object.keys(i)[0],s=i[t];f=d[t].getPixelForValue(s)-b+e}else f="center"===i?(c.left+c.right)/2-b+e:Ys(t,i,e);g=ft(s,r,n),p="left"===i?-E:E}return{titleX:f,titleY:g,maxWidth:u,rotation:p}}(this,h,e,l);Ne(t,i.text,0,0,a,{color:i.color,maxWidth:u,rotation:f,textAlign:Zs(l,e,s),textBaseline:"middle",translation:[c,d]})}draw(t){this._isVisible()&&(this.drawBackground(),this.drawGrid(t),this.drawBorder(),this.drawTitle(),this.drawLabels(t))}_layers(){const t=this.options,e=t.ticks&&t.ticks.z||0,i=l(t.grid&&t.grid.z,-1),s=l(t.border&&t.border.z,0);return this._isVisible()&&this.draw===Js.prototype.draw?[{z:i,draw:t=>{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n<o;++n){const o=e[n];o[i]!==this.id||t&&o.type!==t||s.push(o)}return s}_resolveTickFontOptions(t){return Si(this.options.ticks.setContext(this.getContext(t)).font)}_maxDigits(){const t=this._resolveTickFontOptions(0).lineHeight;return(this.isHorizontal()?this.width:this.height)/t}}class Qs{constructor(t,e,i){this.type=t,this.scope=e,this.override=i,this.items=Object.create(null)}isForType(t){return Object.prototype.isPrototypeOf.call(this.type.prototype,t.prototype)}register(t){const e=Object.getPrototypeOf(t);let i;(function(t){return"id"in t&&"defaults"in t})(e)&&(i=this.register(e));const s=this.items,n=t.id,o=this.scope+"."+n;if(!n)throw new Error("class does not have id: "+t);return n in s||(s[n]=t,function(t,e,i){const s=b(Object.create(null),[i?ue.get(i):{},ue.get(e),t.defaults]);ue.set(e,s),t.defaultRoutes&&function(t,e){Object.keys(e).forEach((i=>{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;e<this._typedRegistries.length;e++){const i=this._typedRegistries[e];if(i.isForType(t))return i}return this.plugins}_get(t,e,i){const s=e.get(t);if(void 0===s)throw new Error('"'+t+'" is not a registered '+i+".");return s}}var en=new tn;class sn{constructor(){this._init=[]}notify(t,e,i,s){"beforeInit"===e&&(this._init=this._createDescriptors(t,!0),this._notify(this._init,t,"install"));const n=s?this._descriptors(t).filter(s):this._descriptors(t),o=this._notify(n,t,e,i);return"afterDestroy"===e&&(this._notify(n,t,"stop"),this._notify(this._init,t,"uninstall")),o}_notify(t,e,i,s){s=s||{};for(const n of t){const t=n.plugin;if(!1===d(t[i],[e,s,n.options],t)&&s.cancelable)return!1}return!0}invalidate(){s(this._cache)||(this._oldCache=this._cache,this._cache=void 0)}_descriptors(t){if(this._cache)return this._cache;const e=this._cache=this._createDescriptors(t);return this._notifyStateChanges(t),e}_createDescriptors(t,e){const i=t&&t.config,s=l(i.options&&i.options.plugins,{}),n=function(t){const e={},i=[],s=Object.keys(en.plugins.items);for(let t=0;t<s.length;t++)i.push(en.getPlugin(s[t]));const n=t.plugins||[];for(let t=0;t<n.length;t++){const s=n[t];-1===i.indexOf(s)&&(i.push(s),e[s.id]=!0)}return{plugins:i,localIds:e}}(i);return!1!==s||e?function(t,{plugins:e,localIds:i},s,n){const o=[],a=t.getContext();for(const r of e){const e=r.id,l=nn(s[e],n);null!==l&&o.push({plugin:r,options:on(t.config,{plugin:r,local:i[e]},l,a)})}return o}(t,n,s,e):[]}_notifyStateChanges(t){const e=this._oldCache||[],i=this._cache,s=(t,e)=>t.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=x(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),x(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];x(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class bn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=xn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=xn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function xn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).reduce(((e,i)=>e||S(t[i])),!1);const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.0";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new bn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(xt.listen(this,"complete",wn),xt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return xt.stop(this),this}resize(t,e){xt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;t<i;++t)this._destroyDatasetMeta(t);t.splice(e,i-e)}this._sortedMetasets=t.slice(0).sort(Mn("order","index"))}_removeUnreferencedMetasets(){const{_metasets:t,data:{datasets:e}}=this;t.length>e.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i<s;i++){const s=e[i];let n=this.getDatasetMeta(i);const o=s.type||this.config.type;if(n.type&&n.type!==o&&(this._destroyDatasetMeta(i),n=this.getDatasetMeta(i)),n.type=o,n.indexAxis=s.indexAxis||an(o,this.options),n.order=s.order||0,n.index=i,n.label=""+s.label,n.visible=this.isDatasetVisible(i),n.controller)n.controller.updateIndex(i),n.controller.linkScales();else{const e=en.getController(o),{datasetElementType:s,dataElementType:a}=ue.datasets[o];Object.assign(e,{dataElementType:en.getElement(a),datasetElementType:s&&en.getElement(s)}),n.controller=new e(this,i),t.push(n.controller)}}return this._updateMetasets(),t}_resetElements(){u(this.data.datasets,((t,e)=>{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t<e;t++){const{controller:e}=this.getDatasetMeta(t),i=!s&&-1===n.indexOf(e);e.buildOrUpdateElements(i),o=Math.max(+e.getMaxOverflow(),o)}o=this._minPadding=i.layout.autoPadding?o:0,this._updateLayout(o),s||u(n,(t=>{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;t<e;t++)if(!P(s,i(t)))return;return Array.from(s).map((t=>t.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t<e;++t)this.getDatasetMeta(t).controller.configure();for(let e=0,i=this.data.datasets.length;e<i;++e)this._updateDataset(e,S(t)?t({datasetIndex:e}):t);this.notifyPlugins("afterDatasetsUpdate",{mode:t})}}_updateDataset(t,e){const i=this.getDatasetMeta(t),s={meta:i,index:t,mode:e,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetUpdate",s)&&(i.controller._update(e),s.cancelable=!1,this.notifyPlugins("afterDatasetUpdate",s))}render(){!1!==this.notifyPlugins("beforeRender",{cancelable:!0})&&(xt.has(this)?this.attached&&!xt.running(this)&&xt.start(this):(this.draw(),wn({chart:this})))}draw(){let t;if(this._resizeBeforeDraw){const{width:t,height:e}=this._resizeBeforeDraw;this._resize(t,e),this._resizeBeforeDraw=null}if(this.clear(),this.width<=0||this.height<=0)return;if(!1===this.notifyPlugins("beforeDraw",{cancelable:!0}))return;const e=this._layers;for(t=0;t<e.length&&e[t].z<=0;++t)e[t].draw(this.chartArea);for(this._drawDatasets();t<e.length;++t)e[t].draw(this.chartArea);this.notifyPlugins("afterDraw")}_getSortedDatasetMetas(t){const e=this._sortedMetasets,i=[];let s,n;for(s=0,n=e.length;s<n;++s){const n=e[s];t&&!n.visible||i.push(n)}return i}getSortedVisibleDatasetMetas(){return this._getSortedDatasetMetas(!0)}_drawDatasets(){if(!1===this.notifyPlugins("beforeDatasetsDraw",{cancelable:!0}))return;const t=this.getSortedVisibleDatasetMetas();for(let e=t.length-1;e>=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xt.remove(this),t=0,e=this.data.datasets.length;t<e;++t)this._destroyDatasetMeta(t)}destroy(){this.notifyPlugins("beforeDestroy");const{canvas:t,ctx:e}=this;this._stop(),this.config.clearCache(),t&&(this.unbindEvents(),Te(t,e),this.platform.releaseContext(e),this.canvas=null,this.ctx=null),delete Pn[this.id],this.notifyPlugins("afterDestroy")}toBase64Image(...t){return this.canvas.toDataURL(...t)}bindEvents(){this.bindUserEvents(),this.options.responsive?this.bindResponsiveEvents():this.attached=!0}bindUserEvents(){const t=this._listeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a<r;++a){o=t[a];const e=o&&this.getDatasetMeta(o.datasetIndex).controller;e&&e[s+"HoverStyle"](o.element,o.datasetIndex,o.index)}}getActiveElements(){return this._active||[]}setActiveElements(t){const e=this._active||[],i=t.map((({datasetIndex:t,index:e})=>{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;e<n;e++)s=s.concat(i[e].controller.getAllParsedValues(t));t._cache.$bar=lt(s.sort(((t,e)=>t-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;s<n;++s)o=e.getPixelForValue(i[s]),l();for(a=void 0,s=0,n=e.ticks.length;s<n;++s)o=e.getPixelForTick(s),l();return r}function zn(t,e,i,s){return n(t)?function(t,e,i,s){const n=i.parse(t[0],s),o=i.parse(t[1],s),a=Math.min(n,o),r=Math.max(n,o);let l=a,h=r;Math.abs(a)>Math.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;h<c;++h)u=e[h],d={},d[n.axis]=r||n.parse(a[h],h),l.push(zn(u,d,o,h));return l}function Vn(t){return t&&void 0!==t.barStart&&void 0!==t.barEnd}function Bn(t,e,i,s){let n=e.borderSkipped;const o={};if(!n)return void(t.borderSkipped=o);if(!0===n)return void(t.borderSkipped={top:!0,right:!0,bottom:!0,left:!0});const{start:a,end:r,reverse:l,top:h,bottom:c}=function(t){let e,i,s,n,o;return t.horizontal?(e=t.base>t.x,i="left",s="right"):(e=t.base<t.y,i="bottom",s="top"),e?(n="end",o="start"):(n="start",o="end"),{start:i,end:s,reverse:e,top:n,bottom:o}}(t);"middle"===n&&i&&(t.enableBorderRadius=!0,(i._top||0)===s?n=h:(i._bottom||0)===s?n=c:(o[Wn(c,a,r,l)]=!0,n=h)),o[Wn(n,a,r,l)]=!0,t.borderSkipped=o}function Wn(t,e,i,s){var n,o,a;return s?(a=i,t=Nn(t=(n=t)===(o=e)?a:n===a?o:n,i,e)):t=Nn(t,e,i),t}function Nn(t,e,i){return"start"===t?e:"end"===t?i:t}function Hn(t,{inflateAmount:e},i){t.inflateAmount="auto"===e?1===i?.33:0:e}class jn extends Ns{static id="doughnut";static defaults={datasetElementType:!1,dataElementType:"arc",animation:{animateRotate:!0,animateScale:!1},animations:{numbers:{type:"number",properties:["circumference","endAngle","innerRadius","outerRadius","startAngle","x","y","offset","borderWidth","spacing"]}},cutout:"50%",rotation:0,circumference:360,radius:"100%",spacing:0,indexAxis:"r"};static descriptors={_scriptable:t=>"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;n<a;++n)s._parsed[n]=r(n)}}_getRotation(){return $(this.options.rotation-90)}_getCircumference(){return $(this.options.circumference)}_getRotationExtents(){let t=O,e=-O;for(let i=0;i<this.chart.data.datasets.length;++i)if(this.chart.isDatasetVisible(i)&&this.chart.getDatasetMeta(i).type===this._type){const s=this.chart.getDatasetMeta(i).controller,n=s._getRotation(),o=s._getCircumference();t=Math.min(t,n),e=Math.max(e,n+o)}return{rotation:t,circumference:e-t}}update(t){const e=this.chart,{chartArea:i}=e,s=this._cachedMeta,n=s.data,o=this.getMaxBorderWidth()+this.getMaxOffset(n)+this.options.spacing,a=Math.max((Math.min(i.width,i.height)-o)/2,0),r=Math.min(h(this.options.cutout,a),1),l=this._getRingWeight(this.index),{circumference:d,rotation:u}=this._getRotationExtents(),{ratioX:f,ratioY:g,offsetX:p,offsetY:m}=function(t,e,i){let s=1,n=1,o=0,a=0;if(e<O){const r=t,l=r+e,h=Math.cos(r),c=Math.sin(r),d=Math.cos(l),u=Math.sin(l),f=(t,e,s)=>Z(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),b=g(C,h,d),x=g(C+E,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p<e;++p)m+=this._circumference(p,n);for(p=e;p<e+i;++p){const e=this._circumference(p,n),i=t[p],o={x:l+this.offsetX,y:h+this.offsetY,startAngle:m,endAngle:m+e,circumference:e,outerRadius:u,innerRadius:d};g&&(o.options=f||this.resolveDataElementOptions(p,i.active?"active":s)),m+=e,this.updateElement(i,p,o,s)}}calculateTotal(){const t=this._cachedMeta,e=t.data;let i,s=0;for(i=0;i<e.length;i++){const n=t._parsed[i];null===n||isNaN(n)||!this.chart.getDataVisibility(i)||e[i].hidden||(s+=Math.abs(n))}return s}calculateCircumference(t){const e=this._cachedMeta.total;return e>0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s<n;++s)if(i.isDatasetVisible(s)){o=i.getDatasetMeta(s),t=o.data,a=o.controller;break}if(!t)return 0;for(s=0,n=t.length;s<n;++s)r=a.resolveDataElementOptions(s),"inner"!==r.borderAlign&&(e=Math.max(e,r.borderWidth||0,r.hoverBorderWidth||0));return e}getMaxOffset(t){let e=0;for(let i=0,s=t.length;i<s;++i){const t=this.resolveDataElementOptions(i);e=Math.max(e,t.offset||0,t.hoverOffset||0)}return e}_getRingWeightOffset(t){let e=0;for(let i=0;i<t;++i)this.chart.isDatasetVisible(i)&&(e+=this._getRingWeight(i));return e}_getRingWeight(t){return Math.max(l(this.chart.data.datasets[t].weight,1),0)}_getVisibleDatasetWeightTotal(){return this._getRingWeightOffset(this.chart.data.datasets.length)||1}}class $n extends Ns{static id="polarArea";static defaults={dataElementType:"arc",animation:{animateRotate:!0,animateScale:!0},animations:{numbers:{type:"number",properties:["x","y","startAngle","endAngle","innerRadius","outerRadius"]}},indexAxis:"r",startAngle:0};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(s<e.min&&(e.min=s),s>e.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d<e;++d)u+=this._computeAngle(d,s,f);for(d=e;d<e+i;d++){const e=t[d];let i=u,g=u+this._computeAngle(d,s,f),p=o.getDataVisibility(d)?r.getDistanceFromCenterForValue(this.getParsed(d).r):0;u=g,n&&(a.animateScale&&(p=0),a.animateRotate&&(i=g=c));const m={x:l,y:h,innerRadius:0,outerRadius:p,startAngle:i,endAngle:g,options:this.resolveDataElementOptions(d,e.active?"active":s)};this.updateElement(e,d,m,s)}}countVisibleElements(){const t=this._cachedMeta;let e=0;return t.data.forEach(((t,i)=>{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;d<u;++d)g=e[d],f={},f[n.axis]=n.parse(M(g,l),d),c.push(zn(M(g,h),f,o,d));return c}updateRangeFromParsed(t,e,i,s){super.updateRangeFromParsed(t,e,i,s);const n=i._custom;n&&e===this._cachedMeta.vScale&&(t.min=Math.min(t.min,n.min),t.max=Math.max(t.max,n.max))}getMaxOverflow(){return 0}getLabelAndValue(t){const e=this._cachedMeta,{iScale:i,vScale:s}=e,n=this.getParsed(t),o=n._custom,a=Vn(o)?"["+o.start+", "+o.end+"]":""+s.getLabelForValue(n[s.axis]);return{label:""+i.getLabelForValue(n[i.axis]),value:a}}initialize(){this.enableOptionSharing=!0,super.initialize();this._cachedMeta.stack=this.getDataset().stack}update(t){const e=this._cachedMeta;this.updateElements(e.data,0,e.data.length,t)}updateElements(t,e,i,n){const o="reset"===n,{index:a,_cachedMeta:{vScale:r}}=this,l=r.getBasePixel(),h=r.isHorizontal(),c=this._getRuler(),{sharedOptions:d,includeOptions:u}=this._getSharedOptions(e,n);for(let f=e;f<e+i;f++){const e=this.getParsed(f),i=o||s(e[r.axis])?{base:l,head:l}:this._calculateBarValuePixels(f),g=this._calculateBarIndexPixels(f,c),p=(e._stacks||{})[r.axis],m={horizontal:h,base:i.base,enableBorderRadius:!p||Vn(e._custom)||a===p._top||a===p._bottom,x:h?i.head:g.center,y:h?g.center:i.head,height:h?g.size:Math.abs(i.size),width:h?Math.abs(i.size):g.size};u&&(m.options=d||this.resolveDataElementOptions(f,t[f].active?"active":n));const b=m.options||t[f].options;Bn(m,b,p,a),Hn(m,b,c.ratio),this.updateElement(t[f],f,m,n)}}_getStacks(t,e){const{iScale:i}=this._cachedMeta,n=i.getMatchingVisibleMetas(this._type).filter((t=>t.controller.options.grouped)),o=i.options.stacked,a=[],r=t=>{const i=t.controller.getParsed(e),n=i&&i[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!r(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n<o;++n)s.push(i.getPixelForValue(this.getParsed(n)[i.axis],n));const a=t.barThickness;return{min:a||In(e),pixels:s,start:i._startPixel,end:i._endPixel,stackCount:this._getStackCount(),scale:i,grouped:t.grouped,ratio:a?1:t.categoryPercentage*t.barPercentage}}_calculateBarValuePixels(t){const{_cachedMeta:{vScale:e,_stacked:i,index:n},options:{base:o,minBarLength:a}}=this,r=o||0,l=this.getParsed(t),h=l._custom,c=Vn(h);let d,u,f=l[e.axis],g=0,p=i?this.applyStack(e,l,i):f;p!==f&&(g=p-f,p=f),c&&(f=h.barStart,p=h.barEnd-h.barStart,0!==f&&F(f)!==F(h.barEnd)&&(g=0),g+=f);const m=s(o)||c?g:o;let b=e.getPixelForValue(m);if(d=this.chart.getDataVisibility(t)?e.getPixelForValue(g+p):b,u=d-b,Math.abs(u)<a){u=function(t,e,i){return 0!==t?F(t):(e.isHorizontal()?1:-1)*(e.min>=i?1:-1)}(u,e,r)*a,f===r&&(b-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);b=Math.max(Math.min(b,h),o),d=b+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(b))}if(b===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;b+=t,u-=t}return{size:u,base:b,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t<n.length-1?n[t+1]:null;const l=i.categoryPercentage;null===a&&(a=o-(null===r?e.end-e.start:r-o)),null===r&&(r=o+o-a);const h=o-(o-Math.min(a,r))/2*l;return{chunk:Math.abs(r-a)/2*l/s,ratio:i.barPercentage,start:h}}(t,e,n,i):function(t,e,i,n){const o=i.barThickness;let a,r;return s(o)?(a=e.min*i.categoryPercentage,r=i.barPercentage):(a=o*n,r=1),{chunk:a/n,ratio:r,start:e.pixels[t]-a/2}}(t,e,n,i),c=this._getStackIndex(this.index,this._cachedMeta.stack,o?t:void 0);r=l.start+l.chunk*c+l.chunk/2,h=Math.min(a,l.chunk*l.ratio)}else r=i.getPixelForValue(this.getParsed(t)[i.axis],t),h=Math.min(a,e.min*e.ratio);return{base:r-h/2,head:r+h/2,center:r,size:h}}draw(){const t=this._cachedMeta,e=t.vScale,i=t.data,s=i.length;let n=0;for(;n<s;++n)null!==this.getParsed(n)[e.axis]&&i[n].draw(this._ctx)}},BubbleController:class extends Ns{static id="bubble";static defaults={datasetElementType:!1,dataElementType:"point",animations:{numbers:{type:"number",properties:["x","y","borderWidth","radius"]}}};static overrides={scales:{x:{type:"linear"},y:{type:"linear"}}};initialize(){this.enableOptionSharing=!0,super.initialize()}parsePrimitiveData(t,e,i,s){const n=super.parsePrimitiveData(t,e,i,s);for(let t=0;t<n.length;t++)n[t]._custom=this.resolveDataElementOptions(t+i).radius;return n}parseArrayData(t,e,i,s){const n=super.parseArrayData(t,e,i,s);for(let t=0;t<n.length;t++){const s=e[i+t];n[t]._custom=l(s[2],this.resolveDataElementOptions(t+i).radius)}return n}parseObjectData(t,e,i,s){const n=super.parseObjectData(t,e,i,s);for(let t=0;t<n.length;t++){const s=e[i+t];n[t]._custom=l(s&&s.r&&+s.r,this.resolveDataElementOptions(t+i).radius)}return n}getMaxOverflow(){const t=this._cachedMeta.data;let e=0;for(let i=t.length-1;i>=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d<e+i;d++){const e=t[d],i=!n&&this.getParsed(d),u={},f=u[h]=n?o.getPixelForDecimal(.5):o.getPixelForValue(i[h]),g=u[c]=n?a.getBasePixel():a.getPixelForValue(i[c]);u.skip=isNaN(f)||isNaN(g),l&&(u.options=r||this.resolveDataElementOptions(d,e.active?"active":s),n&&(u.options.radius=0)),this.updateElement(e,d,u,s)}}resolveDataElementOptions(t,e){const i=this.getParsed(t);let s=super.resolveDataElementOptions(t,e);s.$shared&&(s=Object.assign({},s,{$shared:!1}));const n=s.radius;return"active"!==e&&(s.radius=0),s.radius+=l(i&&i._custom,n),s}},DoughnutController:jn,LineController:class extends Ns{static id="line";static defaults={datasetElementType:"line",dataElementType:"point",showLine:!0,spanGaps:!1};static overrides={scales:{_index_:{type:"category"},_value_:{type:"linear"}}};initialize(){this.enableOptionSharing=!0,this.supportsDecimation=!0,super.initialize()}update(t){const e=this._cachedMeta,{dataset:i,data:s=[],_dataset:n}=e,o=this.chart._animationsDisabled;let{start:a,count:r}=pt(e,s,o);this._drawStart=a,this._drawCount=r,mt(e)&&(a=0,r=s.length),i._chart=this.chart,i._datasetIndex=this.index,i._decimated=!!n._decimated,i.points=s;const l=this.resolveDatasetElementOptions(t);this.options.showLine||(l.borderWidth=0),l.segment=this.options.segment,this.updateElement(i,void 0,{animated:!o,options:l},t),this.updateElements(s,a,r,t)}updateElements(t,e,i,n){const o="reset"===n,{iScale:a,vScale:r,_stacked:l,_dataset:h}=this._cachedMeta,{sharedOptions:c,includeOptions:d}=this._getSharedOptions(e,n),u=a.axis,f=r.axis,{spanGaps:g,segment:p}=this.options,m=N(g)?g:Number.POSITIVE_INFINITY,b=this.chart._animationsDisabled||o||"none"===n,x=e+i,_=t.length;let y=e>0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=b?g:{};if(i<e||i>=x){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),b||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a<e+i;a++){const e=t[a],i=this.resolveDataElementOptions(a,e.active?"active":s),r=n.getPointPositionForValue(a,this.getParsed(a).r),l=o?n.xCenter:r.x,h=o?n.yCenter:r.y,c={x:l,y:h,angle:r.angle,skip:isNaN(l)||isNaN(h),options:i};this.updateElement(e,a,c,s)}}},ScatterController:class extends Ns{static id="scatter";static defaults={datasetElementType:!1,dataElementType:"point",showLine:!1,fill:!1};static overrides={interaction:{mode:"point"},scales:{x:{type:"linear"},y:{type:"linear"}}};getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y);return{label:i[t]||"",value:"("+a+", "+r+")"}}update(t){const e=this._cachedMeta,{data:i=[]}=e,s=this.chart._animationsDisabled;let{start:n,count:o}=pt(e,i,s);if(this._drawStart=n,this._drawCount=o,mt(e)&&(n=0,o=i.length),this.options.showLine){this.datasetElementType||this.addElements();const{dataset:n,_dataset:o}=e;n._chart=this.chart,n._datasetIndex=this.index,n._decimated=!!o._decimated,n.points=i;const a=this.resolveDatasetElementOptions(t);a.segment=this.options.segment,this.updateElement(n,void 0,{animated:!s,options:a},t)}else this.datasetElementType&&(delete e.dataset,this.datasetElementType=!1);this.updateElements(i,n,o,t)}addElements(){const{showLine:t}=this.options;!this.datasetElementType&&t&&(this.datasetElementType=this.chart.registry.getElement("line")),super.addElements()}updateElements(t,e,i,n){const o="reset"===n,{iScale:a,vScale:r,_stacked:l,_dataset:h}=this._cachedMeta,c=this.resolveDataElementOptions(e,n),d=this.getSharedOptions(c),u=this.includeOptions(n,d),f=a.axis,g=r.axis,{spanGaps:p,segment:m}=this.options,b=N(p)?p:Number.POSITIVE_INFINITY,x=this.chart._animationsDisabled||o||"none"===n;let _=e>0&&this.getParsed(e-1);for(let c=e;c<e+i;++c){const e=t[c],i=this.getParsed(c),p=x?e:{},y=s(i[g]),v=p[f]=a.getPixelForValue(i[f],c),M=p[g]=o||y?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,i,l):i[g],c);p.skip=isNaN(v)||isNaN(M)||y,p.stop=c>0&&Math.abs(i[f]-_[f])>b,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,b-m),M=d-x,w=d-_,k=m+x/M,S=b-_/w,P=u+y,D=u+v,O=m+y/P,A=b-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,b+E)}const i=Xn(D,b,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,b+E,A+Math.PI)}const s=(b-v/u+(m+y/u))/2;if(t.arc(a,r,u,b-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),x>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,x,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;e<o;++e)t.stroke();isNaN(r)||(g=a+(r%O||O))}f&&function(t,e,i){const{startAngle:s,pixelMargin:n,x:o,y:a,outerRadius:r,innerRadius:l}=e;let h=n/r;t.beginPath(),t.arc(o,a,r,s-h,i+h),l>n?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=n<a&&o<a||n>r&&o>r;return{count:s,start:l,loop:e.loop,ilen:h<l&&!c?s+h-l:h-l}}function Qn(t,e,i,s){const{points:n,options:o}=e,{count:a,start:r,loop:l,ilen:h}=Jn(n,i,s),c=function(t){return t.stepped?Fe:t.tension||"monotone"===t.cubicInterpolationMode?Ve:Zn}(o);let d,u,f,{move:g=!0,reverse:p}=s||{};for(d=0;d<=h;++d)u=n[(r+(p?h-d:d))%a],u.skip||(g?(t.moveTo(u.x,u.y),g=!1):c(t,f,u,p,o.stepped),f=u);return l&&(u=n[(r+(p?h:0))%a],c(t,f,u,p,o.stepped)),!!l}function to(t,e,i,s){const n=e.points,{count:o,start:a,ilen:r}=Jn(n,i,s),{move:l=!0,reverse:h}=s||{};let c,d,u,f,g,p,m=0,b=0;const x=t=>(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(i<f?f=i:i>g&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l<h;++l){const{start:h,end:c}=o[l],d=n[h],u=n[c];if(d===u){a.push(d);continue}const f=r(d,u,Math.abs((s-d[e])/(u[e]-d[e])),i.stepped);f[e]=t[e],a.push(f)}return 1===a.length?a[0]:a}pathSegment(t,e,i){return eo(this)(t,this,e,i)}path(t,e,i){const s=this.segments,n=eo(this);let o=this._loop;e=e||0,i=i||this.points.length-e;for(const a of s)o&=n(t,this,a,{start:e,end:e+i-1});return!!o}draw(t,e,i,s){const n=this.options||{};(this.points||[]).length&&n.borderWidth&&(t.save(),so(t,this,i,s),t.restore()),this.animated&&(this._pointsUpdated=!1,this._path=void 0)}}function oo(t,e,i,s){const n=t.options,{[i]:o}=t.getProps([i],s);return Math.abs(e-o)<n.radius+n.hitRadius}function ao(t,e){const{x:i,y:s,base:n,width:o,height:a}=t.getProps(["x","y","base","width","height"],e);let r,l,h,c,d;return t.horizontal?(d=a/2,r=Math.min(i,n),l=Math.max(i,n),h=s-d,c=s+d):(d=o/2,r=i-d,l=i+d,h=Math.min(s,n),c=Math.max(s,n)),{left:r,top:h,right:l,bottom:c}}function ro(t,e,i,s){return t?0:J(e,i,s)}function lo(t){const e=ao(t),i=e.right-e.left,s=e.bottom-e.top,n=function(t,e,i){const s=t.options.borderWidth,n=t.borderSkipped,o=Mi(s);return{t:ro(n.top,o.top,0,i),r:ro(n.right,o.right,0,e),b:ro(n.bottom,o.bottom,0,i),l:ro(n.left,o.left,0,e)}}(t,i/2,s/2),a=function(t,e,i){const{enableBorderRadius:s}=t.getProps(["enableBorderRadius"]),n=t.options.borderRadius,a=wi(n),r=Math.min(e,i),l=t.borderSkipped,h=s||o(n);return{topLeft:ro(!h||l.top||l.left,a.topLeft,0,r),topRight:ro(!h||l.top||l.right,a.topRight,0,r),bottomLeft:ro(!h||l.bottom||l.left,a.bottomLeft,0,r),bottomRight:ro(!h||l.bottom||l.right,a.bottomRight,0,r)}}(t,i/2,s/2);return{outer:{x:e.left,y:e.top,w:i,h:s,radius:a},inner:{x:e.left+n.l,y:e.top+n.t,w:i-n.l-n.r,h:s-n.t-n.b,radius:{topLeft:Math.max(0,a.topLeft-Math.max(n.t,n.l)),topRight:Math.max(0,a.topRight-Math.max(n.t,n.r)),bottomLeft:Math.max(0,a.bottomLeft-Math.max(n.b,n.l)),bottomRight:Math.max(0,a.bottomRight-Math.max(n.b,n.r))}}}}function ho(t,e,i,s){const n=null===e,o=null===i,a=t&&!(n&&o)&&ao(t,s);return a&&(n||tt(e,a.left,a.right))&&(o||tt(i,a.top,a.bottom))}function co(t,e){t.rect(e.x,e.y,e.w,e.h)}function uo(t,e,i={}){const s=t.x!==i.x?-e:0,n=t.y!==i.y?-e:0,o=(t.x+t.w!==i.x+i.w?e:0)-s,a=(t.y+t.h!==i.y+i.h?e:0)-n;return{x:t.x+s,y:t.y+n,w:t.w+o,h:t.h+a,radius:t.radius}}var fo=Object.freeze({__proto__:null,ArcElement:class extends Hs{static id="arc";static defaults={borderAlign:"center",borderColor:"#fff",borderDash:[],borderDashOffset:0,borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0};static defaultRoutes={backgroundColor:"backgroundColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a)>=O||Z(n,a,r),g=tt(o,h+u,c+u);return f&&g}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e<o;++e)t.fill();isNaN(r)||(l=a+(r%O||O))}qn(t,e,i,s,l,n),t.fill()}(t,this,r,n,o),Kn(t,this,r,n,o),t.restore()}},BarElement:class extends Hs{static id="bar";static defaults={borderSkipped:"start",borderWidth:0,borderRadius:0,inflateAmount:"auto",pointStyle:void 0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};constructor(t){super(),this.options=void 0,this.horizontal=void 0,this.base=void 0,this.width=void 0,this.height=void 0,this.inflateAmount=void 0,t&&Object.assign(this,t)}draw(t){const{inflateAmount:e,options:{borderColor:i,backgroundColor:s}}=this,{inner:n,outer:o}=lo(this),a=(r=o.radius).topLeft||r.topRight||r.bottomLeft||r.bottomRight?He:co;var r;t.save(),o.w===n.w&&o.h===n.h||(t.beginPath(),a(t,uo(o,e,n)),t.clip(),a(t,uo(n,-e,o)),t.fillStyle=i,t.fill("evenodd")),t.beginPath(),a(t,uo(n,e)),t.fillStyle=s,t.fill(),t.restore()}inRange(t,e,i){return ho(this,t,e,i)}inXRange(t,e){return ho(this,t,null,e)}inYRange(t,e){return ho(this,null,t,e)}getCenterPoint(t){const{x:e,y:i,base:s,horizontal:n}=this.getProps(["x","y","base","horizontal"],t);return{x:n?(e+s)/2:e,y:n?i:(i+s)/2}}getRange(t){return"x"===t?this.width/2:this.height/2}},LineElement:no,PointElement:class extends Hs{static id="point";parsed;skip;stop;static defaults={borderWidth:1,hitRadius:1,hoverBorderWidth:1,hoverRadius:4,pointStyle:"circle",radius:3,rotation:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};constructor(t){super(),this.options=void 0,this.parsed=void 0,this.skip=void 0,this.stop=void 0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.options,{x:n,y:o}=this.getProps(["x","y"],i);return Math.pow(t-n,2)+Math.pow(e-o,2)<Math.pow(s.hitRadius+s.radius,2)}inXRange(t,e){return oo(this,t,"x",e)}inYRange(t,e){return oo(this,t,"y",e)}getCenterPoint(t){const{x:e,y:i}=this.getProps(["x","y"],t);return{x:e,y:i}}size(t){let e=(t=t||this.options||{}).radius||0;e=Math.max(e,e&&t.hoverRadius||0);return 2*(e+(e&&t.borderWidth||0))}draw(t,e){const i=this.options;this.skip||i.radius<.1||!Re(this,e,this.size(i)/2)||(t.strokeStyle=i.borderColor,t.lineWidth=i.borderWidth,t.fillStyle=i.backgroundColor,Le(t,i,this.x,this.y))}getRange(){const t=this.options||{};return t.radius+t.hitRadius}}});function go(t,e,i,s){const n=t.indexOf(e);if(-1===n)return((t,e,i,s)=>("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&t<e.length?e[t]:t}function mo(t,e,{horizontal:i,minRotation:s}){const n=$(s),o=(i?Math.sin(n):Math.cos(n))||.001,a=.75*e*(""+t).length;return Math.min(e/o,a)}class bo extends Js{constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(t,e){return s(t)||("number"==typeof t||t instanceof Number)&&!isFinite(+t)?null:+t}handleTickRangeOptions(){const{beginAtZero:t}=this.options,{minDefined:e,maxDefined:i}=this.getUserBounds();let{min:s,max:n}=this;const o=t=>s=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!s(a),x=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),b&&x&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=b?a:M,w=x?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(b&&(u&&M!==a?(i.push({value:a}),M<a&&D++,V(Math.round((M+D*S)*v)/v,a,mo(a,y,t))&&D++):M<a&&D++);D<k;++D){const t=Math.round((M+D*S)*v)/v;if(x&&t>r)break;i.push({value:t})}return x&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):x&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class xo extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f<i;)s.push({value:f,major:vo(f),significand:u}),u>=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:t<s||t>n?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;u<a;u++){const a=r.setContext(t.getPointLabelContext(u));o[u]=a.padding;const f=t.getPointPosition(u,t.drawingArea+o[u],l),g=Si(a.font),p=(h=t.ctx,c=g,d=n(d=t._pointLabels[u])?d:[d],{w:Oe(h,c.string,d),h:d.length*c.lineHeight});s[u]=p;const m=G(t.getIndexAngle(u)+l),b=Math.round(Y(m));Co(i,e,m,Po(b,f.x,p.w,0,180),Po(b,f.y,p.h,90,270))}var h,c,d;t.setCenterPoint(e.l-i.l,i.r-e.r,e.t-i.t,i.b-e.b),t._pointLabelItems=function(t,e,i){const s=[],n=t._pointLabels.length,o=t.options,{centerPointLabels:a,display:r}=o.pointLabels,l={extra:So(o)/2,additionalAngle:a?C/n:0};let h;for(let o=0;o<n;o++){l.padding=i[o],l.size=e[o];const n=Oo(t,o,l);s.push(n),"auto"===r&&(n.visible=Ao(n,h),n.visible&&(h=n))}return s}(t,s,o)}function Co(t,e,i,s,n){const o=Math.abs(Math.sin(i)),a=Math.abs(Math.cos(i));let r=0,l=0;s.start<e.l?(r=(e.l-s.start)/o,t.l=Math.min(t.l,e.l-r)):s.end>e.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.start<e.t?(l=(e.t-n.start)/a,t.t=Math.min(t.t,e.t-l)):n.end>e.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;o<s;o++)i=t.getPointPosition(o,e),n.lineTo(i.x,i.y)}}class Eo extends bo{static id="radialLinear";static defaults={display:!0,animate:!0,position:"chartArea",angleLines:{display:!0,lineWidth:1,borderDash:[],borderDashOffset:0},grid:{circular:!1},startAngle:0,ticks:{showLabelBackdrop:!0,callback:ae.formatters.numeric},pointLabels:{backdropColor:void 0,backdropPadding:2,display:!0,font:{size:10},callback:t=>t,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t<e.length){const i=e[t];return function(t,e,i){return Ci(t,{label:i,index:e,type:"pointLabel"})}(this.getContext(),t,i)}}getPointPosition(t,e,i=0){const s=this.getIndexAngle(t)-E+i;return{x:Math.cos(s)*e+this.xCenter,y:Math.sin(s)*e+this.yCenter,angle:s}}getPointPositionForValue(t,e){return this.getPointPosition(t,this.getDistanceFromCenterForValue(e))}getBasePosition(t){return this.getPointPositionForValue(t||0,this.getBaseValue())}getPointLabelPosition(t){const{left:e,top:i,right:s,bottom:n}=this._pointLabelItems[t];return{left:e,top:i,right:s,bottom:n}}drawBackground(){const{backgroundColor:t,grid:{circular:e}}=this.options;if(t){const i=this.ctx;i.save(),i.beginPath(),Lo(this,this.getDistanceFromCenterForValue(this._endValue),e,this._pointLabels.length),i.closePath(),i.fillStyle=t,i.fill(),i.restore()}}drawGrid(){const t=this.ctx,e=this.options,{angleLines:i,grid:s,border:n}=e,o=this._pointLabels.length;let a,r,l;if(e.pointLabels.display&&function(t,e){const{ctx:i,options:{pointLabels:s}}=t;for(let n=e-1;n>=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o<n-1;++o){const t=Ro[Io[o]],n=t.steps?t.steps:Number.MAX_SAFE_INTEGER;if(t.common&&Math.ceil((i-e)/(n*t.size))<=s)return Io[o]}return Io[n-1]}function Bo(t,e,i){if(i){if(i.length){const{lo:s,hi:n}=et(i,e);t[i[s]>=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a<o;++a)r=e[a],n[r]=a,s.push({value:r,major:!1});return 0!==o&&i?function(t,e,i,s){const n=t._adapter,o=+n.startOf(e[0].value,s),a=e[e.length-1].value;let r,l;for(r=o;r<=a;r=+n.add(r,1,s))l=i[r],l>=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),x(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e<i;++e)if(Ro[Io[e]].common)return Io[e]}(this._unit):void 0,this.initOffsets(s),t.reverse&&o.reverse(),Wo(this,o,this._majorUnit)}afterAutoSkip(){this.options.offsetAfterAutoskip&&this.initOffsets(this.ticks.map((t=>+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d<i;d=+t.add(d,a,o),u++)Bo(c,d,g);return d!==i&&"ticks"!==s.bounds&&1!==u||Bo(c,d,g),Object.keys(c).sort(zo).map((t=>+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e<i;++e)s=t[e],s.label=this._tickFormatFunction(s.value,e,t)}getDecimalForValue(t){return null===t?NaN:(t-this.min)/(this.max-this.min)}getPixelForValue(t){const e=this._offsets,i=this.getDecimalForValue(t);return this.getPixelForDecimal((e.start+i)*e.factor)}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return this.min+i*(this.max-this.min)}_getLabelSize(t){const e=this.options.ticks,i=this.ctx.measureText(t).width,s=$(this.isHorizontal()?e.maxRotation:e.minRotation),n=Math.cos(s),o=Math.sin(s),a=this._resolveTickFontOptions(0).size;return{w:i*n+a*o,h:i*o+a*n}}_getLabelCapacity(t){const e=this.options.time,i=e.displayFormats,s=i[e.unit]||i.millisecond,n=this._tickFormatFunction(t,0,Wo(this,[t],this._majorUnit),s),o=this._getLabelSize(n),a=Math.floor(this.isHorizontal()?this.width/o.w:this.height/o.h)-1;return a>0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t<e;++t)i=i.concat(s[t].controller.getAllParsedValues(this));return this._cache.data=this.normalize(i)}getLabelTimestamps(){const t=this._cache.labels||[];let e,i;if(t.length)return t;const s=this.getLabels();for(e=0,i=s.length;e<i;++e)t.push(Fo(this,s[e]));return this._cache.labels=this._normalized?t:this.normalize(t)}normalize(t){return lt(t.sort(zo))}}function Ho(t,e,i){let s,n,o,a,r=0,l=t.length-1;i?(e>=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:xo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o<a;++o)l=t[o],l>=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;o<a;++o)h=s[o+1],r=s[o-1],l=s[o],Math.round((h+r)/2)!==l&&n.push({time:l,pos:o/(a-1)});return n}_generate(){const t=this.min,e=this.max;let i=super.getDataTimestamps();return i.includes(t)&&i.length||i.splice(0,0,t),i.includes(e)&&1!==i.length||i.push(e),i.sort(((t,e)=>t-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n;if(!i.forceOverride&&(Ko(s)||(a=n)&&(a.borderColor||a.backgroundColor)||o&&Ko(o)))return;var a;const r=qo(t);s.forEach(r)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;c<o-2;c++){let s,n=0,o=0;const h=Math.floor((c+1)*r)+1+e,m=Math.min(Math.floor((c+2)*r)+1,i)+e,b=m-h;for(s=h;s<m;s++)n+=t[s].x,o+=t[s].y;n/=b,o/=b;const x=Math.floor(c*r)+1+e,_=Math.min(Math.floor((c+1)*r)+1,i)+e,{x:y,y:v}=t[p];for(u=f=-1,s=x;s<_;s++)f=.5*Math.abs((y-n)*(t[s].y-v)-(y-t[s].x)*(o-v)),f>u&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+i-1,_=t[e].x,y=t[x].x-_;for(o=e;o<e+i;++o){a=t[o],r=(a.x-_)/y*n,l=a.y;const e=0|r;if(e===h)l<f?(f=l,c=o):l>g&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&b.push({...t[e],x:p}),s!==u&&s!==i&&b.push({...t[s],x:p})}o>0&&i!==u&&b.push(t[i]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n<i.length;n++){const o=i[n],{first:a,last:r,point:l}=la(o,e,"x");if(!(!l||a&&r))if(a)s.unshift(l);else if(t.push(l),!r)break}t.push(...s)}function la(t,e,i){const s=t.interpolate(e,i);if(!s)return{};const n=s[i],o=t.segments,a=t.points;let r=!1,l=!1;for(let t=0;t<o.length;t++){const e=o[t],s=a[e.start][i],h=a[e.end][i];if(tt(n,s,h)){r=n===s,l=n===h;break}}return{first:r,last:l,point:s}}class ha{constructor(t){this.x=t.x,this.y=t.y,this.radius=t.radius}pathSegment(t,e,i){const{x:s,y:n,radius:o}=this;return e=e||{start:0,end:O},t.arc(s,n,o,e.end,e.start,!0),!i.bounds}interpolate(t){const{x:e,y:i,radius:s}=this,n=t.angle;return{x:e+Math.cos(n)*s,y:i+Math.sin(n)*s,angle:n}}}function ca(t){const{chart:e,fill:i,line:s}=t;if(a(i))return function(t,e){const i=t.getDatasetMeta(e),s=i&&t.isDatasetVisible(e);return s?i.dataset:null}(e,i);if("stack"===i)return function(t){const{scale:e,index:i,line:s}=t,n=[],o=s.segments,a=s.points,r=function(t,e){const i=[],s=t.getMatchingVisibleMetas("line");for(let t=0;t<s.length;t++){const n=s[t];if(n.index===e)break;n.hidden||i.unshift(n.dataset)}return i}(e,i);r.push(sa({x:null,y:e.bottom},s));for(let t=0;t<o.length;t++){const e=o[t];for(let t=e.start;t<=e.end;t++)ra(n,a[t],r)}return new no({points:n,options:{}})}(t);if("shape"===i)return!0;const n=function(t){const e=t.scale||{};if(e.getPointPositionForValue)return function(t){const{scale:e,fill:i}=t,s=e.options,n=e.getLabels().length,a=s.reverse?e.max:e.min,r=function(t,e,i){let s;return s="start"===t?i:"end"===t?e.options.reverse?e.min:e.max:o(t)?t.value:e.getBaseValue(),s}(i,e,a),l=[];if(s.grid.circular){const t=e.getPointPositionForValue(0,a);return new ha({x:t.x,y:t.y,radius:e.getDistanceFromCenterForValue(r)})}for(let t=0;t<n;++t)l.push(e.getPointPositionForValue(t,r));return l}(t);return function(t){const{scale:e={},fill:i}=t,s=function(t,e){let i=null;return"start"===t?i=e.bottom:"end"===t?i=e.top:o(t)?i=e.getPixelForValue(t.value):e.getBasePixel&&(i=e.getBasePixel()),i}(i,e);if(a(s)){const t=e.isHorizontal();return{x:t?s:null,y:t?null:s}}return null}(t)}(t);return n instanceof ha?n:sa(n,s)}function da(t,e,i){const s=ca(e),{line:n,scale:o,axis:a}=e,r=n.options,l=r.fill,h=r.backgroundColor,{above:c=h,below:d=h}=l||{};s&&n.points.length&&(Ie(t,i),function(t,e){const{line:i,target:s,above:n,below:o,area:a,scale:r}=e,l=i._loop?"angle":e.axis;t.save(),"x"===l&&o!==n&&(ua(t,s,a.top),fa(t,{line:i,target:s,color:n,scale:r,property:l}),t.restore(),t.save(),ua(t,s,a.bottom));fa(t,{line:i,target:s,color:o,scale:r,property:l}),t.restore()}(t,{line:n,target:s,above:c,below:d,area:i,scale:o,axis:a}),ze(t))}function ua(t,e,i){const{segments:s,points:n}=e;let o=!0,a=!1;t.beginPath();for(const r of s){const{start:s,end:l}=r,h=n[s],c=n[ea(s,l,n)];o?(t.moveTo(h.x,h.y),o=!1):(t.lineTo(h.x,i),t.lineTo(h.x,h.y)),a=!!e.pathSegment(t,r,{move:a}),a?t.closePath():t.lineTo(c.x,i)}t.lineTo(e.first().x,i),t.closePath(),t.clip()}function fa(t,e){const{line:i,target:s,property:n,color:o,scale:a}=e,r=function(t,e,i){const s=t.segments,n=t.points,o=e.points,a=[];for(const t of s){let{start:s,end:r}=t;r=ea(s,r,n);const l=ta(i,n[s],n[r],t.loop);if(!e.segments){a.push({source:t,target:l,start:n[s],end:n[r]});continue}const h=Ii(e,l);for(const e of h){const s=ta(i,o[e.start],o[e.end],e.loop),r=Ri(t,n,s);for(const t of r)a.push({source:t,target:e,start:{[i]:ia(l,s,"start",Math.max)},end:{[i]:ia(l,s,"end",Math.min)}})}}return a}(i,s,n);for(const{source:e,target:l,start:h,end:c}of r){const{style:{backgroundColor:r=o}={}}=e,d=!0!==s;t.save(),t.fillStyle=r,ga(t,a,d&&ta(n,h,c)),t.beginPath();const u=!!i.pathSegment(t,e);let f;if(d){u?t.closePath():pa(t,s,c,n);const e=!!s.pathSegment(t,l,{move:u,reverse:!0});f=u&&e,f||pa(t,s,h,n)}t.closePath(),t.fill(f?"evenodd":"nonzero"),t.restore()}}function ga(t,e,i){const{top:s,bottom:n}=e.chart.chartArea,{property:o,start:a,end:r}=i||{};"x"===o&&(t.beginPath(),t.rect(a,s,r-a,n-s),t.clip())}function pa(t,e,i,s){const n=e.interpolate(i,s);n&&t.lineTo(n.x,n.y)}var ma={id:"filler",afterDatasetsUpdate(t,e,i){const s=(t.data.datasets||[]).length,n=[];let o,a,r,l;for(a=0;a<s;++a)o=t.getDatasetMeta(a),r=o.dataset,l=null,r&&r.options&&r instanceof no&&(l={visible:t.isDatasetVisible(a),index:a,fill:aa(r,a,s),chart:t,axis:o.controller.options.indexAxis,scale:o.vScale,line:r}),o.$filler=l,n.push(l);for(a=0;a<s;++a)l=n[a],l&&!1!==l.fill&&(l.fill=oa(n,a,i.propagate))},beforeDraw(t,e,i){const s="beforeDraw"===i.drawTime,n=t.getSortedVisibleDatasetMetas(),o=t.chartArea;for(let e=n.length-1;e>=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ba=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class xa extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=ba(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=ba(o,d),b=this.isHorizontal(),x=this._computeTitleHeight();f=b?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+x,line:0}:{x:this.left+c,y:ft(n,this.top+x+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),b?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+x+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,b?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),b)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;i<n.length;++i)if(s=n[i],tt(t,s.left,s.left+s.width)&&tt(e,s.top,s.top+s.height))return this.legendItems[i];return null}handleEvent(t){const e=this.options;if(!function(t,e){if(("mousemove"===t||"mouseout"===t)&&(e.onHover||e.onLeave))return!0;if(e.onClick&&("click"===t||"mouseup"===t))return!0;return!1}(t.type,e))return;const i=this._getLegendItemAt(t.x,t.y);if("mousemove"===t.type||"mouseout"===t.type){const o=this._hoveredItem,a=(n=i,null!==(s=o)&&null!==n&&s.datasetIndex===n.datasetIndex&&s.index===n.index);o&&!a&&d(e.onLeave,[t,o,this],this),this._hoveredItem=i,i&&!a&&d(e.onHover,[t,i,this],this)}else i&&d(e.onClick,[t,i,this],this);var s,n}}function _a(t,e){return e*(t.text?t.text.length:0)}var ya={id:"legend",_element:xa,start(t,e,i){const s=t.legend=new xa({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s)},stop(t){as.removeBox(t,t.legend),delete t.legend},beforeUpdate(t,e,i){const s=t.legend;as.configure(t,s,i),s.options=i},afterUpdate(t){const e=t.legend;e.buildLabels(),e.adjustHitBoxes()},afterEvent(t,e){e.replay||t.legend.handleEvent(e.event)},defaults:{display:!0,position:"top",align:"center",fullSize:!0,reverse:!1,weight:1e3,onClick(t,e,i){const s=e.datasetIndex,n=i.chart;n.isDatasetVisible(s)?(n.hide(s),e.hidden=!0):(n.show(s),e.hidden=!1)},onHover:null,onLeave:null,labels:{color:t=>t.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=0,n=0,o=0;for(e=0,i=t.length;e<i;++e){const i=t[e].element;if(i&&i.hasValue()){const t=i.tooltipPosition();s+=t.x,n+=t.y,++o}}return{x:s/o,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i<s;++i){const s=t[i].element;if(s&&s.hasValue()){const t=q(e,s.getCenterPoint());t<r&&(r=t,n=s)}}if(n){const t=n.tooltipPosition();o=t.x,a=t.y}return{x:o,y:a}}};function Pa(t,e){return e&&(n(e)?Array.prototype.push.apply(t,e):t.push(e)),t}function Da(t){return("string"==typeof t||t instanceof String)&&t.indexOf("\n")>-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return i<s/2?"top":i>t.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex<s)return i[e.dataIndex]}return""},afterTitle:e,beforeBody:e,beforeLabel:e,label(t){if(this&&this.options&&"dataset"===this.options.mode)return t.label+": "+t.formattedValue||t.formattedValue;let e=t.dataset.label||"";e&&(e+=": ");const i=t.formattedValue;return s(i)||(e+=i),e},labelColor(t){const e=t.chart.getDatasetMeta(t.datasetIndex).controller.getStyle(t.dataIndex);return{borderColor:e.borderColor,backgroundColor:e.backgroundColor,borderWidth:e.borderWidth,borderDash:e.borderDash,borderDashOffset:e.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(t){const e=t.chart.getDatasetMeta(t.datasetIndex).controller.getStyle(t.dataIndex);return{pointStyle:e.pointStyle,rotation:e.rotation}},afterLabel:e,afterBody:e,beforeFooter:e,footer:e,afterFooter:e};function Fa(t,e,i,s){const n=t[e].call(i,s);return void 0===n?za[e].call(i,s):n}class Va extends Hs{static positioners=Sa;constructor(t){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=t.chart,this.options=t.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(t){this.options=t,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){const t=this._cachedAnimations;if(t)return t;const e=this.chart,i=this.options.setContext(this.getContext()),s=i.enabled&&e.options.animation&&i.animations,n=new Os(this.chart,s);return s._cacheable&&(this._cachedAnimations=Object.freeze(n)),n}getContext(){return this.$context||(this.$context=(t=this.chart.getContext(),e=this,i=this._tooltipItems,Ci(t,{tooltip:e,tooltipItems:i,type:"tooltip"})));var t,e,i}getTitle(t,e){const{callbacks:i}=e,s=Fa(i,"beforeTitle",this,t),n=Fa(i,"title",this,t),o=Fa(i,"afterTitle",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}getBeforeBody(t,e){return Ra(Fa(e.callbacks,"beforeBody",this,t))}getBody(t,e){const{callbacks:i}=e,s=[];return u(t,(t=>{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;a<r;++a)l.push(Ca(this.chart,e[a]));return t.filter&&(l=l.filter(((e,s,n)=>t.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r<n;++r)e.fillText(s[r],l.x(t.x),t.y+o.lineHeight/2),t.y+=o.lineHeight+a,r+1===n&&(t.y+=i.titleMarginBottom-a)}}_drawColorBox(t,e,i,s,n){const a=this.labelColors[i],r=this.labelPointStyles[i],{boxHeight:l,boxWidth:h}=n,c=Si(n.bodyFont),d=Ea(this,"left",n),u=s.x(d),f=l<c.lineHeight?(c.lineHeight-l)/2:0,g=e.y+f;if(n.usePointStyle){const e={radius:Math.min(h,l)/2,pointStyle:r.pointStyle,rotation:r.rotation,borderWidth:1},i=s.leftForLtr(u,h)+h/2,o=g+l/2;t.strokeStyle=n.multiKeyBackground,t.fillStyle=n.multiKeyBackground,Le(t,e,i,o),t.strokeStyle=a.borderColor,t.fillStyle=a.backgroundColor,Le(t,e,i,o)}else{t.lineWidth=o(a.borderWidth)?Math.max(...Object.values(a.borderWidth)):a.borderWidth||1,t.strokeStyle=a.borderColor,t.setLineDash(a.borderDash||[]),t.lineDashOffset=a.borderDashOffset||0;const e=s.leftForLtr(u,h),i=s.leftForLtr(s.xPlus(u,1),h-2),r=wi(a.borderRadius);Object.values(r).some((t=>0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let b,x,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y<M;++y){for(b=s[y],x=this.labelTextColors[y],e.fillStyle=x,u(b.before,p),_=b.lines,a&&_.length&&(this._drawColorBox(e,t,y,g,i),d=Math.max(c.lineHeight,r)),v=0,w=_.length;v<w;++v)p(_[v]),d=c.lineHeight;u(b.after,p)}f=0,d=c.lineHeight,u(this.afterBody,p),t.y-=n}drawFooter(t,e,i){const s=this.footer,n=s.length;let o,a;if(n){const r=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.footerAlign,i),t.y+=i.footerMarginTop,e.textAlign=r.textAlign(i.footerAlign),e.textBaseline="middle",o=Si(i.footerFont),e.fillStyle=i.footerColor,e.font=o.string,a=0;a<n;++a)e.fillText(s[a],r.x(t.x),t.y+o.lineHeight/2),t.y+=o.lineHeight+i.footerSpacing}}drawBackground(t,e,i,s){const{xAlign:n,yAlign:o}=this,{x:a,y:r}=t,{width:l,height:h}=i,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(s.cornerRadius);e.fillStyle=s.backgroundColor,e.strokeStyle=s.borderColor,e.lineWidth=s.borderWidth,e.beginPath(),e.moveTo(a+c,r),"top"===o&&this.drawCaret(t,e,i,s),e.lineTo(a+l-d,r),e.quadraticCurveTo(a+l,r,a+l,r+d),"center"===o&&"right"===n&&this.drawCaret(t,e,i,s),e.lineTo(a+l,r+h-f),e.quadraticCurveTo(a+l,r+h,a+l-f,r+h),"bottom"===o&&this.drawCaret(t,e,i,s),e.lineTo(a+u,r+h),e.quadraticCurveTo(a,r+h,a,r+h-u),"center"===o&&"left"===n&&this.drawCaret(t,e,i,s),e.lineTo(a,r+c),e.quadraticCurveTo(a,r,a+c,r),e.closePath(),e.fill(),s.borderWidth>0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e;const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=xt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); | |
| 20 | +//# sourceMappingURL=chart.umd.js.map |
M
tools/rustwarden-cli/src/templates.rs
+4
-2
@@ -287,7 +287,8 @@ RATE_LIMIT_LOGIN_PER_MINUTE=5
| 287 | 287 | RATE_LIMIT_REFRESH_PER_MINUTE=10 |
| 288 | 288 | RATE_LIMIT_GENERAL_PER_MINUTE=100 |
| 289 | 289 | |
| 290 | -# --- Cookies --- | |
| 290 | +# --- Cookies & Debug --- | |
| 291 | +APP_DEBUG=true | |
| 291 | 292 | COOKIE_SECURE=false |
| 292 | 293 | COOKIE_SAME_SITE=Lax |
| 293 | 294 |
@@ -365,7 +366,8 @@ RATE_LIMIT_LOGIN_PER_MINUTE=5
| 365 | 366 | RATE_LIMIT_REFRESH_PER_MINUTE=10 |
| 366 | 367 | RATE_LIMIT_GENERAL_PER_MINUTE=100 |
| 367 | 368 | |
| 368 | -# --- Cookies --- | |
| 369 | +# --- Cookies & Debug --- | |
| 370 | +# APP_DEBUG=true # Mettre a true en developpement (desactive les cookies Secure) | |
| 369 | 371 | COOKIE_SECURE=false |
| 370 | 372 | COOKIE_SAME_SITE=Lax |
| 371 | 373 |
GitRust