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(|&timestamp| {
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, '&quot;') + '" 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