Skip to main content

what_core/server/
mod.rs

1//! HTTP Server for What framework
2//!
3//! File-based routing with automatic page rendering and form handling.
4
5use axum::{
6    Router,
7    body::Body,
8    extract::{
9        Form, Multipart, Path, Query, State,
10        ws::{Message, WebSocket, WebSocketUpgrade},
11    },
12    http::{HeaderMap, HeaderValue, Request, StatusCode, header},
13    middleware::Next,
14    response::{Html, IntoResponse, Redirect, Response},
15    routing::{get, post},
16};
17use futures_util::{SinkExt, StreamExt};
18use serde::{Deserialize, Serialize};
19use serde_json::{Value, json};
20use std::collections::{HashMap, HashSet};
21use std::path::PathBuf;
22use std::sync::Arc;
23use std::time::{Duration, Instant};
24use tokio::sync::broadcast;
25use tower::ServiceBuilder;
26use tower_http::services::ServeDir;
27use tower_http::set_header::SetResponseHeaderLayer;
28
29use regex::Regex;
30use std::sync::LazyLock;
31
32use crate::auth::{AuthHandler, UserContext};
33use crate::cache::{CacheKey, CachedValue, WhatCache};
34use crate::components::ComponentRegistry;
35use crate::config::DataSource;
36use crate::database::DatabaseAdapter;
37use crate::parser::{
38    PageDirectives, SessionMutation, WhatConfig, WiredScope, parse_page_directives, parse_what_file,
39};
40use crate::sessions::{self, AtomicMutation, KvSessionStore, SessionBackend, SqliteSessionStore};
41use crate::validation;
42use crate::{Config, Result};
43
44// ---------------------------------------------------------------------------
45// Flash Messages
46// ---------------------------------------------------------------------------
47
48/// Reserved session key for flash data (consumed on next page load)
49const FLASH_SESSION_KEY: &str = "_flash";
50
51/// Flash data stored in session — consumed on read
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53struct FlashData {
54    /// Flash messages: flash.success, flash.error, flash.info
55    #[serde(default)]
56    flash: HashMap<String, String>,
57    /// Validation errors: errors.field_name
58    #[serde(default)]
59    errors: HashMap<String, String>,
60    /// Previous form values: old.field_name
61    #[serde(default)]
62    old: HashMap<String, String>,
63}
64
65/// Store flash data into session
66fn set_flash_data(session: &mut sessions::Session, flash: &FlashData) {
67    if let Ok(json) = serde_json::to_value(flash) {
68        session.data.insert(FLASH_SESSION_KEY.to_string(), json);
69    }
70}
71
72/// Consume flash data from session (read and remove)
73fn consume_flash_data(session: &mut sessions::Session) -> Option<FlashData> {
74    let value = session.data.remove(FLASH_SESSION_KEY)?;
75    serde_json::from_value(value).ok()
76}
77
78/// Inject flash data into the template rendering context
79fn inject_flash_into_context(flash: &FlashData, context: &mut HashMap<String, Value>) {
80    // flash.success, flash.error, flash.info etc.
81    if !flash.flash.is_empty() {
82        let flash_obj: serde_json::Map<String, Value> = flash
83            .flash
84            .iter()
85            .map(|(k, v)| (k.clone(), json!(v)))
86            .collect();
87        context.insert("flash".to_string(), Value::Object(flash_obj));
88    }
89
90    // errors.field_name
91    if !flash.errors.is_empty() {
92        let errors_obj: serde_json::Map<String, Value> = flash
93            .errors
94            .iter()
95            .map(|(k, v)| (k.clone(), json!(v)))
96            .collect();
97        context.insert("errors".to_string(), Value::Object(errors_obj));
98        context.insert("has_errors".to_string(), json!(true));
99    } else {
100        context.insert("has_errors".to_string(), json!(false));
101    }
102
103    // old.field_name
104    if !flash.old.is_empty() {
105        let old_obj: serde_json::Map<String, Value> = flash
106            .old
107            .iter()
108            .map(|(k, v)| (k.clone(), json!(v)))
109            .collect();
110        context.insert("old".to_string(), Value::Object(old_obj));
111    }
112}
113
114mod actions;
115mod engine;
116
117pub use actions::ActionHandler;
118pub use engine::RenderEngine;
119
120// ---------------------------------------------------------------------------
121// Content Root Abstraction
122// ---------------------------------------------------------------------------
123
124/// Determine the content directory name for a project root.
125///
126/// Prefers `site/` if it exists, falls back to `pages/` for backward compatibility.
127/// If neither exists, returns `"site"` (the new default for new projects).
128///
129/// Emits a one-time deprecation warning via `tracing::warn!` when `pages/` is used.
130pub fn content_dir_name(root: &std::path::Path) -> &'static str {
131    use std::sync::Once;
132    static DEPRECATION_WARNED: Once = Once::new();
133    static BOTH_WARNED: Once = Once::new();
134
135    let has_site = root.join("site").is_dir();
136    let has_pages = root.join("pages").is_dir();
137
138    match (has_site, has_pages) {
139        (true, true) => {
140            BOTH_WARNED.call_once(|| {
141                tracing::warn!(
142                    "Both site/ and pages/ directories found. Using site/. \
143                     Remove pages/ to silence this warning."
144                );
145            });
146            "site"
147        }
148        (true, false) => "site",
149        (false, true) => {
150            DEPRECATION_WARNED.call_once(|| {
151                tracing::warn!("pages/ is deprecated — rename to site/: mv pages site");
152            });
153            "pages"
154        }
155        (false, false) => "site",
156    }
157}
158
159/// Resolve the full content directory path for a project root.
160///
161/// Returns `root.join(content_dir_name(root))`.
162pub fn content_dir(root: &std::path::Path) -> PathBuf {
163    root.join(content_dir_name(root))
164}
165
166/// Live reload message type
167#[derive(Clone, Debug)]
168pub enum LiveReloadMessage {
169    /// File changed, trigger reload
170    Reload,
171    /// Cache cleared
172    CacheCleared,
173}
174
175/// A wired message with its scope for filtering
176#[derive(Clone, Debug)]
177pub struct WiredMessage {
178    pub json: String,
179    pub scope: WiredScope,
180}
181
182/// Application state shared across handlers
183#[derive(Clone)]
184pub struct AppState {
185    /// Configuration
186    pub config: Arc<Config>,
187    /// Data store (SQLite, D1, or Supabase — configured via [database] in what.toml)
188    pub store: DatabaseAdapter,
189    /// Cache
190    pub cache: WhatCache,
191    /// Component registry
192    pub components: Arc<ComponentRegistry>,
193    /// Render engine
194    pub engine: Arc<RenderEngine>,
195    /// Project root directory
196    pub root: PathBuf,
197    /// Resolved content directory path (root + "site" or "pages")
198    pub content_dir: PathBuf,
199    /// Session store (pluggable: SQLite or Cloudflare KV)
200    pub sessions: Option<SessionBackend>,
201    /// Authentication handler
202    pub auth: AuthHandler,
203    /// Development mode flag
204    pub dev_mode: bool,
205    /// Framework stylesheet mode ([server] css in what.toml), validated at startup
206    pub css_mode: CssMode,
207    /// Live reload broadcast sender (only active in dev mode)
208    pub live_reload_tx: Option<broadcast::Sender<LiveReloadMessage>>,
209    /// Wired state broadcast sender (real-time push with scope filtering)
210    pub wired_tx: broadcast::Sender<WiredMessage>,
211    /// Scope registry for wired variables (rebuilt on application.what reload)
212    pub wired_scopes: Arc<tokio::sync::RwLock<HashMap<String, WiredScope>>>,
213    /// Scope registry for application variables — write-gates `app.*` mutations
214    /// declared with `[role]` brackets (rebuilt on application.what reload)
215    pub app_scopes: Arc<tokio::sync::RwLock<HashMap<String, WiredScope>>>,
216    /// Compiled collection authorization policies (from `[collections.*]`)
217    pub policies: Arc<crate::policy::PolicyRegistry>,
218    /// Last refresh timestamps for configured data sources
219    pub data_source_loaded: Arc<tokio::sync::RwLock<HashMap<String, Instant>>>,
220    /// Rate limiters for login, upload, and action endpoints
221    pub rate_limiters: Option<RateLimiters>,
222    /// Log level string for debug meta injection (dev mode only)
223    pub log_level: String,
224    /// Background job queue for async operations (session cleanup, email, etc.)
225    pub jobs: crate::jobs::JobQueue,
226    /// Shared HTTP client with configurable timeout for external fetch requests
227    pub http_client: reqwest::Client,
228    /// Upload storage backend (local filesystem or Cloudflare R2)
229    pub upload_backend: Option<crate::uploads::UploadBackend>,
230    /// Named datasources — multiple backends accessible via `dsn:name` in fetch directives
231    pub datasources: HashMap<String, crate::datasource::Datasource>,
232    /// Number of currently connected wired WebSocket clients
233    pub wired_client_count: Arc<std::sync::atomic::AtomicUsize>,
234    /// Registry of form action URLs that require validation (populated at render time).
235    /// When a form with `w-validate` is rendered, its action is recorded here.
236    /// On submission, if the action is registered but `w-rules` is missing, the request is rejected.
237    pub validated_actions: Arc<std::sync::RwLock<HashSet<String>>>,
238}
239
240/// Per-IP rate limiters using moka caches with TTL-based sliding windows.
241/// Each cache maps IP address string to request count.
242#[derive(Clone)]
243pub struct RateLimiters {
244    /// Login endpoint: /w-auth/login
245    pub login: moka::future::Cache<String, u32>,
246    pub login_max: u32,
247    /// Upload endpoint: /w-upload/*
248    pub upload: moka::future::Cache<String, u32>,
249    pub upload_max: u32,
250    /// Action endpoint: /w-action/*
251    pub action: moka::future::Cache<String, u32>,
252    pub action_max: u32,
253}
254
255impl AppState {
256    pub fn new(config: Config, root: PathBuf) -> Result<Self> {
257        Self::with_dev_mode(config, root, false)
258    }
259
260    /// Create AppState with development mode enabled (includes live reload)
261    pub fn with_dev_mode(mut config: Config, root: PathBuf, dev_mode: bool) -> Result<Self> {
262        // Validate [server] css before anything else so typos fail loud at startup
263        let css_mode = CssMode::from_config(&config.server.css)?;
264
265        // Compile collection authorization policies — fail loud on reserved-word
266        // misuse or invalid combinations so config errors surface at startup.
267        let policies = Arc::new(crate::policy::PolicyRegistry::from_config(&config.collections)?);
268        // Warn if identity-based rules exist but no identity source does.
269        if !config.session.enabled && !config.auth.enabled {
270            for (name, policy) in policies.configured() {
271                if policy.is_read_scoped() || policy.owner_mode == crate::policy::OwnerMode::Auto {
272                    tracing::warn!(
273                        target: "what::policy",
274                        "collection '{}' has an identity-based policy but both sessions and auth are disabled — ownership/read scoping will deny everything",
275                        name
276                    );
277                    break;
278                }
279            }
280        }
281
282        // In dev mode, auto-disable Secure flag on cookies (localhost has no TLS)
283        if dev_mode && config.session.secure {
284            config.session.secure = false;
285            tracing::info!("Dev mode: disabled Secure flag on cookies (no TLS on localhost)");
286        }
287
288        // Load .env file if present (for #env.VAR# in fetch directives)
289        let env_path = root.join(".env");
290        if env_path.exists() {
291            let _ = dotenvy::from_path(&env_path);
292            tracing::info!("Loaded .env from {}", env_path.display());
293        }
294
295        let mut components = ComponentRegistry::new();
296        components.register_builtins();
297
298        // Load components from project (prefixed with what-)
299        let components_dir = root.join("components");
300        if components_dir.exists() {
301            components.load_from_directory(&components_dir)?;
302        }
303
304        // Initialize data store based on [database.type] config
305        let store = if let Some(ref db_config) = config.database {
306            match db_config.r#type.as_str() {
307                "d1" => {
308                    // Cloudflare D1: requires [cloudflare] config
309                    let cf = config.cloudflare.as_ref()
310                        .ok_or_else(|| crate::Error::Config("[database] type = \"d1\" requires [cloudflare] section with account_id and api_token".to_string()))?;
311                    let db_id = cf.d1_database_id.as_ref().ok_or_else(|| {
312                        crate::Error::Config(
313                            "[cloudflare] d1_database_id is required for type = \"d1\"".to_string(),
314                        )
315                    })?;
316                    let account_id = resolve_env_value(&cf.account_id)?;
317                    let api_token = resolve_env_value(&cf.api_token)?;
318                    let database_id = resolve_env_value(db_id)?;
319                    let db =
320                        crate::database::D1Database::new(&account_id, &database_id, &api_token);
321                    tracing::info!("Database: Cloudflare D1 ({})", database_id);
322                    DatabaseAdapter::D1(db)
323                }
324                "supabase" => {
325                    // Supabase: requires [supabase] config
326                    let sb = config.supabase.as_ref()
327                        .ok_or_else(|| crate::Error::Config("[database] type = \"supabase\" requires [supabase] section with project_url and api_key".to_string()))?;
328                    let project_url = resolve_env_value(&sb.project_url)?;
329                    let api_key = resolve_env_value(&sb.api_key)?;
330                    let db = crate::database::SupabaseDatabase::new(&project_url, &api_key);
331                    tracing::info!("Database: Supabase ({})", project_url);
332                    DatabaseAdapter::Supabase(db)
333                }
334                "sqlite" | _ => {
335                    let db_path = root.join(&db_config.path);
336                    if let Some(parent) = db_path.parent() {
337                        std::fs::create_dir_all(parent).ok();
338                    }
339                    let db = crate::database::SqliteDatabase::open(&db_path)?;
340                    if db_config.r#type != "sqlite"
341                        && db_config.r#type != "d1"
342                        && db_config.r#type != "supabase"
343                    {
344                        tracing::warn!(
345                            "Unknown database type '{}', falling back to sqlite",
346                            db_config.r#type
347                        );
348                    }
349                    tracing::info!("Database: SQLite ({})", db_path.display());
350                    DatabaseAdapter::Sqlite(db)
351                }
352            }
353        } else {
354            // No [database] config — auto-create SQLite at data/app.db
355            let db_path = root.join("data").join("app.db");
356            if let Some(parent) = db_path.parent() {
357                std::fs::create_dir_all(parent).ok();
358            }
359            let db = crate::database::SqliteDatabase::open(&db_path)?;
360
361            // Auto-import legacy data/store.json if it exists (one-time migration)
362            let store_json_path = root.join("data").join("store.json");
363            if store_json_path.exists() {
364                if let Ok(content) = std::fs::read_to_string(&store_json_path) {
365                    if let Ok(store_data) = serde_json::from_str::<serde_json::Value>(&content) {
366                        if let Some(collections) =
367                            store_data.get("collections").and_then(|c| c.as_object())
368                        {
369                            for (name, items) in collections {
370                                if let Some(items_arr) = items.as_array() {
371                                    db.import_json_collection(name, items_arr);
372                                }
373                            }
374                        }
375                    }
376                }
377            }
378
379            tracing::info!("Database: SQLite auto-default ({})", db_path.display());
380            DatabaseAdapter::Sqlite(db)
381        };
382
383        // Initialize session store if enabled
384        let sessions: Option<SessionBackend> = if config.session.enabled {
385            match config.session.store.as_str() {
386                "cloudflare-kv" => {
387                    if let Some(ref cf_config) = config.session.cloudflare {
388                        // Resolve env vars in cloudflare config
389                        let resolved = crate::config::CloudflareKvConfig {
390                            account_id: resolve_env_value(&cf_config.account_id)?,
391                            namespace_id: resolve_env_value(&cf_config.namespace_id)?,
392                            api_token: resolve_env_value(&cf_config.api_token)?,
393                        };
394                        let store = KvSessionStore::new(&resolved, config.session.max_age);
395                        tracing::info!(
396                            "Session store initialized: Cloudflare KV (namespace: {})",
397                            resolved.namespace_id
398                        );
399                        Some(SessionBackend::CloudflareKv(store))
400                    } else {
401                        tracing::warn!(
402                            "Session store set to cloudflare-kv but [session.cloudflare] config is missing"
403                        );
404                        None
405                    }
406                }
407                _ => {
408                    // Default: SQLite
409                    let db_path = root.join(&config.session.database);
410                    match SqliteSessionStore::new(&db_path, config.session.max_age) {
411                        Ok(store) => {
412                            tracing::info!(
413                                "Session store initialized: SQLite ({})",
414                                db_path.display()
415                            );
416                            Some(SessionBackend::Sqlite(store))
417                        }
418                        Err(e) => {
419                            tracing::warn!("Failed to initialize session store: {}", e);
420                            None
421                        }
422                    }
423                }
424            }
425        } else {
426            None
427        };
428
429        // Initialize upload backend
430        let upload_backend = if config.uploads.enabled {
431            match config.uploads.provider.as_str() {
432                "r2" => {
433                    let cf = config.cloudflare.as_ref().ok_or_else(|| {
434                        crate::Error::Config(
435                            "[uploads] provider = \"r2\" requires [cloudflare] section".to_string(),
436                        )
437                    })?;
438                    let bucket = cf.r2_bucket.as_ref().ok_or_else(|| {
439                        crate::Error::Config(
440                            "[cloudflare] r2_bucket is required for provider = \"r2\"".to_string(),
441                        )
442                    })?;
443                    let public_url = cf.r2_public_url.as_ref().ok_or_else(|| {
444                        crate::Error::Config(
445                            "[cloudflare] r2_public_url is required for provider = \"r2\""
446                                .to_string(),
447                        )
448                    })?;
449                    tracing::info!("Uploads: Cloudflare R2 (bucket: {})", bucket);
450                    Some(crate::uploads::UploadBackend::R2 {
451                        client: crate::http_client::build_http_client(None).map_err(|e| {
452                            crate::Error::Upload(format!("Failed to build R2 HTTP client: {}", e))
453                        })?,
454                        account_id: resolve_env_value(&cf.account_id)?,
455                        bucket: resolve_env_value(bucket)?,
456                        api_token: resolve_env_value(&cf.api_token)?,
457                        public_url: resolve_env_value(public_url)?,
458                    })
459                }
460                _ => {
461                    let uploads_dir = root.join(&config.uploads.directory);
462                    if !uploads_dir.exists() {
463                        std::fs::create_dir_all(&uploads_dir).map_err(|e| {
464                            crate::Error::Upload(format!(
465                                "Failed to create uploads directory: {}",
466                                e
467                            ))
468                        })?;
469                        tracing::info!("Created uploads directory: {}", uploads_dir.display());
470                    }
471                    Some(crate::uploads::UploadBackend::Local {
472                        directory: uploads_dir,
473                    })
474                }
475            }
476        } else {
477            None
478        };
479
480        let engine = RenderEngine::new(components.clone());
481
482        // Initialize auth handler with env var overrides
483        let auth = AuthHandler::from_config_with_env(config.auth.clone());
484        if auth.is_enabled() {
485            tracing::info!("Authentication enabled");
486            if let Some(endpoint) = auth.login_endpoint() {
487                tracing::info!("Login endpoint: {}", endpoint);
488            }
489        }
490
491        // Create live reload channel if in dev mode
492        let live_reload_tx = if dev_mode {
493            let (tx, _) = broadcast::channel(16);
494            Some(tx)
495        } else {
496            None
497        };
498
499        // Create wired state broadcast channel (always active, with scope filtering)
500        let (wired_tx, _) = broadcast::channel::<WiredMessage>(256);
501
502        // Initialize rate limiters if enabled
503        let rate_limiters = if config.rate_limit.enabled {
504            use crate::config::RateLimitConfig;
505            let (login_max, login_window) = RateLimitConfig::parse_limit(&config.rate_limit.login);
506            let (upload_max, upload_window) =
507                RateLimitConfig::parse_limit(&config.rate_limit.upload);
508            let (action_max, action_window) =
509                RateLimitConfig::parse_limit(&config.rate_limit.action);
510            Some(RateLimiters {
511                login: moka::future::Cache::builder()
512                    .time_to_live(Duration::from_secs(login_window))
513                    .build(),
514                login_max,
515                upload: moka::future::Cache::builder()
516                    .time_to_live(Duration::from_secs(upload_window))
517                    .build(),
518                upload_max,
519                action: moka::future::Cache::builder()
520                    .time_to_live(Duration::from_secs(action_window))
521                    .build(),
522                action_max,
523            })
524        } else {
525            None
526        };
527
528        // Start background job queue (session cleanup runs every hour)
529        let jobs = crate::jobs::start(sessions.clone(), config.email.clone());
530
531        // Build HTTP client with configurable fetch timeout
532        let http_client = crate::http_client::build_http_client(Some(Duration::from_secs(
533            config.server.fetch_timeout,
534        )))
535        .map_err(|e| crate::Error::Server(format!("Failed to build HTTP client: {}", e)))?;
536
537        let resolved_content_dir = content_dir(&root);
538
539        // Build named datasources from [datasources.*] config
540        let mut datasources = HashMap::new();
541        for (name, ds_config) in &config.datasources {
542            let datasource = match ds_config.r#type {
543                crate::config::DatasourceType::D1 => {
544                    let account_id = ds_config.account_id.as_deref().ok_or_else(|| {
545                        crate::Error::Config(format!(
546                            "[datasources.{}] type = \"d1\" requires account_id",
547                            name
548                        ))
549                    })?;
550                    let api_token = ds_config.api_token.as_deref().ok_or_else(|| {
551                        crate::Error::Config(format!(
552                            "[datasources.{}] type = \"d1\" requires api_token",
553                            name
554                        ))
555                    })?;
556                    let db_id = ds_config.d1_database_id.as_deref().ok_or_else(|| {
557                        crate::Error::Config(format!(
558                            "[datasources.{}] type = \"d1\" requires d1_database_id",
559                            name
560                        ))
561                    })?;
562                    let db = crate::database::D1Database::new(
563                        &resolve_env_value(account_id)?,
564                        &resolve_env_value(db_id)?,
565                        &resolve_env_value(api_token)?,
566                    );
567                    tracing::info!("Datasource '{}': Cloudflare D1", name);
568                    crate::datasource::Datasource::Database(DatabaseAdapter::D1(db))
569                }
570                crate::config::DatasourceType::Supabase => {
571                    let project_url = ds_config.project_url.as_deref().ok_or_else(|| {
572                        crate::Error::Config(format!(
573                            "[datasources.{}] type = \"supabase\" requires project_url",
574                            name
575                        ))
576                    })?;
577                    let api_key = ds_config.api_key.as_deref().ok_or_else(|| {
578                        crate::Error::Config(format!(
579                            "[datasources.{}] type = \"supabase\" requires api_key",
580                            name
581                        ))
582                    })?;
583                    let db = crate::database::SupabaseDatabase::new(
584                        &resolve_env_value(project_url)?,
585                        &resolve_env_value(api_key)?,
586                    );
587                    tracing::info!("Datasource '{}': Supabase", name);
588                    crate::datasource::Datasource::Database(DatabaseAdapter::Supabase(db))
589                }
590                crate::config::DatasourceType::Sqlite => {
591                    let path = ds_config.path.as_deref().unwrap_or("data/app.db");
592                    let db_path = root.join(path);
593                    if let Some(parent) = db_path.parent() {
594                        std::fs::create_dir_all(parent).ok();
595                    }
596                    let db = crate::database::SqliteDatabase::open(&db_path)?;
597                    tracing::info!("Datasource '{}': SQLite ({})", name, db_path.display());
598                    crate::datasource::Datasource::Database(DatabaseAdapter::Sqlite(db))
599                }
600                crate::config::DatasourceType::Api => {
601                    let url = ds_config.url.as_deref().ok_or_else(|| {
602                        crate::Error::Config(format!(
603                            "[datasources.{}] type = \"api\" requires url",
604                            name
605                        ))
606                    })?;
607                    let base_url = resolve_env_value(url)?;
608                    if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
609                        return Err(crate::Error::Config(format!(
610                            "[datasources.{}] url must start with http:// or https://, got: {}",
611                            name, base_url
612                        )));
613                    }
614                    let mut headers: Vec<(String, String)> = Vec::new();
615                    if let Some(ref h) = ds_config.headers {
616                        for (k, v) in h {
617                            headers.push((k.clone(), resolve_env_value(v)?));
618                        }
619                    }
620                    tracing::info!("Datasource '{}': API ({})", name, base_url);
621                    crate::datasource::Datasource::Api { base_url, headers }
622                }
623            };
624            datasources.insert(name.clone(), datasource);
625        }
626
627        Ok(Self {
628            config: Arc::new(config),
629            store,
630            cache: WhatCache::new(),
631            components: Arc::new(components),
632            engine: Arc::new(engine),
633            content_dir: resolved_content_dir,
634            root,
635            sessions,
636            auth,
637            dev_mode,
638            css_mode,
639            live_reload_tx,
640            wired_tx,
641            wired_scopes: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
642            app_scopes: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
643            policies,
644            data_source_loaded: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
645            rate_limiters,
646            log_level: "info".to_string(),
647            jobs,
648            http_client,
649            upload_backend,
650            datasources,
651            wired_client_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
652            validated_actions: Arc::new(std::sync::RwLock::new(HashSet::new())),
653        })
654    }
655
656    /// Run async initialization for datasources that need it (e.g., D1 system tables).
657    /// Call this after construction from an async context. Fails if any D1 init fails.
658    pub async fn init_datasources(&self) -> crate::Result<()> {
659        // Init primary store if D1
660        if let DatabaseAdapter::D1(ref db) = self.store {
661            db.init().await.map_err(|e| {
662                crate::Error::Config(format!("D1 primary database init failed: {}", e))
663            })?;
664        }
665        // Init each D1 datasource
666        for (name, ds) in &self.datasources {
667            if let crate::datasource::Datasource::Database(DatabaseAdapter::D1(db)) = ds {
668                db.init().await.map_err(|e| {
669                    crate::Error::Config(format!("D1 datasource '{}' init failed: {}", name, e))
670                })?;
671            }
672        }
673
674        // Build initial wired scope registry from application.what files
675        self.rebuild_wired_scopes().await;
676
677        Ok(())
678    }
679
680    /// Trigger a live reload notification (clears cache and notifies clients)
681    pub fn trigger_reload(&self) {
682        // Clear the cache
683        // Note: WhatCache uses async methods, but we can spawn a task
684        let cache = self.cache.clone();
685        tokio::spawn(async move {
686            cache.clear_all().await;
687        });
688
689        // Notify all connected WebSocket clients
690        if let Some(ref tx) = self.live_reload_tx {
691            let _ = tx.send(LiveReloadMessage::Reload);
692            tracing::debug!("Live reload triggered");
693        }
694    }
695
696    /// Trigger live reload — clears cache first, then notifies clients
697    /// Use this from async contexts to guarantee cache is cleared before reload
698    pub async fn trigger_reload_async(&self) {
699        // Clear cache BEFORE broadcasting so the browser fetches fresh content
700        self.cache.clear_all().await;
701
702        // Rebuild wired scope registry (application.what may have changed)
703        self.rebuild_wired_scopes().await;
704
705        // Now notify all WebSocket clients
706        if let Some(ref tx) = self.live_reload_tx {
707            let _ = tx.send(LiveReloadMessage::Reload);
708            tracing::debug!("Live reload triggered");
709        }
710    }
711
712    /// Get a receiver for live reload messages
713    pub fn live_reload_receiver(&self) -> Option<broadcast::Receiver<LiveReloadMessage>> {
714        self.live_reload_tx.as_ref().map(|tx| tx.subscribe())
715    }
716
717    /// Rebuild the wired + app scope registries by scanning all
718    /// application.what files. Wired scopes filter WebSocket delivery; app
719    /// scopes gate who may write `app.*` keys via `w-set`.
720    pub async fn rebuild_wired_scopes(&self) {
721        let mut wired = HashMap::new();
722        let mut app = HashMap::new();
723        self.scan_wired_scopes_dir(&self.content_dir, &mut wired, &mut app);
724        let count = wired.len();
725        let app_count = app.len();
726        *self.wired_scopes.write().await = wired;
727        *self.app_scopes.write().await = app;
728        if count > 0 || app_count > 0 {
729            tracing::info!(
730                "Scopes rebuilt: {} wired, {} application variable(s)",
731                count,
732                app_count
733            );
734        }
735    }
736
737    /// Recursively scan a directory for application.what files and extract
738    /// wired (delivery) and application (write-gate) scopes.
739    fn scan_wired_scopes_dir(
740        &self,
741        dir: &std::path::Path,
742        wired: &mut HashMap<String, WiredScope>,
743        app: &mut HashMap<String, WiredScope>,
744    ) {
745        let config_path = dir.join("application.what");
746        if config_path.exists() {
747            if let Ok(content) = std::fs::read_to_string(&config_path) {
748                let config = parse_what_file(&content);
749                for decl in &config.data_wired {
750                    wired.insert(decl.name.clone(), decl.scope.clone());
751                }
752                for decl in &config.data_application {
753                    // Only scoped app vars need a write gate.
754                    if !matches!(decl.scope, WiredScope::Public) {
755                        app.insert(decl.name.clone(), decl.scope.clone());
756                    }
757                }
758            }
759        }
760        // Recurse into subdirectories
761        if let Ok(entries) = std::fs::read_dir(dir) {
762            for entry in entries.flatten() {
763                if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
764                    self.scan_wired_scopes_dir(&entry.path(), wired, app);
765                }
766            }
767        }
768    }
769
770    /// Look up the scope for a wired key from the registry (defaults to Public)
771    pub async fn get_wired_scope(&self, key: &str) -> WiredScope {
772        self.wired_scopes
773            .read()
774            .await
775            .get(key)
776            .cloned()
777            .unwrap_or_default()
778    }
779
780    /// Look up the write-gate scope for an `app.*` key (defaults to Public).
781    pub async fn get_app_scope(&self, key: &str) -> WiredScope {
782        self.app_scopes
783            .read()
784            .await
785            .get(key)
786            .cloned()
787            .unwrap_or_default()
788    }
789}
790
791// ---------------------------------------------------------------------------
792// Security Headers Middleware
793// ---------------------------------------------------------------------------
794
795/// Middleware that checks global redirect rules from [redirects] in what.toml.
796/// Supports exact matches and wildcard prefix patterns ("/old/*" → "/new").
797async fn redirect_middleware(
798    State(state): State<AppState>,
799    request: Request<Body>,
800    next: Next,
801) -> Response {
802    if !state.config.redirects.is_empty() {
803        let path = request.uri().path();
804
805        // Check exact match first
806        if let Some(target) = state.config.redirects.get(path) {
807            tracing::debug!("Redirect: {} -> {}", path, target);
808            return Redirect::permanent(target).into_response();
809        }
810
811        // Check wildcard patterns: "/old/*" matches "/old/anything"
812        for (pattern, target) in &state.config.redirects {
813            if let Some(prefix) = pattern.strip_suffix("/*") {
814                if path.starts_with(prefix)
815                    && (path.len() == prefix.len()
816                        || path.as_bytes().get(prefix.len()) == Some(&b'/'))
817                {
818                    tracing::debug!(
819                        "Redirect (wildcard): {} -> {} (pattern: {})",
820                        path,
821                        target,
822                        pattern
823                    );
824                    return Redirect::permanent(target).into_response();
825                }
826            }
827        }
828    }
829
830    next.run(request).await
831}
832
833async fn security_headers_middleware(request: Request<Body>, next: Next) -> Response {
834    let mut response = next.run(request).await;
835    let headers = response.headers_mut();
836    headers.insert(
837        header::HeaderName::from_static("x-content-type-options"),
838        HeaderValue::from_static("nosniff"),
839    );
840    headers.insert(
841        header::HeaderName::from_static("x-frame-options"),
842        HeaderValue::from_static("SAMEORIGIN"),
843    );
844    headers.insert(
845        header::HeaderName::from_static("referrer-policy"),
846        HeaderValue::from_static("strict-origin-when-cross-origin"),
847    );
848    headers.insert(
849        header::HeaderName::from_static("x-xss-protection"),
850        HeaderValue::from_static("1; mode=block"),
851    );
852    headers.insert(
853        header::HeaderName::from_static("content-security-policy"),
854        HeaderValue::from_static("default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' wss: ws:"),
855    );
856    response
857}
858
859// ---------------------------------------------------------------------------
860// Rate Limiting Middleware
861// ---------------------------------------------------------------------------
862
863/// Middleware that enforces per-IP rate limits on login, upload, and action endpoints.
864/// Uses moka caches with TTL-based sliding windows.
865async fn rate_limit_middleware(
866    State(state): State<AppState>,
867    request: Request<Body>,
868    next: Next,
869) -> Response {
870    if let Some(ref limiters) = state.rate_limiters {
871        let path = request.uri().path().to_string();
872
873        // Determine which limiter applies
874        let (cache, max) = if path == "/w-auth/login" {
875            Some((&limiters.login, limiters.login_max))
876        } else if path.starts_with("/w-upload/") {
877            Some((&limiters.upload, limiters.upload_max))
878        } else if path.starts_with("/w-action/") {
879            Some((&limiters.action, limiters.action_max))
880        } else {
881            None
882        }
883        .unzip();
884
885        if let (Some(cache), Some(max)) = (cache, max) {
886            // Extract client IP from X-Forwarded-For or peer address
887            let ip = request
888                .headers()
889                .get("x-forwarded-for")
890                .and_then(|v| v.to_str().ok())
891                .and_then(|s| s.split(',').next())
892                .map(|s| s.trim().to_string())
893                .unwrap_or_else(|| "unknown".to_string());
894
895            let key = format!(
896                "{}:{}",
897                ip,
898                path.split('/').take(3).collect::<Vec<_>>().join("/")
899            );
900            let count = cache.get(&key).await.unwrap_or(0) + 1;
901            cache.insert(key.clone(), count).await;
902
903            if count > max {
904                return (
905                    StatusCode::TOO_MANY_REQUESTS,
906                    "Rate limit exceeded. Try again later.",
907                )
908                    .into_response();
909            }
910        }
911    }
912
913    next.run(request).await
914}
915
916// ---------------------------------------------------------------------------
917// Request Logging Middleware
918// ---------------------------------------------------------------------------
919
920/// Middleware that logs every HTTP request with method, path, status, and duration.
921/// Log level varies by status code: INFO for 2xx/3xx, WARN for 4xx, ERROR for 5xx.
922async fn request_logging_middleware(request: Request<Body>, next: Next) -> Response {
923    let method = request.method().clone();
924    let path = request.uri().path().to_string();
925    let start = Instant::now();
926
927    let response = next.run(request).await;
928
929    let status = response.status().as_u16();
930    let duration = start.elapsed();
931    let duration_ms = duration.as_millis();
932
933    // Skip logging for internal framework routes and WebSocket upgrades
934    if !path.starts_with("/w-livereload") && !path.starts_with("/w-wire") {
935        if status >= 500 {
936            tracing::error!("{method}  {path} → {status} ({duration_ms}ms)");
937        } else if status >= 400 {
938            tracing::warn!("{method}  {path} → {status} ({duration_ms}ms)");
939        } else {
940            tracing::info!("{method}  {path} → {status} ({duration_ms}ms)");
941        }
942    }
943
944    response
945}
946
947// ---------------------------------------------------------------------------
948// CSRF Validation Middleware
949// ---------------------------------------------------------------------------
950
951/// Middleware that validates CSRF tokens on all POST requests.
952/// Checks for the token in the `_csrf` form field or `X-CSRF-Token` header.
953/// Exempt paths (WebSocket endpoints) are skipped.
954async fn csrf_middleware(
955    State(state): State<AppState>,
956    request: Request<Body>,
957    next: Next,
958) -> Response {
959    // Only validate POST requests
960    if request.method() != axum::http::Method::POST {
961        return next.run(request).await;
962    }
963
964    let path = request.uri().path().to_string();
965
966    // Skip exempt paths
967    if is_csrf_exempt(&path) {
968        return next.run(request).await;
969    }
970
971    // Skip CSRF validation if sessions are disabled
972    if state.sessions.is_none() {
973        return next.run(request).await;
974    }
975
976    // Extract CSRF token from header (for AJAX requests)
977    let header_token = request
978        .headers()
979        .get("X-CSRF-Token")
980        .and_then(|v| v.to_str().ok())
981        .map(|s| s.to_string());
982
983    // Get session from cookie
984    let cookie_header = request
985        .headers()
986        .get(header::COOKIE)
987        .and_then(|v| v.to_str().ok())
988        .map(|s| s.to_string());
989
990    let session_id =
991        sessions::parse_session_cookie(cookie_header.as_deref(), &state.config.session.cookie_name);
992
993    let session = if let (Some(sessions), Some(id)) = (&state.sessions, &session_id) {
994        sessions.get(id).await.ok().flatten()
995    } else {
996        None
997    };
998
999    // If no session exists, or the session doesn't have a CSRF token,
1000    // skip validation. This handles first-time visitors, pre-existing sessions
1001    // created before CSRF was enabled, and test scenarios.
1002    let session_has_csrf = session
1003        .as_ref()
1004        .and_then(|s| s.data.get(CSRF_SESSION_KEY))
1005        .and_then(|v| v.as_str())
1006        .is_some();
1007
1008    if !session_has_csrf {
1009        return next.run(request).await;
1010    }
1011
1012    // For AJAX requests with X-CSRF-Token header, validate immediately
1013    if let Some(ref token) = header_token {
1014        if validate_csrf_token(session.as_ref(), None, Some(token)) {
1015            return next.run(request).await;
1016        }
1017        return (StatusCode::FORBIDDEN, "CSRF token mismatch").into_response();
1018    }
1019
1020    // For form submissions, we need to read the body to get _csrf field.
1021    // Instead of consuming the body here, we'll pass the session info
1022    // through request extensions and let handlers validate.
1023    // However, for simplicity and robustness, let's peek at the content type.
1024    let content_type = request
1025        .headers()
1026        .get(header::CONTENT_TYPE)
1027        .and_then(|v| v.to_str().ok())
1028        .unwrap_or("")
1029        .to_string();
1030
1031    if content_type.starts_with("application/x-www-form-urlencoded")
1032        || content_type.starts_with("multipart/form-data")
1033    {
1034        // For form submissions without an X-CSRF-Token header,
1035        // we need to extract _csrf from the form body.
1036        // Since axum middleware can't easily peek at form bodies without consuming them,
1037        // we'll use a different strategy: extract the body, parse it, validate, then reconstruct.
1038        let (parts, body) = request.into_parts();
1039        let max_body = crate::config::parse_size_string(&state.config.server.max_body_size);
1040        let bytes = match axum::body::to_bytes(body, max_body).await {
1041            Ok(b) => b,
1042            Err(_) => {
1043                return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response();
1044            }
1045        };
1046
1047        // Parse form data to get _csrf field
1048        let body_str = String::from_utf8_lossy(&bytes);
1049        let form_csrf = serde_urlencoded::from_str::<Vec<(String, String)>>(&body_str)
1050            .ok()
1051            .and_then(|pairs| {
1052                pairs
1053                    .into_iter()
1054                    .find(|(k, _)| k == "_csrf")
1055                    .map(|(_, v)| v)
1056            });
1057
1058        if validate_csrf_token(session.as_ref(), form_csrf.as_deref(), None) {
1059            // Reconstruct the request with the original body
1060            let request = Request::from_parts(parts, Body::from(bytes));
1061            return next.run(request).await;
1062        }
1063
1064        return (StatusCode::FORBIDDEN, "CSRF token mismatch").into_response();
1065    }
1066
1067    // For other content types (JSON, etc.), require X-CSRF-Token header
1068    // which was already checked above. If we reach here, there's no token.
1069    (StatusCode::FORBIDDEN, "CSRF token mismatch").into_response()
1070}
1071
1072/// Create the application router
1073pub fn create_router(state: AppState) -> Router {
1074    let static_dir = state.root.join("static");
1075    let dev_mode = state.dev_mode;
1076
1077    // Choose cache control header based on mode
1078    let cache_control = if dev_mode {
1079        HeaderValue::from_static("no-cache, no-store, must-revalidate")
1080    } else {
1081        HeaderValue::from_static("public, max-age=3600")
1082    };
1083
1084    // Create static file service with cache headers
1085    let static_service = ServiceBuilder::new()
1086        .layer(SetResponseHeaderLayer::overriding(
1087            header::CACHE_CONTROL,
1088            cache_control,
1089        ))
1090        .service(ServeDir::new(static_dir));
1091
1092    // Health check route — defined outside the main router so it bypasses all middleware
1093    let health_router = Router::new().route("/health", get(handle_health));
1094
1095    let mut router = Router::new()
1096        // Framework assets served from embedded bytes (take priority over static/)
1097        .route("/static/what.css", get(handle_embedded_css))
1098        .route("/static/what.js", get(handle_embedded_js))
1099        // User static files (with appropriate cache headers)
1100        .nest_service("/static", static_service);
1101
1102    // Serve uploaded files if uploads are enabled
1103    if state.config.uploads.enabled {
1104        let uploads_dir = state.root.join(&state.config.uploads.directory);
1105        let uploads_service = ServeDir::new(uploads_dir);
1106        router = router.nest_service("/uploads", uploads_service);
1107    }
1108
1109    router = router
1110        // Session management
1111        .route("/w-session/reset", post(handle_session_reset))
1112        // Authentication endpoints
1113        .route("/w-auth/login", post(handle_login))
1114        .route("/w-auth/logout", post(handle_logout))
1115        // Form actions (POST/PUT/DELETE)
1116        .route("/w-action/:collection", post(handle_action))
1117        .route("/w-action/:collection/:id", post(handle_action_with_id))
1118        // File upload actions (multipart/form-data)
1119        .route("/w-upload/:collection", post(handle_upload))
1120        // Declarative state mutations (w-set attribute)
1121        .route("/w-set", post(handle_w_set))
1122        // Wired state WebSocket (real-time push to all connected clients)
1123        .route("/w-wire", get(handle_wire_ws))
1124        .route("/w-session/clear-data", post(handle_session_clear_data))
1125        // Partial rendering (explicit route for AJAX fragments)
1126        .route("/w-partial/*path", get(handle_partial));
1127
1128    // Add dev-only endpoints (source viewer, live reload, debug tools, demo endpoints)
1129    if dev_mode {
1130        router = router
1131            .route("/w-source/*path", get(handle_page_source))
1132            .route("/w-inject/notification", get(handle_inject_notification))
1133            .route("/w-livereload", get(handle_livereload_ws))
1134            .route("/w-cache/clear-all", post(handle_cache_clear_all))
1135            .route("/w-sessions/list", get(handle_sessions_list))
1136            .route("/w-data/info", get(handle_data_info))
1137            .route("/w-inspector", get(handle_inspector));
1138    } else if state.config.server.source_viewer {
1139        // Source viewer enabled in production via config
1140        router = router.route("/w-source/*path", get(handle_page_source));
1141    }
1142
1143    let app = router
1144        // Dynamic page routing
1145        .fallback(handle_page)
1146        .with_state(state.clone())
1147        // Request body size limit — applied globally
1148        .layer(axum::extract::DefaultBodyLimit::max(
1149            crate::config::parse_size_string(&state.config.server.max_body_size),
1150        ))
1151        // Rate limiting — per-IP limits on login, upload, action endpoints
1152        .layer(axum::middleware::from_fn_with_state(
1153            state.clone(),
1154            rate_limit_middleware,
1155        ))
1156        // CSRF validation — runs before route handlers
1157        .layer(axum::middleware::from_fn_with_state(
1158            state.clone(),
1159            csrf_middleware,
1160        ))
1161        // Global redirects — check [redirects] table from what.toml
1162        .layer(axum::middleware::from_fn_with_state(
1163            state.clone(),
1164            redirect_middleware,
1165        ))
1166        // Security headers — applied to every response
1167        .layer(axum::middleware::from_fn(security_headers_middleware))
1168        // Request logging — outermost layer so it captures all requests
1169        .layer(axum::middleware::from_fn(request_logging_middleware));
1170
1171    // Merge health check AFTER middleware layers — /health bypasses all middleware
1172    app.merge(health_router)
1173}
1174
1175/// Health check endpoint — returns 200 OK with status and version.
1176/// Bypasses all middleware (CSRF, rate limiting, auth, etc.)
1177async fn handle_health() -> impl IntoResponse {
1178    axum::Json(json!({
1179        "status": "ok",
1180        "version": env!("CARGO_PKG_VERSION")
1181    }))
1182}
1183
1184/// Handle page requests with file-based routing
1185async fn handle_page(State(state): State<AppState>, request: Request<Body>) -> impl IntoResponse {
1186    let path = request.uri().path().to_string();
1187
1188    // Extract query parameters
1189    let query_params = decode_query_params(request.uri().query());
1190
1191    // Check if this is a partial request (AJAX from What.js)
1192    let is_partial_request = request
1193        .headers()
1194        .get("X-Requested-With")
1195        .and_then(|v| v.to_str().ok())
1196        .map(|v| v == "What")
1197        .unwrap_or(false);
1198
1199    // Extract session from cookie
1200    let cookie_header = request
1201        .headers()
1202        .get(header::COOKIE)
1203        .and_then(|v| v.to_str().ok());
1204
1205    let (mut session, is_new_session) = if let Some(ref sessions) = state.sessions {
1206        let session_id =
1207            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
1208
1209        match sessions.get_or_create(session_id.as_deref()).await {
1210            Ok(session) => {
1211                let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
1212                (Some(session), is_new)
1213            }
1214            Err(e) => {
1215                tracing::warn!("Session error: {}", e);
1216                (None, false)
1217            }
1218        }
1219    } else {
1220        (None, false)
1221    };
1222
1223    // Extract user context from JWT
1224    let user_context = if state.auth.is_enabled() {
1225        if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
1226            match state.auth.decode_jwt(&token) {
1227                Ok(claims) => {
1228                    if claims.is_expired() {
1229                        tracing::debug!("JWT token expired");
1230                        UserContext::unauthenticated()
1231                    } else {
1232                        let user_claims = claims.to_context(state.auth.jwt_claims());
1233                        UserContext::from_claims(user_claims)
1234                    }
1235                }
1236                Err(e) => {
1237                    tracing::debug!("Failed to decode JWT: {}", e);
1238                    UserContext::unauthenticated()
1239                }
1240            }
1241        } else {
1242            UserContext::unauthenticated()
1243        }
1244    } else {
1245        UserContext::unauthenticated()
1246    };
1247
1248    // Try to find matching page file
1249    let resolved = resolve_page_path(&state.root, &path);
1250    let route_params = resolved
1251        .as_ref()
1252        .map(|r| r.params.clone())
1253        .unwrap_or_default();
1254    let page_path = resolved.map(|r| r.path);
1255
1256    // Load the application.what config chain, read the page, and parse
1257    // directives — off the async worker: this is the per-request filesystem
1258    // walk + read, and blocking a tokio worker here stalls unrelated requests.
1259    let (app_config, page_content, mut directives) = {
1260        let root = state.root.clone();
1261        let url_path = path.clone();
1262        let file_path = page_path.clone();
1263        let dev_mode = state.dev_mode;
1264        let load = tokio::task::spawn_blocking(move || {
1265            let app_config = load_application_config(&root, &url_path);
1266            let (content, dirs) = match file_path {
1267                Some(file_path) => match std::fs::read_to_string(&file_path) {
1268                    Ok(content) => {
1269                        if dev_mode {
1270                            engine::warn_template_lints_once(&file_path, &content);
1271                        }
1272                        let (dirs, cleaned) = parse_page_directives(&content);
1273                        (Some(cleaned), dirs)
1274                    }
1275                    Err(_) => (None, PageDirectives::default()),
1276                },
1277                None => (None, PageDirectives::default()),
1278            };
1279            (app_config, content, dirs)
1280        })
1281        .await;
1282        match load {
1283            Ok(loaded) => loaded,
1284            Err(e) => {
1285                tracing::error!("Page load task failed: {}", e);
1286                (WhatConfig::default(), None, PageDirectives::default())
1287            }
1288        }
1289    };
1290
1291    // Merge app config directives into page directives (app config as base, page overrides)
1292    // Only apply app config auth if page doesn't specify its own
1293    if !directives.requires_auth() && app_config.directives.requires_auth() {
1294        directives.auth = app_config.directives.auth.clone();
1295        directives.protected = app_config.directives.protected;
1296        directives.roles = app_config.directives.roles.clone();
1297    }
1298    // Apply other directives from app config if not set on page
1299    if directives.title.is_none() {
1300        directives.title = app_config.directives.title.clone();
1301    }
1302    if directives.redirect.is_none() {
1303        directives.redirect = app_config.directives.redirect.clone();
1304    }
1305    if directives.cache_ttl.is_none() {
1306        directives.cache_ttl = app_config.directives.cache_ttl;
1307    }
1308
1309    // Handle page-level redirect directive
1310    if let Some(ref redirect_to) = directives.redirect {
1311        return Redirect::to(redirect_to).into_response();
1312    }
1313
1314    // Handle excluded pages (404)
1315    if directives.exclude {
1316        let html = render_custom_error_page(
1317            &state,
1318            StatusCode::NOT_FOUND,
1319            &path,
1320            "Page not found",
1321            session.as_ref(),
1322            &user_context,
1323        )
1324        .await;
1325        return build_response(
1326            StatusCode::NOT_FOUND,
1327            vec![(header::CONTENT_TYPE, "text/html".to_string())],
1328            html,
1329        );
1330    }
1331
1332    // Extract user roles from JWT claims
1333    let user_roles: Vec<String> = user_context.roles();
1334
1335    // Check if page requires authentication (new auth directive or legacy protected)
1336    let requires_auth = directives.requires_auth() || state.auth.is_protected(&path);
1337
1338    if requires_auth && !user_context.authenticated {
1339        // Redirect to login page with return URL
1340        let login_path = state.auth.login_path();
1341        let redirect_url = format!("{}?redirect={}", login_path, urlencoding::encode(&path));
1342        return Redirect::to(&redirect_url).into_response();
1343    }
1344
1345    // Check access (handles auth: all | user | roles and legacy roles)
1346    if !directives.check_access(user_context.authenticated, &user_roles) {
1347        let html = render_custom_error_page(
1348            &state,
1349            StatusCode::FORBIDDEN,
1350            &path,
1351            "You don't have permission to access this page.",
1352            session.as_ref(),
1353            &user_context,
1354        )
1355        .await;
1356        return build_response(
1357            StatusCode::FORBIDDEN,
1358            vec![(header::CONTENT_TYPE, "text/html".to_string())],
1359            html,
1360        );
1361    }
1362
1363    // Build response headers
1364    let mut headers = vec![(header::CONTENT_TYPE, "text/html".to_string())];
1365
1366    // Add Set-Cookie header for new sessions
1367    if is_new_session {
1368        if let Some(ref s) = session {
1369            let cookie = sessions::build_session_cookie(
1370                &s.id,
1371                &state.config.session.cookie_name,
1372                state.config.session.max_age,
1373                state.config.session.secure,
1374            );
1375            headers.push((header::SET_COOKIE, cookie));
1376        }
1377    }
1378
1379    // Apply custom headers from application.what (header.Name = "value")
1380    for (name, value) in &app_config.directives.headers {
1381        match header::HeaderName::from_bytes(name.as_bytes()) {
1382            Ok(header_name) => match HeaderValue::from_str(value) {
1383                Ok(header_value) => headers.push((
1384                    header_name,
1385                    header_value.to_str().unwrap_or(value).to_string(),
1386                )),
1387                Err(_) => tracing::warn!(
1388                    "Invalid custom header value for '{}': could not parse value",
1389                    name
1390                ),
1391            },
1392            Err(_) => tracing::warn!(
1393                "Invalid custom header name '{}': could not parse as HTTP header",
1394                name
1395            ),
1396        }
1397    }
1398    // Apply custom headers from page-level directives (overrides app-level)
1399    for (name, value) in &directives.headers {
1400        match header::HeaderName::from_bytes(name.as_bytes()) {
1401            Ok(header_name) => match HeaderValue::from_str(value) {
1402                Ok(header_value) => headers.push((
1403                    header_name,
1404                    header_value.to_str().unwrap_or(value).to_string(),
1405                )),
1406                Err(_) => tracing::warn!(
1407                    "Invalid custom header value for '{}': could not parse value",
1408                    name
1409                ),
1410            },
1411            Err(_) => tracing::warn!(
1412                "Invalid custom header name '{}': could not parse as HTTP header",
1413                name
1414            ),
1415        }
1416    }
1417
1418    // Consume flash messages from session (before rendering)
1419    let flash_data = if let Some(ref mut sess) = session {
1420        let flash = consume_flash_data(sess);
1421        // Persist the removal
1422        if flash.is_some() {
1423            if let Some(ref sessions) = state.sessions {
1424                let _ = sessions.update(&sess.id, sess.data.clone()).await;
1425            }
1426        }
1427        flash
1428    } else {
1429        None
1430    };
1431
1432    match (page_path, page_content) {
1433        (Some(_file_path), Some(content)) => {
1434            // Apply session mutations atomically (SQL-level for SQLite)
1435            if !directives.session_mutations.is_empty() {
1436                if let (Some(sess), Some(sessions)) = (&mut session, &state.sessions) {
1437                    for mutation in &directives.session_mutations {
1438                        let atomic = to_atomic_mutation(mutation, &sess.data);
1439                        match sessions.apply_mutation(&sess.id, &atomic).await {
1440                            Ok(updated_data) => sess.data = updated_data,
1441                            Err(e) => tracing::error!("Failed to apply session mutation: {}", e),
1442                        }
1443                    }
1444                }
1445            }
1446
1447            // Skip cache if we have a session or user (session data is per-user)
1448            let use_cache = session.is_none() && !user_context.authenticated;
1449
1450            if use_cache {
1451                let cache_key = CacheKey::page(&path);
1452                if let Some(cached) = state.cache.get(&cache_key).await {
1453                    return build_response(StatusCode::OK, headers, cached.content);
1454                }
1455            }
1456
1457            // Render the page (content already has <what> directives removed)
1458            // For partial requests, use reactive rendering and append OOB updates
1459            match render_content_internal(
1460                &state,
1461                &content,
1462                session.as_ref(),
1463                &user_context,
1464                Some(&app_config),
1465                Some(&directives),
1466                Some(&query_params),
1467                &route_params,
1468                is_partial_request,
1469                flash_data.as_ref(),
1470            )
1471            .await
1472            {
1473                Ok(render_result) => {
1474                    let mut html = render_result.html;
1475
1476                    // For partial requests, append OOB template with session values
1477                    if is_partial_request && !render_result.session_keys.is_empty() {
1478                        if let Some(ref s) = session {
1479                            // Build JSON object with current session values for used keys
1480                            let mut updates = serde_json::Map::new();
1481                            for key in &render_result.session_keys {
1482                                if let Some(value) = s.data.get(key) {
1483                                    updates.insert(format!("session.{}", key), value.clone());
1484                                }
1485                            }
1486                            if !updates.is_empty() {
1487                                let json_str = serde_json::to_string(&updates).unwrap_or_default();
1488                                html.push_str(&format!(
1489                                    r#"<template data-what-updates>{}</template>"#,
1490                                    json_str
1491                                ));
1492                            }
1493                        }
1494                    }
1495
1496                    // Add X-What-Debug header for partial requests in dev mode
1497                    if state.dev_mode && is_partial_request && !render_result.fetch_debug.is_empty()
1498                    {
1499                        if let Ok(debug_json) = serde_json::to_string(&render_result.fetch_debug) {
1500                            headers.push((
1501                                header::HeaderName::from_static("x-what-debug"),
1502                                debug_json,
1503                            ));
1504                        }
1505                    }
1506
1507                    // Inject CSRF tokens into forms and <head>
1508                    if let Some(ref s) = session {
1509                        if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
1510                            html = inject_csrf_tokens(&html, csrf);
1511                        }
1512                    }
1513
1514                    // Inject SEO meta tags from page directives
1515                    if !is_partial_request {
1516                        html = inject_seo_meta(&html, &directives.custom, &directives.vars);
1517                    }
1518
1519                    // Inject debug meta tag in dev mode
1520                    if state.dev_mode && !is_partial_request {
1521                        html = inject_debug_meta(&html, &state.log_level);
1522                    }
1523
1524                    // Register validated form actions for server-side enforcement
1525                    register_validated_actions(&html, &state);
1526
1527                    // Always inject framework CSS (needed for SPA stylesheet sync)
1528                    html = inject_what_css(&html, state.css_mode);
1529                    // Only inject framework JS on full-page loads (already running for SPA)
1530                    if !is_partial_request {
1531                        html = inject_what_js(&html);
1532                        html = inject_theme_restore(&html);
1533                    }
1534
1535                    // Cache the rendered page only if no session/user (non-partial only)
1536                    if use_cache && !is_partial_request {
1537                        let cache_key = CacheKey::page(&path);
1538                        state
1539                            .cache
1540                            .set(&cache_key, CachedValue::html(html.clone()))
1541                            .await;
1542                    }
1543                    build_response(StatusCode::OK, headers, html)
1544                }
1545                Err(e) => {
1546                    tracing::error!("Render error: {}", e);
1547                    let html = render_custom_error_page(
1548                        &state,
1549                        StatusCode::INTERNAL_SERVER_ERROR,
1550                        &path,
1551                        &e.to_string(),
1552                        session.as_ref(),
1553                        &user_context,
1554                    )
1555                    .await;
1556                    build_response(StatusCode::INTERNAL_SERVER_ERROR, headers, html)
1557                }
1558            }
1559        }
1560        _ => {
1561            // 404 - try custom error page
1562            let html = render_custom_error_page(
1563                &state,
1564                StatusCode::NOT_FOUND,
1565                &path,
1566                "Page not found",
1567                session.as_ref(),
1568                &user_context,
1569            )
1570            .await;
1571            build_response(StatusCode::NOT_FOUND, headers, html)
1572        }
1573    }
1574}
1575
1576/// Convert a parser SessionMutation to an AtomicMutation, resolving session variable references.
1577fn to_atomic_mutation(
1578    mutation: &SessionMutation,
1579    session_data: &HashMap<String, Value>,
1580) -> AtomicMutation {
1581    match mutation {
1582        SessionMutation::Increment { key, value } => AtomicMutation::Increment {
1583            key: key.clone(),
1584            value: *value,
1585        },
1586        SessionMutation::Set { key, value } => {
1587            let resolved = resolve_session_value(value, session_data);
1588            AtomicMutation::Set {
1589                key: key.clone(),
1590                value: resolved,
1591            }
1592        }
1593        SessionMutation::Push { key, value } => {
1594            let resolved = resolve_session_value(value, session_data);
1595            AtomicMutation::Push {
1596                key: key.clone(),
1597                value: resolved,
1598            }
1599        }
1600        SessionMutation::PushMax { key, max, value } => {
1601            let resolved = resolve_session_value(value, session_data);
1602            AtomicMutation::PushMax {
1603                key: key.clone(),
1604                max: *max,
1605                value: resolved,
1606            }
1607        }
1608        SessionMutation::Unshift { key, value } => {
1609            let resolved = resolve_session_value(value, session_data);
1610            AtomicMutation::Unshift {
1611                key: key.clone(),
1612                value: resolved,
1613            }
1614        }
1615        SessionMutation::Clear { key } => AtomicMutation::Clear { key: key.clone() },
1616    }
1617}
1618
1619/// Resolve variable references in a session mutation value.
1620/// Supports full interpolation: `"My friend #session.age# is here"` resolves
1621/// `#session.*#` references from current session data.
1622fn resolve_session_value(value: &Value, session_data: &HashMap<String, Value>) -> Value {
1623    if let Some(s) = value.as_str() {
1624        if s.contains('#') {
1625            // Build a context with session data so #session.key# resolves
1626            let mut context = HashMap::new();
1627            let session_obj: serde_json::Map<String, Value> = session_data
1628                .iter()
1629                .map(|(k, v)| (k.clone(), v.clone()))
1630                .collect();
1631            context.insert("session".to_string(), Value::Object(session_obj));
1632            let resolved = crate::parser::replace_variables(s, &context);
1633            // Try arithmetic evaluation on the resolved string
1634            if let Some(n) = crate::parser::evaluate_arithmetic(&resolved) {
1635                return if n == n.trunc() && n.abs() < i64::MAX as f64 {
1636                    json!(n as i64)
1637                } else {
1638                    json!(n)
1639                };
1640            }
1641            // If result looks like a number, preserve the type
1642            if let Ok(n) = resolved.parse::<i64>() {
1643                return json!(n);
1644            }
1645            if let Ok(n) = resolved.parse::<f64>() {
1646                return json!(n);
1647            }
1648            return json!(resolved);
1649        }
1650        // Try arithmetic on non-interpolated strings (e.g., "10 + 1")
1651        if let Some(n) = crate::parser::evaluate_arithmetic(s) {
1652            return if n == n.trunc() && n.abs() < i64::MAX as f64 {
1653                json!(n as i64)
1654            } else {
1655                json!(n)
1656            };
1657        }
1658    }
1659    value.clone()
1660}
1661
1662/// Build HTTP response with custom headers
1663fn build_response(
1664    status: StatusCode,
1665    headers: Vec<(header::HeaderName, String)>,
1666    body: String,
1667) -> axum::response::Response {
1668    let mut response = axum::response::Response::builder().status(status);
1669
1670    for (name, value) in headers {
1671        response = response.header(name, value);
1672    }
1673
1674    response.body(Body::from(body)).unwrap_or_else(|_| {
1675        axum::response::Response::builder()
1676            .status(StatusCode::INTERNAL_SERVER_ERROR)
1677            .body(Body::from("Internal Server Error"))
1678            .expect("fallback response must build")
1679    })
1680}
1681
1682/// Build an error page that hides details in production mode.
1683/// In dev mode, shows the full error for debugging.
1684/// In production, returns a generic message.
1685/// Render a custom error page (site/404.html, site/500.html, site/403.html)
1686/// with error context variables, falling back to a generic error page.
1687async fn render_custom_error_page(
1688    state: &AppState,
1689    status: StatusCode,
1690    request_path: &str,
1691    detail: &str,
1692    session: Option<&sessions::Session>,
1693    user: &UserContext,
1694) -> String {
1695    let error_file = state.content_dir.join(format!("{}.html", status.as_u16()));
1696    if error_file.exists() {
1697        if let Ok(raw_content) = tokio::fs::read_to_string(&error_file).await {
1698            if state.dev_mode {
1699                engine::warn_template_lints_once(&error_file, &raw_content);
1700            }
1701            let (_directives, content) = parse_page_directives(&raw_content);
1702            // Inject error context variables before rendering
1703            let mut content_with_error = content.clone();
1704            content_with_error =
1705                content_with_error.replace("#error.status#", &status.as_u16().to_string());
1706            content_with_error = content_with_error.replace("#error.path#", request_path);
1707            // Only expose error message in dev mode
1708            if state.dev_mode {
1709                content_with_error = content_with_error.replace("#error.message#", detail);
1710            } else {
1711                content_with_error = content_with_error.replace("#error.message#", "");
1712            }
1713
1714            match render_content(
1715                state,
1716                &content_with_error,
1717                session,
1718                user,
1719                None,
1720                Some(&_directives),
1721                None,
1722            )
1723            .await
1724            {
1725                Ok(html) => return html,
1726                Err(e) => tracing::warn!("Failed to render custom {} page: {}", status.as_u16(), e),
1727            }
1728        }
1729    }
1730
1731    // Fallback to generic error page
1732    error_page_fallback(state.dev_mode, status, detail)
1733}
1734
1735fn error_page_fallback(dev_mode: bool, status: StatusCode, detail: &str) -> String {
1736    if dev_mode {
1737        format!("<h1>Error {}</h1><pre>{}</pre>", status.as_u16(), detail)
1738    } else {
1739        match status {
1740            StatusCode::NOT_FOUND => "<h1>404 - Page Not Found</h1>".to_string(),
1741            StatusCode::FORBIDDEN => "<h1>403 - Forbidden</h1>".to_string(),
1742            _ => "<h1>Something went wrong</h1><p>An internal error occurred.</p>".to_string(),
1743        }
1744    }
1745}
1746
1747// ---------------------------------------------------------------------------
1748// CSRF Protection
1749// ---------------------------------------------------------------------------
1750
1751/// Reserved session key for the CSRF token
1752const CSRF_SESSION_KEY: &str = "_csrf_token";
1753
1754/// Inject CSRF protection into rendered HTML:
1755/// 1. Adds `<input type="hidden" name="_csrf" value="TOKEN">` to all `<form method="post">` tags
1756/// 2. Adds `<meta name="csrf-token" content="TOKEN">` to the `<head>` section
1757fn inject_csrf_tokens(html: &str, csrf_token: &str) -> String {
1758    static FORM_POST_RE: LazyLock<Regex> = LazyLock::new(|| {
1759        Regex::new(r#"(?i)(<form\b[^>]*\bmethod\s*=\s*["']?post["']?[^>]*>)"#).unwrap()
1760    });
1761
1762    let hidden_input = format!(
1763        r#"<input type="hidden" name="_csrf" value="{}">"#,
1764        csrf_token
1765    );
1766
1767    // Inject hidden input after each POST form opening tag
1768    let output = FORM_POST_RE.replace_all(html, |caps: &regex::Captures| {
1769        format!("{}{}", &caps[0], hidden_input)
1770    });
1771
1772    // Inject meta tag in <head>
1773    let meta_tag = format!(r#"<meta name="csrf-token" content="{}">"#, csrf_token);
1774
1775    let output = if let Some(pos) = output.find("</head>") {
1776        format!("{}{}\n{}", &output[..pos], meta_tag, &output[pos..])
1777    } else if let Some(pos) = output.find("</HEAD>") {
1778        format!("{}{}\n{}", &output[..pos], meta_tag, &output[pos..])
1779    } else {
1780        // No <head> section — prepend the meta tag so JS can still find it
1781        format!("{}\n{}", meta_tag, output)
1782    };
1783
1784    output
1785}
1786
1787/// Inject `<script src="/static/what.js"></script>` before `</body>` on full-page responses.
1788/// Skips injection if the script tag already exists in the HTML.
1789fn inject_what_js(html: &str) -> String {
1790    let script_tag = format!(r#"<script src="{}"></script>"#, WHAT_JS_ASSET_PATH.as_str());
1791
1792    // Skip if any what.js tag is already present, versioned or not.
1793    if html.contains(r#"/static/what.js"#) {
1794        return html.to_string();
1795    }
1796
1797    if let Some(pos) = html.find("</body>") {
1798        format!("{}{}\n{}", &html[..pos], script_tag, &html[pos..])
1799    } else if let Some(pos) = html.find("</BODY>") {
1800        format!("{}{}\n{}", &html[..pos], script_tag, &html[pos..])
1801    } else {
1802        html.to_string()
1803    }
1804}
1805
1806/// Inject a tiny inline script right after `<head>` that re-applies the saved
1807/// w-theme localStorage class to `<html>` before first paint (no FOUC). Runs on
1808/// full-page responses only — the head persists across SPA navigations.
1809fn inject_theme_restore(html: &str) -> String {
1810    const THEME_SCRIPT: &str = r#"<script data-w-theme>(function(){try{var t=localStorage.getItem('w-theme');if(t==='dark'||t==='light'){var c=document.documentElement.classList;c.remove('dark','light');c.add(t);}}catch(e){}})()</script>"#;
1811
1812    if html.contains("data-w-theme") {
1813        return html.to_string();
1814    }
1815
1816    // Insert right after the opening <head ...> tag so it runs before any
1817    // stylesheet is applied
1818    for open in ["<head>", "<HEAD>"] {
1819        if let Some(pos) = html.find(open) {
1820            let insert_at = pos + open.len();
1821            return format!("{}{}{}", &html[..insert_at], THEME_SCRIPT, &html[insert_at..]);
1822        }
1823    }
1824    if let Some(pos) = html.find("<head ") {
1825        if let Some(end) = html[pos..].find('>') {
1826            let insert_at = pos + end + 1;
1827            return format!("{}{}{}", &html[..insert_at], THEME_SCRIPT, &html[insert_at..]);
1828        }
1829    }
1830    html.to_string()
1831}
1832
1833/// Scan rendered HTML for `<form w-validate ... action="...">` tags and register
1834/// their action URLs so the server can enforce validation even if w-rules is stripped.
1835fn register_validated_actions(html: &str, state: &AppState) {
1836    static FORM_RE: LazyLock<Regex> = LazyLock::new(|| {
1837        Regex::new(r#"(?si)<form\b[^>]*\bw-validate\b[^>]*>"#).unwrap()
1838    });
1839    static ACTION_RE: LazyLock<Regex> = LazyLock::new(|| {
1840        Regex::new(r#"(?i)action="([^"]+)""#).unwrap()
1841    });
1842
1843    let mut found = Vec::new();
1844    for mat in FORM_RE.find_iter(html) {
1845        if let Some(cap) = ACTION_RE.captures(mat.as_str()) {
1846            if let Some(action) = cap.get(1) {
1847                found.push(action.as_str().to_string());
1848            }
1849        }
1850    }
1851    if !found.is_empty() {
1852        if let Ok(mut registry) = state.validated_actions.write() {
1853            for url in found {
1854                registry.insert(url);
1855            }
1856        }
1857    }
1858}
1859
1860/// Inject `<link rel="stylesheet" href="/static/what.css">` into `<head>` on full-page responses.
1861/// Skips injection if the link tag already exists in the HTML, or entirely in CssMode::None.
1862fn inject_what_css(html: &str, mode: CssMode) -> String {
1863    if mode == CssMode::None {
1864        return html.to_string();
1865    }
1866    let asset_path = match mode {
1867        CssMode::Minimal => WHAT_CSS_MINIMAL_ASSET_PATH.as_str(),
1868        _ => WHAT_CSS_ASSET_PATH.as_str(),
1869    };
1870    let link_tag = format!(r#"<link rel="stylesheet" href="{}">"#, asset_path);
1871
1872    // Insert before the first <link in <head> so CSS variables are defined
1873    // before any dependent stylesheet. Fall back to after <head> if no <link> found.
1874    let head_start = html.find("<head>").or_else(|| html.find("<HEAD>"));
1875
1876    // Skip if what.css is already present in <head> (not in body/code examples).
1877    if let Some(hp) = head_start {
1878        let head_section_end = html[hp..].find("</head>").map(|p| p + hp).unwrap_or(html.len());
1879        if html[hp..head_section_end].contains("/static/what.css") {
1880            return html.to_string();
1881        }
1882    }
1883    if let Some(head_pos) = head_start {
1884        let after_head = head_pos + 6; // length of "<head>"
1885        let head_end = html[after_head..]
1886            .find("</head>")
1887            .or_else(|| html[after_head..].find("</HEAD>"))
1888            .map(|p| p + after_head)
1889            .unwrap_or(html.len());
1890        // Look for first <link within <head>
1891        if let Some(rel) = html[after_head..head_end].find("<link ").or_else(|| html[after_head..head_end].find("<link\n")) {
1892            let insert_at = after_head + rel;
1893            format!(
1894                "{}{}\n  {}",
1895                &html[..insert_at],
1896                link_tag,
1897                &html[insert_at..]
1898            )
1899        } else {
1900            format!(
1901                "{}\n  {}{}",
1902                &html[..after_head],
1903                link_tag,
1904                &html[after_head..]
1905            )
1906        }
1907    } else {
1908        html.to_string()
1909    }
1910}
1911
1912/// Inject SEO meta tags from page directives into the `<head>` section.
1913/// Supports: description, og.title, og.description, og.image, og.url, og.type,
1914/// twitter.card, twitter.title, twitter.description, twitter.image, canonical, robots.
1915fn inject_seo_meta(
1916    html: &str,
1917    custom: &HashMap<String, String>,
1918    vars: &HashMap<String, Value>,
1919) -> String {
1920    // Helper: look up a string value from custom (legacy) or vars (inline)
1921    let get = |key: &str| -> Option<String> {
1922        custom
1923            .get(key)
1924            .cloned()
1925            .or_else(|| vars.get(key).and_then(|v| v.as_str().map(String::from)))
1926    };
1927
1928    let mut tags = Vec::new();
1929
1930    // Standard meta tags
1931    if let Some(desc) = get("description") {
1932        tags.push(format!(
1933            r#"<meta name="description" content="{}">"#,
1934            html_escape(&desc)
1935        ));
1936    }
1937    if let Some(robots) = get("robots") {
1938        tags.push(format!(
1939            r#"<meta name="robots" content="{}">"#,
1940            html_escape(&robots)
1941        ));
1942    }
1943
1944    // Open Graph tags
1945    for (key, prop) in [
1946        ("og.title", "og:title"),
1947        ("og.description", "og:description"),
1948        ("og.image", "og:image"),
1949        ("og.url", "og:url"),
1950        ("og.type", "og:type"),
1951    ] {
1952        if let Some(val) = get(key) {
1953            tags.push(format!(
1954                r#"<meta property="{}" content="{}">"#,
1955                prop,
1956                html_escape(&val)
1957            ));
1958        }
1959    }
1960
1961    // Twitter Card tags
1962    for (key, name) in [
1963        ("twitter.card", "twitter:card"),
1964        ("twitter.title", "twitter:title"),
1965        ("twitter.description", "twitter:description"),
1966        ("twitter.image", "twitter:image"),
1967        ("twitter.creator", "twitter:creator"),
1968    ] {
1969        if let Some(val) = get(key) {
1970            tags.push(format!(
1971                r#"<meta name="{}" content="{}">"#,
1972                name,
1973                html_escape(&val)
1974            ));
1975        }
1976    }
1977
1978    // Canonical link
1979    if let Some(canonical) = get("canonical") {
1980        tags.push(format!(
1981            r#"<link rel="canonical" href="{}">"#,
1982            html_escape(&canonical)
1983        ));
1984    }
1985
1986    if tags.is_empty() {
1987        return html.to_string();
1988    }
1989
1990    let meta_block = tags.join("\n");
1991    inject_before_head_close(html, &meta_block)
1992}
1993
1994/// Inject `<meta name="what-debug">` in dev mode for client-side debug levels.
1995fn inject_debug_meta(html: &str, log_level: &str) -> String {
1996    let meta = format!(r#"<meta name="what-debug" content="{}">"#, log_level);
1997    inject_before_head_close(html, &meta)
1998}
1999
2000/// Helper: inject content before `</head>` (case-insensitive).
2001fn inject_before_head_close(html: &str, content: &str) -> String {
2002    if let Some(pos) = html.find("</head>") {
2003        format!("{}{}\n{}", &html[..pos], content, &html[pos..])
2004    } else if let Some(pos) = html.find("</HEAD>") {
2005        format!("{}{}\n{}", &html[..pos], content, &html[pos..])
2006    } else {
2007        // No <head> section — prepend
2008        format!("{}\n{}", content, html)
2009    }
2010}
2011
2012/// Escape HTML attribute values.
2013fn html_escape(s: &str) -> String {
2014    s.replace('&', "&amp;")
2015        .replace('"', "&quot;")
2016        .replace('<', "&lt;")
2017        .replace('>', "&gt;")
2018}
2019
2020/// Validate a CSRF token from form data or header against the session token.
2021/// Returns true if the token matches.
2022fn validate_csrf_token(
2023    session: Option<&sessions::Session>,
2024    form_token: Option<&str>,
2025    header_token: Option<&str>,
2026) -> bool {
2027    let session_token = session
2028        .and_then(|s| s.data.get(CSRF_SESSION_KEY))
2029        .and_then(|v| v.as_str());
2030
2031    let session_token = match session_token {
2032        Some(t) => t,
2033        None => return false, // No session token — deny
2034    };
2035
2036    // Check form field first, then header
2037    let submitted_token = form_token.or(header_token);
2038
2039    match submitted_token {
2040        Some(t) => t == session_token,
2041        None => false,
2042    }
2043}
2044
2045/// Paths that are excluded from CSRF validation
2046fn is_csrf_exempt(path: &str) -> bool {
2047    // WebSocket upgrade paths and dev-only endpoints
2048    path.starts_with("/w-livereload") || path.starts_with("/w-wire")
2049}
2050
2051/// Result of resolving a URL path to a page file
2052struct ResolvedPage {
2053    path: PathBuf,
2054    /// Dynamic route parameters extracted from the URL (e.g., "id" -> "123")
2055    params: HashMap<String, String>,
2056}
2057
2058/// Resolve a URL path to a page file
2059fn resolve_page_path(root: &PathBuf, url_path: &str) -> Option<ResolvedPage> {
2060    let pages_dir = content_dir(root);
2061    let clean_path = url_path.trim_start_matches('/');
2062
2063    // Try exact match: /about -> site/about.html
2064    let exact = pages_dir.join(format!("{}.html", clean_path));
2065    if exact.exists() {
2066        return Some(ResolvedPage {
2067            path: exact,
2068            params: HashMap::new(),
2069        });
2070    }
2071
2072    // Try index: /about -> site/about/index.html
2073    let index = pages_dir.join(clean_path).join("index.html");
2074    if index.exists() {
2075        return Some(ResolvedPage {
2076            path: index,
2077            params: HashMap::new(),
2078        });
2079    }
2080
2081    // Try root index: / -> site/index.html
2082    if clean_path.is_empty() || clean_path == "/" {
2083        let root_index = pages_dir.join("index.html");
2084        if root_index.exists() {
2085            return Some(ResolvedPage {
2086                path: root_index,
2087                params: HashMap::new(),
2088            });
2089        }
2090    }
2091
2092    // Try dynamic route: /post/123 -> site/post/[id].html
2093    let parts: Vec<&str> = clean_path.split('/').collect();
2094    if parts.len() >= 2 {
2095        let parent = parts[..parts.len() - 1].join("/");
2096        let dynamic_value = parts[parts.len() - 1];
2097        let dynamic = pages_dir.join(&parent).join("[id].html");
2098        if dynamic.exists() {
2099            let mut params = HashMap::new();
2100            params.insert("id".to_string(), dynamic_value.to_string());
2101            return Some(ResolvedPage {
2102                path: dynamic,
2103                params,
2104            });
2105        }
2106    }
2107
2108    None
2109}
2110
2111/// Load all application.what files from root to the page's directory
2112/// Returns merged config with child directories overriding parent
2113fn collect_application_config_paths(root: &PathBuf, url_path: &str) -> Vec<PathBuf> {
2114    let pages_dir = content_dir(root);
2115    let clean_path = url_path.trim_start_matches('/');
2116
2117    // Collect all directories from root to the page's directory
2118    let mut dirs_to_check = vec![pages_dir.clone()];
2119
2120    if !clean_path.is_empty() {
2121        let parts: Vec<&str> = clean_path.split('/').collect();
2122        let mut current = pages_dir.clone();
2123
2124        // For "/admin/users", check site/, site/admin/
2125        // (not pages/admin/users/ since that's a file, not directory)
2126        for part in &parts[..parts.len().saturating_sub(1)] {
2127            current = current.join(part);
2128            if current.is_dir() {
2129                dirs_to_check.push(current.clone());
2130            }
2131        }
2132
2133        // Also check if the last part is a directory (for index.html case)
2134        let last_dir = pages_dir.join(clean_path);
2135        if last_dir.is_dir() {
2136            dirs_to_check.push(last_dir);
2137        }
2138    }
2139
2140    dirs_to_check
2141        .into_iter()
2142        .map(|dir| dir.join("application.what"))
2143        .filter(|path| path.exists())
2144        .collect()
2145}
2146
2147fn load_application_config(root: &PathBuf, url_path: &str) -> WhatConfig {
2148    let mut merged = WhatConfig::default();
2149
2150    for config_path in collect_application_config_paths(root, url_path) {
2151        if let Ok(content) = std::fs::read_to_string(&config_path) {
2152            let config = parse_what_file(&content);
2153            merged.merge(&config);
2154            tracing::debug!("Loaded application.what from {:?}", config_path);
2155        }
2156    }
2157
2158    merged
2159}
2160
2161fn push_source_file(
2162    path: PathBuf,
2163    project_root_canonical: &std::path::Path,
2164    seen_paths: &mut std::collections::HashSet<PathBuf>,
2165    files: &mut Vec<Value>,
2166    scan_queue: &mut Vec<(PathBuf, String)>,
2167) -> bool {
2168    let canonical_path = match path.canonicalize() {
2169        Ok(p) => p,
2170        Err(_) => return false,
2171    };
2172    if !canonical_path.starts_with(project_root_canonical)
2173        || !seen_paths.insert(canonical_path.clone())
2174    {
2175        return false;
2176    }
2177    let content = match std::fs::read_to_string(&canonical_path) {
2178        Ok(c) => c,
2179        Err(_) => return false,
2180    };
2181    let label = canonical_path
2182        .strip_prefix(project_root_canonical)
2183        .unwrap_or(&canonical_path)
2184        .display()
2185        .to_string();
2186    files.push(json!({ "label": label, "content": content }));
2187    scan_queue.push((canonical_path, content));
2188    true
2189}
2190
2191// ---------------------------------------------------------------------------
2192// Enhanced Fetch Directives
2193// ---------------------------------------------------------------------------
2194
2195/// Structured fetch directive with method, headers, body, and path extraction
2196#[derive(Clone, Debug)]
2197#[allow(dead_code)]
2198struct FetchDirective {
2199    key: String,
2200    url: String,
2201    method: String,
2202    headers: Vec<(String, String)>,
2203    body: Option<String>,
2204    /// Dot-notation path to extract from response (e.g., "data.orders")
2205    path: Option<String>,
2206    /// Sort expression for local queries: "field:asc" or "field:desc"
2207    sort: Option<String>,
2208    /// Filter expression for local queries: "field=value", "field>N"
2209    filter: Option<String>,
2210    /// Full-text search term for local queries
2211    search: Option<String>,
2212    /// Fields to search in (comma-separated)
2213    search_fields: Option<String>,
2214    /// Max items to return
2215    limit: Option<usize>,
2216    /// Items to skip
2217    offset: Option<usize>,
2218}
2219
2220impl FetchDirective {
2221    #[allow(dead_code)]
2222    fn simple(key: String, url: String) -> Self {
2223        Self {
2224            key,
2225            url,
2226            method: "GET".to_string(),
2227            headers: Vec::new(),
2228            body: None,
2229            path: None,
2230            sort: None,
2231            filter: None,
2232            search: None,
2233            search_fields: None,
2234            limit: None,
2235            offset: None,
2236        }
2237    }
2238
2239    /// Whether this is a local database query (url starts with "local:")
2240    fn is_local(&self) -> bool {
2241        self.url.starts_with("local:")
2242    }
2243
2244    /// Extract the collection name from a local: URL, without any `?query` string.
2245    /// `local:posts?sort=created_at:desc` -> `posts`
2246    fn local_collection(&self) -> Option<&str> {
2247        self.url
2248            .strip_prefix("local:")
2249            .map(|rest| rest.split('?').next().unwrap_or(rest))
2250    }
2251
2252    /// Extract the raw query string from a local: URL (the part after `?`), if any.
2253    /// `local:posts?sort=created_at:desc&limit=10` -> `sort=created_at:desc&limit=10`
2254    fn local_query(&self) -> Option<&str> {
2255        self.url
2256            .strip_prefix("local:")
2257            .and_then(|rest| rest.split_once('?').map(|(_, q)| q))
2258    }
2259}
2260
2261/// Extract a nested value from JSON using dot-notation path (e.g., "data.items")
2262fn extract_json_path(value: &Value, path: &str) -> Value {
2263    let mut current = value;
2264    for part in path.split('.') {
2265        match current {
2266            Value::Object(obj) => {
2267                if let Some(v) = obj.get(part) {
2268                    current = v;
2269                } else {
2270                    return Value::Null;
2271                }
2272            }
2273            Value::Array(arr) => {
2274                if let Ok(idx) = part.parse::<usize>() {
2275                    if let Some(v) = arr.get(idx) {
2276                        current = v;
2277                    } else {
2278                        return Value::Null;
2279                    }
2280                } else {
2281                    return Value::Null;
2282                }
2283            }
2284            _ => return Value::Null,
2285        }
2286    }
2287    current.clone()
2288}
2289
2290/// Parse a comma-separated header string into key-value pairs
2291/// Format: "Authorization: Bearer token, Accept: application/json"
2292fn parse_header_string(s: &str) -> Vec<(String, String)> {
2293    let mut result = Vec::new();
2294    for part in s.split(',') {
2295        let part = part.trim();
2296        if let Some(colon) = part.find(':') {
2297            let key = part[..colon].trim().to_string();
2298            let value = part[colon + 1..].trim().to_string();
2299            result.push((key, value));
2300        }
2301    }
2302    result
2303}
2304
2305/// Debug info for a single remote fetch (used in X-What-Debug header)
2306#[derive(Clone, Serialize)]
2307pub struct FetchDebugEntry {
2308    pub key: String,
2309    pub url: String,
2310    pub elapsed_ms: u64,
2311    pub result: String,
2312}
2313
2314/// Result of rendering content
2315pub struct RenderResult {
2316    /// The rendered HTML
2317    pub html: String,
2318    /// Session keys that were used (for OOB updates)
2319    pub session_keys: std::collections::HashSet<String>,
2320    /// Debug info for remote fetches (populated in dev mode)
2321    pub fetch_debug: Vec<FetchDebugEntry>,
2322}
2323
2324/// Resolve environment variable references in config values.
2325/// Supports `${VAR_NAME}` syntax for environment variable substitution.
2326/// Returns an error if an env var reference is used but the variable is not set.
2327fn resolve_env_value(value: &str) -> crate::Result<String> {
2328    if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
2329        std::env::var(var_name).map_err(|_| {
2330            crate::Error::Config(format!("Environment variable {} is not set", var_name))
2331        })
2332    } else {
2333        Ok(value.to_string())
2334    }
2335}
2336
2337/// Email trigger data extracted from form fields.
2338struct EmailTrigger {
2339    to: String,
2340    subject: String,
2341    template: Option<String>,
2342}
2343
2344/// Extract `w-email-*` fields from form data.
2345/// Returns `Some(EmailTrigger)` if `w-email-to` is present.
2346fn extract_email_trigger(form_data: &HashMap<String, String>) -> Option<EmailTrigger> {
2347    let to = form_data.get("w-email-to")?;
2348    if to.is_empty() {
2349        return None;
2350    }
2351    Some(EmailTrigger {
2352        to: to.clone(),
2353        subject: form_data
2354            .get("w-email-subject")
2355            .cloned()
2356            .unwrap_or_else(|| "Notification".to_string()),
2357        template: form_data.get("w-email-template").cloned(),
2358    })
2359}
2360
2361/// Build and enqueue a SendEmail job if an email trigger was present.
2362async fn maybe_enqueue_email(state: &AppState, trigger: Option<EmailTrigger>) {
2363    let trigger = match trigger {
2364        Some(t) => t,
2365        None => return,
2366    };
2367
2368    if state.config.email.is_none() {
2369        tracing::warn!("Form has w-email-to but no [email] config in what.toml — skipping");
2370        return;
2371    }
2372
2373    // Build context from form data (all non w- fields are available in the template)
2374    let html_body = if let Some(ref tpl_name) = trigger.template {
2375        let email_config = state.config.email.as_ref().unwrap();
2376        // For template rendering, pass an empty context — the template itself uses #variable# syntax
2377        // In a future version, the form data could be injected here.
2378        match crate::email::render_email_template(
2379            &state.root,
2380            &email_config.template_dir,
2381            tpl_name,
2382            &HashMap::new(),
2383        ) {
2384            Ok(html) => html,
2385            Err(e) => {
2386                tracing::error!("Failed to render email template '{}': {}", tpl_name, e);
2387                return;
2388            }
2389        }
2390    } else {
2391        // No template — use a simple subject-only email
2392        format!("<p>{}</p>", trigger.subject)
2393    };
2394
2395    let message = crate::email::EmailMessage {
2396        to: trigger.to,
2397        subject: trigger.subject,
2398        html_body,
2399        text_body: None,
2400    };
2401
2402    let _ = state
2403        .jobs
2404        .enqueue(crate::jobs::Job::SendEmail { message })
2405        .await;
2406}
2407
2408const DEFAULT_DATA_CACHE_TTL_SECS: u64 = 300;
2409
2410fn data_source_cache_ttl(source: &DataSource) -> u64 {
2411    match source {
2412        DataSource::Url { cache, .. } => *cache,
2413        DataSource::File { cache, .. } => *cache,
2414        DataSource::SimplePath(_) => DEFAULT_DATA_CACHE_TTL_SECS,
2415    }
2416}
2417
2418fn resolve_data_source_path(root: &PathBuf, path: &str) -> PathBuf {
2419    let candidate = PathBuf::from(path);
2420    if candidate.is_absolute() {
2421        candidate
2422    } else {
2423        root.join(candidate)
2424    }
2425}
2426
2427async fn store_data_value(state: &AppState, name: &str, value: Value) -> Result<()> {
2428    match value {
2429        Value::Array(items) => state.store.set_collection(name, items).await,
2430        other => state.store.set(name, other).await,
2431    }
2432}
2433
2434/// Sanitize a file extension: strip path separators, null bytes, and non-ASCII.
2435/// Returns the extension with a leading dot, or empty string if nothing remains.
2436/// Verify a Cloudflare Turnstile token server-side.
2437/// Returns Ok(()) if verification passes or Turnstile is not configured.
2438/// Returns Err(message) if verification fails.
2439async fn verify_turnstile(
2440    state: &AppState,
2441    token: Option<&str>,
2442) -> std::result::Result<(), String> {
2443    let secret = match state
2444        .config
2445        .cloudflare
2446        .as_ref()
2447        .and_then(|cf| cf.turnstile_secret_key.as_ref())
2448    {
2449        Some(s) => resolve_env_value(s).map_err(|e| e.to_string())?,
2450        None => return Ok(()), // Turnstile not configured — skip verification
2451    };
2452
2453    let token = match token {
2454        Some(t) if !t.is_empty() => t,
2455        _ => return Err("Turnstile verification required".to_string()),
2456    };
2457
2458    let resp = state
2459        .http_client
2460        .post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
2461        .form(&[("secret", secret.as_str()), ("response", token)])
2462        .send()
2463        .await
2464        .map_err(|_| "Turnstile verification failed: network error".to_string())?;
2465
2466    let body: serde_json::Value = resp
2467        .json()
2468        .await
2469        .map_err(|_| "Turnstile verification failed: invalid response".to_string())?;
2470
2471    if body
2472        .get("success")
2473        .and_then(|v| v.as_bool())
2474        .unwrap_or(false)
2475    {
2476        Ok(())
2477    } else {
2478        let codes = body
2479            .get("error-codes")
2480            .and_then(|v| v.as_array())
2481            .map(|arr| {
2482                arr.iter()
2483                    .filter_map(|v| v.as_str())
2484                    .collect::<Vec<_>>()
2485                    .join(", ")
2486            })
2487            .unwrap_or_else(|| "unknown".to_string());
2488        Err(format!("Turnstile verification failed: {}", codes))
2489    }
2490}
2491
2492fn sanitize_extension(ext: &str) -> String {
2493    let clean: String = ext
2494        .chars()
2495        .filter(|c| c.is_ascii_alphanumeric() || *c == '.')
2496        .collect();
2497    if clean.is_empty() {
2498        String::new()
2499    } else {
2500        format!(".{}", clean)
2501    }
2502}
2503
2504/// Describe a JSON value for logging (type + count, not the data itself)
2505fn describe_json(value: &Value) -> String {
2506    match value {
2507        Value::Array(arr) => format!("array[{}]", arr.len()),
2508        Value::Object(obj) => {
2509            let summaries: Vec<String> = obj
2510                .iter()
2511                .take(5)
2512                .map(|(k, v)| {
2513                    let v_desc = match v {
2514                        Value::Array(a) => format!("array[{}]", a.len()),
2515                        Value::Object(o) => format!("object{{{}}}", o.len()),
2516                        Value::String(s) => format!("str({})", s.len()),
2517                        Value::Number(_) => "num".to_string(),
2518                        Value::Bool(_) => "bool".to_string(),
2519                        Value::Null => "null".to_string(),
2520                    };
2521                    format!("{}: {}", k, v_desc)
2522                })
2523                .collect();
2524            if obj.len() > 5 {
2525                format!("object{{{}... +{}}}", summaries.join(", "), obj.len() - 5)
2526            } else {
2527                format!("object{{{}}}", summaries.join(", "))
2528            }
2529        }
2530        Value::String(s) => format!("string({}b)", s.len()),
2531        Value::Number(n) => format!("number({})", n),
2532        Value::Bool(b) => format!("bool({})", b),
2533        Value::Null => "null".to_string(),
2534    }
2535}
2536
2537async fn fetch_remote_json(state: &AppState, url: &str, use_cache: bool) -> Result<Value> {
2538    if use_cache && state.config.cache.enabled {
2539        if let Some(cached) = state.cache.get_api(url).await {
2540            tracing::info!("  cache hit: {}", url);
2541            return Ok(serde_json::from_str(&cached)?);
2542        }
2543    }
2544
2545    let start = std::time::Instant::now();
2546
2547    let response = state.http_client.get(url).send().await.map_err(|e| {
2548        tracing::warn!("  fetch failed ({}): {}", start.elapsed().as_millis(), e);
2549        crate::Error::Data(format!("HTTP request failed: {}", e))
2550    })?;
2551    let status = response.status();
2552    let body = response.text().await?;
2553    let elapsed = start.elapsed();
2554
2555    if !status.is_success() {
2556        tracing::warn!("  {} -> {} {}ms", url, status, elapsed.as_millis());
2557        return Err(crate::Error::Data(format!(
2558            "Remote fetch failed ({}): {}",
2559            status, url
2560        )));
2561    }
2562
2563    let value: Value = serde_json::from_str(&body)?;
2564    tracing::info!(
2565        "  {} -> {} {}ms {}",
2566        url,
2567        status,
2568        elapsed.as_millis(),
2569        describe_json(&value)
2570    );
2571
2572    if use_cache && state.config.cache.enabled {
2573        state.cache.set_api(url, body).await;
2574    }
2575
2576    Ok(value)
2577}
2578
2579async fn load_data_source(state: &AppState, name: &str, source: &DataSource) -> Result<()> {
2580    let value = match source {
2581        DataSource::File { file, .. } => {
2582            let path = resolve_data_source_path(&state.root, file);
2583            let content = tokio::fs::read_to_string(&path).await?;
2584            serde_json::from_str(&content)?
2585        }
2586        DataSource::SimplePath(path) => {
2587            let full_path = resolve_data_source_path(&state.root, path);
2588            let content = tokio::fs::read_to_string(&full_path).await?;
2589            serde_json::from_str(&content)?
2590        }
2591        DataSource::Url { url, .. } => fetch_remote_json(state, url, true).await?,
2592    };
2593
2594    store_data_value(state, name, value).await
2595}
2596
2597async fn hydrate_config_data_sources(state: &AppState) -> Result<()> {
2598    if state.config.data.is_empty() {
2599        return Ok(());
2600    }
2601
2602    let mut to_refresh: Vec<(String, DataSource)> = Vec::new();
2603    let use_cache = state.config.cache.enabled;
2604
2605    {
2606        let last_loaded = state.data_source_loaded.read().await;
2607        for (name, source) in &state.config.data {
2608            let ttl = if use_cache {
2609                data_source_cache_ttl(source)
2610            } else {
2611                0
2612            };
2613            let should_refresh = if ttl == 0 {
2614                true
2615            } else {
2616                last_loaded
2617                    .get(name)
2618                    .map(|t| t.elapsed() >= Duration::from_secs(ttl))
2619                    .unwrap_or(true)
2620            };
2621
2622            if should_refresh {
2623                to_refresh.push((name.clone(), source.clone()));
2624            }
2625        }
2626    }
2627
2628    if to_refresh.is_empty() {
2629        return Ok(());
2630    }
2631
2632    for (name, source) in to_refresh {
2633        match load_data_source(state, &name, &source).await {
2634            Ok(()) => {
2635                let mut last_loaded = state.data_source_loaded.write().await;
2636                last_loaded.insert(name, Instant::now());
2637            }
2638            Err(e) => {
2639                tracing::warn!("Failed to load data source {}: {}", name, e);
2640            }
2641        }
2642    }
2643
2644    Ok(())
2645}
2646
2647/// Collect fetch directives from app config and page directives.
2648/// Supports both simple `fetch.key = "url"` and enhanced sub-properties like
2649/// `fetch.key.method = "POST"`, `fetch.key.headers = "Authorization: Bearer x"`, etc.
2650fn collect_fetch_directives(
2651    app_config: Option<&WhatConfig>,
2652    page_directives: Option<&PageDirectives>,
2653) -> HashMap<String, FetchDirective> {
2654    let mut all_entries: HashMap<String, HashMap<String, String>> = HashMap::new();
2655
2656    // Collect raw key-value pairs from both sources
2657    let mut raw_pairs: Vec<(String, String)> = Vec::new();
2658
2659    if let Some(config) = app_config {
2660        for (key, value) in &config.values {
2661            if let Some(rest) = key.strip_prefix("fetch.") {
2662                if let Some(url) = value.as_str() {
2663                    raw_pairs.push((rest.to_string(), url.to_string()));
2664                }
2665            }
2666        }
2667    }
2668
2669    if let Some(directives) = page_directives {
2670        for (key, value) in &directives.custom {
2671            if let Some(rest) = key.strip_prefix("fetch.") {
2672                if !value.is_empty() {
2673                    raw_pairs.push((rest.to_string(), value.to_string()));
2674                }
2675            }
2676        }
2677    }
2678
2679    // Parse into structured directives
2680    for (rest, value) in raw_pairs {
2681        // Check if this is a sub-property: "key.property" vs simple "key"
2682        if let Some(dot_pos) = rest.find('.') {
2683            let fetch_key = rest[..dot_pos].to_string();
2684            let property = rest[dot_pos + 1..].to_string();
2685            all_entries
2686                .entry(fetch_key)
2687                .or_default()
2688                .insert(property, value);
2689        } else {
2690            // Simple: fetch.key = "url"
2691            all_entries
2692                .entry(rest)
2693                .or_default()
2694                .insert("url".to_string(), value);
2695        }
2696    }
2697
2698    // Build FetchDirective from collected entries
2699    let mut result = HashMap::new();
2700    for (key, props) in all_entries {
2701        let url = match props.get("url") {
2702            Some(u) => u.clone(),
2703            None => continue, // No URL, skip
2704        };
2705        let method = props
2706            .get("method")
2707            .cloned()
2708            .unwrap_or_else(|| "GET".to_string())
2709            .to_uppercase();
2710        let headers = props
2711            .get("headers")
2712            .map(|h| parse_header_string(h))
2713            .unwrap_or_default();
2714        let body = props.get("body").cloned();
2715        let path = props.get("path").cloned();
2716        if props.contains_key("poll") {
2717            tracing::warn!(
2718                "fetch.{}.poll was removed in v1.3 — move the markup into a partial and wrap it in <what-fetch url=\"/w-partial/...\" poll=\"...\"> instead",
2719                key
2720            );
2721        }
2722        let sort = props.get("sort").cloned();
2723        let filter = props.get("filter").cloned();
2724        let search = props.get("search").cloned();
2725        let search_fields = props.get("search_fields").cloned();
2726        let limit = props.get("limit").and_then(|l| l.parse().ok());
2727        let offset = props.get("offset").and_then(|o| o.parse().ok());
2728
2729        result.insert(
2730            key.clone(),
2731            FetchDirective {
2732                key,
2733                url,
2734                method,
2735                headers,
2736                body,
2737                path,
2738                sort,
2739                filter,
2740                search,
2741                search_fields,
2742                limit,
2743                offset,
2744            },
2745        );
2746    }
2747
2748    result
2749}
2750
2751/// Execute a fetch with custom method, headers, and body
2752async fn fetch_enhanced(
2753    state: &AppState,
2754    directive: &FetchDirective,
2755    resolved_url: &str,
2756) -> Result<Value> {
2757    let start = std::time::Instant::now();
2758
2759    let method = match directive.method.as_str() {
2760        "POST" => reqwest::Method::POST,
2761        "PUT" => reqwest::Method::PUT,
2762        "DELETE" => reqwest::Method::DELETE,
2763        "PATCH" => reqwest::Method::PATCH,
2764        _ => reqwest::Method::GET,
2765    };
2766
2767    let mut request = state.http_client.request(method, resolved_url);
2768
2769    // Add custom headers
2770    for (key, value) in &directive.headers {
2771        request = request.header(key.as_str(), value.as_str());
2772    }
2773
2774    // Add body if present
2775    if let Some(ref body) = directive.body {
2776        request = request.body(body.clone());
2777        // Set content-type if not already in headers
2778        if !directive
2779            .headers
2780            .iter()
2781            .any(|(k, _)| k.to_lowercase() == "content-type")
2782        {
2783            request = request.header("Content-Type", "application/json");
2784        }
2785    }
2786
2787    let response = request
2788        .send()
2789        .await
2790        .map_err(|e| crate::Error::Data(format!("HTTP request failed: {}", e)))?;
2791
2792    let status = response.status();
2793    let body_text = response.text().await?;
2794    let elapsed = start.elapsed();
2795
2796    if !status.is_success() {
2797        tracing::warn!("  {} -> {} {}ms", resolved_url, status, elapsed.as_millis());
2798        return Err(crate::Error::Data(format!(
2799            "Remote fetch failed ({}): {}",
2800            status, resolved_url
2801        )));
2802    }
2803
2804    let mut value: Value = serde_json::from_str(&body_text)?;
2805    tracing::info!(
2806        "  {} -> {} {}ms {}",
2807        resolved_url,
2808        status,
2809        elapsed.as_millis(),
2810        describe_json(&value)
2811    );
2812
2813    // Apply path extraction if specified
2814    if let Some(ref path) = directive.path {
2815        value = extract_json_path(&value, path);
2816    }
2817
2818    Ok(value)
2819}
2820
2821async fn apply_fetch_directives(
2822    state: &AppState,
2823    context: &mut HashMap<String, Value>,
2824    app_config: Option<&WhatConfig>,
2825    page_directives: Option<&PageDirectives>,
2826    actor: &crate::policy::Actor,
2827) -> Vec<FetchDebugEntry> {
2828    let fetches = collect_fetch_directives(app_config, page_directives);
2829    if fetches.is_empty() {
2830        return Vec::new();
2831    }
2832
2833    let total_start = std::time::Instant::now();
2834
2835    // Separate local, dsn, and remote fetches
2836    let mut local_fetches = Vec::new();
2837    let mut dsn_fetches = Vec::new();
2838    let mut remote_fetches = Vec::new();
2839    for (key, directive) in fetches {
2840        if directive.is_local() {
2841            local_fetches.push((key, directive));
2842        } else if directive.url.starts_with("dsn:") {
2843            dsn_fetches.push((key, directive));
2844        } else {
2845            remote_fetches.push((key, directive));
2846        }
2847    }
2848
2849    if !remote_fetches.is_empty() {
2850        tracing::info!("Fetching {} remote source(s):", remote_fetches.len());
2851    }
2852    if !local_fetches.is_empty() {
2853        tracing::info!("Querying {} local collection(s):", local_fetches.len());
2854    }
2855    if !dsn_fetches.is_empty() {
2856        tracing::info!("Querying {} datasource(s):", dsn_fetches.len());
2857    }
2858
2859    let mut fetch_errors = serde_json::Map::new();
2860    let mut debug_entries = Vec::new();
2861
2862    // Process local fetches (database queries)
2863    for (key, directive) in local_fetches {
2864        let start = std::time::Instant::now();
2865        if let Some(collection) = directive.local_collection() {
2866            // Query parameters can be supplied two ways, and the sub-directive wins:
2867            //   1. sub-directives:  fetch.x.sort = "...", fetch.x.filter = "...", etc.
2868            //   2. URL query string: fetch.x = "local:coll?sort=...&filter=...&limit=10"
2869            let (mut q_sort, mut q_filter, mut q_search, mut q_limit, mut q_offset) =
2870                (None, None, None, None, None);
2871            if let Some(qs) = directive.local_query() {
2872                for pair in qs.split('&') {
2873                    // split_once('=') so filter values may themselves contain '='
2874                    if let Some((k, v)) = pair.split_once('=') {
2875                        match k {
2876                            "sort" => q_sort = Some(v.to_string()),
2877                            "filter" => q_filter = Some(v.to_string()),
2878                            "search" => q_search = Some(v.to_string()),
2879                            "limit" => q_limit = v.parse::<usize>().ok(),
2880                            "offset" => q_offset = v.parse::<usize>().ok(),
2881                            _ => {}
2882                        }
2883                    }
2884                }
2885            }
2886
2887            // Resolve template variables in query parameters
2888            let sort = directive
2889                .sort
2890                .clone()
2891                .or(q_sort)
2892                .map(|s| crate::parser::replace_variables(&s, context));
2893            let filter = directive
2894                .filter
2895                .clone()
2896                .or(q_filter)
2897                .map(|s| crate::parser::replace_variables(&s, context));
2898            let search = directive
2899                .search
2900                .clone()
2901                .or(q_search)
2902                .map(|s| crate::parser::replace_variables(&s, context));
2903            let search_fields = directive.search_fields.clone();
2904            let offset = directive.offset.or(q_offset);
2905            let limit = directive.limit.or(q_limit);
2906
2907            // Apply the collection's read policy: deny (empty), scope (forced
2908            // filters), or allow.
2909            let policy = state.policies.get(collection);
2910            let forced_filters = match policy.read_scope(actor, context) {
2911                crate::policy::ReadScope::Deny => {
2912                    tracing::debug!(target: "what::policy", "read denied for '{}' (no matching identity)", collection);
2913                    debug_entries.push(FetchDebugEntry {
2914                        key: key.clone(),
2915                        url: format!("local:{}", collection),
2916                        elapsed_ms: start.elapsed().as_millis() as u64,
2917                        result: "0 items (policy: denied)".to_string(),
2918                    });
2919                    context.insert(key, json!([]));
2920                    continue;
2921                }
2922                crate::policy::ReadScope::All => Vec::new(),
2923                crate::policy::ReadScope::Filters(f) => f,
2924            };
2925            let scoped = !forced_filters.is_empty();
2926
2927            let query = crate::database::CollectionQuery {
2928                sort,
2929                filter,
2930                search,
2931                search_fields,
2932                limit,
2933                offset,
2934                forced_filters,
2935            };
2936
2937            let has_query = query.sort.is_some()
2938                || query.filter.is_some()
2939                || query.search.is_some()
2940                || query.limit.is_some()
2941                || query.offset.is_some()
2942                || !query.forced_filters.is_empty();
2943
2944            let value = if has_query {
2945                state.store.query_collection(collection, &query).await
2946            } else {
2947                state.store.get_collection(collection).await
2948            };
2949
2950            let elapsed_ms = start.elapsed().as_millis() as u64;
2951            match value {
2952                Some(mut items) => {
2953                    let count = items.len();
2954                    let mut items_val = json!(items.drain(..).collect::<Vec<_>>());
2955                    crate::policy::strip_private_fields(&mut items_val, &policy.private_fields);
2956                    debug_entries.push(FetchDebugEntry {
2957                        key: key.clone(),
2958                        url: format!("local:{}", collection),
2959                        elapsed_ms,
2960                        result: if scoped {
2961                            format!("{} items (policy-scoped)", count)
2962                        } else {
2963                            format!("{} items", count)
2964                        },
2965                    });
2966                    context.insert(key, items_val);
2967                }
2968                None => {
2969                    debug_entries.push(FetchDebugEntry {
2970                        key: key.clone(),
2971                        url: format!("local:{}", collection),
2972                        elapsed_ms,
2973                        result: "empty collection".to_string(),
2974                    });
2975                    context.insert(key, json!([]));
2976                }
2977            }
2978        }
2979    }
2980
2981    // Process DSN fetches (named datasources)
2982    for (key, directive) in dsn_fetches {
2983        let start = std::time::Instant::now();
2984        let resolved_url = crate::parser::replace_variables(&directive.url, context);
2985
2986        if let Some((ds_name, target)) = crate::datasource::parse_dsn(&resolved_url) {
2987            if let Some(datasource) = state.datasources.get(ds_name) {
2988                match (datasource, &target) {
2989                    // DB datasource + collection target
2990                    (
2991                        crate::datasource::Datasource::Database(adapter),
2992                        crate::datasource::DsnTarget::Collection(collection),
2993                    ) => {
2994                        let sort = directive
2995                            .sort
2996                            .as_ref()
2997                            .map(|s| crate::parser::replace_variables(s, context));
2998                        let filter = directive
2999                            .filter
3000                            .as_ref()
3001                            .map(|s| crate::parser::replace_variables(s, context));
3002                        let search = directive
3003                            .search
3004                            .as_ref()
3005                            .map(|s| crate::parser::replace_variables(s, context));
3006                        let search_fields = directive.search_fields.clone();
3007
3008                        // Policies apply by collection name across backends.
3009                        let policy = state.policies.get(collection);
3010                        let forced_filters = match policy.read_scope(actor, context) {
3011                            crate::policy::ReadScope::Deny => {
3012                                debug_entries.push(FetchDebugEntry {
3013                                    key: key.clone(),
3014                                    url: resolved_url.clone(),
3015                                    elapsed_ms: start.elapsed().as_millis() as u64,
3016                                    result: "0 items (policy: denied)".to_string(),
3017                                });
3018                                context.insert(key, json!([]));
3019                                continue;
3020                            }
3021                            crate::policy::ReadScope::All => Vec::new(),
3022                            crate::policy::ReadScope::Filters(f) => f,
3023                        };
3024                        let scoped = !forced_filters.is_empty();
3025
3026                        let query = crate::database::CollectionQuery {
3027                            sort,
3028                            filter,
3029                            search,
3030                            search_fields,
3031                            limit: directive.limit,
3032                            offset: directive.offset,
3033                            forced_filters,
3034                        };
3035
3036                        let has_query = query.sort.is_some()
3037                            || query.filter.is_some()
3038                            || query.search.is_some()
3039                            || query.limit.is_some()
3040                            || query.offset.is_some()
3041                            || !query.forced_filters.is_empty();
3042
3043                        let value = if has_query {
3044                            adapter.query_collection(collection, &query).await
3045                        } else {
3046                            adapter.get_collection(collection).await
3047                        };
3048
3049                        let elapsed_ms = start.elapsed().as_millis() as u64;
3050                        match value {
3051                            Some(mut items) => {
3052                                let count = items.len();
3053                                let mut items_val = json!(items.drain(..).collect::<Vec<_>>());
3054                                crate::policy::strip_private_fields(&mut items_val, &policy.private_fields);
3055                                debug_entries.push(FetchDebugEntry {
3056                                    key: key.clone(),
3057                                    url: resolved_url.clone(),
3058                                    elapsed_ms,
3059                                    result: if scoped {
3060                                        format!("{} items (policy-scoped)", count)
3061                                    } else {
3062                                        format!("{} items", count)
3063                                    },
3064                                });
3065                                context.insert(key, items_val);
3066                            }
3067                            None => {
3068                                debug_entries.push(FetchDebugEntry {
3069                                    key: key.clone(),
3070                                    url: resolved_url.clone(),
3071                                    elapsed_ms,
3072                                    result: "empty collection".to_string(),
3073                                });
3074                                context.insert(key, json!([]));
3075                            }
3076                        }
3077                    }
3078                    // DB datasource + root target — load all collections as context
3079                    (
3080                        crate::datasource::Datasource::Database(adapter),
3081                        crate::datasource::DsnTarget::Root,
3082                    ) => {
3083                        let ctx = adapter.as_context().await;
3084                        let elapsed_ms = start.elapsed().as_millis() as u64;
3085                        let count = ctx.len();
3086                        debug_entries.push(FetchDebugEntry {
3087                            key: key.clone(),
3088                            url: resolved_url.clone(),
3089                            elapsed_ms,
3090                            result: format!("{} entries", count),
3091                        });
3092                        context.insert(key, json!(ctx));
3093                    }
3094                    // API datasource + path target — HTTP GET with configured headers
3095                    (
3096                        crate::datasource::Datasource::Api { base_url, headers },
3097                        crate::datasource::DsnTarget::Path(path),
3098                    ) => {
3099                        let full_url = format!("{}{}", base_url, path);
3100                        let mut request = state.http_client.get(&full_url);
3101                        for (hk, hv) in headers {
3102                            request = request.header(hk.as_str(), hv.as_str());
3103                        }
3104                        // Add any per-fetch headers from the directive
3105                        for (hk, hv) in &directive.headers {
3106                            request = request.header(hk.as_str(), hv.as_str());
3107                        }
3108
3109                        match request.send().await {
3110                            Ok(resp) if resp.status().is_success() => {
3111                                let elapsed_ms = start.elapsed().as_millis() as u64;
3112                                if let Ok(body) = resp.text().await {
3113                                    if let Ok(mut value) = serde_json::from_str::<Value>(&body) {
3114                                        if let Some(ref extract_path) = directive.path {
3115                                            value = extract_json_path(&value, extract_path);
3116                                        }
3117                                        debug_entries.push(FetchDebugEntry {
3118                                            key: key.clone(),
3119                                            url: full_url,
3120                                            elapsed_ms,
3121                                            result: describe_json(&value),
3122                                        });
3123                                        context.insert(key, value);
3124                                    } else {
3125                                        debug_entries.push(FetchDebugEntry {
3126                                            key: key.clone(),
3127                                            url: full_url,
3128                                            elapsed_ms,
3129                                            result: "error: invalid JSON".to_string(),
3130                                        });
3131                                    }
3132                                }
3133                            }
3134                            Ok(resp) => {
3135                                let elapsed_ms = start.elapsed().as_millis() as u64;
3136                                let status = resp.status();
3137                                debug_entries.push(FetchDebugEntry {
3138                                    key: key.clone(),
3139                                    url: full_url,
3140                                    elapsed_ms,
3141                                    result: format!("error: HTTP {}", status),
3142                                });
3143                                fetch_errors.insert(key, json!(format!("HTTP {}", status)));
3144                            }
3145                            Err(e) => {
3146                                let elapsed_ms = start.elapsed().as_millis() as u64;
3147                                debug_entries.push(FetchDebugEntry {
3148                                    key: key.clone(),
3149                                    url: full_url,
3150                                    elapsed_ms,
3151                                    result: format!("error: {}", e),
3152                                });
3153                                fetch_errors.insert(key, json!(e.to_string()));
3154                            }
3155                        }
3156                    }
3157                    // API datasource + root target — hit base URL
3158                    (
3159                        crate::datasource::Datasource::Api { base_url, headers },
3160                        crate::datasource::DsnTarget::Root,
3161                    ) => {
3162                        let mut request = state.http_client.get(base_url.as_str());
3163                        for (hk, hv) in headers {
3164                            request = request.header(hk.as_str(), hv.as_str());
3165                        }
3166                        match request.send().await {
3167                            Ok(resp) if resp.status().is_success() => {
3168                                let elapsed_ms = start.elapsed().as_millis() as u64;
3169                                if let Ok(body) = resp.text().await {
3170                                    if let Ok(value) = serde_json::from_str::<Value>(&body) {
3171                                        debug_entries.push(FetchDebugEntry {
3172                                            key: key.clone(),
3173                                            url: base_url.clone(),
3174                                            elapsed_ms,
3175                                            result: describe_json(&value),
3176                                        });
3177                                        context.insert(key, value);
3178                                    }
3179                                }
3180                            }
3181                            _ => {
3182                                let elapsed_ms = start.elapsed().as_millis() as u64;
3183                                debug_entries.push(FetchDebugEntry {
3184                                    key: key.clone(),
3185                                    url: base_url.clone(),
3186                                    elapsed_ms,
3187                                    result: "error: request failed".to_string(),
3188                                });
3189                            }
3190                        }
3191                    }
3192                    // DB datasource + path target — not supported, treat as collection
3193                    (
3194                        crate::datasource::Datasource::Database(adapter),
3195                        crate::datasource::DsnTarget::Path(path),
3196                    ) => {
3197                        // Treat path as collection name (strip leading /)
3198                        let collection = path.trim_start_matches('/');
3199                        let value = adapter.get_collection(collection).await;
3200                        let elapsed_ms = start.elapsed().as_millis() as u64;
3201                        match value {
3202                            Some(items) => {
3203                                debug_entries.push(FetchDebugEntry {
3204                                    key: key.clone(),
3205                                    url: resolved_url.clone(),
3206                                    elapsed_ms,
3207                                    result: format!("{} items", items.len()),
3208                                });
3209                                context.insert(key, json!(items));
3210                            }
3211                            None => {
3212                                debug_entries.push(FetchDebugEntry {
3213                                    key: key.clone(),
3214                                    url: resolved_url.clone(),
3215                                    elapsed_ms,
3216                                    result: "empty collection".to_string(),
3217                                });
3218                                context.insert(key, json!([]));
3219                            }
3220                        }
3221                    }
3222                    // API datasource + collection target — append as path
3223                    (
3224                        crate::datasource::Datasource::Api { base_url, headers },
3225                        crate::datasource::DsnTarget::Collection(collection),
3226                    ) => {
3227                        let full_url = format!("{}/{}", base_url, collection);
3228                        let mut request = state.http_client.get(&full_url);
3229                        for (hk, hv) in headers {
3230                            request = request.header(hk.as_str(), hv.as_str());
3231                        }
3232                        match request.send().await {
3233                            Ok(resp) if resp.status().is_success() => {
3234                                let elapsed_ms = start.elapsed().as_millis() as u64;
3235                                if let Ok(body) = resp.text().await {
3236                                    if let Ok(value) = serde_json::from_str::<Value>(&body) {
3237                                        debug_entries.push(FetchDebugEntry {
3238                                            key: key.clone(),
3239                                            url: full_url,
3240                                            elapsed_ms,
3241                                            result: describe_json(&value),
3242                                        });
3243                                        context.insert(key, value);
3244                                    }
3245                                }
3246                            }
3247                            _ => {
3248                                let elapsed_ms = start.elapsed().as_millis() as u64;
3249                                debug_entries.push(FetchDebugEntry {
3250                                    key: key.clone(),
3251                                    url: full_url,
3252                                    elapsed_ms,
3253                                    result: "error: request failed".to_string(),
3254                                });
3255                            }
3256                        }
3257                    }
3258                }
3259            } else {
3260                let elapsed_ms = start.elapsed().as_millis() as u64;
3261                let ds_name_owned = ds_name.to_string();
3262                tracing::warn!("  dsn '{}' not found in [datasources]", ds_name_owned);
3263                debug_entries.push(FetchDebugEntry {
3264                    key: key.clone(),
3265                    url: resolved_url.clone(),
3266                    elapsed_ms,
3267                    result: format!("error: datasource '{}' not configured", ds_name_owned),
3268                });
3269                fetch_errors.insert(
3270                    key,
3271                    json!(format!("datasource '{}' not configured", ds_name_owned)),
3272                );
3273            }
3274        } else {
3275            let elapsed_ms = start.elapsed().as_millis() as u64;
3276            tracing::warn!("  invalid dsn URL: {}", resolved_url);
3277            debug_entries.push(FetchDebugEntry {
3278                key: key.clone(),
3279                url: resolved_url.clone(),
3280                elapsed_ms,
3281                result: "error: invalid dsn URL format".to_string(),
3282            });
3283            fetch_errors.insert(key, json!("invalid dsn URL format"));
3284        }
3285    }
3286
3287    // Run all remote fetches concurrently
3288    let handles: Vec<_> = remote_fetches
3289        .into_iter()
3290        .map(|(key, directive)| {
3291            let state = state.clone();
3292            let resolved_url = crate::parser::replace_variables(&directive.url, context);
3293            let directive = directive.clone();
3294            tokio::spawn(async move {
3295                let start = std::time::Instant::now();
3296                let result = if directive.method == "GET"
3297                    && directive.headers.is_empty()
3298                    && directive.body.is_none()
3299                    && directive.path.is_none()
3300                {
3301                    // Simple GET — use existing cached fetcher
3302                    fetch_remote_json(&state, &resolved_url, false).await
3303                } else {
3304                    // Enhanced fetch
3305                    fetch_enhanced(&state, &directive, &resolved_url).await
3306                };
3307                let elapsed_ms = start.elapsed().as_millis() as u64;
3308                (
3309                    key,
3310                    resolved_url,
3311                    elapsed_ms,
3312                    result,
3313                    directive.path.clone(),
3314                )
3315            })
3316        })
3317        .collect();
3318
3319    for handle in handles {
3320        match handle.await {
3321            Ok((key, url, elapsed_ms, Ok(value), _path)) => {
3322                let desc = describe_json(&value);
3323                debug_entries.push(FetchDebugEntry {
3324                    key: key.clone(),
3325                    url,
3326                    elapsed_ms,
3327                    result: desc,
3328                });
3329                context.insert(key, value);
3330            }
3331            Ok((key, url, elapsed_ms, Err(e), _)) => {
3332                let err_str = e.to_string();
3333                debug_entries.push(FetchDebugEntry {
3334                    key: key.clone(),
3335                    url,
3336                    elapsed_ms,
3337                    result: format!("error: {}", err_str),
3338                });
3339                fetch_errors.insert(key, json!(err_str));
3340            }
3341            Err(e) => {
3342                tracing::warn!("  fetch task panicked: {}", e);
3343            }
3344        }
3345    }
3346
3347    tracing::info!(
3348        "All fetches done in {}ms",
3349        total_start.elapsed().as_millis()
3350    );
3351
3352    if !fetch_errors.is_empty() {
3353        context.insert("fetch_errors".to_string(), Value::Object(fetch_errors));
3354    }
3355
3356    debug_entries
3357}
3358
3359/// Render content string to HTML with session, user, and application context
3360/// If `is_partial` is true, uses reactive rendering which tracks session variables
3361async fn render_content_internal(
3362    state: &AppState,
3363    content: &str,
3364    session: Option<&sessions::Session>,
3365    user: &UserContext,
3366    app_config: Option<&WhatConfig>,
3367    page_directives: Option<&PageDirectives>,
3368    query_params: Option<&HashMap<String, String>>,
3369    route_params: &HashMap<String, String>,
3370    _is_partial: bool,
3371    flash: Option<&FlashData>,
3372) -> Result<RenderResult> {
3373    if let Err(e) = hydrate_config_data_sources(state).await {
3374        tracing::warn!("Failed to hydrate configured data sources: {}", e);
3375    }
3376
3377    // Build context from data store
3378    let mut context = state.store.as_context().await;
3379
3380    // The base context exposes every collection for looping. Scrub it: drop
3381    // read-scoped collections entirely (fetch directives re-add them scoped)
3382    // and strip private fields from the rest. Unconfigured collections (the
3383    // implicit read="all", no private fields) are untouched.
3384    crate::policy::scrub_base_context(&state.policies, &mut context);
3385
3386    // Add base path for includes (project root) and content directory
3387    context.insert(
3388        "_base_path".to_string(),
3389        json!(state.root.to_string_lossy()),
3390    );
3391    context.insert(
3392        "_content_dir".to_string(),
3393        json!(state.content_dir.to_string_lossy()),
3394    );
3395    context.insert("_dev_mode".to_string(), json!(state.dev_mode));
3396    context.insert("_strict".to_string(), json!(state.config.strict));
3397    if let Some(ref cf) = state.config.cloudflare {
3398        if let Some(ref site_key) = cf.turnstile_site_key {
3399            context.insert("_turnstile_site_key".to_string(), json!(site_key));
3400        }
3401    }
3402
3403    // Always inject empty flash/errors/old so unresolved #old.field# etc. become ""
3404    context.insert("flash".to_string(), Value::Object(serde_json::Map::new()));
3405    context.insert("errors".to_string(), Value::Object(serde_json::Map::new()));
3406    context.insert("old".to_string(), Value::Object(serde_json::Map::new()));
3407    context.insert("has_errors".to_string(), json!(false));
3408
3409    // Inject flash messages into context (consume-on-read, already removed from session)
3410    if let Some(flash) = flash {
3411        inject_flash_into_context(flash, &mut context);
3412    }
3413
3414    // Add dynamic route parameters to context (e.g., #id# from /post/[id].html)
3415    for (key, value) in route_params {
3416        context.insert(key.clone(), json!(value));
3417    }
3418
3419    // Add query parameters to context
3420    if let Some(params) = query_params {
3421        let query_obj: serde_json::Map<String, Value> =
3422            params.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
3423        context.insert("query".to_string(), Value::Object(query_obj));
3424    } else {
3425        context.insert("query".to_string(), Value::Object(serde_json::Map::new()));
3426    }
3427
3428    // Add application config values (from application.what files)
3429    if let Some(config) = app_config {
3430        for (key, value) in &config.values {
3431            if !key.starts_with("fetch.") {
3432                context.insert(key.clone(), value.clone());
3433            }
3434        }
3435    }
3436
3437    // Add page directive values to context (these override app_config values)
3438    if let Some(directives) = page_directives {
3439        if let Some(ref title) = directives.title {
3440            context.insert("title".to_string(), json!(title));
3441        }
3442        for (key, value) in &directives.custom {
3443            if !key.starts_with("fetch.") {
3444                context.insert(key.clone(), json!(value));
3445            }
3446        }
3447        // Inject inline variables (typed: arrays, objects, numbers, strings)
3448        // These go before fetches so fetch URLs can reference them
3449        // Dotted keys (e.g. "wired.counter") are nested into objects so
3450        // #wired.counter# resolves via the standard dot-path resolver.
3451        for (key, value) in &directives.vars {
3452            if let Some((root, child)) = key.split_once('.') {
3453                if let Some(Value::Object(obj)) = context.get_mut(root) {
3454                    obj.entry(child.to_string())
3455                        .or_insert_with(|| value.clone());
3456                } else {
3457                    let mut obj = serde_json::Map::new();
3458                    obj.insert(child.to_string(), value.clone());
3459                    context.insert(root.to_string(), Value::Object(obj));
3460                }
3461            } else {
3462                context.insert(key.clone(), value.clone());
3463            }
3464        }
3465    }
3466
3467    // Add session and user context BEFORE fetch directives so #session.*# and
3468    // #user.*# variables resolve in fetch URLs (e.g. fetch.api = "https://api.example.com?page=#session.page#")
3469    if let Some(s) = session {
3470        context.insert("session".to_string(), s.to_context());
3471    }
3472    // The policy actor identifies who is reading — drives owner/tenant scoping.
3473    let actor = crate::policy::Actor::from_parts(user, session);
3474    // Expose #user.owner# (the actor's primary owner key) for template-side
3475    // ownership checks like <if note._owner == user.owner>.
3476    let mut user_ctx = user.to_context();
3477    if let (Value::Object(map), Some(owner)) = (&mut user_ctx, actor.primary_owner_key()) {
3478        map.insert("owner".to_string(), json!(owner));
3479    }
3480    context.insert("user".to_string(), user_ctx);
3481
3482    // Built-in #now# variable — current datetime as ISO string
3483    context.insert(
3484        "now".to_string(),
3485        json!(chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()),
3486    );
3487
3488    let fetch_debug =
3489        apply_fetch_directives(state, &mut context, app_config, page_directives, &actor).await;
3490    tracing::debug!(
3491        "Context keys after fetch: {:?}",
3492        context.keys().collect::<Vec<_>>()
3493    );
3494
3495    // Build data object with application and session sub-objects
3496    let mut data_obj = serde_json::Map::new();
3497
3498    // data.application - shared application data (from DataStore)
3499    if let Some(config) = app_config {
3500        let mut app_data = serde_json::Map::new();
3501        for decl in &config.data_application {
3502            let key = &decl.name;
3503            // Try key-value store first (scalar values like counters),
3504            // then fall back to collections (arrays of objects)
3505            if let Some(value) = state.store.get(key).await {
3506                app_data.insert(key.clone(), value);
3507            } else if state.policies.is_read_scoped(key) {
3508                // A read-scoped collection must not be exposed wholesale via
3509                // data.application — force it empty (fetch directives are the
3510                // sanctioned scoped path).
3511                tracing::debug!(target: "what::policy", "data.application '{}' is read-scoped — exposing empty", key);
3512                app_data.insert(key.clone(), json!([]));
3513            } else if let Some(value) = state.store.get_collection(key).await {
3514                let mut v = json!(value);
3515                crate::policy::strip_private_fields(&mut v, &state.policies.get(key).private_fields);
3516                app_data.insert(key.clone(), v);
3517            } else {
3518                // Default to 0 for uninitialized values
3519                app_data.insert(key.clone(), json!(0));
3520            }
3521        }
3522        data_obj.insert("application".to_string(), Value::Object(app_data));
3523
3524        // wired.* - real-time synced data (reads from same DataStore, auto-wrapped with w-bind)
3525        let mut wired_data = serde_json::Map::new();
3526        for decl in &config.data_wired {
3527            if let Some(value) = state.store.get(&decl.name).await {
3528                wired_data.insert(decl.name.clone(), value);
3529            } else {
3530                wired_data.insert(decl.name.clone(), json!(0));
3531            }
3532        }
3533        if !wired_data.is_empty() {
3534            context.insert("wired".to_string(), Value::Object(wired_data));
3535        }
3536    }
3537
3538    // Process set.wired.* directives — resolve template vars from fetch results,
3539    // persist to DataStore, update context, and broadcast to all connected clients.
3540    // Must run AFTER wired context building above so context["wired"] already exists.
3541    if let Some(directives) = page_directives {
3542        for (key, value_template) in &directives.custom {
3543            if let Some(wired_key) = key.strip_prefix("set.wired.") {
3544                let resolved = crate::parser::replace_variables(value_template, &context);
3545                tracing::info!("set.wired.{} = {}", wired_key, &resolved);
3546                if let Err(e) = state.store.set(wired_key, json!(&resolved)).await {
3547                    tracing::error!("Failed to set wired.{}: {}", wired_key, e);
3548                }
3549                // Update wired context so #wired.key# resolves in this render
3550                if let Some(Value::Object(wired_data)) = context.get_mut("wired") {
3551                    wired_data.insert(wired_key.to_string(), json!(&resolved));
3552                } else {
3553                    let mut wired_data = serde_json::Map::new();
3554                    wired_data.insert(wired_key.to_string(), json!(&resolved));
3555                    context.insert("wired".to_string(), Value::Object(wired_data));
3556                }
3557                // Broadcast to connected WebSocket clients (with scope filtering)
3558                let mut wired_map = serde_json::Map::new();
3559                wired_map.insert(format!("wired.{}", wired_key), Value::String(resolved));
3560                let json_str = serde_json::to_string(&Value::Object(wired_map)).unwrap_or_default();
3561                let mut scope = state.get_wired_scope(wired_key).await;
3562                // For [user] scope, fill in the current user's ID
3563                if matches!(scope, WiredScope::User(ref uid) if uid.is_empty()) {
3564                    let uid = user
3565                        .claims
3566                        .get("sub")
3567                        .and_then(|v| v.as_str())
3568                        .unwrap_or("")
3569                        .to_string();
3570                    scope = WiredScope::User(uid);
3571                }
3572                let _ = state.wired_tx.send(WiredMessage {
3573                    json: json_str,
3574                    scope,
3575                });
3576            }
3577        }
3578    }
3579
3580    // Inject mutation.* directives into context as nested "mutation" object
3581    // so #mutation.name# resolves in w-set attributes
3582    if let Some(directives) = page_directives {
3583        let mut mutation_data = serde_json::Map::new();
3584        for (key, value) in &directives.custom {
3585            if let Some(name) = key.strip_prefix("mutation.") {
3586                mutation_data.insert(name.to_string(), Value::String(value.clone()));
3587            }
3588        }
3589        if !mutation_data.is_empty() {
3590            context.insert("mutation".to_string(), Value::Object(mutation_data));
3591        }
3592    }
3593
3594    // data.session - per-user session data
3595    if let Some(s) = session {
3596        let mut session_data = serde_json::Map::new();
3597        if let Some(config) = app_config {
3598            for key in &config.data_session {
3599                if let Some(value) = s.data.get(key) {
3600                    session_data.insert(key.clone(), value.clone());
3601                } else {
3602                    // Default to 0 for uninitialized values
3603                    session_data.insert(key.clone(), json!(0));
3604                }
3605            }
3606        }
3607        data_obj.insert("session".to_string(), Value::Object(session_data));
3608    }
3609
3610    context.insert("data".to_string(), Value::Object(data_obj));
3611
3612    // Resolve computed variables from page directives
3613    // These use string interpolation: compute.name = "Hello #user.name#!"
3614    // Available as #name# (without compute. prefix) in templates
3615    if let Some(directives) = page_directives {
3616        if !directives.computed.is_empty() {
3617            crate::parser::resolve_computed_variables(&directives.computed, &mut context);
3618        }
3619    }
3620
3621    // Determine the layout to use:
3622    // 1. Page-level layout directive takes priority
3623    // 2. Fall back to app config layout
3624    // 3. "none" explicitly disables layout
3625    let layout_path = page_directives
3626        .and_then(|d| d.layout.as_ref())
3627        .or_else(|| app_config.and_then(|c| c.layout.as_ref()));
3628
3629    // Validation secret for form signing
3630    let validation_secret = state
3631        .config
3632        .auth
3633        .jwt_secret
3634        .as_deref()
3635        .unwrap_or("wwwhat-validation-secret");
3636
3637    // Render the page content
3638    // Always use reactive rendering to wrap session variables with w-bind
3639    // Client-side JS will remove w-bind from injected content to prevent unwanted updates
3640    let render_result = state
3641        .engine
3642        .render_reactive_with_secret(content, &context, Some(validation_secret))
3643        .await?;
3644    let (page_html, session_keys) = (render_result.html, render_result.session_keys);
3645
3646    // Layout path already determined above
3647
3648    // Apply layout if specified and not "none"
3649    if let Some(layout) = layout_path {
3650        if layout.to_lowercase() != "none" {
3651            let layout_file = state.root.join(layout);
3652            if layout_file.exists() {
3653                let raw_layout = tokio::fs::read_to_string(&layout_file).await?;
3654                if state.dev_mode {
3655                    engine::warn_template_lints_once(&layout_file, &raw_layout);
3656                }
3657                // Strip <what> directive block from layout (contains props/defaults)
3658                let (_, layout_content) = parse_page_directives(&raw_layout);
3659                // Replace <slot/> or <slot /> with page content
3660                let wrapped = layout_content
3661                    .replace("<slot/>", &page_html)
3662                    .replace("<slot />", &page_html);
3663                // Render the wrapped content (for variable substitution in layout)
3664                // Always use reactive rendering for consistent w-bind wrapping
3665                let layout_result = state
3666                    .engine
3667                    .render_reactive_with_secret(&wrapped, &context, Some(validation_secret))
3668                    .await?;
3669                let (final_html, layout_session_keys) =
3670                    (layout_result.html, layout_result.session_keys);
3671                // Merge session keys from both page and layout
3672                let mut all_keys = session_keys;
3673                all_keys.extend(layout_session_keys);
3674                return Ok(RenderResult {
3675                    html: final_html,
3676                    session_keys: all_keys,
3677                    fetch_debug,
3678                });
3679            } else {
3680                tracing::warn!("Layout file not found: {:?}", layout_file);
3681            }
3682        }
3683    }
3684
3685    Ok(RenderResult {
3686        html: page_html,
3687        session_keys,
3688        fetch_debug,
3689    })
3690}
3691
3692/// Wrapper for render_content_internal - maintains backward compatibility
3693async fn render_content(
3694    state: &AppState,
3695    content: &str,
3696    session: Option<&sessions::Session>,
3697    user: &UserContext,
3698    app_config: Option<&WhatConfig>,
3699    page_directives: Option<&PageDirectives>,
3700    query_params: Option<&HashMap<String, String>>,
3701) -> Result<String> {
3702    let result = render_content_internal(
3703        state,
3704        content,
3705        session,
3706        user,
3707        app_config,
3708        page_directives,
3709        query_params,
3710        &HashMap::new(),
3711        false,
3712        None,
3713    )
3714    .await?;
3715    Ok(result.html)
3716}
3717
3718/// Discover all page routes from the content directory (site/ or pages/).
3719/// Returns (url_path, is_dynamic) pairs. Dynamic routes have `[id]` segments.
3720pub fn discover_routes(root: &std::path::Path) -> Vec<(String, bool)> {
3721    let pages_dir = content_dir(root);
3722    if !pages_dir.exists() {
3723        return Vec::new();
3724    }
3725
3726    let mut routes = Vec::new();
3727    collect_routes(&pages_dir, &pages_dir, &mut routes);
3728    routes.sort_by(|a, b| a.0.cmp(&b.0));
3729    routes
3730}
3731
3732fn collect_routes(base: &std::path::Path, dir: &std::path::Path, routes: &mut Vec<(String, bool)>) {
3733    let entries = match std::fs::read_dir(dir) {
3734        Ok(e) => e,
3735        Err(_) => return,
3736    };
3737
3738    for entry in entries.flatten() {
3739        let path = entry.path();
3740        if path.is_dir() {
3741            // Skip partials directory
3742            if path.file_name().map_or(false, |n| n == "partials") {
3743                continue;
3744            }
3745            collect_routes(base, &path, routes);
3746        } else if path.extension().map_or(false, |e| e == "html") {
3747            let file_name = path.file_stem().unwrap_or_default().to_string_lossy();
3748            let relative = path.strip_prefix(base).unwrap_or(&path);
3749            let is_dynamic = file_name.starts_with('[') && file_name.ends_with(']');
3750
3751            // Convert file path to URL path
3752            let parent = relative.parent().unwrap_or(std::path::Path::new(""));
3753            let parent_str = parent.to_string_lossy();
3754
3755            let url_path = if file_name == "index" {
3756                if parent_str.is_empty() {
3757                    "/".to_string()
3758                } else {
3759                    format!("/{}", parent_str)
3760                }
3761            } else if parent_str.is_empty() {
3762                format!("/{}", file_name)
3763            } else {
3764                format!("/{}/{}", parent_str, file_name)
3765            };
3766
3767            routes.push((url_path, is_dynamic));
3768        }
3769    }
3770}
3771
3772/// Render a page to static HTML for pre-rendering / build output.
3773///
3774/// Resolves URL path to a page file, loads application config, builds a minimal
3775/// context (no session, no user, no query params), and renders through the engine.
3776/// Returns None if the page requires authentication or is excluded.
3777pub async fn render_page_to_html(state: &AppState, url_path: &str) -> Result<Option<String>> {
3778    let resolved = resolve_page_path(&state.root, url_path);
3779    let page_path = match resolved {
3780        Some(r) if r.params.is_empty() => r.path,
3781        // Dynamic routes can't be pre-rendered
3782        _ => return Ok(None),
3783    };
3784
3785    let raw_content = tokio::fs::read_to_string(&page_path).await?;
3786    let (mut directives, content) = parse_page_directives(&raw_content);
3787
3788    // Load and merge application config
3789    let app_config = load_application_config(&state.root, url_path);
3790    if !directives.requires_auth() && app_config.directives.requires_auth() {
3791        directives.auth = app_config.directives.auth.clone();
3792        directives.protected = app_config.directives.protected;
3793        directives.roles = app_config.directives.roles.clone();
3794    }
3795
3796    // Skip pages that require auth or are excluded
3797    if directives.requires_auth() || directives.exclude {
3798        return Ok(None);
3799    }
3800
3801    let user = UserContext::unauthenticated();
3802    let result = render_content_internal(
3803        state,
3804        &content,
3805        None,
3806        &user,
3807        Some(&app_config),
3808        Some(&directives),
3809        None,
3810        &HashMap::new(),
3811        false,
3812        None,
3813    )
3814    .await?;
3815
3816    register_validated_actions(&result.html, state);
3817    let html = inject_what_css(&result.html, state.css_mode);
3818    let html = inject_what_js(&html);
3819    let html = inject_theme_restore(&html);
3820    Ok(Some(html))
3821}
3822
3823/// Handle form actions (CRUD operations)
3824#[derive(Deserialize)]
3825struct ActionParams {
3826    #[serde(rename = "w-action")]
3827    action: Option<String>,
3828    #[serde(rename = "w-redirect")]
3829    redirect: Option<String>,
3830    #[serde(flatten)]
3831    extra: HashMap<String, String>,
3832}
3833
3834fn decode_query_params(query: Option<&str>) -> HashMap<String, String> {
3835    query
3836        .map(|q| {
3837            q.split('&')
3838                .filter_map(|pair| {
3839                    let mut parts = pair.splitn(2, '=');
3840                    let key = parts.next()?;
3841                    let value = parts.next().unwrap_or("");
3842                    Some((
3843                        urlencoding::decode(key).unwrap_or_default().into_owned(),
3844                        urlencoding::decode(value).unwrap_or_default().into_owned(),
3845                    ))
3846                })
3847                .collect()
3848        })
3849        .unwrap_or_default()
3850}
3851
3852/// Helper: extract session from cookie headers and return (session, session_store_ref)
3853async fn extract_session_from_headers(
3854    state: &AppState,
3855    headers: &HeaderMap,
3856) -> (Option<sessions::Session>, bool) {
3857    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
3858    if let Some(ref sessions) = state.sessions {
3859        let session_id =
3860            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
3861        match sessions.get_or_create(session_id.as_deref()).await {
3862            Ok(session) => {
3863                let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
3864                (Some(session), is_new)
3865            }
3866            Err(_) => (None, false),
3867        }
3868    } else {
3869        (None, false)
3870    }
3871}
3872
3873/// Helper: extract the authenticated user context from cookie headers.
3874/// Mirrors the JWT flow in `handle_page` so action/upload/w-set handlers can
3875/// identify the actor without duplicating the decode logic.
3876fn extract_user_context(state: &AppState, headers: &HeaderMap) -> UserContext {
3877    if !state.auth.is_enabled() {
3878        return UserContext::unauthenticated();
3879    }
3880    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
3881    if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
3882        match state.auth.decode_jwt(&token) {
3883            Ok(claims) if !claims.is_expired() => {
3884                UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
3885            }
3886            _ => UserContext::unauthenticated(),
3887        }
3888    } else {
3889        UserContext::unauthenticated()
3890    }
3891}
3892
3893/// Helper: build the policy Actor for a request from its user context + session.
3894fn extract_actor(
3895    state: &AppState,
3896    headers: &HeaderMap,
3897    session: Option<&sessions::Session>,
3898) -> crate::policy::Actor {
3899    let user = extract_user_context(state, headers);
3900    crate::policy::Actor::from_parts(&user, session)
3901}
3902
3903/// Build a policy-denial response. Full-page form submits get a flash error +
3904/// 303 redirect back (matching the validation/error UX); AJAX/partial requests
3905/// get a 403 with a plain-text message and, in dev mode, an `X-What-Policy`
3906/// header describing the denied rule.
3907async fn deny_response(
3908    state: &AppState,
3909    session: &mut Option<sessions::Session>,
3910    is_new_session: bool,
3911    is_partial: bool,
3912    referer: Option<&str>,
3913    msg: String,
3914    dev_detail: String,
3915) -> axum::response::Response {
3916    tracing::info!(target: "what::policy", "denied: {}", dev_detail);
3917
3918    if is_partial {
3919        let mut headers = vec![(header::CONTENT_TYPE, "text/plain".to_string())];
3920        if state.dev_mode {
3921            headers.push((
3922                header::HeaderName::from_static("x-what-policy"),
3923                format!("deny; {}", dev_detail),
3924            ));
3925        }
3926        return build_response(StatusCode::FORBIDDEN, headers, msg);
3927    }
3928
3929    if let Some(sess) = session {
3930        let flash = FlashData {
3931            flash: HashMap::from([("error".to_string(), msg)]),
3932            ..Default::default()
3933        };
3934        set_flash_data(sess, &flash);
3935        if let Some(ref sessions) = state.sessions {
3936            let _ = sessions.update(&sess.id, sess.data.clone()).await;
3937        }
3938    }
3939    let fallback = referer.unwrap_or("/");
3940    redirect_with_session(state, fallback, session.as_ref(), is_new_session)
3941}
3942
3943/// Helper: build redirect response with optional session cookie
3944fn redirect_with_session(
3945    state: &AppState,
3946    redirect_to: &str,
3947    session: Option<&sessions::Session>,
3948    is_new_session: bool,
3949) -> axum::response::Response {
3950    let mut headers = vec![(header::LOCATION, redirect_to.to_string())];
3951    if is_new_session {
3952        if let Some(s) = session {
3953            headers.push((
3954                header::SET_COOKIE,
3955                sessions::build_session_cookie(
3956                    &s.id,
3957                    &state.config.session.cookie_name,
3958                    state.config.session.max_age,
3959                    state.config.session.secure,
3960                ),
3961            ));
3962        }
3963    }
3964    build_response(StatusCode::SEE_OTHER, headers, String::new())
3965}
3966
3967/// Validate form data if w-rules token is present.
3968/// Returns Ok(()) if valid or no rules. Returns Err with redirect response if invalid.
3969/// If the action_url is registered as requiring validation but w-rules is missing,
3970/// the request is rejected (fail-closed: prevents bypassing validation by stripping the token).
3971async fn validate_form_data(
3972    state: &AppState,
3973    form_data: &HashMap<String, String>,
3974    session: &mut Option<sessions::Session>,
3975    is_new_session: bool,
3976    referer: Option<&str>,
3977    action_url: Option<String>,
3978) -> std::result::Result<(), axum::response::Response> {
3979    let rules_token = match form_data.get("w-rules") {
3980        Some(t) => t,
3981        None => {
3982            // Fail-closed: if this action URL was registered as requiring validation
3983            // (from a rendered <form w-validate>), reject submissions without w-rules.
3984            if let Some(ref url) = action_url {
3985                // Check registry — drop guard before any .await
3986                let requires_validation = {
3987                    let registry = state.validated_actions.read().unwrap_or_else(|e| e.into_inner());
3988                    registry.contains(url.as_str())
3989                        || registry.iter().any(|r| {
3990                            let r_path = r.split('?').next().unwrap_or(r);
3991                            r_path == url.as_str()
3992                        })
3993                };
3994                if requires_validation {
3995                    tracing::warn!(
3996                        "Validation bypass rejected: w-rules missing for registered action '{}'",
3997                        url
3998                    );
3999                    let redirect_to = referer.unwrap_or("/");
4000                    let flash = FlashData {
4001                        flash: HashMap::from([(
4002                            "error".to_string(),
4003                            "Form validation required. Please reload and try again.".to_string(),
4004                        )]),
4005                        errors: HashMap::new(),
4006                        old: HashMap::new(),
4007                    };
4008                    if let Some(sess) = session {
4009                        set_flash_data(sess, &flash);
4010                        if let Some(ref sessions) = state.sessions {
4011                            let _ = sessions.update(&sess.id, sess.data.clone()).await;
4012                        }
4013                    }
4014                    return Err(redirect_with_session(
4015                        state,
4016                        redirect_to,
4017                        session.as_ref(),
4018                        is_new_session,
4019                    ));
4020                }
4021            }
4022            return Ok(());
4023        }
4024    };
4025
4026    let secret = state
4027        .config
4028        .auth
4029        .jwt_secret
4030        .as_deref()
4031        .unwrap_or("wwwhat-validation-secret");
4032    let rules = match validation::decode_rules(rules_token, secret) {
4033        Some(r) => r,
4034        None => {
4035            tracing::warn!("Invalid w-rules token — possible tampering, rejecting submission");
4036            // Fail closed: reject the submission when validation rules are tampered
4037            let redirect_to = referer.unwrap_or("/");
4038            let flash = FlashData {
4039                flash: HashMap::from([(
4040                    "error".to_string(),
4041                    "Form validation failed. Please reload and try again.".to_string(),
4042                )]),
4043                errors: HashMap::new(),
4044                old: HashMap::new(),
4045            };
4046            if let Some(sess) = session {
4047                set_flash_data(sess, &flash);
4048                if let Some(ref sessions) = state.sessions {
4049                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4050                }
4051            }
4052            return Err(redirect_with_session(
4053                state,
4054                redirect_to,
4055                session.as_ref(),
4056                is_new_session,
4057            ));
4058        }
4059    };
4060
4061    let mut result = validation::validate_form(form_data, &rules);
4062
4063    // Enforce w-unique constraints against DataStore
4064    for (field_name, field_rules) in &rules.fields {
4065        if let Some(ref unique_spec) = field_rules.unique {
4066            let value = form_data.get(field_name).map(|s| s.as_str()).unwrap_or("");
4067            if !value.is_empty() {
4068                let parts: Vec<&str> = unique_spec.split('.').collect();
4069                if parts.len() == 2 {
4070                    let collection = parts[0];
4071                    let check_field = parts[1];
4072                    // Search collection for existing records with this value
4073                    if let Some(items) = state.store.get_collection(collection).await {
4074                        let exists = items.iter().any(|item| {
4075                            item.get(check_field)
4076                                .and_then(|v| v.as_str())
4077                                .map(|v| v == value)
4078                                .unwrap_or(false)
4079                        });
4080                        if exists {
4081                            let msg = field_rules
4082                                .error_message
4083                                .clone()
4084                                .unwrap_or_else(|| format!("{} already exists", field_name));
4085                            result.errors.insert(field_name.clone(), msg);
4086                            result.is_valid = false;
4087                        }
4088                    }
4089                }
4090            }
4091        }
4092    }
4093
4094    if result.is_valid {
4095        return Ok(());
4096    }
4097
4098    // Validation failed — store errors + old values as flash, redirect back
4099    let redirect_to = referer.unwrap_or("/");
4100    let flash = FlashData {
4101        flash: HashMap::from([(
4102            "error".to_string(),
4103            "Please fix the errors below".to_string(),
4104        )]),
4105        errors: result.errors,
4106        old: form_data
4107            .iter()
4108            .filter(|(k, _)| !k.starts_with("w-"))
4109            .map(|(k, v)| (k.clone(), v.clone()))
4110            .collect(),
4111    };
4112
4113    if let Some(sess) = session {
4114        set_flash_data(sess, &flash);
4115        if let Some(ref sessions) = state.sessions {
4116            let _ = sessions.update(&sess.id, sess.data.clone()).await;
4117        }
4118    }
4119
4120    Err(redirect_with_session(
4121        state,
4122        redirect_to,
4123        session.as_ref(),
4124        is_new_session,
4125    ))
4126}
4127
4128/// Framework-internal form fields that must never be persisted as record data.
4129/// Real app fields (including `_session_id`) are kept; only control fields are dropped.
4130fn is_framework_field(key: &str) -> bool {
4131    key.starts_with("w-")
4132        || key == "_csrf"
4133        || key == "redirect"
4134        || key == "cf-turnstile-response"
4135}
4136
4137/// Warn once per collection when a mutation is permitted only because a record
4138/// predates ownership tracking (implicit default policy + unowned record).
4139fn warn_legacy_mutation_once(collection: &str) {
4140    static WARNED: LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
4141        LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
4142    let mut warned = WARNED.lock().unwrap_or_else(|e| e.into_inner());
4143    if warned.insert(collection.to_string()) {
4144        tracing::warn!(
4145            target: "what::policy",
4146            "collection '{}' has records without an _owner (created before ownership tracking) — these remain modifiable by anyone under the default policy. Declare an explicit policy or backfill _owner to lock them.",
4147            collection
4148        );
4149    }
4150}
4151
4152async fn handle_action(
4153    State(state): State<AppState>,
4154    Path(collection): Path<String>,
4155    headers: HeaderMap,
4156    Query(params): Query<ActionParams>,
4157    Form(form_data): Form<HashMap<String, String>>,
4158) -> impl IntoResponse {
4159    let action = params.action.as_deref().unwrap_or("create");
4160    let referer = headers
4161        .get(header::REFERER)
4162        .and_then(|v| v.to_str().ok())
4163        .map(|s| s.to_string());
4164    let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
4165    let action_url = format!("/w-action/{}", collection);
4166
4167    // Validate if w-rules present
4168    if let Err(resp) = validate_form_data(
4169        &state,
4170        &form_data,
4171        &mut session,
4172        is_new_session,
4173        referer.as_deref(),
4174        Some(action_url.clone()),
4175    )
4176    .await
4177    {
4178        return resp;
4179    }
4180
4181    // Verify Turnstile if configured
4182    let turnstile_token = form_data.get("cf-turnstile-response").map(|s| s.as_str());
4183    if let Err(msg) = verify_turnstile(&state, turnstile_token).await {
4184        tracing::warn!("Turnstile verification failed: {}", msg);
4185        if let Some(ref mut sess) = session {
4186            let flash = FlashData {
4187                flash: HashMap::from([("error".to_string(), msg)]),
4188                ..Default::default()
4189            };
4190            set_flash_data(sess, &flash);
4191            if let Some(ref sessions) = state.sessions {
4192                let _ = sessions.update(&sess.id, sess.data.clone()).await;
4193            }
4194        }
4195        let fallback = referer.as_deref().unwrap_or("/");
4196        return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4197    }
4198
4199    // Extract email trigger and partial path before consuming form_data
4200    let email_trigger = extract_email_trigger(&form_data);
4201    let partial_src = form_data.get("w-partial").cloned();
4202    let is_partial = headers
4203        .get("X-Requested-With")
4204        .and_then(|v| v.to_str().ok())
4205        .map(|v| v == "What")
4206        .unwrap_or(false);
4207
4208    // Authorization: does this actor may create in this collection?
4209    let actor = extract_actor(&state, &headers, session.as_ref());
4210    let policy = state.policies.get(&collection);
4211    if action == "create" && !policy.allows_create(&actor) {
4212        return deny_response(
4213            &state,
4214            &mut session,
4215            is_new_session,
4216            is_partial,
4217            referer.as_deref(),
4218            format!("You don't have permission to add to {}.", collection),
4219            format!("collection={}; action=create; rule={:?}", collection, policy.create),
4220        )
4221        .await;
4222    }
4223
4224    let result = match action {
4225        "create" => {
4226            let mut map: serde_json::Map<String, Value> = form_data
4227                .into_iter()
4228                .filter(|(k, _)| !is_framework_field(k))
4229                .map(|(k, v)| (k, Value::String(v)))
4230                .collect();
4231            // Drop spoofed _owner + readonly fields, then stamp the real owner.
4232            policy.sanitize_input(&mut map);
4233            policy.stamp_owner(&mut map, &actor);
4234            state.store.create(&collection, Value::Object(map)).await
4235        }
4236        _ => Err(crate::Error::Action(format!("Unknown action: {}", action))),
4237    };
4238
4239    // Invalidate cache for this collection
4240    state.cache.invalidate_content_type(&collection).await;
4241
4242    let redirect_to = params.redirect.as_deref().unwrap_or("/");
4243
4244    match result {
4245        Ok(_) => {
4246            // Enqueue email if w-email-to was specified
4247            maybe_enqueue_email(&state, email_trigger).await;
4248
4249            // Return rendered partial for AJAX requests
4250            if is_partial {
4251                if let Some(ref partial_path) = partial_src {
4252                    if let Ok(html) = render_partial_for_action(
4253                        &state,
4254                        &headers,
4255                        partial_path,
4256                        &params.extra,
4257                    )
4258                    .await
4259                    {
4260                        return build_partial_response(
4261                            html,
4262                            session.as_ref(),
4263                            is_new_session,
4264                            &state,
4265                        );
4266                    }
4267                }
4268            }
4269
4270            // Set success flash and redirect for full-page requests
4271            if let Some(ref mut sess) = session {
4272                let flash = FlashData {
4273                    flash: HashMap::from([(
4274                        "success".to_string(),
4275                        format!("Item created in {}", collection),
4276                    )]),
4277                    ..Default::default()
4278                };
4279                set_flash_data(sess, &flash);
4280                if let Some(ref sessions) = state.sessions {
4281                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4282                }
4283            }
4284            redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
4285        }
4286        Err(e) => {
4287            tracing::error!("Action error: {}", e);
4288            // Set error flash and redirect back
4289            if let Some(ref mut sess) = session {
4290                let flash = FlashData {
4291                    flash: HashMap::from([(
4292                        "error".to_string(),
4293                        format!("Failed to create: {}", e),
4294                    )]),
4295                    ..Default::default()
4296                };
4297                set_flash_data(sess, &flash);
4298                if let Some(ref sessions) = state.sessions {
4299                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4300                }
4301            }
4302            let fallback = referer.as_deref().unwrap_or(redirect_to);
4303            redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
4304        }
4305    }
4306}
4307
4308async fn handle_action_with_id(
4309    State(state): State<AppState>,
4310    Path((collection, id)): Path<(String, String)>,
4311    headers: HeaderMap,
4312    Query(params): Query<ActionParams>,
4313    Form(form_data): Form<HashMap<String, String>>,
4314) -> impl IntoResponse {
4315    let action = params.action.as_deref().unwrap_or("update");
4316    let referer = headers
4317        .get(header::REFERER)
4318        .and_then(|v| v.to_str().ok())
4319        .map(|s| s.to_string());
4320    let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
4321    // Include the id so update/delete on /w-action/{collection}/{id} don't
4322    // collide with the create form's registered validated action at
4323    // /w-action/{collection} (which would wrongly reject them as missing w-rules).
4324    let action_url = format!("/w-action/{}/{}", collection, id);
4325
4326    // Validate if w-rules present
4327    if let Err(resp) = validate_form_data(
4328        &state,
4329        &form_data,
4330        &mut session,
4331        is_new_session,
4332        referer.as_deref(),
4333        Some(action_url.clone()),
4334    )
4335    .await
4336    {
4337        return resp;
4338    }
4339
4340    // Extract email trigger and partial path before consuming form_data
4341    let email_trigger = extract_email_trigger(&form_data);
4342    let partial_src = form_data.get("w-partial").cloned();
4343    let is_partial = headers
4344        .get("X-Requested-With")
4345        .and_then(|v| v.to_str().ok())
4346        .map(|v| v == "What")
4347        .unwrap_or(false);
4348
4349    let id_value: Value = if let Ok(num) = id.parse::<i64>() {
4350        Value::Number(num.into())
4351    } else {
4352        Value::String(id)
4353    };
4354
4355    // Authorization: fetch the existing record ONCE and check ownership /
4356    // tenant scope before any mutation. This is the gate that closes the
4357    // "edit/delete anyone's record by id" hole.
4358    let actor = extract_actor(&state, &headers, session.as_ref());
4359    let policy = state.policies.get(&collection);
4360    let kind = if action == "delete" {
4361        crate::policy::MutationKind::Delete
4362    } else {
4363        crate::policy::MutationKind::Update
4364    };
4365    let existing = state
4366        .store
4367        .find_by(&collection, "id", &id_value)
4368        .await
4369        .into_iter()
4370        .next();
4371    // Resolve the tenant filter (if any) against the current request context.
4372    let mut filter_ctx: HashMap<String, Value> = HashMap::new();
4373    {
4374        let user = extract_user_context(&state, &headers);
4375        filter_ctx.insert("user".to_string(), user.to_context());
4376        if let Some(s) = session.as_ref() {
4377            filter_ctx.insert("session".to_string(), s.to_context());
4378        }
4379    }
4380    let resolved_filter = policy.resolved_filter(&filter_ctx);
4381    if action == "update" || action == "delete" {
4382        match policy.allows_mutation(&actor, existing.as_ref(), kind, resolved_filter.as_deref()) {
4383            crate::policy::Decision::Deny => {
4384                return deny_response(
4385                    &state,
4386                    &mut session,
4387                    is_new_session,
4388                    is_partial,
4389                    referer.as_deref(),
4390                    format!("You don't have permission to {} that item.", action),
4391                    format!("collection={}; action={}; id={}", collection, action, id_value),
4392                )
4393                .await;
4394            }
4395            crate::policy::Decision::AllowLegacy => {
4396                warn_legacy_mutation_once(&collection);
4397            }
4398            crate::policy::Decision::Allow => {}
4399        }
4400    }
4401
4402    let result = match action {
4403        "update" => {
4404            let mut map: serde_json::Map<String, Value> = form_data
4405                .into_iter()
4406                .filter(|(k, _)| !is_framework_field(k))
4407                .map(|(k, v)| (k, Value::String(v)))
4408                .collect();
4409            // Drop spoofed _owner + readonly fields (owner is immutable).
4410            policy.sanitize_input(&mut map);
4411            state
4412                .store
4413                .update(&collection, &id_value, Value::Object(map))
4414                .await
4415                .map(|_| ())
4416        }
4417        "delete" => {
4418            // Clean up uploaded files using the record we already fetched.
4419            if state.config.uploads.enabled {
4420                if let Some(record) = existing.as_ref() {
4421                    cleanup_uploaded_files(&state.root, &state.config.uploads.directory, record)
4422                        .await;
4423                }
4424            }
4425            state.store.delete(&collection, &id_value).await.map(|_| ())
4426        }
4427        _ => Err(crate::Error::Action(format!("Unknown action: {}", action))),
4428    };
4429
4430    // Invalidate cache
4431    state.cache.invalidate_content_type(&collection).await;
4432
4433    let redirect_to = params.redirect.as_deref().unwrap_or("/");
4434
4435    match result {
4436        Ok(_) => {
4437            // Enqueue email if w-email-to was specified
4438            maybe_enqueue_email(&state, email_trigger).await;
4439
4440            // Return rendered partial for AJAX requests
4441            if is_partial {
4442                if let Some(ref partial_path) = partial_src {
4443                    if let Ok(html) = render_partial_for_action(
4444                        &state,
4445                        &headers,
4446                        partial_path,
4447                        &params.extra,
4448                    )
4449                    .await
4450                    {
4451                        return build_partial_response(
4452                            html,
4453                            session.as_ref(),
4454                            is_new_session,
4455                            &state,
4456                        );
4457                    }
4458                }
4459            }
4460
4461            // Set success flash and redirect for full-page requests
4462            if let Some(ref mut sess) = session {
4463                let action_label = if action == "delete" {
4464                    "deleted"
4465                } else {
4466                    "updated"
4467                };
4468                let flash = FlashData {
4469                    flash: HashMap::from([(
4470                        "success".to_string(),
4471                        format!("Item {} in {}", action_label, collection),
4472                    )]),
4473                    ..Default::default()
4474                };
4475                set_flash_data(sess, &flash);
4476                if let Some(ref sessions) = state.sessions {
4477                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4478                }
4479            }
4480            redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
4481        }
4482        Err(e) => {
4483            tracing::error!("Action error: {}", e);
4484            if let Some(ref mut sess) = session {
4485                let flash = FlashData {
4486                    flash: HashMap::from([("error".to_string(), format!("Action failed: {}", e))]),
4487                    ..Default::default()
4488                };
4489                set_flash_data(sess, &flash);
4490                if let Some(ref sessions) = state.sessions {
4491                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4492                }
4493            }
4494            let fallback = referer.as_deref().unwrap_or(redirect_to);
4495            redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
4496        }
4497    }
4498}
4499
4500/// Render a partial template for returning inline HTML from action handlers.
4501/// Reuses the same rendering pipeline as handle_partial() but takes the state
4502/// and headers directly instead of being an axum handler.
4503async fn render_partial_for_action(
4504    state: &AppState,
4505    headers: &HeaderMap,
4506    partial_path: &str,
4507    query_params: &HashMap<String, String>,
4508) -> Result<String> {
4509    let clean_path = partial_path.trim_start_matches('/');
4510    let partials_dir = state.content_dir.join("partials");
4511    let file_path = {
4512        let with_ext = partials_dir.join(format!("{}.html", clean_path));
4513        if with_ext.exists() {
4514            with_ext
4515        } else {
4516            partials_dir.join(clean_path).join("index.html")
4517        }
4518    };
4519
4520    // Path traversal protection
4521    let canonical = file_path
4522        .canonicalize()
4523        .map_err(|_| crate::Error::Action("Partial not found".into()))?;
4524    let partials_canonical = partials_dir
4525        .canonicalize()
4526        .map_err(|_| crate::Error::Action("Partials dir not found".into()))?;
4527    if !canonical.starts_with(&partials_canonical) {
4528        return Err(crate::Error::Action("Access denied".into()));
4529    }
4530
4531    let raw_content = tokio::fs::read_to_string(&canonical)
4532        .await
4533        .map_err(|_| crate::Error::Action("Partial not found".into()))?;
4534
4535    if state.dev_mode {
4536        engine::warn_template_lints_once(&canonical, &raw_content);
4537    }
4538
4539    let (directives, content) = parse_page_directives(&raw_content);
4540
4541    // Load session from headers
4542    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
4543    let session = if let Some(ref sessions) = state.sessions {
4544        let session_id =
4545            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
4546        sessions.get_or_create(session_id.as_deref()).await.ok()
4547    } else {
4548        None
4549    };
4550
4551    let user_context = if state.auth.is_enabled() {
4552        if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
4553            match state.auth.decode_jwt(&token) {
4554                Ok(claims) if !claims.is_expired() => {
4555                    UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
4556                }
4557                _ => UserContext::unauthenticated(),
4558            }
4559        } else {
4560            UserContext::unauthenticated()
4561        }
4562    } else {
4563        UserContext::unauthenticated()
4564    };
4565
4566    let render_result = render_content_internal(
4567        state,
4568        &content,
4569        session.as_ref(),
4570        &user_context,
4571        None,
4572        Some(&directives),
4573        Some(query_params),
4574        &HashMap::new(),
4575        true,
4576        None,
4577    )
4578    .await?;
4579
4580    let mut html = render_result.html;
4581
4582    // Inject CSRF tokens into any forms in the partial
4583    if let Some(ref s) = session {
4584        if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
4585            html = inject_csrf_tokens(&html, csrf);
4586        }
4587    }
4588
4589    Ok(html)
4590}
4591
4592/// Build an HTML response for a partial update, with session cookie if needed.
4593fn build_partial_response(
4594    html: String,
4595    session: Option<&sessions::Session>,
4596    is_new_session: bool,
4597    state: &AppState,
4598) -> Response {
4599    let mut resp_headers: Vec<(header::HeaderName, String)> =
4600        vec![(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())];
4601    if is_new_session {
4602        if let Some(s) = session {
4603            resp_headers.push((
4604                header::SET_COOKIE,
4605                sessions::build_session_cookie(
4606                    &s.id,
4607                    &state.config.session.cookie_name,
4608                    state.config.session.max_age,
4609                    state.config.session.secure,
4610                ),
4611            ));
4612        }
4613    }
4614    build_response(StatusCode::OK, resp_headers, html)
4615}
4616
4617/// Handle file upload via multipart/form-data
4618async fn handle_upload(
4619    State(state): State<AppState>,
4620    Path(collection): Path<String>,
4621    headers: HeaderMap,
4622    Query(params): Query<ActionParams>,
4623    mut multipart: Multipart,
4624) -> impl IntoResponse {
4625    let referer = headers
4626        .get(header::REFERER)
4627        .and_then(|v| v.to_str().ok())
4628        .map(|s| s.to_string());
4629    let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
4630    let redirect_to = params.redirect.as_deref().unwrap_or("/");
4631
4632    // Check if uploads are enabled
4633    if !state.config.uploads.enabled {
4634        if let Some(ref mut sess) = session {
4635            let flash = FlashData {
4636                flash: HashMap::from([(
4637                    "error".to_string(),
4638                    "File uploads are not enabled".to_string(),
4639                )]),
4640                ..Default::default()
4641            };
4642            set_flash_data(sess, &flash);
4643            if let Some(ref sessions) = state.sessions {
4644                let _ = sessions.update(&sess.id, sess.data.clone()).await;
4645            }
4646        }
4647        let fallback = referer.as_deref().unwrap_or(redirect_to);
4648        return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4649    }
4650
4651    let upload_backend = match state.upload_backend {
4652        Some(ref backend) => backend.clone(),
4653        None => {
4654            if let Some(ref mut sess) = session {
4655                let flash = FlashData {
4656                    flash: HashMap::from([(
4657                        "error".to_string(),
4658                        "Upload backend not configured".to_string(),
4659                    )]),
4660                    ..Default::default()
4661                };
4662                set_flash_data(sess, &flash);
4663                if let Some(ref sessions) = state.sessions {
4664                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4665                }
4666            }
4667            let fallback = referer.as_deref().unwrap_or(redirect_to);
4668            return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4669        }
4670    };
4671    // Authorization: uploads obey the collection's create rule. Checked before
4672    // the multipart loop so no files are written on denial.
4673    let actor = extract_actor(&state, &headers, session.as_ref());
4674    let policy = state.policies.get(&collection);
4675    if !policy.allows_create(&actor) {
4676        let is_partial = headers
4677            .get("X-Requested-With")
4678            .and_then(|v| v.to_str().ok())
4679            .map(|v| v == "What")
4680            .unwrap_or(false);
4681        return deny_response(
4682            &state,
4683            &mut session,
4684            is_new_session,
4685            is_partial,
4686            referer.as_deref(),
4687            format!("You don't have permission to upload to {}.", collection),
4688            format!("collection={}; action=upload", collection),
4689        )
4690        .await;
4691    }
4692
4693    let max_size = state.config.uploads.max_size_bytes();
4694    let mut form_fields: HashMap<String, String> = HashMap::new();
4695    let mut uploaded_files: Vec<(String, String)> = Vec::new(); // (field_name, saved_filename)
4696
4697    // Process multipart fields
4698    while let Ok(Some(field)) = multipart.next_field().await {
4699        let field_name = field.name().unwrap_or("").to_string();
4700
4701        if let Some(file_name) = field.file_name().map(|s| s.to_string()) {
4702            // This is a file field
4703            if file_name.is_empty() {
4704                continue; // Skip empty file inputs
4705            }
4706
4707            let content_type = field
4708                .content_type()
4709                .unwrap_or("application/octet-stream")
4710                .to_string();
4711
4712            // Check allowed types
4713            if !state.config.uploads.is_type_allowed(&content_type) {
4714                if let Some(ref mut sess) = session {
4715                    let flash = FlashData {
4716                        flash: HashMap::from([(
4717                            "error".to_string(),
4718                            format!("File type '{}' is not allowed", content_type),
4719                        )]),
4720                        ..Default::default()
4721                    };
4722                    set_flash_data(sess, &flash);
4723                    if let Some(ref sessions) = state.sessions {
4724                        let _ = sessions.update(&sess.id, sess.data.clone()).await;
4725                    }
4726                }
4727                // Clean up any already-saved files
4728                for (_, saved) in &uploaded_files {
4729                    let _ = upload_backend.delete(saved).await;
4730                }
4731                let fallback = referer.as_deref().unwrap_or(redirect_to);
4732                return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4733            }
4734
4735            // Read file data
4736            let data = match field.bytes().await {
4737                Ok(bytes) => bytes,
4738                Err(e) => {
4739                    tracing::error!("Failed to read upload field: {}", e);
4740                    continue;
4741                }
4742            };
4743
4744            // Check file size
4745            if data.len() > max_size {
4746                if let Some(ref mut sess) = session {
4747                    let flash = FlashData {
4748                        flash: HashMap::from([(
4749                            "error".to_string(),
4750                            format!(
4751                                "File '{}' exceeds maximum size of {}",
4752                                file_name, state.config.uploads.max_size
4753                            ),
4754                        )]),
4755                        ..Default::default()
4756                    };
4757                    set_flash_data(sess, &flash);
4758                    if let Some(ref sessions) = state.sessions {
4759                        let _ = sessions.update(&sess.id, sess.data.clone()).await;
4760                    }
4761                }
4762                for (_, saved) in &uploaded_files {
4763                    let _ = upload_backend.delete(saved).await;
4764                }
4765                let fallback = referer.as_deref().unwrap_or(redirect_to);
4766                return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4767            }
4768
4769            // Generate unique filename with sanitized extension
4770            let extension = std::path::Path::new(&file_name)
4771                .extension()
4772                .and_then(|e| e.to_str())
4773                .map(|e| sanitize_extension(e))
4774                .unwrap_or_default();
4775            let saved_name = format!("{}{}", uuid::Uuid::new_v4(), extension);
4776
4777            // Save file via upload backend
4778            match upload_backend.put(&saved_name, &data, &content_type).await {
4779                Ok(public_url) => {
4780                    form_fields.insert(field_name.clone(), public_url);
4781                    uploaded_files.push((field_name, saved_name));
4782                }
4783                Err(e) => {
4784                    tracing::error!("Failed to save uploaded file: {}", e);
4785                    if let Some(ref mut sess) = session {
4786                        let flash = FlashData {
4787                            flash: HashMap::from([(
4788                                "error".to_string(),
4789                                "Failed to save uploaded file".to_string(),
4790                            )]),
4791                            ..Default::default()
4792                        };
4793                        set_flash_data(sess, &flash);
4794                        if let Some(ref sessions) = state.sessions {
4795                            let _ = sessions.update(&sess.id, sess.data.clone()).await;
4796                        }
4797                    }
4798                    for (_, saved) in &uploaded_files {
4799                        let _ = upload_backend.delete(saved).await;
4800                    }
4801                    let fallback = referer.as_deref().unwrap_or(redirect_to);
4802                    return redirect_with_session(
4803                        &state,
4804                        fallback,
4805                        session.as_ref(),
4806                        is_new_session,
4807                    );
4808                }
4809            }
4810        } else {
4811            // Regular form field
4812            let value = field.text().await.unwrap_or_default();
4813            form_fields.insert(field_name, value);
4814        }
4815    }
4816
4817    let action_url = format!("/w-upload/{}", collection);
4818
4819    // Validate if w-rules present
4820    if let Err(resp) = validate_form_data(
4821        &state,
4822        &form_fields,
4823        &mut session,
4824        is_new_session,
4825        referer.as_deref(),
4826        Some(action_url.clone()),
4827    )
4828    .await
4829    {
4830        // Clean up uploaded files on validation failure
4831        for (_, saved) in &uploaded_files {
4832            let _ = upload_backend.delete(saved).await;
4833        }
4834        return resp;
4835    }
4836
4837    // Create the record in the data store
4838    let mut item_map: serde_json::Map<String, Value> = form_fields
4839        .into_iter()
4840        .filter(|(k, _)| !k.starts_with("w-"))
4841        .map(|(k, v)| (k, Value::String(v)))
4842        .collect();
4843    policy.sanitize_input(&mut item_map);
4844    policy.stamp_owner(&mut item_map, &actor);
4845
4846    let result = state.store.create(&collection, Value::Object(item_map)).await;
4847    state.cache.invalidate_content_type(&collection).await;
4848
4849    match result {
4850        Ok(_) => {
4851            if let Some(ref mut sess) = session {
4852                let flash = FlashData {
4853                    flash: HashMap::from([(
4854                        "success".to_string(),
4855                        format!("Item created in {}", collection),
4856                    )]),
4857                    ..Default::default()
4858                };
4859                set_flash_data(sess, &flash);
4860                if let Some(ref sessions) = state.sessions {
4861                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4862                }
4863            }
4864            redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
4865        }
4866        Err(e) => {
4867            tracing::error!("Upload action error: {}", e);
4868            // Clean up uploaded files on store error
4869            for (_, saved) in &uploaded_files {
4870                let _ = upload_backend.delete(saved).await;
4871            }
4872            if let Some(ref mut sess) = session {
4873                let flash = FlashData {
4874                    flash: HashMap::from([(
4875                        "error".to_string(),
4876                        format!("Failed to create: {}", e),
4877                    )]),
4878                    ..Default::default()
4879                };
4880                set_flash_data(sess, &flash);
4881                if let Some(ref sessions) = state.sessions {
4882                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4883                }
4884            }
4885            let fallback = referer.as_deref().unwrap_or(redirect_to);
4886            redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
4887        }
4888    }
4889}
4890
4891/// Clean up uploaded files referenced in a data store record
4892async fn cleanup_uploaded_files(root: &std::path::Path, upload_dir: &str, record: &Value) {
4893    if let Value::Object(map) = record {
4894        for (_key, value) in map {
4895            if let Value::String(s) = value {
4896                // Check if the value looks like an upload path
4897                if s.starts_with("/uploads/") {
4898                    let filename = &s["/uploads/".len()..];
4899                    let file_path = root.join(upload_dir).join(filename);
4900                    if file_path.exists() {
4901                        if let Err(e) = tokio::fs::remove_file(&file_path).await {
4902                            tracing::warn!(
4903                                "Failed to delete uploaded file {}: {}",
4904                                file_path.display(),
4905                                e
4906                            );
4907                        } else {
4908                            tracing::debug!("Cleaned up uploaded file: {}", file_path.display());
4909                        }
4910                    }
4911                }
4912            }
4913        }
4914    }
4915}
4916
4917/// Handle session reset - clears the current session and creates a new one
4918async fn handle_session_reset(
4919    State(state): State<AppState>,
4920    headers: HeaderMap,
4921    Form(form): Form<HashMap<String, String>>,
4922) -> impl IntoResponse {
4923    // Extract current session cookie
4924    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
4925
4926    // Delete the old session if it exists
4927    if let Some(ref sessions) = state.sessions {
4928        if let Some(session_id) =
4929            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name)
4930        {
4931            let _ = sessions.delete(&session_id).await;
4932        }
4933
4934        // Create a new session
4935        match sessions.create().await {
4936            Ok(new_session) => {
4937                let cookie = sessions::build_session_cookie(
4938                    &new_session.id,
4939                    &state.config.session.cookie_name,
4940                    state.config.session.max_age,
4941                    state.config.session.secure,
4942                );
4943
4944                // Get redirect from form, referer, or default to /state
4945                let redirect_url = form
4946                    .get("redirect")
4947                    .map(|s| s.as_str())
4948                    .or_else(|| headers.get(header::REFERER).and_then(|v| v.to_str().ok()))
4949                    .unwrap_or("/state");
4950
4951                // Redirect back with new session cookie
4952                build_response(
4953                    StatusCode::SEE_OTHER,
4954                    vec![
4955                        (header::LOCATION, redirect_url.to_string()),
4956                        (header::SET_COOKIE, cookie),
4957                    ],
4958                    String::new(),
4959                )
4960            }
4961            Err(e) => {
4962                tracing::error!("Session reset error: {}", e);
4963                build_response(
4964                    StatusCode::INTERNAL_SERVER_ERROR,
4965                    vec![(header::CONTENT_TYPE, "text/plain".to_string())],
4966                    "Failed to reset session".to_string(),
4967                )
4968            }
4969        }
4970    } else {
4971        // Sessions not enabled, just redirect back
4972        Redirect::to("/state").into_response()
4973    }
4974}
4975
4976/// Parsed w-set operation
4977enum SetOp {
4978    Increment(i64),
4979    Decrement(i64),
4980    SetInt(i64),
4981    SetStr(String),
4982}
4983
4984/// Validate that a redirect URL is safe (relative path only, no open redirect).
4985fn sanitize_redirect(url: &str, fallback: &str) -> String {
4986    let trimmed = url.trim();
4987    if trimmed.starts_with('/') && !trimmed.starts_with("//") {
4988        trimmed.to_string()
4989    } else {
4990        fallback.to_string()
4991    }
4992}
4993
4994/// Parse a w-set expression like "session.counter += 1" or "app.visits = 0"
4995fn parse_w_set_expr(expr: &str) -> Option<(String, String, SetOp)> {
4996    let expr = expr.trim();
4997
4998    // Try +=
4999    if let Some((left, right)) = expr.split_once("+=") {
5000        let left = left.trim();
5001        let right = right.trim();
5002        let (scope, key) = left.split_once('.')?;
5003        let val: i64 = right.parse().ok()?;
5004        return Some((scope.to_string(), key.to_string(), SetOp::Increment(val)));
5005    }
5006
5007    // Try -=
5008    if let Some((left, right)) = expr.split_once("-=") {
5009        let left = left.trim();
5010        let right = right.trim();
5011        let (scope, key) = left.split_once('.')?;
5012        let val: i64 = right.parse().ok()?;
5013        return Some((scope.to_string(), key.to_string(), SetOp::Decrement(val)));
5014    }
5015
5016    // Try = (assignment)
5017    if let Some((left, right)) = expr.split_once('=') {
5018        let left = left.trim();
5019        let right = right.trim();
5020        let (scope, key) = left.split_once('.')?;
5021        // Try integer first
5022        if let Ok(val) = right.parse::<i64>() {
5023            return Some((scope.to_string(), key.to_string(), SetOp::SetInt(val)));
5024        }
5025        // Try quoted string: 'value' or "value"
5026        let s = right.trim_matches(|c| c == '\'' || c == '"');
5027        return Some((
5028            scope.to_string(),
5029            key.to_string(),
5030            SetOp::SetStr(s.to_string()),
5031        ));
5032    }
5033
5034    None
5035}
5036
5037/// Apply a SetOp to a current value, returning the new value
5038fn apply_set_op(current: Option<&Value>, op: &SetOp) -> Value {
5039    match op {
5040        SetOp::Increment(delta) => {
5041            let cur = current.and_then(|v| v.as_i64()).unwrap_or(0);
5042            json!(cur + delta)
5043        }
5044        SetOp::Decrement(delta) => {
5045            let cur = current.and_then(|v| v.as_i64()).unwrap_or(0);
5046            json!(cur - delta)
5047        }
5048        SetOp::SetInt(val) => json!(val),
5049        SetOp::SetStr(val) => json!(val),
5050    }
5051}
5052
5053/// Convert a Value to its display string for OOB updates
5054fn value_display_string(v: &Value) -> String {
5055    match v {
5056        Value::String(s) => s.clone(),
5057        Value::Null => String::new(),
5058        other => other.to_string(),
5059    }
5060}
5061
5062/// Handle w-set attribute — declarative state mutations from HTML.
5063/// Supports multiple expressions separated by semicolons:
5064///   w-set="session.counter += 1; app.total += 1"
5065async fn handle_w_set(
5066    State(state): State<AppState>,
5067    headers: HeaderMap,
5068    Form(params): Form<HashMap<String, String>>,
5069) -> impl IntoResponse {
5070    let expr_str = match params.get("expr") {
5071        Some(e) => e.as_str(),
5072        None => return (StatusCode::BAD_REQUEST, "Missing expr").into_response(),
5073    };
5074
5075    // Parse all expressions upfront (fail fast on any invalid)
5076    let mut parsed: Vec<(String, String, SetOp)> = Vec::new();
5077    for part in expr_str.split(';') {
5078        let part = part.trim();
5079        if part.is_empty() {
5080            continue;
5081        }
5082        match parse_w_set_expr(part) {
5083            Some((scope, key, op)) => {
5084                if scope != "session" && scope != "app" && scope != "wired" {
5085                    return (
5086                        StatusCode::BAD_REQUEST,
5087                        "Invalid scope (use session, app, or wired)",
5088                    )
5089                        .into_response();
5090                }
5091                parsed.push((scope, key, op));
5092            }
5093            None => {
5094                return (
5095                    StatusCode::BAD_REQUEST,
5096                    format!("Invalid expression: {}", part),
5097                )
5098                    .into_response();
5099            }
5100        }
5101    }
5102
5103    if parsed.is_empty() {
5104        return (StatusCode::BAD_REQUEST, "Empty expression").into_response();
5105    }
5106
5107    let mut oob_map = serde_json::Map::new();
5108    let mut wired_updates: Vec<(String, String)> = Vec::new(); // (key, display_value)
5109    let mut session_mutations: Vec<(String, SetOp)> = Vec::new();
5110
5111    // Extract user_id for [user] scoped wired variables
5112    let cookie_header_str = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5113    let mutator_user_id: Option<String> = if state.auth.is_enabled() {
5114        state
5115            .auth
5116            .parse_jwt_cookie(cookie_header_str)
5117            .and_then(|token| {
5118                state.auth.decode_jwt(&token).ok().and_then(|claims| {
5119                    if claims.is_expired() {
5120                        return None;
5121                    }
5122                    claims.sub.clone()
5123                })
5124            })
5125    } else {
5126        None
5127    };
5128
5129    // Require authentication for app.* and wired.* mutations (shared state)
5130    // Session mutations are always allowed (per-user scope)
5131    let has_shared_mutation = parsed.iter().any(|(s, _, _)| s == "app" || s == "wired");
5132    if has_shared_mutation {
5133        let cookie_header_for_auth = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5134        let is_authenticated = if state.auth.is_enabled() {
5135            state
5136                .auth
5137                .parse_jwt_cookie(cookie_header_for_auth)
5138                .and_then(|token| state.auth.decode_jwt(&token).ok())
5139                .map(|claims| !claims.is_expired())
5140                .unwrap_or(false)
5141        } else {
5142            // If auth is not enabled, require at least a valid session
5143            let session_id = sessions::parse_session_cookie(
5144                cookie_header_for_auth,
5145                &state.config.session.cookie_name,
5146            );
5147            session_id.is_some()
5148        };
5149        if !is_authenticated {
5150            return (
5151                StatusCode::FORBIDDEN,
5152                "Authentication required for app/wired mutations",
5153            )
5154                .into_response();
5155        }
5156
5157        // Role/user scope gating: a `wired.x [admin]` or `app.x [admin]`
5158        // declaration restricts WHO may write the key, not just who receives it.
5159        let mutator_roles = extract_user_context(&state, &headers).roles();
5160        for (mscope, mkey, _) in &parsed {
5161            let var_scope = match mscope.as_str() {
5162                "wired" => state.get_wired_scope(mkey).await,
5163                "app" => state.get_app_scope(mkey).await,
5164                _ => continue,
5165            };
5166            let allowed = match &var_scope {
5167                WiredScope::Public => true,
5168                // Role scope: mutator must hold a matching role. Requires auth —
5169                // with auth disabled there are no roles, so this fails closed.
5170                WiredScope::Roles(_) => {
5171                    state.auth.is_enabled()
5172                        && var_scope.allows(&mutator_roles, mutator_user_id.as_deref())
5173                }
5174                // [user] scope on a write means any authenticated user (the
5175                // value is shared; delivery is already per-user).
5176                WiredScope::User(_) => state.auth.is_enabled() && mutator_user_id.is_some(),
5177            };
5178            if !allowed {
5179                tracing::info!(
5180                    target: "what::policy",
5181                    "w-set denied: {}.{} requires scope {:?}",
5182                    mscope, mkey, var_scope
5183                );
5184                let mut resp = (
5185                    StatusCode::FORBIDDEN,
5186                    format!("Insufficient permissions for {}.{}", mscope, mkey),
5187                )
5188                    .into_response();
5189                if state.dev_mode {
5190                    if let Ok(hv) = format!("deny; {}.{}; scope={:?}", mscope, mkey, var_scope).parse()
5191                    {
5192                        resp.headers_mut()
5193                            .insert(header::HeaderName::from_static("x-what-policy"), hv);
5194                    }
5195                }
5196                return resp;
5197            }
5198        }
5199    }
5200
5201    // Process each expression — app/wired mutations atomically, collect session mutations
5202    for (scope, key, op) in parsed {
5203        if scope == "app" || scope == "wired" {
5204            match state
5205                .store
5206                .atomic_modify(&key, move |current| apply_set_op(current, &op))
5207                .await
5208            {
5209                Ok(val) => {
5210                    let display = value_display_string(&val);
5211                    if scope == "wired" {
5212                        oob_map.insert(format!("wired.{}", key), Value::String(display.clone()));
5213                        wired_updates.push((key, display));
5214                    } else {
5215                        oob_map.insert(format!("app.{}", key), Value::String(display));
5216                    }
5217                }
5218                Err(e) => {
5219                    tracing::error!("w-set {} mutation failed: {}", scope, e);
5220                    return (StatusCode::INTERNAL_SERVER_ERROR, "Mutation failed").into_response();
5221                }
5222            }
5223        } else {
5224            // Block mutations to reserved session keys (prefix _)
5225            if key.starts_with('_') {
5226                tracing::warn!("w-set blocked: reserved session key '{}'", key);
5227                continue;
5228            }
5229            session_mutations.push((key, op));
5230        }
5231    }
5232
5233    // Broadcast wired updates with scope filtering
5234    for (key, display) in wired_updates {
5235        let mut wired_map = serde_json::Map::new();
5236        wired_map.insert(format!("wired.{}", key), Value::String(display));
5237        let json = serde_json::to_string(&Value::Object(wired_map)).unwrap_or_default();
5238        let mut scope = state.get_wired_scope(&key).await;
5239        // For [user] scope, fill in the mutator's user_id
5240        if matches!(scope, WiredScope::User(ref uid) if uid.is_empty()) {
5241            scope = WiredScope::User(mutator_user_id.clone().unwrap_or_default());
5242        }
5243        let _ = state.wired_tx.send(WiredMessage { json, scope });
5244    }
5245
5246    // Apply session mutations atomically (each uses SQL-level json_set, no read-modify-write race)
5247    if !session_mutations.is_empty() {
5248        let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5249
5250        if let Some(ref sessions) = state.sessions {
5251            let session_id =
5252                sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5253
5254            if let Some(id) = session_id {
5255                for (key, op) in &session_mutations {
5256                    let atomic_op = match op {
5257                        SetOp::Increment(n) => sessions::AtomicMutation::Increment {
5258                            key: key.clone(),
5259                            value: *n,
5260                        },
5261                        SetOp::Decrement(n) => sessions::AtomicMutation::Increment {
5262                            key: key.clone(),
5263                            value: -*n,
5264                        },
5265                        SetOp::SetInt(n) => sessions::AtomicMutation::Set {
5266                            key: key.clone(),
5267                            value: serde_json::json!(*n),
5268                        },
5269                        SetOp::SetStr(s) => sessions::AtomicMutation::Set {
5270                            key: key.clone(),
5271                            value: serde_json::json!(s),
5272                        },
5273                    };
5274
5275                    match sessions.apply_mutation(&id, &atomic_op).await {
5276                        Ok(data) => {
5277                            if let Some(val) = data.get(key) {
5278                                oob_map.insert(
5279                                    format!("session.{}", key),
5280                                    Value::String(value_display_string(val)),
5281                                );
5282                            }
5283                        }
5284                        Err(e) => {
5285                            tracing::error!("w-set session mutation failed: {}", e);
5286                        }
5287                    }
5288                }
5289            }
5290        }
5291    }
5292
5293    // Check if this is a partial (AJAX) request
5294    let is_partial = headers
5295        .get("X-Requested-With")
5296        .and_then(|v| v.to_str().ok())
5297        .map(|v| v == "What")
5298        .unwrap_or(false);
5299
5300    if is_partial {
5301        let json_str = serde_json::to_string(&Value::Object(oob_map)).unwrap_or_default();
5302        let oob = format!(r##"<template data-what-updates>{}</template>"##, json_str);
5303        return Html(oob).into_response();
5304    }
5305
5306    // Full-page request: redirect back (extract path only to prevent open redirect)
5307    let redirect_url = headers
5308        .get(header::REFERER)
5309        .and_then(|v| v.to_str().ok())
5310        .and_then(|url| {
5311            // Extract path component from full URL to prevent open redirect
5312            if let Some(idx) = url.find("://") {
5313                // Full URL: find path after host
5314                url[idx + 3..].find('/').map(|i| &url[idx + 3 + i..])
5315            } else {
5316                Some(url)
5317            }
5318        })
5319        .map(|path| sanitize_redirect(path, "/"))
5320        .unwrap_or_else(|| "/".to_string());
5321
5322    Redirect::to(&redirect_url).into_response()
5323}
5324
5325/// Handle clearing session data (keeps session, clears data)
5326async fn handle_session_clear_data(
5327    State(state): State<AppState>,
5328    headers: HeaderMap,
5329) -> impl IntoResponse {
5330    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5331
5332    let referer = headers
5333        .get(header::REFERER)
5334        .and_then(|v| v.to_str().ok())
5335        .unwrap_or("/");
5336    let redirect_to = sanitize_redirect(referer, "/");
5337
5338    if let Some(ref sessions) = state.sessions {
5339        let session_id =
5340            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5341
5342        if let Some(id) = session_id {
5343            // Clear session data by updating with empty HashMap
5344            if let Err(e) = sessions.update(&id, std::collections::HashMap::new()).await {
5345                tracing::error!("Failed to clear session data: {}", e);
5346            }
5347        }
5348    }
5349
5350    Redirect::to(&redirect_to).into_response()
5351}
5352
5353/// Handle injection notification demo - increments session counter and returns HTML
5354async fn handle_inject_notification(
5355    State(state): State<AppState>,
5356    headers: HeaderMap,
5357) -> impl IntoResponse {
5358    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5359
5360    let mut count: i64 = 1;
5361
5362    if let Some(ref sessions) = state.sessions {
5363        let session_id =
5364            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5365
5366        if let Some(id) = session_id {
5367            if let Ok(Some(mut session)) = sessions.get(&id).await {
5368                // Get current injection count
5369                let current = session
5370                    .data
5371                    .get("inject_count")
5372                    .and_then(|v| v.as_i64())
5373                    .unwrap_or(0);
5374
5375                // Increment
5376                count = current + 1;
5377                session
5378                    .data
5379                    .insert("inject_count".to_string(), json!(count));
5380
5381                // Save session data
5382                if let Err(e) = sessions.update(&id, session.data).await {
5383                    tracing::error!("Failed to update inject count: {}", e);
5384                }
5385            }
5386        }
5387    }
5388
5389    // Return HTML partial with the counter
5390    let html = format!(
5391        r#"<div class="p-4 bg-blue-100 border border-blue-300 rounded flex items-center gap-3">
5392  <span class="text-2xl">&#x1F514;</span>
5393  <div>
5394    <strong>Notification {}</strong>
5395    <p class="text-sm text-gray-600">Injected without reloading the page</p>
5396  </div>
5397</div>"#,
5398        count
5399    );
5400
5401    axum::response::Html(html).into_response()
5402}
5403
5404/// Handle login form submission
5405/// Receives form data, calls the backend login endpoint, and sets the JWT cookie
5406async fn handle_login(
5407    State(state): State<AppState>,
5408    _headers: HeaderMap,
5409    Form(form_data): Form<HashMap<String, String>>,
5410) -> impl IntoResponse {
5411    // Get login endpoint from config
5412    let login_endpoint = match state.auth.login_endpoint() {
5413        Some(url) => url.to_string(),
5414        None => {
5415            tracing::error!("Login endpoint not configured");
5416            let msg = if state.dev_mode {
5417                "Login not configured"
5418            } else {
5419                "Something went wrong"
5420            };
5421            return (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response();
5422        }
5423    };
5424
5425    // Get redirect URL from form or use default (validated against open redirect)
5426    let fallback = state.auth.after_login_path().to_string();
5427    let redirect_url = form_data
5428        .get("redirect")
5429        .or_else(|| form_data.get("w-redirect"))
5430        .map(|s| sanitize_redirect(s, &fallback))
5431        .unwrap_or(fallback);
5432
5433    // Filter out internal fields and prepare login request
5434    let login_data: HashMap<String, String> = form_data
5435        .into_iter()
5436        .filter(|(k, _)| !k.starts_with("w-") && k != "redirect")
5437        .collect();
5438
5439    // Call backend login endpoint
5440    let response = match state
5441        .http_client
5442        .post(&login_endpoint)
5443        .json(&login_data)
5444        .send()
5445        .await
5446    {
5447        Ok(resp) => resp,
5448        Err(e) => {
5449            tracing::error!("Login request failed: {}", e);
5450            return Redirect::to(&format!("{}?error=connection", state.auth.login_path()))
5451                .into_response();
5452        }
5453    };
5454
5455    if !response.status().is_success() {
5456        tracing::warn!("Login failed with status: {}", response.status());
5457        return Redirect::to(&format!("{}?error=invalid", state.auth.login_path())).into_response();
5458    }
5459
5460    // Extract token from response
5461    // Try to get token from JSON response body
5462    let token = match response.json::<serde_json::Value>().await {
5463        Ok(json) => {
5464            // Try common field names: token, access_token, jwt
5465            json.get("token")
5466                .or_else(|| json.get("access_token"))
5467                .or_else(|| json.get("jwt"))
5468                .and_then(|v| v.as_str())
5469                .map(|s| s.to_string())
5470        }
5471        Err(e) => {
5472            tracing::error!("Failed to parse login response: {}", e);
5473            None
5474        }
5475    };
5476
5477    match token {
5478        Some(jwt) => {
5479            // Build JWT cookie
5480            let cookie = state.auth.build_jwt_cookie(
5481                &jwt,
5482                state.config.session.max_age,
5483                state.config.session.secure,
5484            );
5485
5486            let mut response_headers = vec![
5487                (header::LOCATION, redirect_url),
5488                (header::SET_COOKIE, cookie),
5489            ];
5490
5491            // Session fixation prevention: regenerate session ID after successful login
5492            if let Some(ref sessions) = state.sessions {
5493                let cookie_header = _headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5494                let old_id = sessions::parse_session_cookie(
5495                    cookie_header,
5496                    &state.config.session.cookie_name,
5497                );
5498
5499                if let Some(ref old) = old_id {
5500                    // Get old session data, create new session, copy data, delete old
5501                    if let Ok(Some(old_session)) = sessions.get(old).await {
5502                        if let Ok(new_session) = sessions.create().await {
5503                            let mut data = old_session.data;
5504                            // Preserve CSRF token (regenerate for new session)
5505                            data.insert(
5506                                sessions::CSRF_TOKEN_KEY.to_string(),
5507                                serde_json::json!(sessions::generate_csrf_token()),
5508                            );
5509                            let _ = sessions.update(&new_session.id, data).await;
5510                            let _ = sessions.delete(old).await;
5511                            response_headers.push((
5512                                header::SET_COOKIE,
5513                                sessions::build_session_cookie(
5514                                    &new_session.id,
5515                                    &state.config.session.cookie_name,
5516                                    state.config.session.max_age,
5517                                    state.config.session.secure,
5518                                ),
5519                            ));
5520                        }
5521                    }
5522                }
5523            }
5524
5525            build_response(StatusCode::SEE_OTHER, response_headers, String::new())
5526        }
5527        None => {
5528            Redirect::to(&format!("{}?error=no_token", state.auth.login_path())).into_response()
5529        }
5530    }
5531}
5532
5533/// Handle logout - clears the JWT cookie and optionally calls backend logout
5534async fn handle_logout(
5535    State(state): State<AppState>,
5536    request_headers: HeaderMap,
5537    Form(form_data): Form<HashMap<String, String>>,
5538) -> impl IntoResponse {
5539    // Get redirect URL (validated against open redirect)
5540    let redirect_url = form_data
5541        .get("redirect")
5542        .or_else(|| form_data.get("w-redirect"))
5543        .map(|s| sanitize_redirect(s, "/"))
5544        .unwrap_or_else(|| "/".to_string());
5545
5546    // Optionally call backend logout endpoint
5547    if let Some(logout_endpoint) = state.auth.logout_endpoint() {
5548        // Extract JWT to send with logout request
5549        let cookie_header = request_headers
5550            .get(header::COOKIE)
5551            .and_then(|v| v.to_str().ok());
5552
5553        if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
5554            let _ = state
5555                .http_client
5556                .post(logout_endpoint)
5557                .bearer_auth(&token)
5558                .send()
5559                .await;
5560        }
5561    }
5562
5563    // Clear the JWT cookie
5564    let clear_cookie = state.auth.build_clear_cookie();
5565
5566    // Redirect with cleared cookie
5567    build_response(
5568        StatusCode::SEE_OTHER,
5569        vec![
5570            (header::LOCATION, redirect_url),
5571            (header::SET_COOKIE, clear_cookie),
5572        ],
5573        String::new(),
5574    )
5575}
5576
5577/// Handle clearing all server-side caches (dev mode only)
5578async fn handle_cache_clear_all(State(state): State<AppState>) -> impl IntoResponse {
5579    // Clear all caches
5580    state.cache.clear_all().await;
5581
5582    tracing::info!("All server caches cleared");
5583
5584    // Return JSON response
5585    axum::Json(serde_json::json!({
5586        "success": true,
5587        "message": "All caches cleared"
5588    }))
5589}
5590
5591/// Handle listing all sessions (dev mode only)
5592async fn handle_sessions_list(State(state): State<AppState>) -> impl IntoResponse {
5593    match &state.sessions {
5594        Some(sessions) => {
5595            let count = sessions.count().await.unwrap_or(0);
5596            let ids = sessions.list_session_ids().await.unwrap_or_default();
5597
5598            axum::Json(serde_json::json!({
5599                "count": count,
5600                "ids": ids
5601            }))
5602        }
5603        None => axum::Json(serde_json::json!({
5604            "error": "Sessions not enabled",
5605            "count": 0,
5606            "ids": []
5607        })),
5608    }
5609}
5610
5611/// Handle data info request (dev mode only)
5612async fn handle_data_info(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
5613    // Get session data if available
5614    let session_data = if let Some(ref sessions) = state.sessions {
5615        let cookie_header = headers.get(header::COOKIE).and_then(|h| h.to_str().ok());
5616        let session_id =
5617            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5618
5619        if let Some(id) = session_id {
5620            if let Ok(Some(session)) = sessions.get(&id).await {
5621                session.data.clone()
5622            } else {
5623                std::collections::HashMap::new()
5624            }
5625        } else {
5626            std::collections::HashMap::new()
5627        }
5628    } else {
5629        std::collections::HashMap::new()
5630    };
5631
5632    // Get application data from store
5633    let application_data = state.store.as_context().await;
5634
5635    axum::Json(serde_json::json!({
5636        "application": application_data,
5637        "session": session_data
5638    }))
5639}
5640
5641// ---------------------------------------------------------------------------
5642// Dev Inspector (dev-mode only, /w-inspector)
5643// ---------------------------------------------------------------------------
5644
5645/// Resolved metadata for one route, for the inspector's Routes panel.
5646struct RouteMeta {
5647    url: String,
5648    dynamic: bool,
5649    auth: String,
5650    layout: String,
5651    missing: bool,
5652}
5653
5654/// The auth a directive set imposes, or None if it leaves the page public.
5655/// Accounts for legacy `protected`/`roles`.
5656fn inspector_auth_display(d: &PageDirectives) -> Option<String> {
5657    use crate::parser::AuthLevel;
5658    match &d.auth {
5659        AuthLevel::User => Some("user".to_string()),
5660        AuthLevel::Roles(v) => Some(format!("roles: {}", v.join(", "))),
5661        AuthLevel::All => {
5662            if d.protected {
5663                if d.roles.is_empty() {
5664                    Some("user (protected)".to_string())
5665                } else {
5666                    Some(format!("roles: {} (legacy)", d.roles.join(", ")))
5667                }
5668            } else {
5669                None
5670            }
5671        }
5672    }
5673}
5674
5675/// Resolve a route's effective auth + layout the way the render path does:
5676/// the page's own `<what>` wins; `application.what` applies only when the page
5677/// is public.
5678fn resolve_route_meta(root: &PathBuf, url_path: &str, dynamic: bool) -> RouteMeta {
5679    let Some(resolved) = resolve_page_path(root, url_path) else {
5680        return RouteMeta {
5681            url: url_path.to_string(),
5682            dynamic,
5683            auth: String::new(),
5684            layout: String::new(),
5685            missing: true,
5686        };
5687    };
5688    let raw = std::fs::read_to_string(&resolved.path).unwrap_or_default();
5689    let (directives, _) = parse_page_directives(&raw);
5690    let app_config = load_application_config(root, url_path);
5691
5692    let auth = inspector_auth_display(&directives)
5693        .or_else(|| inspector_auth_display(&app_config.directives))
5694        .unwrap_or_else(|| "all".to_string());
5695
5696    let layout = directives.layout.clone().or_else(|| app_config.layout.clone());
5697    let layout = match layout.as_deref() {
5698        Some("none") => "none (disabled)".to_string(),
5699        Some(l) => l.to_string(),
5700        None => "(default)".to_string(),
5701    };
5702
5703    RouteMeta { url: url_path.to_string(), dynamic, auth, layout, missing: false }
5704}
5705
5706/// Recursively collect `application.what` files under a directory (relative
5707/// paths + parsed config), for the inspector's inheritance panel.
5708fn collect_application_what_files(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<(String, WhatConfig)>) {
5709    let cfg = dir.join("application.what");
5710    if cfg.exists() {
5711        if let Ok(content) = std::fs::read_to_string(&cfg) {
5712            let rel = cfg.strip_prefix(root).unwrap_or(&cfg).to_string_lossy().to_string();
5713            out.push((rel, parse_what_file(&content)));
5714        }
5715    }
5716    if let Ok(entries) = std::fs::read_dir(dir) {
5717        let mut dirs: Vec<_> = entries
5718            .flatten()
5719            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
5720            .map(|e| e.path())
5721            .collect();
5722        dirs.sort();
5723        for d in dirs {
5724            collect_application_what_files(&d, root, out);
5725        }
5726    }
5727}
5728
5729/// Dev-mode inspector dashboard — a single self-contained, zero-JS HTML page
5730/// that surfaces the app's routes, collections/policies, config inheritance,
5731/// sessions, scopes, and template lints. Registered only in dev mode.
5732async fn handle_inspector(State(state): State<AppState>) -> axum::response::Response {
5733    let esc = engine::escape_html;
5734    let mut b = String::new();
5735
5736    // ---- Panel 1: Overview ----
5737    b.push_str("<section id=\"overview\"><h2>Overview</h2><table>");
5738    let cfg = &state.config;
5739    let rows = [
5740        ("Framework version", env!("CARGO_PKG_VERSION").to_string()),
5741        ("Mode", if state.dev_mode { "development".into() } else { "production".into() }),
5742        ("Host : Port", format!("{}:{}", cfg.server.host, cfg.server.port)),
5743        ("CSS mode", format!("{:?}", state.css_mode).to_lowercase()),
5744        ("Sessions", if cfg.session.enabled { "enabled".into() } else { "disabled".into() }),
5745        ("Auth", if cfg.auth.enabled { "enabled".into() } else { "disabled".into() }),
5746        ("Uploads", if cfg.uploads.enabled { "enabled".into() } else { "disabled".into() }),
5747        ("Source viewer", if cfg.server.source_viewer { "on".into() } else { "off".into() }),
5748        ("Strict mode", if cfg.strict { "on".into() } else { "off".into() }),
5749    ];
5750    for (k, v) in rows {
5751        b.push_str(&format!("<tr><th>{}</th><td>{}</td></tr>", esc(k), esc(&v)));
5752    }
5753    b.push_str("</table></section>");
5754
5755    // ---- Panel 2: Routes ----
5756    b.push_str("<section id=\"routes\"><h2>Routes</h2><table><tr><th>Path</th><th>Auth</th><th>Layout</th><th></th></tr>");
5757    let routes = discover_routes(&state.root);
5758    for (url, dynamic) in &routes {
5759        let m = resolve_route_meta(&state.root, url, *dynamic);
5760        let tags = format!(
5761            "{}{}",
5762            if m.dynamic { "<span class=\"tag\">dynamic</span>" } else { "" },
5763            if m.missing { "<span class=\"tag warn\">missing file</span>" } else { "" },
5764        );
5765        b.push_str(&format!(
5766            "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
5767            esc(&m.url), esc(&m.auth), esc(&m.layout), tags
5768        ));
5769    }
5770    b.push_str(&format!("</table><p class=\"muted\">{} route(s).</p></section>", routes.len()));
5771
5772    // ---- Panel 3: Collections & Policies ----
5773    b.push_str("<section id=\"collections\"><h2>Collections &amp; Policies</h2>");
5774    b.push_str("<table><tr><th>Collection</th><th>Rows</th><th>Policy</th></tr>");
5775    let ctx = state.store.as_context().await;
5776    let mut names: std::collections::BTreeSet<String> = ctx.keys().cloned().collect();
5777    for (name, _) in state.policies.configured() {
5778        names.insert(name.to_string());
5779    }
5780    if names.is_empty() {
5781        b.push_str("<tr><td colspan=\"3\" class=\"muted\">No collections yet.</td></tr>");
5782    }
5783    for name in &names {
5784        let count = ctx.get(name).and_then(|v| v.as_array()).map(|a| a.len());
5785        let count_str = count.map(|c| c.to_string()).unwrap_or_else(|| "&mdash;".to_string());
5786        let p = state.policies.get(name);
5787        let badge = if p.explicit { "<span class=\"tag\">explicit</span>" } else { "<span class=\"tag muted-tag\">default</span>" };
5788        let mut policy = format!(
5789            "create=<b>{}</b> · read=<b>{}</b> · update=<b>{}</b> · delete=<b>{}</b> · owner={}",
5790            esc(&p.create.to_string()), esc(&p.read.to_string()),
5791            esc(&p.update.to_string()), esc(&p.delete.to_string()), esc(&p.owner_mode.to_string()),
5792        );
5793        if let Some(f) = &p.filter {
5794            policy.push_str(&format!(" · filter=<code>{}</code>", esc(f)));
5795        }
5796        if !p.readonly_fields.is_empty() {
5797            policy.push_str(&format!(" · readonly=[{}]", esc(&p.readonly_fields.join(", "))));
5798        }
5799        if !p.private_fields.is_empty() {
5800            policy.push_str(&format!(" · private=[{}]", esc(&p.private_fields.join(", "))));
5801        }
5802        b.push_str(&format!(
5803            "<tr><td><code>{}</code> {}</td><td>{}</td><td class=\"policy\">{}</td></tr>",
5804            esc(name), badge, count_str, policy
5805        ));
5806    }
5807    b.push_str("</table></section>");
5808
5809    // ---- Panel 4: Config & inheritance ----
5810    b.push_str("<section id=\"config\"><h2>application.what Inheritance</h2>");
5811    let mut app_files = Vec::new();
5812    collect_application_what_files(&state.content_dir, &state.root, &mut app_files);
5813    if app_files.is_empty() {
5814        b.push_str("<p class=\"muted\">No application.what files.</p>");
5815    } else {
5816        b.push_str("<table><tr><th>File</th><th>Declares</th></tr>");
5817        for (rel, wc) in &app_files {
5818            let mut parts = Vec::new();
5819            if let Some(a) = inspector_auth_display(&wc.directives) {
5820                parts.push(format!("auth={}", a));
5821            }
5822            let layout = wc.directives.layout.clone().or_else(|| wc.layout.clone());
5823            if let Some(l) = layout {
5824                parts.push(format!("layout={}", l));
5825            }
5826            if !wc.data_application.is_empty() {
5827                let ns: Vec<&str> = wc.data_application.iter().map(|d| d.name.as_str()).collect();
5828                parts.push(format!("data.application=[{}]", ns.join(", ")));
5829            }
5830            if !wc.data_wired.is_empty() {
5831                let ns: Vec<&str> = wc.data_wired.iter().map(|d| d.name.as_str()).collect();
5832                parts.push(format!("data.wired=[{}]", ns.join(", ")));
5833            }
5834            if !wc.data_session.is_empty() {
5835                parts.push(format!("data.session=[{}]", wc.data_session.join(", ")));
5836            }
5837            let desc = if parts.is_empty() { "(nothing auth/layout/data)".to_string() } else { parts.join(" · ") };
5838            b.push_str(&format!("<tr><td><code>{}</code></td><td>{}</td></tr>", esc(rel), esc(&desc)));
5839        }
5840        b.push_str("</table><p class=\"muted\">Child directories override parents.</p>");
5841    }
5842    b.push_str("</section>");
5843
5844    // ---- Panel 5: Sessions ----
5845    b.push_str("<section id=\"sessions\"><h2>Sessions</h2>");
5846    match &state.sessions {
5847        Some(s) => {
5848            let count = s.count().await.unwrap_or(0);
5849            let ids = s.list_session_ids().await.unwrap_or_default();
5850            b.push_str(&format!("<p><b>{}</b> active session(s).</p>", count));
5851            if !ids.is_empty() {
5852                b.push_str("<details><summary>Session IDs</summary><ul>");
5853                for id in ids.iter().take(200) {
5854                    let short: String = id.chars().take(16).collect();
5855                    b.push_str(&format!("<li><code>{}…</code></li>", esc(&short)));
5856                }
5857                b.push_str("</ul></details>");
5858            }
5859        }
5860        None => b.push_str("<p class=\"muted\">Sessions disabled.</p>"),
5861    }
5862    b.push_str("</section>");
5863
5864    // ---- Panel 6: Scopes ----
5865    b.push_str("<section id=\"scopes\"><h2>Write Scopes</h2>");
5866    let wired = state.wired_scopes.read().await;
5867    let app = state.app_scopes.read().await;
5868    if wired.is_empty() && app.is_empty() {
5869        b.push_str("<p class=\"muted\">No scoped wired/application variables.</p>");
5870    } else {
5871        b.push_str("<table><tr><th>Variable</th><th>Kind</th><th>Scope</th></tr>");
5872        let mut wk: Vec<_> = wired.iter().collect();
5873        wk.sort_by_key(|(k, _)| k.clone());
5874        for (k, v) in wk {
5875            b.push_str(&format!("<tr><td><code>wired.{}</code></td><td>wired</td><td>{}</td></tr>", esc(k), esc(&v.to_string())));
5876        }
5877        let mut ak: Vec<_> = app.iter().collect();
5878        ak.sort_by_key(|(k, _)| k.clone());
5879        for (k, v) in ak {
5880            b.push_str(&format!("<tr><td><code>app.{}</code></td><td>application</td><td>{}</td></tr>", esc(k), esc(&v.to_string())));
5881        }
5882        b.push_str("</table>");
5883    }
5884    b.push_str("</section>");
5885
5886    // ---- Panel 7: Template lints ----
5887    b.push_str("<section id=\"lints\"><h2>Template Lints</h2>");
5888    let mut total = 0;
5889    let mut lint_rows = String::new();
5890    for (url, dynamic) in &routes {
5891        if let Some(resolved) = resolve_page_path(&state.root, url) {
5892            let _ = dynamic;
5893            if let Ok(raw) = std::fs::read_to_string(&resolved.path) {
5894                let lints = engine::collect_template_lints(&raw);
5895                for l in lints {
5896                    total += 1;
5897                    lint_rows.push_str(&format!(
5898                        "<tr><td><code>{}</code></td><td><span class=\"tag warn\">{}</span></td><td>{}</td></tr>",
5899                        esc(url), esc(l.kind), esc(&l.message)
5900                    ));
5901                }
5902            }
5903        }
5904    }
5905    if total == 0 {
5906        b.push_str("<p class=\"ok\">✓ No template issues found.</p>");
5907    } else {
5908        b.push_str(&format!("<table><tr><th>Page</th><th>Kind</th><th>Detail</th></tr>{}</table>", lint_rows));
5909    }
5910    b.push_str("</section>");
5911
5912    let html = render_inspector_shell(&b);
5913    build_response(
5914        StatusCode::OK,
5915        vec![(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())],
5916        html,
5917    )
5918}
5919
5920/// Wrap the inspector panels in a self-contained, zero-JS HTML page.
5921fn render_inspector_shell(body: &str) -> String {
5922    format!(
5923        r##"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>What Inspector</title><style>
5924:root {{ color-scheme: light; }}
5925* {{ box-sizing: border-box; }}
5926body {{ margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; color: #1f2937; background: #f8fafc; }}
5927header {{ background: #0f172a; color: #e2e8f0; padding: 14px 20px; position: sticky; top: 0; z-index: 10; }}
5928header b {{ color: #fff; font-size: 15px; }}
5929header .badge {{ background: #334155; color: #cbd5e1; border-radius: 4px; padding: 2px 7px; font-size: 11px; margin-left: 8px; }}
5930nav {{ padding: 8px 20px; background: #1e293b; position: sticky; top: 46px; z-index: 9; }}
5931nav a {{ color: #93c5fd; text-decoration: none; margin-right: 14px; font-size: 12px; }}
5932nav a:hover {{ text-decoration: underline; }}
5933main {{ padding: 20px; max-width: 1100px; }}
5934section {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 18px; margin-bottom: 18px; }}
5935h2 {{ margin: 0 0 12px; font-size: 15px; color: #0f172a; }}
5936table {{ width: 100%; border-collapse: collapse; }}
5937th, td {{ text-align: left; padding: 6px 8px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }}
5938th {{ color: #64748b; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .03em; }}
5939td code, .policy code {{ background: #f1f5f9; padding: 1px 5px; border-radius: 3px; }}
5940.policy {{ font-size: 12px; }}
5941.muted {{ color: #94a3b8; }}
5942.ok {{ color: #16a34a; }}
5943.tag {{ display: inline-block; background: #dbeafe; color: #1e40af; border-radius: 3px; padding: 1px 6px; font-size: 11px; margin-left: 4px; }}
5944.tag.warn {{ background: #fef3c7; color: #92400e; }}
5945.tag.muted-tag {{ background: #f1f5f9; color: #64748b; }}
5946details summary {{ cursor: pointer; color: #2563eb; }}
5947ul {{ margin: 8px 0; padding-left: 20px; }}
5948</style></head><body>
5949<header><b>What Inspector</b><span class="badge">dev</span></header>
5950<nav><a href="#overview">Overview</a><a href="#routes">Routes</a><a href="#collections">Collections</a><a href="#config">Config</a><a href="#sessions">Sessions</a><a href="#scopes">Scopes</a><a href="#lints">Lints</a></nav>
5951<main>{body}</main>
5952</body></html>"##,
5953        body = body
5954    )
5955}
5956
5957/// Serve a partial HTML fragment from the partials/ directory.
5958///
5959/// Partials are rendered through the template engine but skip layout wrapping,
5960/// SEO injection, and what.js injection. They include OOB session updates for
5961/// reactive variables and are tagged with noindex headers.
5962async fn handle_partial(
5963    State(state): State<AppState>,
5964    headers: HeaderMap,
5965    axum::extract::Path(path): axum::extract::Path<String>,
5966    Query(query_params): Query<HashMap<String, String>>,
5967) -> Response {
5968    let clean_path = path.trim_start_matches('/');
5969
5970    // Resolve partial file: {content_dir}/partials/{path}.html
5971    let partials_dir = state.content_dir.join("partials");
5972    let file_path = if clean_path.is_empty() {
5973        partials_dir.join("index.html")
5974    } else {
5975        let with_ext = partials_dir.join(format!("{}.html", clean_path));
5976        if with_ext.exists() {
5977            with_ext
5978        } else {
5979            partials_dir.join(clean_path).join("index.html")
5980        }
5981    };
5982
5983    // Path traversal protection
5984    let canonical = match file_path.canonicalize() {
5985        Ok(p) => p,
5986        Err(_) => {
5987            return (StatusCode::NOT_FOUND, "Partial not found").into_response();
5988        }
5989    };
5990    let partials_canonical = match partials_dir.canonicalize() {
5991        Ok(p) => p,
5992        Err(_) => {
5993            return (StatusCode::NOT_FOUND, "Partial not found").into_response();
5994        }
5995    };
5996    if !canonical.starts_with(&partials_canonical) {
5997        return (StatusCode::FORBIDDEN, "Access denied").into_response();
5998    }
5999
6000    // Read partial file
6001    let raw_content = match tokio::fs::read_to_string(&canonical).await {
6002        Ok(c) => c,
6003        Err(_) => {
6004            return (StatusCode::NOT_FOUND, "Partial not found").into_response();
6005        }
6006    };
6007
6008    if state.dev_mode {
6009        engine::warn_template_lints_once(&canonical, &raw_content);
6010    }
6011
6012    // Parse directives and extract content
6013    let (directives, content) = parse_page_directives(&raw_content);
6014
6015    // Load session
6016    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
6017
6018    let (session, is_new_session) = if let Some(ref sessions) = state.sessions {
6019        let session_id =
6020            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
6021        match sessions.get_or_create(session_id.as_deref()).await {
6022            Ok(session) => {
6023                let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
6024                (Some(session), is_new)
6025            }
6026            Err(_) => (None, false),
6027        }
6028    } else {
6029        (None, false)
6030    };
6031
6032    // Extract user context
6033    let user_context = if state.auth.is_enabled() {
6034        if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
6035            match state.auth.decode_jwt(&token) {
6036                Ok(claims) if !claims.is_expired() => {
6037                    UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
6038                }
6039                _ => UserContext::unauthenticated(),
6040            }
6041        } else {
6042            UserContext::unauthenticated()
6043        }
6044    } else {
6045        UserContext::unauthenticated()
6046    };
6047
6048    // Render as partial (reactive mode, no layout)
6049    match render_content_internal(
6050        &state,
6051        &content,
6052        session.as_ref(),
6053        &user_context,
6054        None,
6055        Some(&directives),
6056        Some(&query_params),
6057        &HashMap::new(),
6058        true,
6059        None,
6060    )
6061    .await
6062    {
6063        Ok(render_result) => {
6064            let mut html = render_result.html;
6065
6066            // Append OOB session updates
6067            if !render_result.session_keys.is_empty() {
6068                if let Some(ref s) = session {
6069                    let mut updates = serde_json::Map::new();
6070                    for key in &render_result.session_keys {
6071                        if let Some(value) = s.data.get(key) {
6072                            updates.insert(format!("session.{}", key), value.clone());
6073                        }
6074                    }
6075                    if !updates.is_empty() {
6076                        let json_str = serde_json::to_string(&updates).unwrap_or_default();
6077                        html.push_str(&format!(
6078                            r#"<template data-what-updates>{}</template>"#,
6079                            json_str
6080                        ));
6081                    }
6082                }
6083            }
6084
6085            // Inject CSRF tokens into forms
6086            if let Some(ref s) = session {
6087                if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
6088                    html = inject_csrf_tokens(&html, csrf);
6089                }
6090            }
6091
6092            // Build response headers
6093            let mut resp_headers: Vec<(header::HeaderName, String)> = vec![
6094                (header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
6095                (
6096                    header::HeaderName::from_static("x-robots-tag"),
6097                    "noindex, nofollow".to_string(),
6098                ),
6099            ];
6100
6101            // Set session cookie if new
6102            if is_new_session {
6103                if let Some(ref s) = session {
6104                    let cookie = sessions::build_session_cookie(
6105                        &s.id,
6106                        &state.config.session.cookie_name,
6107                        state.config.session.max_age,
6108                        state.config.session.secure,
6109                    );
6110                    resp_headers.push((header::SET_COOKIE, cookie));
6111                }
6112            }
6113
6114            build_response(StatusCode::OK, resp_headers, html)
6115        }
6116        Err(e) => {
6117            tracing::error!("Partial render error: {}", e);
6118            (StatusCode::INTERNAL_SERVER_ERROR, "Render error").into_response()
6119        }
6120    }
6121}
6122
6123// ---------------------------------------------------------------------------
6124// Embedded Framework Assets (what.js, what.css)
6125// ---------------------------------------------------------------------------
6126
6127/// Which slice of the embedded framework stylesheet an app serves.
6128/// Configured via `[server] css` in what.toml.
6129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6130pub enum CssMode {
6131    /// The whole design system (default).
6132    Full,
6133    /// Reset, theme variables, and utilities only — component styles are cut.
6134    Minimal,
6135    /// what.css is not auto-injected; /static/what.css stays available for manual linking.
6136    None,
6137}
6138
6139impl CssMode {
6140    pub fn from_config(value: &str) -> crate::Result<Self> {
6141        match value {
6142            "full" => Ok(Self::Full),
6143            "minimal" => Ok(Self::Minimal),
6144            "none" => Ok(Self::None),
6145            other => Err(crate::Error::Config(format!(
6146                "[server] css must be \"full\", \"minimal\", or \"none\" (got \"{}\")",
6147                other
6148            ))),
6149        }
6150    }
6151}
6152
6153/// Embedded framework CSS — served at /static/what.css without requiring the file on disk.
6154const EMBEDDED_WHAT_CSS: &str = include_str!("../../assets/client/what.css");
6155
6156/// Cut markers in what.css around the components + interactions layers.
6157/// "minimal" mode drops everything between them, keeping base, utilities,
6158/// and the source-viewer styles.
6159const MINIMAL_CUT_START: &str = "/*! what:minimal-cut-start */";
6160const MINIMAL_CUT_END: &str = "/*! what:minimal-cut-end */";
6161
6162static MINIMAL_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6163    match (
6164        EMBEDDED_WHAT_CSS.find(MINIMAL_CUT_START),
6165        EMBEDDED_WHAT_CSS.find(MINIMAL_CUT_END),
6166    ) {
6167        (Some(start), Some(end)) if end > start => format!(
6168            "{}{}",
6169            &EMBEDDED_WHAT_CSS[..start],
6170            &EMBEDDED_WHAT_CSS[end + MINIMAL_CUT_END.len()..]
6171        ),
6172        _ => EMBEDDED_WHAT_CSS.to_string(),
6173    }
6174});
6175
6176/// Embedded framework JS — served at /static/what.js without requiring the file on disk.
6177const EMBEDDED_WHAT_JS: &str = include_str!("../../assets/client/what.js");
6178
6179/// The framework CSS embedded in this crate (crates/what-core/assets/client/what.css).
6180/// Exposed so downstream crates that vendor their own copy can assert it hasn't drifted.
6181pub fn embedded_what_css() -> &'static str {
6182    EMBEDDED_WHAT_CSS
6183}
6184
6185/// The framework JS embedded in this crate (crates/what-core/assets/client/what.js).
6186/// Exposed so downstream crates that vendor their own copy can assert it hasn't drifted.
6187pub fn embedded_what_js() -> &'static str {
6188    EMBEDDED_WHAT_JS
6189}
6190
6191/// Minified versions of embedded assets — computed once at first access.
6192static MINIFIED_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6193    let cfg = minify_html::Cfg { minify_css: true, ..minify_html::Cfg::default() };
6194    let wrapped = format!("<style>{}</style>", EMBEDDED_WHAT_CSS);
6195    let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6196    String::from_utf8(minified)
6197        .map(|s| s.strip_prefix("<style>").unwrap_or(&s).strip_suffix("</style>").unwrap_or(&s).to_string())
6198        .unwrap_or_else(|_| EMBEDDED_WHAT_CSS.to_string())
6199});
6200
6201static MINIFIED_MINIMAL_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6202    let cfg = minify_html::Cfg { minify_css: true, ..minify_html::Cfg::default() };
6203    let wrapped = format!("<style>{}</style>", MINIMAL_WHAT_CSS.as_str());
6204    let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6205    String::from_utf8(minified)
6206        .map(|s| s.strip_prefix("<style>").unwrap_or(&s).strip_suffix("</style>").unwrap_or(&s).to_string())
6207        .unwrap_or_else(|_| MINIMAL_WHAT_CSS.clone())
6208});
6209
6210static MINIFIED_WHAT_JS: LazyLock<String> = LazyLock::new(|| {
6211    let cfg = minify_html::Cfg { minify_js: true, ..minify_html::Cfg::default() };
6212    let wrapped = format!("<script>{}</script>", EMBEDDED_WHAT_JS);
6213    let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6214    String::from_utf8(minified)
6215        .map(|s| s.strip_prefix("<script>").unwrap_or(&s).strip_suffix("</script>").unwrap_or(&s).to_string())
6216        .unwrap_or_else(|_| EMBEDDED_WHAT_JS.to_string())
6217});
6218
6219/// Cache-busted embedded asset paths so browsers fetch fresh framework assets after deploys.
6220/// Hash includes the crate version so recompiles after version bumps bust the cache.
6221static WHAT_CSS_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6222    let hash = embedded_asset_hash(EMBEDDED_WHAT_CSS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6223    format!("/static/what.css?v={:08x}", hash)
6224});
6225static WHAT_CSS_MINIMAL_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6226    let hash =
6227        embedded_asset_hash(&MINIMAL_WHAT_CSS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6228    format!("/static/what.css?v={:08x}", hash)
6229});
6230static WHAT_JS_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6231    let hash = embedded_asset_hash(EMBEDDED_WHAT_JS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6232    format!("/static/what.js?v={:08x}", hash)
6233});
6234
6235/// The embedded framework CSS for a given mode — used by the CLI's static build.
6236pub fn embedded_what_css_for_mode(mode: CssMode) -> &'static str {
6237    match mode {
6238        CssMode::Minimal => MINIMAL_WHAT_CSS.as_str(),
6239        _ => EMBEDDED_WHAT_CSS,
6240    }
6241}
6242
6243fn embedded_asset_hash(content: &str) -> u32 {
6244    let mut hash: u32 = 0x811c9dc5;
6245    for byte in content.as_bytes() {
6246        hash ^= *byte as u32;
6247        hash = hash.wrapping_mul(0x0100_0193);
6248    }
6249    hash
6250}
6251
6252/// Serve the embedded what.css framework stylesheet.
6253/// In production mode, serves the minified version.
6254async fn handle_embedded_css(State(state): State<AppState>) -> impl IntoResponse {
6255    let minimal = state.css_mode == CssMode::Minimal;
6256    let (cache, content) = if state.dev_mode {
6257        let raw = if minimal { MINIMAL_WHAT_CSS.as_str() } else { EMBEDDED_WHAT_CSS };
6258        ("no-cache, no-store, must-revalidate", raw.to_string())
6259    } else {
6260        let min = if minimal { MINIFIED_MINIMAL_WHAT_CSS.clone() } else { MINIFIED_WHAT_CSS.clone() };
6261        ("public, max-age=31536000, immutable", min)
6262    };
6263    (
6264        StatusCode::OK,
6265        [
6266            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
6267            (header::CACHE_CONTROL, cache),
6268        ],
6269        content,
6270    )
6271}
6272
6273/// Serve the embedded what.js framework script.
6274/// In production mode, serves the minified version.
6275async fn handle_embedded_js(State(state): State<AppState>) -> impl IntoResponse {
6276    let (cache, content) = if state.dev_mode {
6277        ("no-cache, no-store, must-revalidate", EMBEDDED_WHAT_JS.to_string())
6278    } else {
6279        ("public, max-age=31536000, immutable", MINIFIED_WHAT_JS.clone())
6280    };
6281    (
6282        StatusCode::OK,
6283        [
6284            (
6285                header::CONTENT_TYPE,
6286                "application/javascript; charset=utf-8",
6287            ),
6288            (header::CACHE_CONTROL, cache),
6289        ],
6290        content,
6291    )
6292}
6293
6294/// Serve page source plus referenced components as JSON (for "view source" modal)
6295async fn handle_page_source(
6296    State(state): State<AppState>,
6297    axum::extract::Path(path): axum::extract::Path<String>,
6298) -> impl IntoResponse {
6299    let not_available = axum::Json(json!({
6300        "files": [{ "label": "Page", "content": "Source not available" }]
6301    }));
6302
6303    // Strip leading slash if present
6304    let clean_path = path.trim_start_matches('/');
6305    let pages_dir = state.content_dir.clone();
6306    let file_path = pages_dir.join(clean_path);
6307
6308    // Try with .html extension, or index.html for directories
6309    let file_path = if file_path.extension().is_some() {
6310        file_path
6311    } else {
6312        let with_ext = file_path.with_extension("html");
6313        if with_ext.exists() {
6314            with_ext
6315        } else {
6316            file_path.join("index.html")
6317        }
6318    };
6319
6320    // Path traversal protection: canonicalize and verify within content dir
6321    let canonical = match file_path.canonicalize() {
6322        Ok(p) => p,
6323        Err(_) => return not_available,
6324    };
6325    let pages_canonical = match pages_dir.canonicalize() {
6326        Ok(p) => p,
6327        Err(_) => return not_available,
6328    };
6329    if !canonical.starts_with(&pages_canonical) {
6330        return not_available;
6331    }
6332
6333    // Read once to verify the page exists and is readable (push_source_file re-reads).
6334    if std::fs::read_to_string(&canonical).is_err() {
6335        return not_available;
6336    }
6337
6338    // Build list of source files: page, inherited config/layout, then referenced components/includes
6339    let mut files: Vec<Value> = Vec::new();
6340    let mut scan_queue: Vec<(PathBuf, String)> = Vec::new();
6341
6342    // Scan for <what-*> component tags and <include> tags
6343    static COMPONENT_RE: LazyLock<Regex> =
6344        LazyLock::new(|| Regex::new(r"<what-([a-zA-Z][a-zA-Z0-9_-]*)[\s/>]").unwrap());
6345    static INCLUDE_RE: LazyLock<Regex> =
6346        LazyLock::new(|| Regex::new(r#"<include\s+src="([^"]+)""#).unwrap());
6347    static PARTIAL_ROUTE_RE: LazyLock<Regex> =
6348        LazyLock::new(|| Regex::new(r#"w-get="/partials/([^"?#]+)""#).unwrap());
6349    static PARTIAL_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
6350        Regex::new(r#"name="w-partial"[^>]*value="([^"]+)"|value="([^"]+)"[^>]*name="w-partial""#)
6351            .unwrap()
6352    });
6353
6354    let project_root = pages_dir.parent().unwrap_or(&pages_dir);
6355    let project_root_canonical = match project_root.canonicalize() {
6356        Ok(p) => p,
6357        Err(_) => return not_available,
6358    };
6359    let components_dir = project_root.join("components");
6360    let mut seen_paths = std::collections::HashSet::new();
6361
6362    push_source_file(
6363        canonical.clone(),
6364        &project_root_canonical,
6365        &mut seen_paths,
6366        &mut files,
6367        &mut scan_queue,
6368    );
6369
6370    // Show "your code" only: the page plus the components/includes it uses.
6371    // The layout wrapper (with its <html>/<head>) and inherited application.what
6372    // config are framework plumbing and are intentionally not listed here.
6373
6374    while let Some((current_path, current_content)) = scan_queue.pop() {
6375        for cap in COMPONENT_RE.captures_iter(&current_content) {
6376            let name = cap[1].to_string();
6377            push_source_file(
6378                components_dir.join(format!("{}.html", name)),
6379                &project_root_canonical,
6380                &mut seen_paths,
6381                &mut files,
6382                &mut scan_queue,
6383            );
6384        }
6385
6386        for cap in INCLUDE_RE.captures_iter(&current_content) {
6387            let src = cap[1].to_string();
6388            let file_dir = current_path.parent().unwrap_or(project_root);
6389            let candidates = [
6390                project_root.join(&src),
6391                pages_dir.join(&src),
6392                file_dir.join(&src),
6393            ];
6394            for candidate in candidates {
6395                if push_source_file(
6396                    candidate,
6397                    &project_root_canonical,
6398                    &mut seen_paths,
6399                    &mut files,
6400                    &mut scan_queue,
6401                ) {
6402                    break;
6403                }
6404            }
6405        }
6406
6407        for cap in PARTIAL_ROUTE_RE.captures_iter(&current_content) {
6408            let partial = format!("{}.html", &cap[1]);
6409            push_source_file(
6410                pages_dir.join("partials").join(partial),
6411                &project_root_canonical,
6412                &mut seen_paths,
6413                &mut files,
6414                &mut scan_queue,
6415            );
6416        }
6417
6418        for cap in PARTIAL_NAME_RE.captures_iter(&current_content) {
6419            if let Some(name) = cap.get(1).or_else(|| cap.get(2)) {
6420                push_source_file(
6421                    pages_dir.join("partials").join(format!("{}.html", name.as_str())),
6422                    &project_root_canonical,
6423                    &mut seen_paths,
6424                    &mut files,
6425                    &mut scan_queue,
6426                );
6427            }
6428        }
6429    }
6430
6431    axum::Json(json!({ "files": files }))
6432}
6433
6434/// Handle WebSocket connection for live reload
6435async fn handle_livereload_ws(
6436    ws: WebSocketUpgrade,
6437    State(state): State<AppState>,
6438) -> impl IntoResponse {
6439    ws.on_upgrade(|socket| handle_livereload_socket(socket, state))
6440}
6441
6442/// Handle an individual live reload WebSocket connection
6443async fn handle_livereload_socket(socket: WebSocket, state: AppState) {
6444    let (mut sender, mut receiver) = socket.split();
6445
6446    // Get a receiver for live reload messages
6447    let mut reload_rx = match state.live_reload_receiver() {
6448        Some(rx) => rx,
6449        None => {
6450            tracing::warn!("Live reload not enabled");
6451            return;
6452        }
6453    };
6454
6455    tracing::debug!("Live reload client connected");
6456
6457    // Send initial connected message
6458    let _ = sender
6459        .send(Message::Text("{\"type\":\"connected\"}".to_string()))
6460        .await;
6461
6462    // Create a channel to signal when the receiver task should stop
6463    let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
6464
6465    // Spawn task to handle incoming messages (mainly for ping/pong and close)
6466    let recv_task = tokio::spawn(async move {
6467        while let Some(msg) = receiver.next().await {
6468            match msg {
6469                Ok(Message::Close(_)) => break,
6470                Ok(Message::Ping(_)) => {
6471                    // Pong is handled automatically by axum
6472                }
6473                Err(e) => {
6474                    tracing::debug!("WebSocket receive error: {}", e);
6475                    break;
6476                }
6477                _ => {}
6478            }
6479        }
6480        // Signal that we should stop
6481        let _ = stop_tx.send(());
6482    });
6483
6484    // Send reload messages to client
6485    loop {
6486        tokio::select! {
6487            result = reload_rx.recv() => {
6488                match result {
6489                    Ok(LiveReloadMessage::Reload) => {
6490                        if sender.send(Message::Text("{\"type\":\"reload\"}".to_string())).await.is_err() {
6491                            break;
6492                        }
6493                    }
6494                    Ok(LiveReloadMessage::CacheCleared) => {
6495                        if sender.send(Message::Text("{\"type\":\"cache_cleared\"}".to_string())).await.is_err() {
6496                            break;
6497                        }
6498                    }
6499                    Err(broadcast::error::RecvError::Closed) => break,
6500                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
6501                }
6502            }
6503            _ = &mut stop_rx => {
6504                // Receiver task finished (client disconnected)
6505                break;
6506            }
6507        }
6508    }
6509
6510    // Clean up the receiver task
6511    recv_task.abort();
6512
6513    tracing::debug!("Live reload client disconnected");
6514}
6515
6516/// Handle WebSocket upgrade for wired state (with scope filtering)
6517async fn handle_wire_ws(
6518    ws: WebSocketUpgrade,
6519    headers: HeaderMap,
6520    State(state): State<AppState>,
6521) -> impl IntoResponse {
6522    // Extract client roles and user_id from JWT cookie at connection time
6523    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
6524    let (client_roles, client_user_id) = if state.auth.is_enabled() {
6525        if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
6526            match state.auth.decode_jwt(&token) {
6527                Ok(claims) if !claims.is_expired() => {
6528                    let user =
6529                        UserContext::from_claims(claims.to_context(state.auth.jwt_claims()));
6530                    (user.roles(), claims.sub.clone())
6531                }
6532                _ => (Vec::new(), None),
6533            }
6534        } else {
6535            (Vec::new(), None)
6536        }
6537    } else {
6538        (Vec::new(), None)
6539    };
6540
6541    ws.on_upgrade(move |socket| handle_wire_socket(socket, state, client_roles, client_user_id))
6542}
6543
6544/// Handle an individual wired state WebSocket connection (with scope filtering)
6545async fn handle_wire_socket(
6546    socket: WebSocket,
6547    state: AppState,
6548    client_roles: Vec<String>,
6549    client_user_id: Option<String>,
6550) {
6551    let (mut sender, mut receiver) = socket.split();
6552    let mut wired_rx = state.wired_tx.subscribe();
6553
6554    // Track connected client count
6555    let count = state
6556        .wired_client_count
6557        .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6558        + 1;
6559    tracing::debug!(
6560        "Wired client connected (roles: {:?}, user: {:?}, total: {})",
6561        client_roles,
6562        client_user_id,
6563        count
6564    );
6565
6566    // Send connected message with current client count
6567    let connected_msg = format!(r#"{{"type":"connected","clients":{}}}"#, count);
6568    let _ = sender.send(Message::Text(connected_msg)).await;
6569
6570    // Broadcast updated client count to all connected clients
6571    let _ = state.wired_tx.send(WiredMessage {
6572        json: format!(r#"{{"wired._clients":"{}"}}"#, count),
6573        scope: WiredScope::Public,
6574    });
6575
6576    let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
6577
6578    let recv_task = tokio::spawn(async move {
6579        while let Some(msg) = receiver.next().await {
6580            match msg {
6581                Ok(Message::Close(_)) => break,
6582                Err(_) => break,
6583                _ => {}
6584            }
6585        }
6586        let _ = stop_tx.send(());
6587    });
6588
6589    loop {
6590        tokio::select! {
6591            result = wired_rx.recv() => {
6592                match result {
6593                    Ok(msg) => {
6594                        // Filter by scope — only send if the client is authorized
6595                        if msg.scope.allows(&client_roles, client_user_id.as_deref()) {
6596                            if sender.send(Message::Text(msg.json)).await.is_err() {
6597                                break;
6598                            }
6599                        }
6600                    }
6601                    Err(broadcast::error::RecvError::Closed) => break,
6602                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
6603                }
6604            }
6605            _ = &mut stop_rx => {
6606                break;
6607            }
6608        }
6609    }
6610
6611    recv_task.abort();
6612
6613    // Decrement connected client count and broadcast
6614    let count = state
6615        .wired_client_count
6616        .fetch_sub(1, std::sync::atomic::Ordering::Relaxed)
6617        - 1;
6618    let _ = state.wired_tx.send(WiredMessage {
6619        json: format!(r#"{{"wired._clients":"{}"}}"#, count),
6620        scope: WiredScope::Public,
6621    });
6622    tracing::debug!("Wired client disconnected (total: {})", count);
6623}
6624
6625/// Start the What server
6626pub async fn serve(state: AppState) -> Result<()> {
6627    let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
6628    let listener = tokio::net::TcpListener::bind(&addr).await?;
6629
6630    tracing::info!("What server running at http://{}", addr);
6631
6632    let router = create_router(state);
6633    axum::serve(listener, router).await?;
6634
6635    Ok(())
6636}
6637
6638/// Start the server with graceful shutdown support.
6639/// The `shutdown` future resolves when the server should stop accepting new connections.
6640pub async fn serve_with_shutdown(
6641    state: AppState,
6642    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
6643) -> Result<()> {
6644    let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
6645    let listener = tokio::net::TcpListener::bind(&addr).await?;
6646    serve_on_listener(state, listener, shutdown).await
6647}
6648
6649/// Serve on an already-bound listener. Lets callers bind first — surfacing
6650/// port-in-use errors before printing any success banner — then serve.
6651pub async fn serve_on_listener(
6652    state: AppState,
6653    listener: tokio::net::TcpListener,
6654    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
6655) -> Result<()> {
6656    tracing::info!("What server running at http://{}", listener.local_addr()?);
6657
6658    let router = create_router(state);
6659    axum::serve(listener, router)
6660        .with_graceful_shutdown(shutdown)
6661        .await?;
6662
6663    Ok(())
6664}
6665
6666#[cfg(test)]
6667mod tests {
6668    use super::*;
6669    use std::fs;
6670    use tempfile::TempDir;
6671
6672    /// Create a test project structure in a temporary directory
6673    fn create_test_project() -> TempDir {
6674        let temp_dir = TempDir::new().unwrap();
6675        let root = temp_dir.path();
6676        let cd = content_dir_name(root);
6677
6678        // Create content directory structure
6679        fs::create_dir_all(root.join(cd).join("admin")).unwrap();
6680        fs::create_dir_all(root.join(cd).join("blog")).unwrap();
6681
6682        // Create page files
6683        fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
6684        fs::write(root.join(cd).join("about.html"), "<h1>About</h1>").unwrap();
6685        fs::write(root.join(cd).join("admin/index.html"), "<h1>Admin</h1>").unwrap();
6686        fs::write(root.join(cd).join("admin/users.html"), "<h1>Users</h1>").unwrap();
6687        fs::write(root.join(cd).join("blog/[id].html"), "<h1>Blog Post</h1>").unwrap();
6688
6689        temp_dir
6690    }
6691
6692    #[test]
6693    fn test_resolve_page_path_exact_match() {
6694        let temp_dir = create_test_project();
6695        let root = temp_dir.path().to_path_buf();
6696
6697        // /about -> site/about.html
6698        let result = resolve_page_path(&root, "/about");
6699        assert!(result.is_some());
6700        let resolved = result.unwrap();
6701        assert!(resolved.path.ends_with("about.html"));
6702        assert!(resolved.params.is_empty());
6703    }
6704
6705    #[test]
6706    fn test_resolve_page_path_index() {
6707        let temp_dir = create_test_project();
6708        let root = temp_dir.path().to_path_buf();
6709
6710        // / -> site/index.html
6711        let result = resolve_page_path(&root, "/");
6712        assert!(result.is_some());
6713        assert!(result.unwrap().path.ends_with("index.html"));
6714
6715        // /admin -> site/admin/index.html
6716        let result = resolve_page_path(&root, "/admin");
6717        assert!(result.is_some());
6718        let resolved = result.unwrap();
6719        assert!(resolved.path.to_string_lossy().contains("admin"));
6720        assert!(resolved.path.ends_with("index.html"));
6721    }
6722
6723    #[test]
6724    fn test_resolve_page_path_nested() {
6725        let temp_dir = create_test_project();
6726        let root = temp_dir.path().to_path_buf();
6727
6728        // /admin/users -> site/admin/users.html
6729        let result = resolve_page_path(&root, "/admin/users");
6730        assert!(result.is_some());
6731        assert!(result.unwrap().path.ends_with("users.html"));
6732    }
6733
6734    #[test]
6735    fn test_resolve_page_path_dynamic() {
6736        let temp_dir = create_test_project();
6737        let root = temp_dir.path().to_path_buf();
6738
6739        // /blog/123 -> site/blog/[id].html
6740        let result = resolve_page_path(&root, "/blog/123");
6741        assert!(result.is_some());
6742        let resolved = result.unwrap();
6743        assert!(resolved.path.ends_with("[id].html"));
6744        assert_eq!(resolved.params.get("id"), Some(&"123".to_string()));
6745    }
6746
6747    #[test]
6748    fn test_resolve_page_path_not_found() {
6749        let temp_dir = create_test_project();
6750        let root = temp_dir.path().to_path_buf();
6751
6752        // /nonexistent should return None
6753        let result = resolve_page_path(&root, "/nonexistent");
6754        assert!(result.is_none());
6755    }
6756
6757    /// Create a test project with application.what files
6758    fn create_test_project_with_config() -> TempDir {
6759        let temp_dir = TempDir::new().unwrap();
6760        let root = temp_dir.path();
6761        let cd = content_dir_name(root);
6762
6763        // Create content directory structure
6764        fs::create_dir_all(root.join(cd).join("admin")).unwrap();
6765        fs::create_dir_all(root.join(cd).join("admin/settings")).unwrap();
6766
6767        // Create root application.what
6768        fs::write(
6769            root.join(cd).join("application.what"),
6770            r#"
6771title = "My App"
6772theme = "light"
6773auth = "all"
6774"#,
6775        )
6776        .unwrap();
6777
6778        // Create admin application.what (overrides)
6779        fs::write(
6780            root.join(cd).join("admin/application.what"),
6781            r#"
6782title = "Admin Panel"
6783auth = "admin"
6784"#,
6785        )
6786        .unwrap();
6787
6788        // Create admin/settings application.what (further overrides)
6789        fs::write(
6790            root.join(cd).join("admin/settings/application.what"),
6791            r#"
6792title = "Settings"
6793debug = true
6794"#,
6795        )
6796        .unwrap();
6797
6798        // Create page files
6799        fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
6800        fs::write(root.join(cd).join("admin/index.html"), "<h1>Admin</h1>").unwrap();
6801        fs::write(
6802            root.join(cd).join("admin/settings/index.html"),
6803            "<h1>Settings</h1>",
6804        )
6805        .unwrap();
6806
6807        temp_dir
6808    }
6809
6810    #[test]
6811    fn test_load_application_config_root() {
6812        let temp_dir = create_test_project_with_config();
6813        let root = temp_dir.path().to_path_buf();
6814
6815        // Load config for root page
6816        let config = load_application_config(&root, "/");
6817
6818        assert_eq!(config.get_string("title"), Some("My App"));
6819        assert_eq!(config.get_string("theme"), Some("light"));
6820        assert_eq!(config.directives.auth, crate::parser::AuthLevel::All);
6821    }
6822
6823    #[test]
6824    fn test_load_application_config_inheritance() {
6825        let temp_dir = create_test_project_with_config();
6826        let root = temp_dir.path().to_path_buf();
6827
6828        // Load config for admin page - should have admin's title but root's theme
6829        let config = load_application_config(&root, "/admin");
6830
6831        assert_eq!(config.get_string("title"), Some("Admin Panel"));
6832        assert_eq!(config.get_string("theme"), Some("light")); // Inherited from root
6833        assert_eq!(
6834            config.directives.auth,
6835            crate::parser::AuthLevel::Roles(vec!["admin".to_string()])
6836        );
6837    }
6838
6839    #[test]
6840    fn test_load_application_config_deep_inheritance() {
6841        let temp_dir = create_test_project_with_config();
6842        let root = temp_dir.path().to_path_buf();
6843
6844        // Load config for admin/settings - should inherit from root and admin
6845        let config = load_application_config(&root, "/admin/settings");
6846
6847        assert_eq!(config.get_string("title"), Some("Settings"));
6848        assert_eq!(config.get_string("theme"), Some("light")); // From root
6849        assert_eq!(config.get_bool("debug"), Some(true)); // From settings
6850        // Auth should be from admin (child overrides but settings doesn't set auth)
6851        assert_eq!(
6852            config.directives.auth,
6853            crate::parser::AuthLevel::Roles(vec!["admin".to_string()])
6854        );
6855    }
6856
6857    #[test]
6858    fn test_load_application_config_no_config() {
6859        let temp_dir = create_test_project();
6860        let root = temp_dir.path().to_path_buf();
6861
6862        // Load config for project without application.what files
6863        let config = load_application_config(&root, "/about");
6864
6865        // Should return default empty config
6866        assert!(config.values.is_empty());
6867        assert_eq!(config.directives.auth, crate::parser::AuthLevel::All);
6868    }
6869
6870    #[test]
6871    fn test_content_dir_name_prefers_site() {
6872        let temp = TempDir::new().unwrap();
6873        let root = temp.path();
6874
6875        // Neither exists -> defaults to "site"
6876        assert_eq!(content_dir_name(root), "site");
6877
6878        // Only pages/ exists -> uses "pages"
6879        fs::create_dir_all(root.join("pages")).unwrap();
6880        assert_eq!(content_dir_name(root), "pages");
6881
6882        // Both exist -> prefers "site"
6883        fs::create_dir_all(root.join("site")).unwrap();
6884        assert_eq!(content_dir_name(root), "site");
6885    }
6886
6887    #[test]
6888    fn test_content_dir_name_only_site() {
6889        let temp = TempDir::new().unwrap();
6890        let root = temp.path();
6891        fs::create_dir_all(root.join("site")).unwrap();
6892        assert_eq!(content_dir_name(root), "site");
6893    }
6894
6895    #[test]
6896    fn test_app_state_dev_mode_disabled() {
6897        let temp_dir = create_test_project();
6898        let root = temp_dir.path().to_path_buf();
6899        let config = crate::Config::default();
6900
6901        let state = AppState::new(config, root).unwrap();
6902
6903        assert!(!state.dev_mode);
6904        assert!(state.live_reload_tx.is_none());
6905        assert!(state.live_reload_receiver().is_none());
6906    }
6907
6908    #[test]
6909    fn test_app_state_dev_mode_enabled() {
6910        let temp_dir = create_test_project();
6911        let root = temp_dir.path().to_path_buf();
6912        let config = crate::Config::default();
6913
6914        let state = AppState::with_dev_mode(config, root, true).unwrap();
6915
6916        assert!(state.dev_mode);
6917        assert!(state.live_reload_tx.is_some());
6918        assert!(state.live_reload_receiver().is_some());
6919    }
6920
6921    #[test]
6922    fn test_dev_mode_disables_secure_cookies() {
6923        let temp_dir = create_test_project();
6924        let root = temp_dir.path().to_path_buf();
6925        let config = crate::Config::default();
6926        // Default config has secure=true
6927        assert!(config.session.secure);
6928
6929        // In dev mode, secure is auto-disabled
6930        let state = AppState::with_dev_mode(config, root, true).unwrap();
6931        assert!(!state.config.session.secure);
6932    }
6933
6934    #[test]
6935    fn test_production_mode_keeps_secure_cookies() {
6936        let temp_dir = create_test_project();
6937        let root = temp_dir.path().to_path_buf();
6938        let config = crate::Config::default();
6939
6940        // In production mode (dev_mode=false), secure stays true
6941        let state = AppState::new(config, root).unwrap();
6942        assert!(state.config.session.secure);
6943    }
6944
6945    #[test]
6946    fn test_live_reload_broadcast() {
6947        let temp_dir = create_test_project();
6948        let root = temp_dir.path().to_path_buf();
6949        let config = crate::Config::default();
6950
6951        let state = AppState::with_dev_mode(config, root, true).unwrap();
6952
6953        // Subscribe before triggering
6954        let mut rx = state.live_reload_receiver().unwrap();
6955
6956        // Trigger reload
6957        if let Some(ref tx) = state.live_reload_tx {
6958            tx.send(LiveReloadMessage::Reload).unwrap();
6959        }
6960
6961        // Should receive the message
6962        let msg = rx.try_recv().unwrap();
6963        assert!(matches!(msg, LiveReloadMessage::Reload));
6964    }
6965
6966    #[test]
6967    fn test_live_reload_multiple_subscribers() {
6968        let temp_dir = create_test_project();
6969        let root = temp_dir.path().to_path_buf();
6970        let config = crate::Config::default();
6971
6972        let state = AppState::with_dev_mode(config, root, true).unwrap();
6973
6974        // Create multiple subscribers
6975        let mut rx1 = state.live_reload_receiver().unwrap();
6976        let mut rx2 = state.live_reload_receiver().unwrap();
6977
6978        // Trigger reload
6979        if let Some(ref tx) = state.live_reload_tx {
6980            tx.send(LiveReloadMessage::Reload).unwrap();
6981        }
6982
6983        // Both should receive the message
6984        assert!(matches!(rx1.try_recv().unwrap(), LiveReloadMessage::Reload));
6985        assert!(matches!(rx2.try_recv().unwrap(), LiveReloadMessage::Reload));
6986    }
6987
6988    // =========================================================================
6989    // Fetch Directive Collection Tests
6990    // =========================================================================
6991
6992    #[test]
6993    fn test_collect_fetch_directives_from_page() {
6994        let mut directives = crate::parser::PageDirectives::default();
6995        directives.custom.insert(
6996            "fetch.dog_facts".to_string(),
6997            "https://dogapi.dog/api/v2/facts?limit=3".to_string(),
6998        );
6999        directives.custom.insert(
7000            "fetch.dog_breeds".to_string(),
7001            "https://dogapi.dog/api/v2/breeds".to_string(),
7002        );
7003
7004        let fetches = collect_fetch_directives(None, Some(&directives));
7005
7006        assert_eq!(fetches.len(), 2);
7007        assert_eq!(
7008            fetches.get("dog_facts").map(|f| f.url.as_str()),
7009            Some("https://dogapi.dog/api/v2/facts?limit=3")
7010        );
7011        assert_eq!(
7012            fetches.get("dog_breeds").map(|f| f.url.as_str()),
7013            Some("https://dogapi.dog/api/v2/breeds")
7014        );
7015    }
7016
7017    #[test]
7018    fn test_collect_fetch_directives_excludes_non_fetch() {
7019        let mut directives = crate::parser::PageDirectives::default();
7020        directives
7021            .custom
7022            .insert("page".to_string(), "remote-data".to_string());
7023        directives.custom.insert(
7024            "fetch.api".to_string(),
7025            "https://example.com/api".to_string(),
7026        );
7027
7028        let fetches = collect_fetch_directives(None, Some(&directives));
7029
7030        assert_eq!(fetches.len(), 1);
7031        assert!(fetches.contains_key("api"));
7032        assert!(!fetches.contains_key("page"));
7033    }
7034
7035    #[test]
7036    fn test_collect_fetch_directives_empty() {
7037        let directives = crate::parser::PageDirectives::default();
7038        let fetches = collect_fetch_directives(None, Some(&directives));
7039        assert!(fetches.is_empty());
7040    }
7041
7042    #[test]
7043    fn test_collect_fetch_directives_enhanced() {
7044        let mut directives = crate::parser::PageDirectives::default();
7045        directives.custom.insert(
7046            "fetch.users.url".to_string(),
7047            "https://api.example.com/users".to_string(),
7048        );
7049        directives
7050            .custom
7051            .insert("fetch.users.method".to_string(), "POST".to_string());
7052        directives.custom.insert(
7053            "fetch.users.headers".to_string(),
7054            "Authorization: Bearer token".to_string(),
7055        );
7056        directives
7057            .custom
7058            .insert("fetch.users.path".to_string(), "data.results".to_string());
7059
7060        let fetches = collect_fetch_directives(None, Some(&directives));
7061        assert_eq!(fetches.len(), 1);
7062        let users = fetches.get("users").unwrap();
7063        assert_eq!(users.url, "https://api.example.com/users");
7064        assert_eq!(users.method, "POST");
7065        assert_eq!(users.headers.len(), 1);
7066        assert_eq!(
7067            users.headers[0],
7068            ("Authorization".to_string(), "Bearer token".to_string())
7069        );
7070        assert_eq!(users.path.as_deref(), Some("data.results"));
7071    }
7072
7073    #[test]
7074    fn test_extract_json_path() {
7075        let value = serde_json::json!({
7076            "data": {
7077                "results": [1, 2, 3],
7078                "meta": { "total": 100 }
7079            }
7080        });
7081        assert_eq!(
7082            extract_json_path(&value, "data.results"),
7083            serde_json::json!([1, 2, 3])
7084        );
7085        assert_eq!(
7086            extract_json_path(&value, "data.meta.total"),
7087            serde_json::json!(100)
7088        );
7089        assert_eq!(
7090            extract_json_path(&value, "data.missing"),
7091            serde_json::Value::Null
7092        );
7093    }
7094
7095    #[test]
7096    fn test_parse_header_string() {
7097        let headers = parse_header_string("Authorization: Bearer token, Accept: application/json");
7098        assert_eq!(headers.len(), 2);
7099        assert_eq!(
7100            headers[0],
7101            ("Authorization".to_string(), "Bearer token".to_string())
7102        );
7103        assert_eq!(
7104            headers[1],
7105            ("Accept".to_string(), "application/json".to_string())
7106        );
7107    }
7108
7109    // =========================================================================
7110    // w-set Expression Parsing Tests
7111    // =========================================================================
7112
7113    #[test]
7114    fn test_parse_w_set_increment() {
7115        let result = parse_w_set_expr("session.counter += 1");
7116        assert!(result.is_some());
7117        let (scope, key, op) = result.unwrap();
7118        assert_eq!(scope, "session");
7119        assert_eq!(key, "counter");
7120        assert!(matches!(op, SetOp::Increment(1)));
7121    }
7122
7123    #[test]
7124    fn test_parse_w_set_decrement() {
7125        let result = parse_w_set_expr("session.counter -= 1");
7126        assert!(result.is_some());
7127        let (scope, key, op) = result.unwrap();
7128        assert_eq!(scope, "session");
7129        assert_eq!(key, "counter");
7130        assert!(matches!(op, SetOp::Decrement(1)));
7131    }
7132
7133    #[test]
7134    fn test_parse_w_set_assign_int() {
7135        let result = parse_w_set_expr("app.app_counter = 0");
7136        assert!(result.is_some());
7137        let (scope, key, op) = result.unwrap();
7138        assert_eq!(scope, "app");
7139        assert_eq!(key, "app_counter");
7140        assert!(matches!(op, SetOp::SetInt(0)));
7141    }
7142
7143    #[test]
7144    fn test_parse_w_set_assign_string() {
7145        let result = parse_w_set_expr("session.name = 'Jorge'");
7146        assert!(result.is_some());
7147        let (scope, key, op) = result.unwrap();
7148        assert_eq!(scope, "session");
7149        assert_eq!(key, "name");
7150        match op {
7151            SetOp::SetStr(s) => assert_eq!(s, "Jorge"),
7152            _ => panic!("Expected SetStr"),
7153        }
7154    }
7155
7156    #[test]
7157    fn test_parse_w_set_large_increment() {
7158        let result = parse_w_set_expr("app.views += 100");
7159        assert!(result.is_some());
7160        let (scope, key, op) = result.unwrap();
7161        assert_eq!(scope, "app");
7162        assert_eq!(key, "views");
7163        assert!(matches!(op, SetOp::Increment(100)));
7164    }
7165
7166    #[test]
7167    fn test_parse_w_set_whitespace() {
7168        let result = parse_w_set_expr("  session.counter  +=  5  ");
7169        assert!(result.is_some());
7170        let (scope, key, op) = result.unwrap();
7171        assert_eq!(scope, "session");
7172        assert_eq!(key, "counter");
7173        assert!(matches!(op, SetOp::Increment(5)));
7174    }
7175
7176    #[test]
7177    fn test_parse_w_set_invalid_no_scope() {
7178        assert!(parse_w_set_expr("counter += 1").is_none());
7179    }
7180
7181    #[test]
7182    fn test_parse_w_set_invalid_bad_value() {
7183        assert!(parse_w_set_expr("session.counter += abc").is_none());
7184    }
7185
7186    #[test]
7187    fn test_parse_w_set_invalid_empty() {
7188        assert!(parse_w_set_expr("").is_none());
7189    }
7190
7191    #[test]
7192    fn test_parse_w_set_multi_expression() {
7193        let expr_str = "session.counter += 1; app.views += 1";
7194        let parsed: Vec<_> = expr_str
7195            .split(';')
7196            .map(|s| s.trim())
7197            .filter(|s| !s.is_empty())
7198            .filter_map(|s| parse_w_set_expr(s))
7199            .collect();
7200        assert_eq!(parsed.len(), 2);
7201        assert_eq!(parsed[0].0, "session");
7202        assert_eq!(parsed[0].1, "counter");
7203        assert!(matches!(parsed[0].2, SetOp::Increment(1)));
7204        assert_eq!(parsed[1].0, "app");
7205        assert_eq!(parsed[1].1, "views");
7206        assert!(matches!(parsed[1].2, SetOp::Increment(1)));
7207    }
7208
7209    #[test]
7210    fn test_apply_set_op_increment_from_none() {
7211        let result = apply_set_op(None, &SetOp::Increment(1));
7212        assert_eq!(result, json!(1));
7213    }
7214
7215    #[test]
7216    fn test_apply_set_op_increment_existing() {
7217        let current = json!(5);
7218        let result = apply_set_op(Some(&current), &SetOp::Increment(3));
7219        assert_eq!(result, json!(8));
7220    }
7221
7222    #[test]
7223    fn test_apply_set_op_set_string() {
7224        let result = apply_set_op(None, &SetOp::SetStr("hello".to_string()));
7225        assert_eq!(result, json!("hello"));
7226    }
7227
7228    #[test]
7229    fn test_parse_w_set_wired_scope() {
7230        let result = parse_w_set_expr("wired.total_dogs += 1");
7231        assert!(result.is_some());
7232        let (scope, key, op) = result.unwrap();
7233        assert_eq!(scope, "wired");
7234        assert_eq!(key, "total_dogs");
7235        assert!(matches!(op, SetOp::Increment(1)));
7236    }
7237
7238    #[test]
7239    fn test_parse_w_set_wired_reset() {
7240        let result = parse_w_set_expr("wired.counter = 0");
7241        assert!(result.is_some());
7242        let (scope, key, op) = result.unwrap();
7243        assert_eq!(scope, "wired");
7244        assert_eq!(key, "counter");
7245        assert!(matches!(op, SetOp::SetInt(0)));
7246    }
7247
7248    #[test]
7249    fn test_wired_broadcast_channel() {
7250        let temp_dir = create_test_project();
7251        let root = temp_dir.path().to_path_buf();
7252        let config = crate::Config::default();
7253        let state = AppState::new(config, root).unwrap();
7254
7255        let mut rx = state.wired_tx.subscribe();
7256        let _ = state.wired_tx.send(WiredMessage {
7257            json: r#"{"wired.counter":"5"}"#.to_string(),
7258            scope: WiredScope::Public,
7259        });
7260        let msg = rx.try_recv().unwrap();
7261        assert!(msg.json.contains("wired.counter"));
7262        assert!(msg.json.contains("5"));
7263    }
7264
7265    #[test]
7266    fn test_error_page_dev_mode_shows_detail() {
7267        let page = error_page_fallback(
7268            true,
7269            StatusCode::INTERNAL_SERVER_ERROR,
7270            "template parse failed at line 42",
7271        );
7272        assert!(page.contains("template parse failed at line 42"));
7273        assert!(page.contains("500"));
7274    }
7275
7276    #[test]
7277    fn test_error_page_production_hides_detail() {
7278        let page = error_page_fallback(
7279            false,
7280            StatusCode::INTERNAL_SERVER_ERROR,
7281            "template parse failed at line 42",
7282        );
7283        assert!(!page.contains("template parse failed"));
7284        assert!(!page.contains("line 42"));
7285        assert!(page.contains("Something went wrong"));
7286    }
7287
7288    #[test]
7289    fn test_error_page_404_production() {
7290        let page = error_page_fallback(false, StatusCode::NOT_FOUND, "/secret/path/file.html");
7291        assert!(!page.contains("/secret/path"));
7292        assert!(page.contains("Page Not Found"));
7293    }
7294
7295    #[test]
7296    fn test_error_page_403_production() {
7297        let page = error_page_fallback(false, StatusCode::FORBIDDEN, "user lacks admin role");
7298        assert!(!page.contains("admin role"));
7299        assert!(page.contains("Forbidden"));
7300    }
7301
7302    // ---- CSRF Protection Tests ----
7303
7304    #[test]
7305    fn test_inject_csrf_into_post_form() {
7306        let html = r##"<html><head></head><body><form method="post" action="/submit"><input name="name"></form></body></html>"##;
7307        let result = inject_csrf_tokens(html, "test_token_abc");
7308        assert!(result.contains(r#"<input type="hidden" name="_csrf" value="test_token_abc">"#));
7309        assert!(result.contains(r#"<meta name="csrf-token" content="test_token_abc">"#));
7310    }
7311
7312    #[test]
7313    fn test_inject_csrf_skips_get_form() {
7314        let html = r##"<html><head></head><body><form method="get" action="/search"><input name="q"></form></body></html>"##;
7315        let result = inject_csrf_tokens(html, "test_token");
7316        // Should NOT inject hidden input into GET form
7317        assert!(!result.contains(r#"name="_csrf""#));
7318        // But should still inject meta tag
7319        assert!(result.contains(r#"<meta name="csrf-token" content="test_token">"#));
7320    }
7321
7322    #[test]
7323    fn test_inject_csrf_multiple_forms() {
7324        let html = r##"<html><head></head><body><form method="POST"><input></form><form method="post"><input></form></body></html>"##;
7325        let result = inject_csrf_tokens(html, "tok123");
7326        // Count occurrences of the hidden input
7327        let count = result.matches(r#"name="_csrf""#).count();
7328        assert_eq!(count, 2, "Should inject into both POST forms");
7329    }
7330
7331    #[test]
7332    fn test_inject_what_js_before_body() {
7333        let html = "<html><head></head><body><p>Hello</p></body></html>";
7334        let result = inject_what_js(html);
7335        assert!(result.contains(WHAT_JS_ASSET_PATH.as_str()));
7336        assert!(result.contains("</body>"));
7337    }
7338
7339    #[test]
7340    fn test_inject_what_js_skips_if_present() {
7341        let html =
7342            r#"<html><head></head><body><script src="/static/what.js"></script></body></html>"#;
7343        let result = inject_what_js(html);
7344        assert_eq!(result.matches("what.js").count(), 1, "Should not duplicate");
7345    }
7346
7347    #[test]
7348    fn test_inject_what_js_no_body() {
7349        let html = "<p>just a fragment</p>";
7350        let result = inject_what_js(html);
7351        assert!(!result.contains("what.js"), "Should skip without </body>");
7352        assert_eq!(result, html);
7353    }
7354
7355    #[test]
7356    fn test_inject_theme_restore_after_head() {
7357        let html = "<html><head><title>T</title></head><body></body></html>";
7358        let result = inject_theme_restore(html);
7359        assert!(result.contains("data-w-theme"));
7360        // Must land right after the opening <head>, before any other head content
7361        let head_pos = result.find("<head>").unwrap();
7362        let script_pos = result.find("<script data-w-theme>").unwrap();
7363        let title_pos = result.find("<title>").unwrap();
7364        assert!(script_pos > head_pos && script_pos < title_pos);
7365    }
7366
7367    #[test]
7368    fn test_inject_theme_restore_idempotent() {
7369        let html = "<html><head></head><body></body></html>";
7370        let once = inject_theme_restore(html);
7371        let twice = inject_theme_restore(&once);
7372        assert_eq!(once, twice, "Should not duplicate");
7373        assert_eq!(twice.matches("data-w-theme").count(), 1);
7374    }
7375
7376    #[test]
7377    fn test_inject_theme_restore_head_with_attrs_and_fragment() {
7378        let html = r#"<html><head lang="en"><title>T</title></head><body></body></html>"#;
7379        let result = inject_theme_restore(html);
7380        assert!(result.contains("data-w-theme"));
7381
7382        // Fragments without a <head> stay untouched
7383        let fragment = "<p>partial</p>";
7384        assert_eq!(inject_theme_restore(fragment), fragment);
7385    }
7386
7387    #[test]
7388    fn test_inject_what_css_into_head() {
7389        let html = "<html><head><title>Test</title></head><body></body></html>";
7390        let result = inject_what_css(html, CssMode::Full);
7391        assert!(result.contains(WHAT_CSS_ASSET_PATH.as_str()));
7392        assert!(result.contains("<head>\n"));
7393    }
7394
7395    #[test]
7396    fn test_inject_what_css_skips_if_present() {
7397        let html = r#"<html><head><link rel="stylesheet" href="/static/what.css"></head><body></body></html>"#;
7398        let result = inject_what_css(html, CssMode::Full);
7399        assert_eq!(
7400            result.matches("what.css").count(),
7401            1,
7402            "Should not duplicate"
7403        );
7404    }
7405
7406    #[test]
7407    fn test_inject_what_css_no_head() {
7408        let html = "<p>just a fragment</p>";
7409        let result = inject_what_css(html, CssMode::Full);
7410        assert!(!result.contains("what.css"), "Should skip without <head>");
7411        assert_eq!(result, html);
7412    }
7413
7414    #[test]
7415    fn test_inject_what_css_none_mode_skips() {
7416        let html = "<html><head><title>Test</title></head><body></body></html>";
7417        let result = inject_what_css(html, CssMode::None);
7418        assert_eq!(result, html, "none mode must not inject anything");
7419    }
7420
7421    #[test]
7422    fn test_inject_what_css_minimal_mode_uses_minimal_path() {
7423        let html = "<html><head><title>Test</title></head><body></body></html>";
7424        let result = inject_what_css(html, CssMode::Minimal);
7425        assert!(result.contains(WHAT_CSS_MINIMAL_ASSET_PATH.as_str()));
7426        assert_ne!(
7427            WHAT_CSS_MINIMAL_ASSET_PATH.as_str(),
7428            WHAT_CSS_ASSET_PATH.as_str(),
7429            "minimal and full variants must cache-bust independently"
7430        );
7431    }
7432
7433    #[test]
7434    fn test_minimal_css_slice() {
7435        let minimal = MINIMAL_WHAT_CSS.as_str();
7436        // Cut markers must exist in the source stylesheet — the slice silently
7437        // falls back to the full sheet if an edit ever removes them.
7438        assert!(EMBEDDED_WHAT_CSS.contains(MINIMAL_CUT_START), "cut-start marker missing from what.css");
7439        assert!(EMBEDDED_WHAT_CSS.contains(MINIMAL_CUT_END), "cut-end marker missing from what.css");
7440        assert!(minimal.len() < EMBEDDED_WHAT_CSS.len());
7441        // Keeps: reset/theme layer, utilities layer, source viewer
7442        assert!(minimal.contains("@layer base"));
7443        assert!(minimal.contains("@layer utilities"));
7444        assert!(minimal.contains(".page-source"));
7445        // Drops: components + interactions layers
7446        assert!(!minimal.contains("@layer components"));
7447        assert!(!minimal.contains("@layer interactions {"));
7448        assert!(!minimal.contains(".what-pagination"));
7449    }
7450
7451    #[test]
7452    fn test_css_mode_from_config() {
7453        assert_eq!(CssMode::from_config("full").unwrap(), CssMode::Full);
7454        assert_eq!(CssMode::from_config("minimal").unwrap(), CssMode::Minimal);
7455        assert_eq!(CssMode::from_config("none").unwrap(), CssMode::None);
7456        let err = CssMode::from_config("compact").unwrap_err().to_string();
7457        assert!(err.contains("compact"), "error should echo the bad value: {err}");
7458    }
7459
7460    #[test]
7461    fn test_validate_csrf_token_valid() {
7462        let mut session = sessions::Session::new(3600);
7463        session
7464            .data
7465            .insert(CSRF_SESSION_KEY.to_string(), json!("correct_token"));
7466        assert!(validate_csrf_token(
7467            Some(&session),
7468            Some("correct_token"),
7469            None
7470        ));
7471    }
7472
7473    #[test]
7474    fn test_validate_csrf_token_invalid() {
7475        let mut session = sessions::Session::new(3600);
7476        session
7477            .data
7478            .insert(CSRF_SESSION_KEY.to_string(), json!("correct_token"));
7479        assert!(!validate_csrf_token(
7480            Some(&session),
7481            Some("wrong_token"),
7482            None
7483        ));
7484    }
7485
7486    #[test]
7487    fn test_validate_csrf_token_from_header() {
7488        let mut session = sessions::Session::new(3600);
7489        session
7490            .data
7491            .insert(CSRF_SESSION_KEY.to_string(), json!("header_token"));
7492        assert!(validate_csrf_token(
7493            Some(&session),
7494            None,
7495            Some("header_token")
7496        ));
7497    }
7498
7499    #[test]
7500    fn test_validate_csrf_token_no_session() {
7501        assert!(!validate_csrf_token(None, Some("any_token"), None));
7502    }
7503
7504    #[test]
7505    fn test_validate_csrf_token_missing_token() {
7506        let mut session = sessions::Session::new(3600);
7507        session
7508            .data
7509            .insert(CSRF_SESSION_KEY.to_string(), json!("token"));
7510        assert!(!validate_csrf_token(Some(&session), None, None));
7511    }
7512
7513    #[test]
7514    fn test_csrf_exempt_paths() {
7515        assert!(is_csrf_exempt("/w-livereload"));
7516        assert!(is_csrf_exempt("/w-wire"));
7517        assert!(!is_csrf_exempt("/w-set"));
7518        assert!(!is_csrf_exempt("/w-action/items"));
7519        assert!(!is_csrf_exempt("/w-auth/login"));
7520    }
7521
7522    #[test]
7523    fn test_session_new_has_csrf_token() {
7524        let session = sessions::Session::new(3600);
7525        let csrf_token = session.data.get(CSRF_SESSION_KEY);
7526        assert!(csrf_token.is_some(), "New session should have a CSRF token");
7527        let token_str = csrf_token.unwrap().as_str().unwrap();
7528        assert_eq!(
7529            token_str.len(),
7530            64,
7531            "CSRF token should be 64 hex chars (32 bytes)"
7532        );
7533    }
7534
7535    // ---- Filename Sanitization Tests ----
7536
7537    #[test]
7538    fn test_sanitize_extension_normal() {
7539        assert_eq!(sanitize_extension("jpg"), ".jpg");
7540        assert_eq!(sanitize_extension("png"), ".png");
7541        assert_eq!(sanitize_extension("PDF"), ".PDF");
7542    }
7543
7544    #[test]
7545    fn test_sanitize_extension_strips_path_separators() {
7546        // Path separators and backslashes are stripped; dots are kept (harmless with UUID prefix)
7547        let result = sanitize_extension("../../../etc/passwd");
7548        assert!(!result.contains('/'));
7549        assert!(!result.contains('\\'));
7550        assert!(result.ends_with("etcpasswd"));
7551
7552        let result = sanitize_extension("..\\..\\windows");
7553        assert!(!result.contains('\\'));
7554    }
7555
7556    #[test]
7557    fn test_sanitize_extension_strips_null_bytes() {
7558        assert_eq!(sanitize_extension("jpg\0exe"), ".jpgexe");
7559    }
7560
7561    #[test]
7562    fn test_sanitize_extension_strips_non_ascii() {
7563        assert_eq!(sanitize_extension("jp\u{00e9}g"), ".jpg");
7564    }
7565
7566    #[test]
7567    fn test_sanitize_extension_empty() {
7568        assert_eq!(sanitize_extension(""), "");
7569        assert_eq!(sanitize_extension("///"), "");
7570    }
7571
7572    #[test]
7573    fn test_collect_fetch_local_directive() {
7574        let mut directives = crate::parser::PageDirectives::default();
7575        directives
7576            .custom
7577            .insert("fetch.posts".to_string(), "local:posts".to_string());
7578        directives.custom.insert(
7579            "fetch.posts.sort".to_string(),
7580            "created_at:desc".to_string(),
7581        );
7582        directives.custom.insert(
7583            "fetch.posts.filter".to_string(),
7584            "status=published".to_string(),
7585        );
7586        directives
7587            .custom
7588            .insert("fetch.posts.limit".to_string(), "10".to_string());
7589        directives
7590            .custom
7591            .insert("fetch.posts.offset".to_string(), "20".to_string());
7592        directives
7593            .custom
7594            .insert("fetch.posts.search".to_string(), "rust".to_string());
7595        directives.custom.insert(
7596            "fetch.posts.search_fields".to_string(),
7597            "title,content".to_string(),
7598        );
7599
7600        let fetches = collect_fetch_directives(None, Some(&directives));
7601        assert_eq!(fetches.len(), 1);
7602        let posts = fetches.get("posts").unwrap();
7603        assert!(posts.is_local());
7604        assert_eq!(posts.local_collection(), Some("posts"));
7605        assert_eq!(posts.sort.as_deref(), Some("created_at:desc"));
7606        assert_eq!(posts.filter.as_deref(), Some("status=published"));
7607        assert_eq!(posts.limit, Some(10));
7608        assert_eq!(posts.offset, Some(20));
7609        assert_eq!(posts.search.as_deref(), Some("rust"));
7610        assert_eq!(posts.search_fields.as_deref(), Some("title,content"));
7611    }
7612
7613    #[test]
7614    fn test_local_collection_strips_query_string() {
7615        // Regression: `local:coll?sort=...` must yield collection `coll`, not a
7616        // sanitized table name like `collsortcreated_atdesc`.
7617        let mut directives = crate::parser::PageDirectives::default();
7618        directives.custom.insert(
7619            "fetch.posts".to_string(),
7620            "local:posts?sort=created_at:desc&limit=5&filter=status=published".to_string(),
7621        );
7622        let fetches = collect_fetch_directives(None, Some(&directives));
7623        let posts = fetches.get("posts").unwrap();
7624        assert!(posts.is_local());
7625        assert_eq!(posts.local_collection(), Some("posts"));
7626        assert_eq!(
7627            posts.local_query(),
7628            Some("sort=created_at:desc&limit=5&filter=status=published")
7629        );
7630
7631        // Plain local: has no query string.
7632        let mut d2 = crate::parser::PageDirectives::default();
7633        d2.custom
7634            .insert("fetch.x".to_string(), "local:items".to_string());
7635        let f2 = collect_fetch_directives(None, Some(&d2));
7636        assert_eq!(f2.get("x").unwrap().local_collection(), Some("items"));
7637        assert_eq!(f2.get("x").unwrap().local_query(), None);
7638    }
7639
7640    #[test]
7641    fn test_is_framework_field() {
7642        // Control fields are dropped before persisting a record...
7643        assert!(is_framework_field("_csrf"));
7644        assert!(is_framework_field("w-rules"));
7645        assert!(is_framework_field("w-partial"));
7646        assert!(is_framework_field("redirect"));
7647        assert!(is_framework_field("cf-turnstile-response"));
7648        // ...but real app data (incl. _session_id) is kept.
7649        assert!(!is_framework_field("_session_id"));
7650        assert!(!is_framework_field("message"));
7651        assert!(!is_framework_field("name"));
7652    }
7653
7654    #[test]
7655    fn test_fetch_local_not_remote() {
7656        let d = FetchDirective::simple("posts".to_string(), "local:posts".to_string());
7657        assert!(d.is_local());
7658        assert_eq!(d.local_collection(), Some("posts"));
7659
7660        let d2 = FetchDirective::simple(
7661            "api".to_string(),
7662            "https://api.example.com/data".to_string(),
7663        );
7664        assert!(!d2.is_local());
7665        assert_eq!(d2.local_collection(), None);
7666    }
7667
7668    // ---- Middleware / Hooks tests (v0.9.0 feature 4.1) ----
7669
7670    #[test]
7671    fn test_redirects_config_parsing() {
7672        let toml_str = r##"
7673[redirects]
7674"/old-blog" = "/blog"
7675"/legacy/*" = "/modern"
7676"/about-us" = "/about"
7677"##;
7678        let config: crate::Config = toml::from_str(toml_str).unwrap();
7679        assert_eq!(config.redirects.len(), 3);
7680        assert_eq!(config.redirects.get("/old-blog").unwrap(), "/blog");
7681        assert_eq!(config.redirects.get("/legacy/*").unwrap(), "/modern");
7682        assert_eq!(config.redirects.get("/about-us").unwrap(), "/about");
7683    }
7684
7685    #[test]
7686    fn test_redirects_config_empty_by_default() {
7687        let config = crate::Config::default();
7688        assert!(config.redirects.is_empty());
7689    }
7690
7691    #[test]
7692    fn test_custom_headers_in_application_what() {
7693        let content = r#"header.Access-Control-Allow-Origin = "*"
7694header.Cache-Control = "no-store"
7695header.X-Custom = "hello"
7696"#;
7697        let config = crate::parser::parse_what_file(content);
7698        assert_eq!(config.directives.headers.len(), 3);
7699        assert_eq!(
7700            config
7701                .directives
7702                .headers
7703                .get("access-control-allow-origin")
7704                .unwrap(),
7705            "*"
7706        );
7707        assert_eq!(
7708            config.directives.headers.get("cache-control").unwrap(),
7709            "no-store"
7710        );
7711        assert_eq!(config.directives.headers.get("x-custom").unwrap(), "hello");
7712    }
7713
7714    #[test]
7715    fn test_custom_headers_merge_in_config() {
7716        let parent_content = r#"header.X-Frame-Options = "DENY"
7717header.X-Custom = "parent"
7718"#;
7719        let child_content = r#"header.X-Custom = "child"
7720header.X-New = "added"
7721"#;
7722        let mut parent = crate::parser::parse_what_file(parent_content);
7723        let child = crate::parser::parse_what_file(child_content);
7724        parent.merge(&child);
7725
7726        // Child overrides parent for same header
7727        assert_eq!(parent.directives.headers.get("x-custom").unwrap(), "child");
7728        // Parent headers preserved
7729        assert_eq!(
7730            parent.directives.headers.get("x-frame-options").unwrap(),
7731            "DENY"
7732        );
7733        // New child headers added
7734        assert_eq!(parent.directives.headers.get("x-new").unwrap(), "added");
7735    }
7736
7737    #[test]
7738    fn test_custom_headers_in_page_directive_content() {
7739        let html = r##"<what>
7740header.Cache-Control: no-cache
7741header.X-Robots-Tag: noindex
7742</what>
7743<h1>Hello</h1>"##;
7744        let (directives, _cleaned) = crate::parser::parse_page_directives(html);
7745        assert_eq!(directives.headers.get("cache-control").unwrap(), "no-cache");
7746        assert_eq!(directives.headers.get("x-robots-tag").unwrap(), "noindex");
7747    }
7748
7749    #[test]
7750    fn test_custom_headers_not_in_template_context() {
7751        let content = r#"title = "My Page"
7752header.X-Custom = "value"
7753"#;
7754        let config = crate::parser::parse_what_file(content);
7755        // Title should be in values, headers should NOT
7756        assert!(config.values.contains_key("title"));
7757        assert!(!config.values.contains_key("header.x-custom"));
7758    }
7759
7760    #[tokio::test]
7761    async fn test_redirect_middleware_exact_match() {
7762        use axum::body::Body;
7763        use axum::http::Request;
7764        use std::collections::HashMap;
7765        use tower::ServiceExt;
7766
7767        let mut redirects = HashMap::new();
7768        redirects.insert("/old".to_string(), "/new".to_string());
7769
7770        let mut config = crate::Config::default();
7771        config.redirects = redirects;
7772        config.session.enabled = false;
7773
7774        let temp_dir = create_test_project();
7775        let root = temp_dir.path().to_path_buf();
7776        let state = AppState::with_dev_mode(config, root, true).unwrap();
7777
7778        let app = create_router(state);
7779
7780        let request = Request::builder().uri("/old").body(Body::empty()).unwrap();
7781
7782        let response = app.oneshot(request).await.unwrap();
7783        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
7784        assert_eq!(
7785            response
7786                .headers()
7787                .get("location")
7788                .unwrap()
7789                .to_str()
7790                .unwrap(),
7791            "/new"
7792        );
7793    }
7794
7795    #[tokio::test]
7796    async fn test_redirect_middleware_wildcard() {
7797        use axum::body::Body;
7798        use axum::http::Request;
7799        use std::collections::HashMap;
7800        use tower::ServiceExt;
7801
7802        let mut redirects = HashMap::new();
7803        redirects.insert("/legacy/*".to_string(), "/modern".to_string());
7804
7805        let mut config = crate::Config::default();
7806        config.redirects = redirects;
7807        config.session.enabled = false;
7808
7809        let temp_dir = create_test_project();
7810        let root = temp_dir.path().to_path_buf();
7811        let state = AppState::with_dev_mode(config, root, true).unwrap();
7812
7813        let app = create_router(state);
7814
7815        let request = Request::builder()
7816            .uri("/legacy/old-page")
7817            .body(Body::empty())
7818            .unwrap();
7819
7820        let response = app.oneshot(request).await.unwrap();
7821        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
7822        assert_eq!(
7823            response
7824                .headers()
7825                .get("location")
7826                .unwrap()
7827                .to_str()
7828                .unwrap(),
7829            "/modern"
7830        );
7831    }
7832
7833    #[tokio::test]
7834    async fn test_redirect_middleware_no_match_passes_through() {
7835        use axum::body::Body;
7836        use axum::http::Request;
7837        use std::collections::HashMap;
7838        use tower::ServiceExt;
7839
7840        let mut redirects = HashMap::new();
7841        redirects.insert("/old".to_string(), "/new".to_string());
7842
7843        let mut config = crate::Config::default();
7844        config.redirects = redirects;
7845        config.session.enabled = false;
7846
7847        let temp_dir = create_test_project();
7848        let root = temp_dir.path().to_path_buf();
7849        let state = AppState::with_dev_mode(config, root, true).unwrap();
7850
7851        let app = create_router(state);
7852
7853        let request = Request::builder()
7854            .uri("/unrelated")
7855            .body(Body::empty())
7856            .unwrap();
7857
7858        let response = app.oneshot(request).await.unwrap();
7859        // Should NOT be a redirect — should pass through to normal routing (404 in this case)
7860        assert_ne!(response.status(), StatusCode::PERMANENT_REDIRECT);
7861    }
7862
7863    #[tokio::test]
7864    async fn test_health_endpoint_returns_ok() {
7865        use axum::body::Body;
7866        use axum::http::Request;
7867        use tower::ServiceExt;
7868
7869        let mut config = crate::Config::default();
7870        config.session.enabled = false;
7871
7872        let temp_dir = create_test_project();
7873        let root = temp_dir.path().to_path_buf();
7874        let state = AppState::with_dev_mode(config, root, true).unwrap();
7875
7876        let app = create_router(state);
7877
7878        let request = Request::builder()
7879            .uri("/health")
7880            .body(Body::empty())
7881            .unwrap();
7882
7883        let response = app.oneshot(request).await.unwrap();
7884        assert_eq!(response.status(), StatusCode::OK);
7885
7886        let body = axum::body::to_bytes(response.into_body(), 1024)
7887            .await
7888            .unwrap();
7889        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7890        assert_eq!(json["status"], "ok");
7891        assert_eq!(json["version"], env!("CARGO_PKG_VERSION"));
7892    }
7893
7894    #[test]
7895    fn test_fetch_timeout_default() {
7896        let config = crate::Config::default();
7897        assert_eq!(config.server.fetch_timeout, 10);
7898    }
7899
7900    #[test]
7901    fn test_fetch_timeout_custom() {
7902        let toml_str = r#"
7903[server]
7904fetch_timeout = 30
7905"#;
7906        let config: crate::Config = toml::from_str(toml_str).unwrap();
7907        assert_eq!(config.server.fetch_timeout, 30);
7908    }
7909
7910    #[test]
7911    fn test_http_client_uses_configured_timeout() {
7912        let mut config = crate::Config::default();
7913        config.server.fetch_timeout = 5;
7914        config.session.enabled = false;
7915
7916        let temp_dir = create_test_project();
7917        let root = temp_dir.path().to_path_buf();
7918        let state = AppState::with_dev_mode(config, root, false).unwrap();
7919
7920        // Verify the client was created (no panic) with the custom timeout
7921        // The reqwest::Client doesn't expose timeout publicly, but we verify it was built
7922        let _client = &state.http_client;
7923    }
7924
7925    // ---- Wired Scope Filtering Tests ----
7926
7927    #[test]
7928    fn wired_public_reaches_all() {
7929        let temp_dir = create_test_project();
7930        let root = temp_dir.path().to_path_buf();
7931        let config = crate::Config::default();
7932        let state = AppState::new(config, root).unwrap();
7933
7934        let mut rx = state.wired_tx.subscribe();
7935        let _ = state.wired_tx.send(WiredMessage {
7936            json: r#"{"wired.counter":"5"}"#.to_string(),
7937            scope: WiredScope::Public,
7938        });
7939        let msg = rx.try_recv().unwrap();
7940        // Public messages reach all clients (no filtering needed)
7941        assert!(msg.scope.allows(&[], None)); // anonymous
7942        assert!(msg.scope.allows(&["admin".into()], Some("user1"))); // authenticated
7943    }
7944
7945    #[test]
7946    fn wired_role_filters() {
7947        let temp_dir = create_test_project();
7948        let root = temp_dir.path().to_path_buf();
7949        let config = crate::Config::default();
7950        let state = AppState::new(config, root).unwrap();
7951
7952        let mut rx = state.wired_tx.subscribe();
7953        let _ = state.wired_tx.send(WiredMessage {
7954            json: r#"{"wired.revenue":"1000"}"#.to_string(),
7955            scope: WiredScope::Roles(vec!["admin".into()]),
7956        });
7957        let msg = rx.try_recv().unwrap();
7958        assert!(msg.scope.allows(&["admin".into()], None));
7959        assert!(!msg.scope.allows(&["viewer".into()], None));
7960        assert!(!msg.scope.allows(&[], None)); // anonymous
7961    }
7962
7963    #[test]
7964    fn wired_multi_role() {
7965        let scope = WiredScope::Roles(vec!["admin".into(), "editor".into()]);
7966        assert!(scope.allows(&["editor".into()], None));
7967        assert!(scope.allows(&["admin".into()], None));
7968        assert!(!scope.allows(&["viewer".into()], None));
7969    }
7970
7971    #[test]
7972    fn wired_user_scope() {
7973        let scope = WiredScope::User("user42".into());
7974        assert!(scope.allows(&[], Some("user42")));
7975        assert!(!scope.allows(&["admin".into()], Some("other_user")));
7976        assert!(!scope.allows(&[], None));
7977    }
7978
7979    #[test]
7980    fn wired_unauthenticated_public_only() {
7981        // Anonymous client should only receive Public messages
7982        let public = WiredScope::Public;
7983        let admin_only = WiredScope::Roles(vec!["admin".into()]);
7984        let user_only = WiredScope::User("user1".into());
7985
7986        assert!(public.allows(&[], None));
7987        assert!(!admin_only.allows(&[], None));
7988        assert!(!user_only.allows(&[], None));
7989    }
7990
7991    #[test]
7992    fn wired_undeclared_key_defaults_public() {
7993        let temp_dir = create_test_project();
7994        let root = temp_dir.path().to_path_buf();
7995        let config = crate::Config::default();
7996        let state = AppState::new(config, root).unwrap();
7997
7998        // get_wired_scope is async, but we can test the default behavior
7999        // by checking that the scopes map is empty (all keys default to Public)
8000        let rt = tokio::runtime::Runtime::new().unwrap();
8001        let scope = rt.block_on(state.get_wired_scope("nonexistent_key"));
8002        assert!(matches!(scope, WiredScope::Public));
8003    }
8004
8005    #[test]
8006    fn test_session_mutation_with_filters() {
8007        let mut session_data = HashMap::new();
8008        session_data.insert("name".to_string(), json!("alice"));
8009        session_data.insert("bio".to_string(), json!("Hello world from alice"));
8010
8011        // Filters inside #...# references should work
8012        let val = resolve_session_value(&json!("#session.name|uppercase#"), &session_data);
8013        assert_eq!(val, json!("ALICE"));
8014
8015        let val = resolve_session_value(&json!("#session.bio|truncate:10#"), &session_data);
8016        assert_eq!(val, json!("Hello worl..."));
8017
8018        let val = resolve_session_value(&json!("Hi #session.name|capitalize#!"), &session_data);
8019        assert_eq!(val, json!("Hi Alice!"));
8020    }
8021}