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