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