Skip to main content

what_core/server/
mod.rs

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