1use 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
44const FLASH_SESSION_KEY: &str = "_flash";
50
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53struct FlashData {
54 #[serde(default)]
56 flash: HashMap<String, String>,
57 #[serde(default)]
59 errors: HashMap<String, String>,
60 #[serde(default)]
62 old: HashMap<String, String>,
63}
64
65fn 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
72fn 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
78fn inject_flash_into_context(flash: &FlashData, context: &mut HashMap<String, Value>) {
80 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 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 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
120pub 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
159pub fn content_dir(root: &std::path::Path) -> PathBuf {
163 root.join(content_dir_name(root))
164}
165
166#[derive(Clone, Debug)]
168pub enum LiveReloadMessage {
169 Reload,
171 CacheCleared,
173}
174
175#[derive(Clone, Debug)]
177pub struct WiredMessage {
178 pub json: String,
179 pub scope: WiredScope,
180}
181
182#[derive(Clone)]
184pub struct AppState {
185 pub config: Arc<Config>,
187 pub store: DatabaseAdapter,
189 pub cache: WhatCache,
191 pub components: Arc<ComponentRegistry>,
193 pub engine: Arc<RenderEngine>,
195 pub root: PathBuf,
197 pub content_dir: PathBuf,
199 pub sessions: Option<SessionBackend>,
201 pub auth: AuthHandler,
203 pub dev_mode: bool,
205 pub css_mode: CssMode,
207 pub live_reload_tx: Option<broadcast::Sender<LiveReloadMessage>>,
209 pub wired_tx: broadcast::Sender<WiredMessage>,
211 pub wired_scopes: Arc<tokio::sync::RwLock<HashMap<String, WiredScope>>>,
213 pub app_scopes: Arc<tokio::sync::RwLock<HashMap<String, WiredScope>>>,
216 pub policies: Arc<crate::policy::PolicyRegistry>,
218 pub data_source_loaded: Arc<tokio::sync::RwLock<HashMap<String, Instant>>>,
220 pub rate_limiters: Option<RateLimiters>,
222 pub log_level: String,
224 pub jobs: crate::jobs::JobQueue,
226 pub http_client: reqwest::Client,
228 pub fetch_http_client: reqwest::Client,
231 pub upload_backend: Option<crate::uploads::UploadBackend>,
233 pub datasources: HashMap<String, crate::datasource::Datasource>,
235 pub wired_client_count: Arc<std::sync::atomic::AtomicUsize>,
237 pub validated_actions: Arc<std::sync::RwLock<HashSet<String>>>,
241 pub activity_log: Arc<std::sync::Mutex<VecDeque<ActivityEvent>>>,
244}
245
246const ACTIVITY_LOG_CAPACITY: usize = 200;
248
249#[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 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#[derive(Clone)]
290pub struct RateLimiters {
291 pub login: moka::future::Cache<String, u32>,
293 pub login_max: u32,
294 pub upload: moka::future::Cache<String, u32>,
296 pub upload_max: u32,
297 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 pub fn with_dev_mode(mut config: Config, root: PathBuf, dev_mode: bool) -> Result<Self> {
309 let css_mode = CssMode::from_config(&config.server.css)?;
311
312 let policies = Arc::new(crate::policy::PolicyRegistry::from_config(&config.collections)?);
315 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 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 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 let components_dir = root.join("components");
347 if components_dir.exists() {
348 components.load_from_directory(&components_dir)?;
349 }
350
351 let store = if let Some(ref db_config) = config.database {
353 match db_config.r#type.as_str() {
354 "d1" => {
355 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 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 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 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 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 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 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 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 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 let live_reload_tx = if dev_mode {
540 let (tx, _) = broadcast::channel(16);
541 Some(tx)
542 } else {
543 None
544 };
545
546 let (wired_tx, _) = broadcast::channel::<WiredMessage>(256);
548
549 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 let jobs = crate::jobs::start(sessions.clone(), config.email.clone());
577
578 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 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 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 pub async fn init_datasources(&self) -> crate::Result<()> {
720 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 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 self.rebuild_wired_scopes().await;
737
738 Ok(())
739 }
740
741 pub fn trigger_reload(&self) {
743 let cache = self.cache.clone();
746 tokio::spawn(async move {
747 cache.clear_all().await;
748 });
749
750 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 pub async fn trigger_reload_async(&self) {
760 self.cache.clear_all().await;
762
763 self.rebuild_wired_scopes().await;
765
766 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 pub fn live_reload_receiver(&self) -> Option<broadcast::Receiver<LiveReloadMessage>> {
775 self.live_reload_tx.as_ref().map(|tx| tx.subscribe())
776 }
777
778 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 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 if !matches!(decl.scope, WiredScope::Public) {
816 app.insert(decl.name.clone(), decl.scope.clone());
817 }
818 }
819 }
820 }
821 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 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 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
852async 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 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 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
894fn 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 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
958fn 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
1002async 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 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 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
1049async 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 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 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
1096async fn csrf_middleware(
1104 State(state): State<AppState>,
1105 request: Request<Body>,
1106 next: Next,
1107) -> Response {
1108 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 if is_csrf_exempt(&path) {
1117 return next.run(request).await;
1118 }
1119
1120 if state.sessions.is_none() {
1122 return next.run(request).await;
1123 }
1124
1125 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 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 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 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 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 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 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 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 (StatusCode::FORBIDDEN, "CSRF token mismatch").into_response()
1219}
1220
1221pub fn create_router(state: AppState) -> Router {
1223 let static_dir = state.root.join("static");
1224 let dev_mode = state.dev_mode;
1225
1226 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 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 let health_router = Router::new().route("/health", get(handle_health));
1243
1244 let mut router = Router::new()
1245 .route("/static/what.css", get(handle_embedded_css))
1247 .route("/static/what.js", get(handle_embedded_js))
1248 .nest_service("/static", static_service);
1250
1251 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 .route("/w-session/reset", post(handle_session_reset))
1261 .route("/w-auth/login", post(handle_login))
1263 .route("/w-auth/logout", post(handle_logout))
1264 .route("/w-action/:collection", post(handle_action))
1266 .route("/w-action/:collection/:id", post(handle_action_with_id))
1267 .route("/w-upload/:collection", post(handle_upload))
1269 .route("/w-set", post(handle_w_set))
1271 .route("/w-wire", get(handle_wire_ws))
1273 .route("/w-session/clear-data", post(handle_session_clear_data))
1274 .route("/w-partial/*path", get(handle_partial));
1276
1277 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 router = router.route("/w-source/*path", get(handle_page_source));
1290 }
1291
1292 let app = router
1293 .fallback(handle_page)
1295 .with_state(state.clone())
1296 .layer(axum::extract::DefaultBodyLimit::max(
1298 crate::config::parse_size_string(&state.config.server.max_body_size),
1299 ))
1300 .layer(axum::middleware::from_fn_with_state(
1302 state.clone(),
1303 rate_limit_middleware,
1304 ))
1305 .layer(axum::middleware::from_fn_with_state(
1307 state.clone(),
1308 csrf_middleware,
1309 ))
1310 .layer(axum::middleware::from_fn_with_state(
1312 state.clone(),
1313 redirect_middleware,
1314 ))
1315 .layer(axum::middleware::from_fn(security_headers_middleware))
1317 .layer(axum::middleware::from_fn_with_state(
1319 state.clone(),
1320 request_logging_middleware,
1321 ));
1322
1323 app.merge(health_router)
1325}
1326
1327async fn handle_health() -> impl IntoResponse {
1330 axum::Json(json!({
1331 "status": "ok",
1332 "version": env!("CARGO_PKG_VERSION")
1333 }))
1334}
1335
1336async fn handle_page(State(state): State<AppState>, request: Request<Body>) -> impl IntoResponse {
1338 let path = request.uri().path().to_string();
1339
1340 let query_params = decode_query_params(request.uri().query());
1342
1343 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 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 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 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 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 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 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 if let Some(ref redirect_to) = directives.redirect {
1463 return Redirect::to(redirect_to).into_response();
1464 }
1465
1466 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 let user_roles: Vec<String> = user_context.roles();
1486
1487 let requires_auth = directives.requires_auth() || state.auth.is_protected(&path);
1489
1490 if requires_auth && !user_context.authenticated {
1491 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 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 let mut headers = vec![(header::CONTENT_TYPE, "text/html".to_string())];
1517
1518 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 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 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 let flash_data = if let Some(ref mut sess) = session {
1572 let flash = consume_flash_data(sess);
1573 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 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 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 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 if is_partial_request && !render_result.session_keys.is_empty() {
1630 if let Some(ref s) = session {
1631 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 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 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 if !is_partial_request {
1668 html = inject_seo_meta(&html, &directives.custom, &directives.vars);
1669 }
1670
1671 if state.dev_mode && !is_partial_request {
1673 html = inject_debug_meta(&html, &state.log_level);
1674 }
1675
1676 register_validated_actions(&html, &state);
1678
1679 html = inject_what_css(&html, state.css_mode);
1681 if !is_partial_request {
1683 html = inject_what_js(&html);
1684 html = inject_theme_restore(&html);
1685 }
1686
1687 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 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
1728fn 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
1771fn resolve_session_value(value: &Value, session_data: &HashMap<String, Value>) -> Value {
1775 if let Some(s) = value.as_str() {
1776 if s.contains('#') {
1777 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 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 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 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
1814fn 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
1834async 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 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 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 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
1899const CSRF_SESSION_KEY: &str = "_csrf_token";
1905
1906fn 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 let output = FORM_POST_RE.replace_all(html, |caps: ®ex::Captures| {
1921 format!("{}{}", &caps[0], hidden_input)
1922 });
1923
1924 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 format!("{}\n{}", meta_tag, output)
1934 };
1935
1936 output
1937}
1938
1939fn inject_what_js(html: &str) -> String {
1942 let script_tag = format!(r#"<script src="{}"></script>"#, WHAT_JS_ASSET_PATH.as_str());
1943
1944 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
1958fn 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 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
1985fn 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
2012fn 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 let head_start = html.find("<head>").or_else(|| html.find("<HEAD>"));
2027
2028 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; 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 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
2064fn inject_seo_meta(
2068 html: &str,
2069 custom: &HashMap<String, String>,
2070 vars: &HashMap<String, Value>,
2071) -> String {
2072 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 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 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 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 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
2146fn 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
2152fn 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 format!("{}\n{}", content, html)
2161 }
2162}
2163
2164fn html_escape(s: &str) -> String {
2166 s.replace('&', "&")
2167 .replace('"', """)
2168 .replace('<', "<")
2169 .replace('>', ">")
2170}
2171
2172fn 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, };
2187
2188 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
2197fn is_csrf_exempt(path: &str) -> bool {
2199 path.starts_with("/w-livereload") || path.starts_with("/w-wire")
2201}
2202
2203struct ResolvedPage {
2205 path: PathBuf,
2206 params: HashMap<String, String>,
2208}
2209
2210fn 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 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 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 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 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
2263fn 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 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 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 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#[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 path: Option<String>,
2358 sort: Option<String>,
2360 filter: Option<String>,
2362 search: Option<String>,
2364 search_fields: Option<String>,
2366 limit: Option<usize>,
2368 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 fn is_local(&self) -> bool {
2393 self.url.starts_with("local:")
2394 }
2395
2396 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 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
2413fn 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
2442fn 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#[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
2466pub struct RenderResult {
2468 pub html: String,
2470 pub session_keys: std::collections::HashSet<String>,
2472 pub fetch_debug: Vec<FetchDebugEntry>,
2474}
2475
2476fn 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
2489struct EmailTrigger {
2491 to: String,
2492 subject: String,
2493 template: Option<String>,
2494}
2495
2496fn 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
2513async 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 let html_body = if let Some(ref tpl_name) = trigger.template {
2527 let email_config = state.config.email.as_ref().unwrap();
2528 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 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
2586async 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(()), };
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
2656fn 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
2689async 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(¤t, 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
2866fn 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 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 for (rest, value) in raw_pairs {
2900 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 all_entries
2911 .entry(rest)
2912 .or_default()
2913 .insert("url".to_string(), value);
2914 }
2915 }
2916
2917 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, };
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
2970async 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 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 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 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 for (key, directive) in local_fetches {
3069 let start = std::time::Instant::now();
3070 if let Some(collection) = directive.local_collection() {
3071 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 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 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 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 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 (
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 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 (
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 (
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 (
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 (
3405 crate::datasource::Datasource::Database(adapter),
3406 crate::datasource::DsnTarget::Path(path),
3407 ) => {
3408 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 (
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 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 fetch_remote_json(&state, &resolved_url, false).await
3519 } else {
3520 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
3575async 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 let mut context = state.store.as_context().await;
3595
3596 crate::policy::scrub_base_context(&state.policies, &mut context);
3601
3602 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 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 if let Some(flash) = flash {
3627 inject_flash_into_context(flash, &mut context);
3628 }
3629
3630 for (key, value) in route_params {
3632 context.insert(key.clone(), json!(value));
3633 }
3634
3635 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 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 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 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 if let Some(s) = session {
3686 context.insert("session".to_string(), s.to_context());
3687 }
3688 let actor = crate::policy::Actor::from_parts(user, session);
3690 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 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 let mut data_obj = serde_json::Map::new();
3722
3723 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 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 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 app_data.insert(key.clone(), json!(0));
3745 }
3746 }
3747 data_obj.insert("application".to_string(), Value::Object(app_data));
3748
3749 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 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 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 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 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 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 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 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 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 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 let validation_secret = state
3856 .config
3857 .auth
3858 .jwt_secret
3859 .as_deref()
3860 .unwrap_or("wwwhat-validation-secret");
3861
3862 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 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 let (_, layout_content) = parse_page_directives(&raw_layout);
3884 let wrapped = layout_content
3886 .replace("<slot/>", &page_html)
3887 .replace("<slot />", &page_html);
3888 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 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
3917async 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
3943pub 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 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 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
3997pub 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 _ => 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 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 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#[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
4077async 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
4098fn 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
4118fn 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
4128async 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
4172fn 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
4196async 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 if let Some(ref url) = action_url {
4214 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 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 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 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 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
4357fn is_framework_field(key: &str) -> bool {
4360 key.starts_with("w-")
4361 || key == "_csrf"
4362 || key == "redirect"
4363 || key == "cf-turnstile-response"
4364}
4365
4366fn 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 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 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 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 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 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 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 maybe_enqueue_email(&state, email_trigger).await;
4487
4488 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 ¶ms.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 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 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 let action_url = format!("/w-action/{}/{}", collection, id);
4564
4565 if let Err(resp) = validate_form_data(
4567 &state,
4568 &form_data,
4569 &mut session,
4570 is_new_session,
4571 referer.as_deref(),
4572 Some(action_url.clone()),
4573 )
4574 .await
4575 {
4576 return resp;
4577 }
4578
4579 let email_trigger = extract_email_trigger(&form_data);
4581 let partial_src = form_data.get("w-partial").cloned();
4582 let is_partial = headers
4583 .get("X-Requested-With")
4584 .and_then(|v| v.to_str().ok())
4585 .map(|v| v == "What")
4586 .unwrap_or(false);
4587
4588 let id_value: Value = if let Ok(num) = id.parse::<i64>() {
4589 Value::Number(num.into())
4590 } else {
4591 Value::String(id)
4592 };
4593
4594 let actor = extract_actor(&state, &headers, session.as_ref());
4598 let policy = state.policies.get(&collection);
4599 let kind = if action == "delete" {
4600 crate::policy::MutationKind::Delete
4601 } else {
4602 crate::policy::MutationKind::Update
4603 };
4604 let existing = state
4605 .store
4606 .find_by(&collection, "id", &id_value)
4607 .await
4608 .into_iter()
4609 .next();
4610 let mut filter_ctx: HashMap<String, Value> = HashMap::new();
4612 {
4613 let user = extract_user_context(&state, &headers);
4614 filter_ctx.insert("user".to_string(), user.to_context());
4615 if let Some(s) = session.as_ref() {
4616 filter_ctx.insert("session".to_string(), s.to_context());
4617 }
4618 }
4619 let resolved_filter = policy.resolved_filter(&filter_ctx);
4620 if action == "update" || action == "delete" {
4621 match policy.allows_mutation(&actor, existing.as_ref(), kind, resolved_filter.as_deref()) {
4622 crate::policy::Decision::Deny => {
4623 return deny_response(
4624 &state,
4625 &mut session,
4626 is_new_session,
4627 is_partial,
4628 referer.as_deref(),
4629 format!("You don't have permission to {} that item.", action),
4630 format!("collection={}; action={}; id={}", collection, action, id_value),
4631 )
4632 .await;
4633 }
4634 crate::policy::Decision::AllowLegacy => {
4635 warn_legacy_mutation_once(&collection);
4636 }
4637 crate::policy::Decision::Allow => {}
4638 }
4639 }
4640
4641 let result = match action {
4642 "update" => {
4643 let mut map: serde_json::Map<String, Value> = form_data
4644 .into_iter()
4645 .filter(|(k, _)| !is_framework_field(k))
4646 .map(|(k, v)| (k, Value::String(v)))
4647 .collect();
4648 policy.sanitize_input(&mut map);
4650 state
4651 .store
4652 .update(&collection, &id_value, Value::Object(map))
4653 .await
4654 .map(|_| ())
4655 }
4656 "delete" => {
4657 if state.config.uploads.enabled {
4659 if let Some(record) = existing.as_ref() {
4660 cleanup_uploaded_files(&state.root, &state.config.uploads.directory, record)
4661 .await;
4662 }
4663 }
4664 state.store.delete(&collection, &id_value).await.map(|_| ())
4665 }
4666 _ => Err(crate::Error::Action(format!("Unknown action: {}", action))),
4667 };
4668
4669 state.cache.invalidate_content_type(&collection).await;
4671
4672 let redirect_to = params.redirect.as_deref().unwrap_or("/");
4673
4674 match result {
4675 Ok(_) => {
4676 maybe_enqueue_email(&state, email_trigger).await;
4678
4679 if is_partial {
4681 if let Some(ref partial_path) = partial_src {
4682 if let Ok(html) = render_partial_for_action(
4683 &state,
4684 &headers,
4685 partial_path,
4686 ¶ms.extra,
4687 )
4688 .await
4689 {
4690 return build_partial_response(
4691 html,
4692 session.as_ref(),
4693 is_new_session,
4694 &state,
4695 );
4696 }
4697 }
4698 }
4699
4700 if let Some(ref mut sess) = session {
4702 let action_label = if action == "delete" {
4703 "deleted"
4704 } else {
4705 "updated"
4706 };
4707 let flash = FlashData {
4708 flash: HashMap::from([(
4709 "success".to_string(),
4710 format!("Item {} in {}", action_label, collection),
4711 )]),
4712 ..Default::default()
4713 };
4714 set_flash_data(sess, &flash);
4715 if let Some(ref sessions) = state.sessions {
4716 let _ = sessions.update(&sess.id, sess.data.clone()).await;
4717 }
4718 }
4719 redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
4720 }
4721 Err(e) => {
4722 tracing::error!("Action error: {}", e);
4723 if let Some(ref mut sess) = session {
4724 let flash = FlashData {
4725 flash: HashMap::from([("error".to_string(), format!("Action failed: {}", e))]),
4726 ..Default::default()
4727 };
4728 set_flash_data(sess, &flash);
4729 if let Some(ref sessions) = state.sessions {
4730 let _ = sessions.update(&sess.id, sess.data.clone()).await;
4731 }
4732 }
4733 let fallback = referer.as_deref().unwrap_or(redirect_to);
4734 redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
4735 }
4736 }
4737}
4738
4739fn partial_access_allowed(
4751 state: &AppState,
4752 clean_path: &str,
4753 inline: &PageDirectives,
4754 user_context: &UserContext,
4755) -> bool {
4756 let mut directives = inline.clone();
4757 let app_config = load_application_config(&state.root, &format!("partials/{clean_path}"));
4758 if !directives.requires_auth() && app_config.directives.requires_auth() {
4759 directives.auth = app_config.directives.auth.clone();
4760 directives.protected = app_config.directives.protected;
4761 directives.roles = app_config.directives.roles.clone();
4762 }
4763 if directives.requires_auth() && !user_context.authenticated {
4764 return false;
4765 }
4766 directives.check_access(user_context.authenticated, &user_context.roles())
4767}
4768
4769async fn render_partial_for_action(
4770 state: &AppState,
4771 headers: &HeaderMap,
4772 partial_path: &str,
4773 query_params: &HashMap<String, String>,
4774) -> Result<String> {
4775 let clean_path = partial_path.trim_start_matches('/');
4776 let partials_dir = state.content_dir.join("partials");
4777 let file_path = {
4778 let with_ext = partials_dir.join(format!("{}.html", clean_path));
4779 if with_ext.exists() {
4780 with_ext
4781 } else {
4782 partials_dir.join(clean_path).join("index.html")
4783 }
4784 };
4785
4786 let canonical = file_path
4788 .canonicalize()
4789 .map_err(|_| crate::Error::Action("Partial not found".into()))?;
4790 let partials_canonical = partials_dir
4791 .canonicalize()
4792 .map_err(|_| crate::Error::Action("Partials dir not found".into()))?;
4793 if !canonical.starts_with(&partials_canonical) {
4794 return Err(crate::Error::Action("Access denied".into()));
4795 }
4796
4797 let raw_content = tokio::fs::read_to_string(&canonical)
4798 .await
4799 .map_err(|_| crate::Error::Action("Partial not found".into()))?;
4800
4801 if state.dev_mode {
4802 engine::warn_template_lints_once(&canonical, &raw_content);
4803 }
4804
4805 let (directives, content) = parse_page_directives(&raw_content);
4806
4807 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
4809 let session = if let Some(ref sessions) = state.sessions {
4810 let session_id =
4811 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
4812 sessions.get_or_create(session_id.as_deref()).await.ok()
4813 } else {
4814 None
4815 };
4816
4817 let user_context = if state.auth.is_enabled() {
4818 if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
4819 match state.auth.decode_jwt(&token) {
4820 Ok(claims) if !claims.is_expired() => {
4821 UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
4822 }
4823 _ => UserContext::unauthenticated(),
4824 }
4825 } else {
4826 UserContext::unauthenticated()
4827 }
4828 } else {
4829 UserContext::unauthenticated()
4830 };
4831
4832 if !partial_access_allowed(state, clean_path, &directives, &user_context) {
4838 return Err(crate::Error::Action("Access denied".into()));
4839 }
4840
4841 let render_result = render_content_internal(
4842 state,
4843 &content,
4844 session.as_ref(),
4845 &user_context,
4846 None,
4847 Some(&directives),
4848 Some(query_params),
4849 &HashMap::new(),
4850 true,
4851 None,
4852 )
4853 .await?;
4854
4855 let mut html = render_result.html;
4856
4857 if let Some(ref s) = session {
4859 if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
4860 html = inject_csrf_tokens(&html, csrf);
4861 }
4862 }
4863
4864 Ok(html)
4865}
4866
4867fn build_partial_response(
4869 html: String,
4870 session: Option<&sessions::Session>,
4871 is_new_session: bool,
4872 state: &AppState,
4873) -> Response {
4874 let mut resp_headers: Vec<(header::HeaderName, String)> =
4875 vec![(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())];
4876 if is_new_session {
4877 if let Some(s) = session {
4878 resp_headers.push((
4879 header::SET_COOKIE,
4880 sessions::build_session_cookie(
4881 &s.id,
4882 &state.config.session.cookie_name,
4883 state.config.session.max_age,
4884 state.config.session.secure,
4885 ),
4886 ));
4887 }
4888 }
4889 build_response(StatusCode::OK, resp_headers, html)
4890}
4891
4892async fn handle_upload(
4894 State(state): State<AppState>,
4895 Path(collection): Path<String>,
4896 headers: HeaderMap,
4897 Query(params): Query<ActionParams>,
4898 mut multipart: Multipart,
4899) -> impl IntoResponse {
4900 let referer = headers
4901 .get(header::REFERER)
4902 .and_then(|v| v.to_str().ok())
4903 .map(|s| s.to_string());
4904 let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
4905 let redirect_to = params.redirect.as_deref().unwrap_or("/");
4906
4907 if !state.config.uploads.enabled {
4909 if let Some(ref mut sess) = session {
4910 let flash = FlashData {
4911 flash: HashMap::from([(
4912 "error".to_string(),
4913 "File uploads are not enabled".to_string(),
4914 )]),
4915 ..Default::default()
4916 };
4917 set_flash_data(sess, &flash);
4918 if let Some(ref sessions) = state.sessions {
4919 let _ = sessions.update(&sess.id, sess.data.clone()).await;
4920 }
4921 }
4922 let fallback = referer.as_deref().unwrap_or(redirect_to);
4923 return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4924 }
4925
4926 let upload_backend = match state.upload_backend {
4927 Some(ref backend) => backend.clone(),
4928 None => {
4929 if let Some(ref mut sess) = session {
4930 let flash = FlashData {
4931 flash: HashMap::from([(
4932 "error".to_string(),
4933 "Upload backend not configured".to_string(),
4934 )]),
4935 ..Default::default()
4936 };
4937 set_flash_data(sess, &flash);
4938 if let Some(ref sessions) = state.sessions {
4939 let _ = sessions.update(&sess.id, sess.data.clone()).await;
4940 }
4941 }
4942 let fallback = referer.as_deref().unwrap_or(redirect_to);
4943 return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4944 }
4945 };
4946 let actor = extract_actor(&state, &headers, session.as_ref());
4949 let policy = state.policies.get(&collection);
4950 if !policy.allows_create(&actor) {
4951 let is_partial = headers
4952 .get("X-Requested-With")
4953 .and_then(|v| v.to_str().ok())
4954 .map(|v| v == "What")
4955 .unwrap_or(false);
4956 return deny_response(
4957 &state,
4958 &mut session,
4959 is_new_session,
4960 is_partial,
4961 referer.as_deref(),
4962 format!("You don't have permission to upload to {}.", collection),
4963 format!("collection={}; action=upload", collection),
4964 )
4965 .await;
4966 }
4967
4968 let max_size = state.config.uploads.max_size_bytes();
4969 let mut form_fields: HashMap<String, String> = HashMap::new();
4970 let mut uploaded_files: Vec<(String, String)> = Vec::new(); while let Ok(Some(field)) = multipart.next_field().await {
4974 let field_name = field.name().unwrap_or("").to_string();
4975
4976 if let Some(file_name) = field.file_name().map(|s| s.to_string()) {
4977 if file_name.is_empty() {
4979 continue; }
4981
4982 let content_type = field
4983 .content_type()
4984 .unwrap_or("application/octet-stream")
4985 .to_string();
4986
4987 if !state.config.uploads.is_type_allowed(&content_type) {
4989 if let Some(ref mut sess) = session {
4990 let flash = FlashData {
4991 flash: HashMap::from([(
4992 "error".to_string(),
4993 format!("File type '{}' is not allowed", content_type),
4994 )]),
4995 ..Default::default()
4996 };
4997 set_flash_data(sess, &flash);
4998 if let Some(ref sessions) = state.sessions {
4999 let _ = sessions.update(&sess.id, sess.data.clone()).await;
5000 }
5001 }
5002 for (_, saved) in &uploaded_files {
5004 let _ = upload_backend.delete(saved).await;
5005 }
5006 let fallback = referer.as_deref().unwrap_or(redirect_to);
5007 return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
5008 }
5009
5010 let data = match field.bytes().await {
5012 Ok(bytes) => bytes,
5013 Err(e) => {
5014 tracing::error!("Failed to read upload field: {}", e);
5015 continue;
5016 }
5017 };
5018
5019 if data.len() > max_size {
5021 if let Some(ref mut sess) = session {
5022 let flash = FlashData {
5023 flash: HashMap::from([(
5024 "error".to_string(),
5025 format!(
5026 "File '{}' exceeds maximum size of {}",
5027 file_name, state.config.uploads.max_size
5028 ),
5029 )]),
5030 ..Default::default()
5031 };
5032 set_flash_data(sess, &flash);
5033 if let Some(ref sessions) = state.sessions {
5034 let _ = sessions.update(&sess.id, sess.data.clone()).await;
5035 }
5036 }
5037 for (_, saved) in &uploaded_files {
5038 let _ = upload_backend.delete(saved).await;
5039 }
5040 let fallback = referer.as_deref().unwrap_or(redirect_to);
5041 return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
5042 }
5043
5044 let extension = std::path::Path::new(&file_name)
5046 .extension()
5047 .and_then(|e| e.to_str())
5048 .map(|e| sanitize_extension(e))
5049 .unwrap_or_default();
5050 let saved_name = format!("{}{}", uuid::Uuid::new_v4(), extension);
5051
5052 match upload_backend.put(&saved_name, &data, &content_type).await {
5054 Ok(public_url) => {
5055 form_fields.insert(field_name.clone(), public_url);
5056 uploaded_files.push((field_name, saved_name));
5057 }
5058 Err(e) => {
5059 tracing::error!("Failed to save uploaded file: {}", e);
5060 if let Some(ref mut sess) = session {
5061 let flash = FlashData {
5062 flash: HashMap::from([(
5063 "error".to_string(),
5064 "Failed to save uploaded file".to_string(),
5065 )]),
5066 ..Default::default()
5067 };
5068 set_flash_data(sess, &flash);
5069 if let Some(ref sessions) = state.sessions {
5070 let _ = sessions.update(&sess.id, sess.data.clone()).await;
5071 }
5072 }
5073 for (_, saved) in &uploaded_files {
5074 let _ = upload_backend.delete(saved).await;
5075 }
5076 let fallback = referer.as_deref().unwrap_or(redirect_to);
5077 return redirect_with_session(
5078 &state,
5079 fallback,
5080 session.as_ref(),
5081 is_new_session,
5082 );
5083 }
5084 }
5085 } else {
5086 let value = field.text().await.unwrap_or_default();
5088 form_fields.insert(field_name, value);
5089 }
5090 }
5091
5092 let action_url = format!("/w-upload/{}", collection);
5093
5094 if let Err(resp) = validate_form_data(
5096 &state,
5097 &form_fields,
5098 &mut session,
5099 is_new_session,
5100 referer.as_deref(),
5101 Some(action_url.clone()),
5102 )
5103 .await
5104 {
5105 for (_, saved) in &uploaded_files {
5107 let _ = upload_backend.delete(saved).await;
5108 }
5109 return resp;
5110 }
5111
5112 let mut item_map: serde_json::Map<String, Value> = form_fields
5114 .into_iter()
5115 .filter(|(k, _)| !k.starts_with("w-"))
5116 .map(|(k, v)| (k, Value::String(v)))
5117 .collect();
5118 policy.sanitize_input(&mut item_map);
5119 policy.stamp_owner(&mut item_map, &actor);
5120 {
5121 let mut filter_ctx: HashMap<String, Value> = HashMap::new();
5122 let user = extract_user_context(&state, &headers);
5123 filter_ctx.insert("user".to_string(), user.to_context());
5124 if let Some(s) = session.as_ref() {
5125 filter_ctx.insert("session".to_string(), s.to_context());
5126 }
5127 policy.stamp_tenant(&mut item_map, &filter_ctx);
5128 }
5129
5130 let result = state.store.create(&collection, Value::Object(item_map)).await;
5131 state.cache.invalidate_content_type(&collection).await;
5132
5133 match result {
5134 Ok(_) => {
5135 if let Some(ref mut sess) = session {
5136 let flash = FlashData {
5137 flash: HashMap::from([(
5138 "success".to_string(),
5139 format!("Item created in {}", collection),
5140 )]),
5141 ..Default::default()
5142 };
5143 set_flash_data(sess, &flash);
5144 if let Some(ref sessions) = state.sessions {
5145 let _ = sessions.update(&sess.id, sess.data.clone()).await;
5146 }
5147 }
5148 redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
5149 }
5150 Err(e) => {
5151 tracing::error!("Upload action error: {}", e);
5152 for (_, saved) in &uploaded_files {
5154 let _ = upload_backend.delete(saved).await;
5155 }
5156 if let Some(ref mut sess) = session {
5157 let flash = FlashData {
5158 flash: HashMap::from([(
5159 "error".to_string(),
5160 format!("Failed to create: {}", e),
5161 )]),
5162 ..Default::default()
5163 };
5164 set_flash_data(sess, &flash);
5165 if let Some(ref sessions) = state.sessions {
5166 let _ = sessions.update(&sess.id, sess.data.clone()).await;
5167 }
5168 }
5169 let fallback = referer.as_deref().unwrap_or(redirect_to);
5170 redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
5171 }
5172 }
5173}
5174
5175async fn cleanup_uploaded_files(root: &std::path::Path, upload_dir: &str, record: &Value) {
5185 let Value::Object(map) = record else { return };
5186 let uploads_root = root.join(upload_dir);
5187 for (_key, value) in map {
5188 let Value::String(s) = value else { continue };
5189 let Some(filename) = s.strip_prefix("/uploads/") else {
5190 continue;
5191 };
5192 if filename.is_empty()
5194 || filename.contains("..")
5195 || filename.contains('/')
5196 || filename.contains('\\')
5197 || filename.contains('\0')
5198 {
5199 tracing::warn!("Skipping suspicious upload cleanup path: {:?}", s);
5200 continue;
5201 }
5202 let file_path = uploads_root.join(filename);
5203 if !file_path.exists() {
5204 continue;
5205 }
5206 let contained = match (uploads_root.canonicalize(), file_path.canonicalize()) {
5208 (Ok(base), Ok(resolved)) => resolved.starts_with(&base),
5209 _ => false,
5210 };
5211 if !contained {
5212 tracing::warn!(
5213 "Refusing to delete file outside uploads dir: {}",
5214 file_path.display()
5215 );
5216 continue;
5217 }
5218 if let Err(e) = tokio::fs::remove_file(&file_path).await {
5219 tracing::warn!("Failed to delete uploaded file {}: {}", file_path.display(), e);
5220 } else {
5221 tracing::debug!("Cleaned up uploaded file: {}", file_path.display());
5222 }
5223 }
5224}
5225
5226async fn handle_session_reset(
5228 State(state): State<AppState>,
5229 headers: HeaderMap,
5230 Form(form): Form<HashMap<String, String>>,
5231) -> impl IntoResponse {
5232 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5234
5235 if let Some(ref sessions) = state.sessions {
5237 if let Some(session_id) =
5238 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name)
5239 {
5240 let _ = sessions.delete(&session_id).await;
5241 }
5242
5243 match sessions.create().await {
5245 Ok(new_session) => {
5246 let cookie = sessions::build_session_cookie(
5247 &new_session.id,
5248 &state.config.session.cookie_name,
5249 state.config.session.max_age,
5250 state.config.session.secure,
5251 );
5252
5253 let raw_redirect = form
5257 .get("redirect")
5258 .map(|s| s.as_str())
5259 .or_else(|| headers.get(header::REFERER).and_then(|v| v.to_str().ok()))
5260 .unwrap_or("/state");
5261 let redirect_url = sanitize_redirect(raw_redirect, "/state");
5262
5263 build_response(
5265 StatusCode::SEE_OTHER,
5266 vec![
5267 (header::LOCATION, redirect_url),
5268 (header::SET_COOKIE, cookie),
5269 ],
5270 String::new(),
5271 )
5272 }
5273 Err(e) => {
5274 tracing::error!("Session reset error: {}", e);
5275 build_response(
5276 StatusCode::INTERNAL_SERVER_ERROR,
5277 vec![(header::CONTENT_TYPE, "text/plain".to_string())],
5278 "Failed to reset session".to_string(),
5279 )
5280 }
5281 }
5282 } else {
5283 Redirect::to("/state").into_response()
5285 }
5286}
5287
5288enum SetOp {
5290 Increment(i64),
5291 Decrement(i64),
5292 SetInt(i64),
5293 SetStr(String),
5294}
5295
5296fn sanitize_redirect(url: &str, fallback: &str) -> String {
5298 let trimmed = url.trim();
5299 if trimmed.starts_with('/') && !trimmed.starts_with("//") {
5300 trimmed.to_string()
5301 } else {
5302 fallback.to_string()
5303 }
5304}
5305
5306fn parse_w_set_expr(expr: &str) -> Option<(String, String, SetOp)> {
5308 let expr = expr.trim();
5309
5310 if let Some((left, right)) = expr.split_once("+=") {
5312 let left = left.trim();
5313 let right = right.trim();
5314 let (scope, key) = left.split_once('.')?;
5315 let val: i64 = right.parse().ok()?;
5316 return Some((scope.to_string(), key.to_string(), SetOp::Increment(val)));
5317 }
5318
5319 if let Some((left, right)) = expr.split_once("-=") {
5321 let left = left.trim();
5322 let right = right.trim();
5323 let (scope, key) = left.split_once('.')?;
5324 let val: i64 = right.parse().ok()?;
5325 return Some((scope.to_string(), key.to_string(), SetOp::Decrement(val)));
5326 }
5327
5328 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 if let Ok(val) = right.parse::<i64>() {
5335 return Some((scope.to_string(), key.to_string(), SetOp::SetInt(val)));
5336 }
5337 let s = right.trim_matches(|c| c == '\'' || c == '"');
5339 return Some((
5340 scope.to_string(),
5341 key.to_string(),
5342 SetOp::SetStr(s.to_string()),
5343 ));
5344 }
5345
5346 None
5347}
5348
5349fn apply_set_op(current: Option<&Value>, op: &SetOp) -> Value {
5351 match op {
5352 SetOp::Increment(delta) => {
5353 let cur = current.and_then(|v| v.as_i64()).unwrap_or(0);
5354 json!(cur + delta)
5355 }
5356 SetOp::Decrement(delta) => {
5357 let cur = current.and_then(|v| v.as_i64()).unwrap_or(0);
5358 json!(cur - delta)
5359 }
5360 SetOp::SetInt(val) => json!(val),
5361 SetOp::SetStr(val) => json!(val),
5362 }
5363}
5364
5365fn value_display_string(v: &Value) -> String {
5367 match v {
5368 Value::String(s) => s.clone(),
5369 Value::Null => String::new(),
5370 other => other.to_string(),
5371 }
5372}
5373
5374async fn handle_w_set(
5378 State(state): State<AppState>,
5379 headers: HeaderMap,
5380 Form(params): Form<HashMap<String, String>>,
5381) -> impl IntoResponse {
5382 let expr_str = match params.get("expr") {
5383 Some(e) => e.as_str(),
5384 None => return (StatusCode::BAD_REQUEST, "Missing expr").into_response(),
5385 };
5386
5387 let mut parsed: Vec<(String, String, SetOp)> = Vec::new();
5389 for part in expr_str.split(';') {
5390 let part = part.trim();
5391 if part.is_empty() {
5392 continue;
5393 }
5394 match parse_w_set_expr(part) {
5395 Some((scope, key, op)) => {
5396 if scope != "session" && scope != "app" && scope != "wired" {
5397 return (
5398 StatusCode::BAD_REQUEST,
5399 "Invalid scope (use session, app, or wired)",
5400 )
5401 .into_response();
5402 }
5403 parsed.push((scope, key, op));
5404 }
5405 None => {
5406 return (
5407 StatusCode::BAD_REQUEST,
5408 format!("Invalid expression: {}", part),
5409 )
5410 .into_response();
5411 }
5412 }
5413 }
5414
5415 if parsed.is_empty() {
5416 return (StatusCode::BAD_REQUEST, "Empty expression").into_response();
5417 }
5418
5419 let mut oob_map = serde_json::Map::new();
5420 let mut wired_updates: Vec<(String, String)> = Vec::new(); let mut session_mutations: Vec<(String, SetOp)> = Vec::new();
5422
5423 let cookie_header_str = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5425 let mutator_user_id: Option<String> = if state.auth.is_enabled() {
5426 state
5427 .auth
5428 .parse_jwt_cookie(cookie_header_str)
5429 .and_then(|token| {
5430 state.auth.decode_jwt(&token).ok().and_then(|claims| {
5431 if claims.is_expired() {
5432 return None;
5433 }
5434 claims.sub.clone()
5435 })
5436 })
5437 } else {
5438 None
5439 };
5440
5441 let has_shared_mutation = parsed.iter().any(|(s, _, _)| s == "app" || s == "wired");
5444 if has_shared_mutation {
5445 let cookie_header_for_auth = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5446 let is_authenticated = if state.auth.is_enabled() {
5447 state
5448 .auth
5449 .parse_jwt_cookie(cookie_header_for_auth)
5450 .and_then(|token| state.auth.decode_jwt(&token).ok())
5451 .map(|claims| !claims.is_expired())
5452 .unwrap_or(false)
5453 } else {
5454 let session_id = sessions::parse_session_cookie(
5456 cookie_header_for_auth,
5457 &state.config.session.cookie_name,
5458 );
5459 session_id.is_some()
5460 };
5461 if !is_authenticated {
5462 return (
5463 StatusCode::FORBIDDEN,
5464 "Authentication required for app/wired mutations",
5465 )
5466 .into_response();
5467 }
5468
5469 let mutator_roles = extract_user_context(&state, &headers).roles();
5472 for (mscope, mkey, _) in &parsed {
5473 let var_scope = match mscope.as_str() {
5474 "wired" => state.get_wired_scope(mkey).await,
5475 "app" => state.get_app_scope(mkey).await,
5476 _ => continue,
5477 };
5478 let allowed = match &var_scope {
5479 WiredScope::Public => true,
5480 WiredScope::Roles(_) => {
5483 state.auth.is_enabled()
5484 && var_scope.allows(&mutator_roles, mutator_user_id.as_deref())
5485 }
5486 WiredScope::User(_) => state.auth.is_enabled() && mutator_user_id.is_some(),
5489 };
5490 if !allowed {
5491 tracing::info!(
5492 target: "what::policy",
5493 "w-set denied: {}.{} requires scope {:?}",
5494 mscope, mkey, var_scope
5495 );
5496 let mut resp = (
5497 StatusCode::FORBIDDEN,
5498 format!("Insufficient permissions for {}.{}", mscope, mkey),
5499 )
5500 .into_response();
5501 if state.dev_mode {
5502 if let Ok(hv) = format!("deny; {}.{}; scope={:?}", mscope, mkey, var_scope).parse()
5503 {
5504 resp.headers_mut()
5505 .insert(header::HeaderName::from_static("x-what-policy"), hv);
5506 }
5507 }
5508 return resp;
5509 }
5510 }
5511 }
5512
5513 for (scope, key, op) in parsed {
5515 if scope == "app" || scope == "wired" {
5516 match state
5517 .store
5518 .atomic_modify(&key, move |current| apply_set_op(current, &op))
5519 .await
5520 {
5521 Ok(val) => {
5522 let display = value_display_string(&val);
5523 if scope == "wired" {
5524 oob_map.insert(format!("wired.{}", key), Value::String(display.clone()));
5525 wired_updates.push((key, display));
5526 } else {
5527 oob_map.insert(format!("app.{}", key), Value::String(display));
5528 }
5529 }
5530 Err(e) => {
5531 tracing::error!("w-set {} mutation failed: {}", scope, e);
5532 return (StatusCode::INTERNAL_SERVER_ERROR, "Mutation failed").into_response();
5533 }
5534 }
5535 } else {
5536 if key.starts_with('_') {
5538 tracing::warn!("w-set blocked: reserved session key '{}'", key);
5539 continue;
5540 }
5541 session_mutations.push((key, op));
5542 }
5543 }
5544
5545 for (key, display) in wired_updates {
5547 let mut wired_map = serde_json::Map::new();
5548 wired_map.insert(format!("wired.{}", key), Value::String(display));
5549 let json = serde_json::to_string(&Value::Object(wired_map)).unwrap_or_default();
5550 let mut scope = state.get_wired_scope(&key).await;
5551 if matches!(scope, WiredScope::User(ref uid) if uid.is_empty()) {
5553 scope = WiredScope::User(mutator_user_id.clone().unwrap_or_default());
5554 }
5555 let _ = state.wired_tx.send(WiredMessage { json, scope });
5556 }
5557
5558 if !session_mutations.is_empty() {
5560 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5561
5562 if let Some(ref sessions) = state.sessions {
5563 let session_id =
5564 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5565
5566 if let Some(id) = session_id {
5567 for (key, op) in &session_mutations {
5568 let atomic_op = match op {
5569 SetOp::Increment(n) => sessions::AtomicMutation::Increment {
5570 key: key.clone(),
5571 value: *n,
5572 },
5573 SetOp::Decrement(n) => sessions::AtomicMutation::Increment {
5574 key: key.clone(),
5575 value: -*n,
5576 },
5577 SetOp::SetInt(n) => sessions::AtomicMutation::Set {
5578 key: key.clone(),
5579 value: serde_json::json!(*n),
5580 },
5581 SetOp::SetStr(s) => sessions::AtomicMutation::Set {
5582 key: key.clone(),
5583 value: serde_json::json!(s),
5584 },
5585 };
5586
5587 match sessions.apply_mutation(&id, &atomic_op).await {
5588 Ok(data) => {
5589 if let Some(val) = data.get(key) {
5590 oob_map.insert(
5591 format!("session.{}", key),
5592 Value::String(value_display_string(val)),
5593 );
5594 }
5595 }
5596 Err(e) => {
5597 tracing::error!("w-set session mutation failed: {}", e);
5598 }
5599 }
5600 }
5601 }
5602 }
5603 }
5604
5605 let is_partial = headers
5607 .get("X-Requested-With")
5608 .and_then(|v| v.to_str().ok())
5609 .map(|v| v == "What")
5610 .unwrap_or(false);
5611
5612 if is_partial {
5613 let json_str = serde_json::to_string(&Value::Object(oob_map)).unwrap_or_default();
5614 let oob = format!(r##"<template data-what-updates>{}</template>"##, json_str);
5615 return Html(oob).into_response();
5616 }
5617
5618 let redirect_url = headers
5620 .get(header::REFERER)
5621 .and_then(|v| v.to_str().ok())
5622 .and_then(|url| {
5623 if let Some(idx) = url.find("://") {
5625 url[idx + 3..].find('/').map(|i| &url[idx + 3 + i..])
5627 } else {
5628 Some(url)
5629 }
5630 })
5631 .map(|path| sanitize_redirect(path, "/"))
5632 .unwrap_or_else(|| "/".to_string());
5633
5634 Redirect::to(&redirect_url).into_response()
5635}
5636
5637async fn handle_session_clear_data(
5639 State(state): State<AppState>,
5640 headers: HeaderMap,
5641) -> impl IntoResponse {
5642 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5643
5644 let referer = headers
5645 .get(header::REFERER)
5646 .and_then(|v| v.to_str().ok())
5647 .unwrap_or("/");
5648 let redirect_to = sanitize_redirect(referer, "/");
5649
5650 if let Some(ref sessions) = state.sessions {
5651 let session_id =
5652 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5653
5654 if let Some(id) = session_id {
5655 if let Err(e) = sessions.update(&id, std::collections::HashMap::new()).await {
5657 tracing::error!("Failed to clear session data: {}", e);
5658 }
5659 }
5660 }
5661
5662 Redirect::to(&redirect_to).into_response()
5663}
5664
5665async fn handle_inject_notification(
5667 State(state): State<AppState>,
5668 headers: HeaderMap,
5669) -> impl IntoResponse {
5670 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5671
5672 let mut count: i64 = 1;
5673
5674 if let Some(ref sessions) = state.sessions {
5675 let session_id =
5676 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5677
5678 if let Some(id) = session_id {
5679 if let Ok(Some(mut session)) = sessions.get(&id).await {
5680 let current = session
5682 .data
5683 .get("inject_count")
5684 .and_then(|v| v.as_i64())
5685 .unwrap_or(0);
5686
5687 count = current + 1;
5689 session
5690 .data
5691 .insert("inject_count".to_string(), json!(count));
5692
5693 if let Err(e) = sessions.update(&id, session.data).await {
5695 tracing::error!("Failed to update inject count: {}", e);
5696 }
5697 }
5698 }
5699 }
5700
5701 let html = format!(
5703 r#"<div class="p-4 bg-blue-100 border border-blue-300 rounded flex items-center gap-3">
5704 <span class="text-2xl">🔔</span>
5705 <div>
5706 <strong>Notification {}</strong>
5707 <p class="text-sm text-gray-600">Injected without reloading the page</p>
5708 </div>
5709</div>"#,
5710 count
5711 );
5712
5713 axum::response::Html(html).into_response()
5714}
5715
5716async fn handle_login(
5719 State(state): State<AppState>,
5720 _headers: HeaderMap,
5721 Form(form_data): Form<HashMap<String, String>>,
5722) -> impl IntoResponse {
5723 let login_endpoint = match state.auth.login_endpoint() {
5725 Some(url) => url.to_string(),
5726 None => {
5727 tracing::error!("Login endpoint not configured");
5728 let msg = if state.dev_mode {
5729 "Login not configured"
5730 } else {
5731 "Something went wrong"
5732 };
5733 return (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response();
5734 }
5735 };
5736
5737 let fallback = state.auth.after_login_path().to_string();
5739 let redirect_url = form_data
5740 .get("redirect")
5741 .or_else(|| form_data.get("w-redirect"))
5742 .map(|s| sanitize_redirect(s, &fallback))
5743 .unwrap_or(fallback);
5744
5745 let login_data: HashMap<String, String> = form_data
5747 .into_iter()
5748 .filter(|(k, _)| !k.starts_with("w-") && k != "redirect")
5749 .collect();
5750
5751 let response = match state
5753 .http_client
5754 .post(&login_endpoint)
5755 .json(&login_data)
5756 .send()
5757 .await
5758 {
5759 Ok(resp) => resp,
5760 Err(e) => {
5761 tracing::error!("Login request failed: {}", e);
5762 return Redirect::to(&format!("{}?error=connection", state.auth.login_path()))
5763 .into_response();
5764 }
5765 };
5766
5767 if !response.status().is_success() {
5768 tracing::warn!("Login failed with status: {}", response.status());
5769 return Redirect::to(&format!("{}?error=invalid", state.auth.login_path())).into_response();
5770 }
5771
5772 let token = match response.json::<serde_json::Value>().await {
5775 Ok(json) => {
5776 json.get("token")
5778 .or_else(|| json.get("access_token"))
5779 .or_else(|| json.get("jwt"))
5780 .and_then(|v| v.as_str())
5781 .map(|s| s.to_string())
5782 }
5783 Err(e) => {
5784 tracing::error!("Failed to parse login response: {}", e);
5785 None
5786 }
5787 };
5788
5789 match token {
5790 Some(jwt) => {
5791 let cookie = state.auth.build_jwt_cookie(
5793 &jwt,
5794 state.config.session.max_age,
5795 state.config.session.secure,
5796 );
5797
5798 let mut response_headers = vec![
5799 (header::LOCATION, redirect_url),
5800 (header::SET_COOKIE, cookie),
5801 ];
5802
5803 if let Some(ref sessions) = state.sessions {
5805 let cookie_header = _headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5806 let old_id = sessions::parse_session_cookie(
5807 cookie_header,
5808 &state.config.session.cookie_name,
5809 );
5810
5811 if let Some(ref old) = old_id {
5812 if let Ok(Some(old_session)) = sessions.get(old).await {
5814 if let Ok(new_session) = sessions.create().await {
5815 let mut data = old_session.data;
5816 data.insert(
5818 sessions::CSRF_TOKEN_KEY.to_string(),
5819 serde_json::json!(sessions::generate_csrf_token()),
5820 );
5821 let _ = sessions.update(&new_session.id, data).await;
5822 let _ = sessions.delete(old).await;
5823 response_headers.push((
5824 header::SET_COOKIE,
5825 sessions::build_session_cookie(
5826 &new_session.id,
5827 &state.config.session.cookie_name,
5828 state.config.session.max_age,
5829 state.config.session.secure,
5830 ),
5831 ));
5832 }
5833 }
5834 }
5835 }
5836
5837 build_response(StatusCode::SEE_OTHER, response_headers, String::new())
5838 }
5839 None => {
5840 Redirect::to(&format!("{}?error=no_token", state.auth.login_path())).into_response()
5841 }
5842 }
5843}
5844
5845async fn handle_logout(
5847 State(state): State<AppState>,
5848 request_headers: HeaderMap,
5849 Form(form_data): Form<HashMap<String, String>>,
5850) -> impl IntoResponse {
5851 let redirect_url = form_data
5853 .get("redirect")
5854 .or_else(|| form_data.get("w-redirect"))
5855 .map(|s| sanitize_redirect(s, "/"))
5856 .unwrap_or_else(|| "/".to_string());
5857
5858 if let Some(logout_endpoint) = state.auth.logout_endpoint() {
5860 let cookie_header = request_headers
5862 .get(header::COOKIE)
5863 .and_then(|v| v.to_str().ok());
5864
5865 if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
5866 let _ = state
5867 .http_client
5868 .post(logout_endpoint)
5869 .bearer_auth(&token)
5870 .send()
5871 .await;
5872 }
5873 }
5874
5875 let clear_cookie = state.auth.build_clear_cookie();
5877
5878 let mut response_cookies = vec![
5883 (header::LOCATION, redirect_url),
5884 (header::SET_COOKIE, clear_cookie),
5885 ];
5886 if let Some(ref sessions) = state.sessions {
5887 let cookie_header = request_headers
5888 .get(header::COOKIE)
5889 .and_then(|v| v.to_str().ok());
5890 if let Some(session_id) =
5891 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name)
5892 {
5893 let _ = sessions.delete(&session_id).await;
5894 }
5895 response_cookies.push((
5896 header::SET_COOKIE,
5897 sessions::build_clear_session_cookie(&state.config.session.cookie_name),
5898 ));
5899 }
5900
5901 build_response(StatusCode::SEE_OTHER, response_cookies, String::new())
5903}
5904
5905async fn handle_cache_clear_all(State(state): State<AppState>) -> impl IntoResponse {
5907 state.cache.clear_all().await;
5909
5910 tracing::info!("All server caches cleared");
5911
5912 axum::Json(serde_json::json!({
5914 "success": true,
5915 "message": "All caches cleared"
5916 }))
5917}
5918
5919async fn handle_sessions_list(State(state): State<AppState>) -> impl IntoResponse {
5921 match &state.sessions {
5922 Some(sessions) => {
5923 let count = sessions.count().await.unwrap_or(0);
5924 let ids = sessions.list_session_ids().await.unwrap_or_default();
5925
5926 axum::Json(serde_json::json!({
5927 "count": count,
5928 "ids": ids
5929 }))
5930 }
5931 None => axum::Json(serde_json::json!({
5932 "error": "Sessions not enabled",
5933 "count": 0,
5934 "ids": []
5935 })),
5936 }
5937}
5938
5939async fn handle_data_info(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
5941 let session_data = if let Some(ref sessions) = state.sessions {
5943 let cookie_header = headers.get(header::COOKIE).and_then(|h| h.to_str().ok());
5944 let session_id =
5945 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5946
5947 if let Some(id) = session_id {
5948 if let Ok(Some(session)) = sessions.get(&id).await {
5949 session.data.clone()
5950 } else {
5951 std::collections::HashMap::new()
5952 }
5953 } else {
5954 std::collections::HashMap::new()
5955 }
5956 } else {
5957 std::collections::HashMap::new()
5958 };
5959
5960 let application_data = state.store.as_context().await;
5962
5963 axum::Json(serde_json::json!({
5964 "application": application_data,
5965 "session": session_data
5966 }))
5967}
5968
5969struct RouteMeta {
5975 url: String,
5976 dynamic: bool,
5977 auth: String,
5978 layout: String,
5979 missing: bool,
5980}
5981
5982fn inspector_auth_display(d: &PageDirectives) -> Option<String> {
5985 use crate::parser::AuthLevel;
5986 match &d.auth {
5987 AuthLevel::User => Some("user".to_string()),
5988 AuthLevel::Roles(v) => Some(format!("roles: {}", v.join(", "))),
5989 AuthLevel::All => {
5990 if d.protected {
5991 if d.roles.is_empty() {
5992 Some("user (protected)".to_string())
5993 } else {
5994 Some(format!("roles: {} (legacy)", d.roles.join(", ")))
5995 }
5996 } else {
5997 None
5998 }
5999 }
6000 }
6001}
6002
6003fn resolve_route_meta(root: &PathBuf, url_path: &str, dynamic: bool) -> RouteMeta {
6007 let Some(resolved) = resolve_page_path(root, url_path) else {
6008 return RouteMeta {
6009 url: url_path.to_string(),
6010 dynamic,
6011 auth: String::new(),
6012 layout: String::new(),
6013 missing: true,
6014 };
6015 };
6016 let raw = std::fs::read_to_string(&resolved.path).unwrap_or_default();
6017 let (directives, _) = parse_page_directives(&raw);
6018 let app_config = load_application_config(root, url_path);
6019
6020 let auth = inspector_auth_display(&directives)
6021 .or_else(|| inspector_auth_display(&app_config.directives))
6022 .unwrap_or_else(|| "all".to_string());
6023
6024 let layout = directives.layout.clone().or_else(|| app_config.layout.clone());
6025 let layout = match layout.as_deref() {
6026 Some("none") => "none (disabled)".to_string(),
6027 Some(l) => l.to_string(),
6028 None => "(default)".to_string(),
6029 };
6030
6031 RouteMeta { url: url_path.to_string(), dynamic, auth, layout, missing: false }
6032}
6033
6034fn collect_application_what_files(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<(String, WhatConfig)>) {
6037 let cfg = dir.join("application.what");
6038 if cfg.exists() {
6039 if let Ok(content) = std::fs::read_to_string(&cfg) {
6040 let rel = cfg.strip_prefix(root).unwrap_or(&cfg).to_string_lossy().to_string();
6041 out.push((rel, parse_what_file(&content)));
6042 }
6043 }
6044 if let Ok(entries) = std::fs::read_dir(dir) {
6045 let mut dirs: Vec<_> = entries
6046 .flatten()
6047 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
6048 .map(|e| e.path())
6049 .collect();
6050 dirs.sort();
6051 for d in dirs {
6052 collect_application_what_files(&d, root, out);
6053 }
6054 }
6055}
6056
6057async fn handle_inspector(
6062 State(state): State<AppState>,
6063 Query(params): Query<HashMap<String, String>>,
6064) -> axum::response::Response {
6065 let esc = engine::escape_html;
6066 let refresh: Option<u32> = params
6067 .get("refresh")
6068 .and_then(|v| v.parse().ok())
6069 .filter(|n| (1..=60).contains(n));
6070 let mut b = String::new();
6071
6072 b.push_str("<section id=\"overview\"><h2>Overview</h2><table>");
6074 let cfg = &state.config;
6075 let rows = [
6076 ("Framework version", env!("CARGO_PKG_VERSION").to_string()),
6077 ("Mode", if state.dev_mode { "development".into() } else { "production".into() }),
6078 ("Host : Port", format!("{}:{}", cfg.server.host, cfg.server.port)),
6079 ("CSS mode", format!("{:?}", state.css_mode).to_lowercase()),
6080 ("Sessions", if cfg.session.enabled { "enabled".into() } else { "disabled".into() }),
6081 ("Auth", if cfg.auth.enabled { "enabled".into() } else { "disabled".into() }),
6082 ("Uploads", if cfg.uploads.enabled { "enabled".into() } else { "disabled".into() }),
6083 ("Source viewer", if cfg.server.source_viewer { "on".into() } else { "off".into() }),
6084 ("Strict mode", if cfg.strict { "on".into() } else { "off".into() }),
6085 ];
6086 for (k, v) in rows {
6087 b.push_str(&format!("<tr><th>{}</th><td>{}</td></tr>", esc(k), esc(&v)));
6088 }
6089 b.push_str("</table></section>");
6090
6091 b.push_str("<section id=\"routes\"><h2>Routes</h2><table><tr><th>Path</th><th>Auth</th><th>Layout</th><th></th></tr>");
6093 let routes = discover_routes(&state.root);
6094 for (url, dynamic) in &routes {
6095 let m = resolve_route_meta(&state.root, url, *dynamic);
6096 let tags = format!(
6097 "{}{}",
6098 if m.dynamic { "<span class=\"tag\">dynamic</span>" } else { "" },
6099 if m.missing { "<span class=\"tag warn\">missing file</span>" } else { "" },
6100 );
6101 b.push_str(&format!(
6102 "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
6103 esc(&m.url), esc(&m.auth), esc(&m.layout), tags
6104 ));
6105 }
6106 b.push_str(&format!("</table><p class=\"muted\">{} route(s).</p></section>", routes.len()));
6107
6108 b.push_str("<section id=\"collections\"><h2>Collections & Policies</h2>");
6110 b.push_str("<table><tr><th>Collection</th><th>Rows</th><th>Policy</th></tr>");
6111 let ctx = state.store.as_context().await;
6112 let mut names: std::collections::BTreeSet<String> = ctx.keys().cloned().collect();
6113 for (name, _) in state.policies.configured() {
6114 names.insert(name.to_string());
6115 }
6116 if names.is_empty() {
6117 b.push_str("<tr><td colspan=\"3\" class=\"muted\">No collections yet.</td></tr>");
6118 }
6119 for name in &names {
6120 let count = ctx.get(name).and_then(|v| v.as_array()).map(|a| a.len());
6121 let count_str = count.map(|c| c.to_string()).unwrap_or_else(|| "—".to_string());
6122 let p = state.policies.get(name);
6123 let badge = if p.explicit { "<span class=\"tag\">explicit</span>" } else { "<span class=\"tag muted-tag\">default</span>" };
6124 let mut policy = format!(
6125 "create=<b>{}</b> · read=<b>{}</b> · update=<b>{}</b> · delete=<b>{}</b> · owner={}",
6126 esc(&p.create.to_string()), esc(&p.read.to_string()),
6127 esc(&p.update.to_string()), esc(&p.delete.to_string()), esc(&p.owner_mode.to_string()),
6128 );
6129 if let Some(f) = &p.filter {
6130 policy.push_str(&format!(" · filter=<code>{}</code>", esc(f)));
6131 }
6132 if !p.readonly_fields.is_empty() {
6133 policy.push_str(&format!(" · readonly=[{}]", esc(&p.readonly_fields.join(", "))));
6134 }
6135 if !p.private_fields.is_empty() {
6136 policy.push_str(&format!(" · private=[{}]", esc(&p.private_fields.join(", "))));
6137 }
6138 b.push_str(&format!(
6139 "<tr><td><code>{}</code> {}</td><td>{}</td><td class=\"policy\">{}</td></tr>",
6140 esc(name), badge, count_str, policy
6141 ));
6142 }
6143 b.push_str("</table></section>");
6144
6145 b.push_str("<section id=\"config\"><h2>application.what Inheritance</h2>");
6147 let mut app_files = Vec::new();
6148 collect_application_what_files(&state.content_dir, &state.root, &mut app_files);
6149 if app_files.is_empty() {
6150 b.push_str("<p class=\"muted\">No application.what files.</p>");
6151 } else {
6152 b.push_str("<table><tr><th>File</th><th>Declares</th></tr>");
6153 for (rel, wc) in &app_files {
6154 let mut parts = Vec::new();
6155 if let Some(a) = inspector_auth_display(&wc.directives) {
6156 parts.push(format!("auth={}", a));
6157 }
6158 let layout = wc.directives.layout.clone().or_else(|| wc.layout.clone());
6159 if let Some(l) = layout {
6160 parts.push(format!("layout={}", l));
6161 }
6162 if !wc.data_application.is_empty() {
6163 let ns: Vec<&str> = wc.data_application.iter().map(|d| d.name.as_str()).collect();
6164 parts.push(format!("data.application=[{}]", ns.join(", ")));
6165 }
6166 if !wc.data_wired.is_empty() {
6167 let ns: Vec<&str> = wc.data_wired.iter().map(|d| d.name.as_str()).collect();
6168 parts.push(format!("data.wired=[{}]", ns.join(", ")));
6169 }
6170 if !wc.data_session.is_empty() {
6171 parts.push(format!("data.session=[{}]", wc.data_session.join(", ")));
6172 }
6173 let desc = if parts.is_empty() { "(nothing auth/layout/data)".to_string() } else { parts.join(" · ") };
6174 b.push_str(&format!("<tr><td><code>{}</code></td><td>{}</td></tr>", esc(rel), esc(&desc)));
6175 }
6176 b.push_str("</table><p class=\"muted\">Child directories override parents.</p>");
6177 }
6178 b.push_str("</section>");
6179
6180 b.push_str("<section id=\"sessions\"><h2>Sessions</h2>");
6182 match &state.sessions {
6183 Some(s) => {
6184 let count = s.count().await.unwrap_or(0);
6185 let ids = s.list_session_ids().await.unwrap_or_default();
6186 b.push_str(&format!("<p><b>{}</b> active session(s).</p>", count));
6187 if !ids.is_empty() {
6188 b.push_str("<details><summary>Session IDs</summary><ul>");
6189 for id in ids.iter().take(200) {
6190 let short: String = id.chars().take(16).collect();
6191 b.push_str(&format!("<li><code>{}…</code></li>", esc(&short)));
6192 }
6193 b.push_str("</ul></details>");
6194 }
6195 }
6196 None => b.push_str("<p class=\"muted\">Sessions disabled.</p>"),
6197 }
6198 b.push_str("</section>");
6199
6200 b.push_str("<section id=\"scopes\"><h2>Write Scopes</h2>");
6202 let wired = state.wired_scopes.read().await;
6203 let app = state.app_scopes.read().await;
6204 if wired.is_empty() && app.is_empty() {
6205 b.push_str("<p class=\"muted\">No scoped wired/application variables.</p>");
6206 } else {
6207 b.push_str("<table><tr><th>Variable</th><th>Kind</th><th>Scope</th></tr>");
6208 let mut wk: Vec<_> = wired.iter().collect();
6209 wk.sort_by_key(|(k, _)| k.clone());
6210 for (k, v) in wk {
6211 b.push_str(&format!("<tr><td><code>wired.{}</code></td><td>wired</td><td>{}</td></tr>", esc(k), esc(&v.to_string())));
6212 }
6213 let mut ak: Vec<_> = app.iter().collect();
6214 ak.sort_by_key(|(k, _)| k.clone());
6215 for (k, v) in ak {
6216 b.push_str(&format!("<tr><td><code>app.{}</code></td><td>application</td><td>{}</td></tr>", esc(k), esc(&v.to_string())));
6217 }
6218 b.push_str("</table>");
6219 }
6220 b.push_str("</section>");
6221
6222 b.push_str("<section id=\"lints\"><h2>Template Lints</h2>");
6224 let mut total = 0;
6225 let mut lint_rows = String::new();
6226 for (url, dynamic) in &routes {
6227 if let Some(resolved) = resolve_page_path(&state.root, url) {
6228 let _ = dynamic;
6229 if let Ok(raw) = std::fs::read_to_string(&resolved.path) {
6230 let lints = engine::collect_template_lints(&raw);
6231 for l in lints {
6232 total += 1;
6233 lint_rows.push_str(&format!(
6234 "<tr><td><code>{}</code></td><td><span class=\"tag warn\">{}</span></td><td>{}</td></tr>",
6235 esc(url), esc(l.kind), esc(&l.message)
6236 ));
6237 }
6238 }
6239 }
6240 }
6241 if total == 0 {
6242 b.push_str("<p class=\"ok\">✓ No template issues found.</p>");
6243 } else {
6244 b.push_str(&format!("<table><tr><th>Page</th><th>Kind</th><th>Detail</th></tr>{}</table>", lint_rows));
6245 }
6246 b.push_str("</section>");
6247
6248 b.push_str("<section id=\"activity\"><h2>Activity");
6250 if refresh.is_some() {
6251 b.push_str(" <a class=\"refresh-link\" href=\"/w-inspector#activity\">⏸ stop auto-refresh</a>");
6252 } else {
6253 b.push_str(" <a class=\"refresh-link\" href=\"/w-inspector?refresh=2#activity\">↻ auto-refresh (2s)</a>");
6254 }
6255 b.push_str("</h2>");
6256 let events: Vec<ActivityEvent> = {
6257 let log = state.activity_log.lock().unwrap();
6258 log.iter().rev().cloned().collect()
6259 };
6260 if events.is_empty() {
6261 b.push_str("<p class=\"muted\">No activity yet — make some requests.</p>");
6262 } else {
6263 b.push_str("<table><tr><th>Time</th><th>Kind</th><th>Detail</th></tr>");
6264 for ev in &events {
6265 match ev {
6266 ActivityEvent::Request { time, method, path, status, duration_ms } => {
6267 let tag_class = if *status >= 400 { "tag warn" } else { "tag" };
6268 b.push_str(&format!(
6269 "<tr><td class=\"muted\">{}</td><td><span class=\"{}\">request</span></td><td><code>{} {}</code> → {} ({}ms)</td></tr>",
6270 time.format("%H:%M:%S"), tag_class, esc(method), esc(path), status, duration_ms
6271 ));
6272 }
6273 ActivityEvent::PolicyDenial { time, detail } => {
6274 b.push_str(&format!(
6275 "<tr><td class=\"muted\">{}</td><td><span class=\"tag warn\">deny</span></td><td>{}</td></tr>",
6276 time.format("%H:%M:%S"), esc(detail)
6277 ));
6278 }
6279 ActivityEvent::Fetch { time, key, url, elapsed_ms, result } => {
6280 b.push_str(&format!(
6281 "<tr><td class=\"muted\">{}</td><td><span class=\"tag muted-tag\">fetch</span></td><td><code>{}</code> ← <code>{}</code> → {} ({}ms)</td></tr>",
6282 time.format("%H:%M:%S"), esc(key), esc(url), esc(result), elapsed_ms
6283 ));
6284 }
6285 }
6286 }
6287 b.push_str("</table>");
6288 }
6289 b.push_str("</section>");
6290
6291 let html = render_inspector_shell(&b, refresh);
6292 build_response(
6293 StatusCode::OK,
6294 vec![(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())],
6295 html,
6296 )
6297}
6298
6299fn render_inspector_shell(body: &str, refresh: Option<u32>) -> String {
6302 let refresh_meta = refresh
6303 .map(|n| format!("<meta http-equiv=\"refresh\" content=\"{n}\">"))
6304 .unwrap_or_default();
6305 format!(
6306 r##"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">{refresh_meta}<title>What Inspector</title><style>
6307:root {{ color-scheme: light; }}
6308* {{ box-sizing: border-box; }}
6309body {{ margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; color: #1f2937; background: #f8fafc; }}
6310header {{ background: #0f172a; color: #e2e8f0; padding: 14px 20px; position: sticky; top: 0; z-index: 10; }}
6311header b {{ color: #fff; font-size: 15px; }}
6312header .badge {{ background: #334155; color: #cbd5e1; border-radius: 4px; padding: 2px 7px; font-size: 11px; margin-left: 8px; }}
6313nav {{ padding: 8px 20px; background: #1e293b; position: sticky; top: 46px; z-index: 9; }}
6314nav a {{ color: #93c5fd; text-decoration: none; margin-right: 14px; font-size: 12px; }}
6315nav a:hover {{ text-decoration: underline; }}
6316main {{ padding: 20px; max-width: 1100px; }}
6317section {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 18px; margin-bottom: 18px; }}
6318h2 {{ margin: 0 0 12px; font-size: 15px; color: #0f172a; }}
6319table {{ width: 100%; border-collapse: collapse; }}
6320th, td {{ text-align: left; padding: 6px 8px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }}
6321th {{ color: #64748b; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .03em; }}
6322td code, .policy code {{ background: #f1f5f9; padding: 1px 5px; border-radius: 3px; }}
6323.policy {{ font-size: 12px; }}
6324.muted {{ color: #94a3b8; }}
6325.ok {{ color: #16a34a; }}
6326.tag {{ display: inline-block; background: #dbeafe; color: #1e40af; border-radius: 3px; padding: 1px 6px; font-size: 11px; margin-left: 4px; }}
6327.tag.warn {{ background: #fef3c7; color: #92400e; }}
6328.tag.muted-tag {{ background: #f1f5f9; color: #64748b; }}
6329details summary {{ cursor: pointer; color: #2563eb; }}
6330ul {{ margin: 8px 0; padding-left: 20px; }}
6331.refresh-link {{ font-size: 11px; font-weight: 400; color: #2563eb; text-decoration: none; margin-left: 8px; }}
6332.refresh-link:hover {{ text-decoration: underline; }}
6333</style></head><body>
6334<header><b>What Inspector</b><span class="badge">dev</span></header>
6335<nav><a href="#overview">Overview</a><a href="#routes">Routes</a><a href="#collections">Collections</a><a href="#config">Config</a><a href="#sessions">Sessions</a><a href="#scopes">Scopes</a><a href="#lints">Lints</a><a href="#activity">Activity</a></nav>
6336<main>{body}</main>
6337</body></html>"##,
6338 body = body,
6339 refresh_meta = refresh_meta
6340 )
6341}
6342
6343async fn handle_partial(
6349 State(state): State<AppState>,
6350 headers: HeaderMap,
6351 axum::extract::Path(path): axum::extract::Path<String>,
6352 Query(query_params): Query<HashMap<String, String>>,
6353) -> Response {
6354 let clean_path = path.trim_start_matches('/');
6355
6356 let partials_dir = state.content_dir.join("partials");
6358 let file_path = if clean_path.is_empty() {
6359 partials_dir.join("index.html")
6360 } else {
6361 let with_ext = partials_dir.join(format!("{}.html", clean_path));
6362 if with_ext.exists() {
6363 with_ext
6364 } else {
6365 partials_dir.join(clean_path).join("index.html")
6366 }
6367 };
6368
6369 let canonical = match file_path.canonicalize() {
6371 Ok(p) => p,
6372 Err(_) => {
6373 return (StatusCode::NOT_FOUND, "Partial not found").into_response();
6374 }
6375 };
6376 let partials_canonical = match partials_dir.canonicalize() {
6377 Ok(p) => p,
6378 Err(_) => {
6379 return (StatusCode::NOT_FOUND, "Partial not found").into_response();
6380 }
6381 };
6382 if !canonical.starts_with(&partials_canonical) {
6383 return (StatusCode::FORBIDDEN, "Access denied").into_response();
6384 }
6385
6386 let raw_content = match tokio::fs::read_to_string(&canonical).await {
6388 Ok(c) => c,
6389 Err(_) => {
6390 return (StatusCode::NOT_FOUND, "Partial not found").into_response();
6391 }
6392 };
6393
6394 if state.dev_mode {
6395 engine::warn_template_lints_once(&canonical, &raw_content);
6396 }
6397
6398 let (directives, content) = parse_page_directives(&raw_content);
6400
6401 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
6403
6404 let (session, is_new_session) = if let Some(ref sessions) = state.sessions {
6405 let session_id =
6406 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
6407 match sessions.get_or_create(session_id.as_deref()).await {
6408 Ok(session) => {
6409 let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
6410 (Some(session), is_new)
6411 }
6412 Err(_) => (None, false),
6413 }
6414 } else {
6415 (None, false)
6416 };
6417
6418 let user_context = if state.auth.is_enabled() {
6420 if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
6421 match state.auth.decode_jwt(&token) {
6422 Ok(claims) if !claims.is_expired() => {
6423 UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
6424 }
6425 _ => UserContext::unauthenticated(),
6426 }
6427 } else {
6428 UserContext::unauthenticated()
6429 }
6430 } else {
6431 UserContext::unauthenticated()
6432 };
6433
6434 if !partial_access_allowed(&state, clean_path, &directives, &user_context) {
6438 return (StatusCode::FORBIDDEN, "Access denied").into_response();
6439 }
6440
6441 match render_content_internal(
6443 &state,
6444 &content,
6445 session.as_ref(),
6446 &user_context,
6447 None,
6448 Some(&directives),
6449 Some(&query_params),
6450 &HashMap::new(),
6451 true,
6452 None,
6453 )
6454 .await
6455 {
6456 Ok(render_result) => {
6457 let mut html = render_result.html;
6458
6459 if !render_result.session_keys.is_empty() {
6461 if let Some(ref s) = session {
6462 let mut updates = serde_json::Map::new();
6463 for key in &render_result.session_keys {
6464 if let Some(value) = s.data.get(key) {
6465 updates.insert(format!("session.{}", key), value.clone());
6466 }
6467 }
6468 if !updates.is_empty() {
6469 let json_str = serde_json::to_string(&updates).unwrap_or_default();
6470 html.push_str(&format!(
6471 r#"<template data-what-updates>{}</template>"#,
6472 json_str
6473 ));
6474 }
6475 }
6476 }
6477
6478 if let Some(ref s) = session {
6480 if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
6481 html = inject_csrf_tokens(&html, csrf);
6482 }
6483 }
6484
6485 let mut resp_headers: Vec<(header::HeaderName, String)> = vec![
6487 (header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
6488 (
6489 header::HeaderName::from_static("x-robots-tag"),
6490 "noindex, nofollow".to_string(),
6491 ),
6492 ];
6493
6494 if is_new_session {
6496 if let Some(ref s) = session {
6497 let cookie = sessions::build_session_cookie(
6498 &s.id,
6499 &state.config.session.cookie_name,
6500 state.config.session.max_age,
6501 state.config.session.secure,
6502 );
6503 resp_headers.push((header::SET_COOKIE, cookie));
6504 }
6505 }
6506
6507 build_response(StatusCode::OK, resp_headers, html)
6508 }
6509 Err(e) => {
6510 tracing::error!("Partial render error: {}", e);
6511 (StatusCode::INTERNAL_SERVER_ERROR, "Render error").into_response()
6512 }
6513 }
6514}
6515
6516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6523pub enum CssMode {
6524 Full,
6526 Minimal,
6528 None,
6530}
6531
6532impl CssMode {
6533 pub fn from_config(value: &str) -> crate::Result<Self> {
6534 match value {
6535 "full" => Ok(Self::Full),
6536 "minimal" => Ok(Self::Minimal),
6537 "none" => Ok(Self::None),
6538 other => Err(crate::Error::Config(format!(
6539 "[server] css must be \"full\", \"minimal\", or \"none\" (got \"{}\")",
6540 other
6541 ))),
6542 }
6543 }
6544}
6545
6546const EMBEDDED_WHAT_CSS: &str = include_str!("../../assets/client/what.css");
6548
6549const MINIMAL_CUT_START: &str = "/*! what:minimal-cut-start */";
6553const MINIMAL_CUT_END: &str = "/*! what:minimal-cut-end */";
6554
6555static MINIMAL_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6556 match (
6557 EMBEDDED_WHAT_CSS.find(MINIMAL_CUT_START),
6558 EMBEDDED_WHAT_CSS.find(MINIMAL_CUT_END),
6559 ) {
6560 (Some(start), Some(end)) if end > start => format!(
6561 "{}{}",
6562 &EMBEDDED_WHAT_CSS[..start],
6563 &EMBEDDED_WHAT_CSS[end + MINIMAL_CUT_END.len()..]
6564 ),
6565 _ => EMBEDDED_WHAT_CSS.to_string(),
6566 }
6567});
6568
6569const EMBEDDED_WHAT_JS: &str = include_str!("../../assets/client/what.js");
6571
6572pub fn embedded_what_css() -> &'static str {
6575 EMBEDDED_WHAT_CSS
6576}
6577
6578pub fn embedded_what_js() -> &'static str {
6581 EMBEDDED_WHAT_JS
6582}
6583
6584static MINIFIED_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6586 let cfg = minify_html::Cfg { minify_css: true, ..minify_html::Cfg::default() };
6587 let wrapped = format!("<style>{}</style>", EMBEDDED_WHAT_CSS);
6588 let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6589 String::from_utf8(minified)
6590 .map(|s| s.strip_prefix("<style>").unwrap_or(&s).strip_suffix("</style>").unwrap_or(&s).to_string())
6591 .unwrap_or_else(|_| EMBEDDED_WHAT_CSS.to_string())
6592});
6593
6594static MINIFIED_MINIMAL_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6595 let cfg = minify_html::Cfg { minify_css: true, ..minify_html::Cfg::default() };
6596 let wrapped = format!("<style>{}</style>", MINIMAL_WHAT_CSS.as_str());
6597 let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6598 String::from_utf8(minified)
6599 .map(|s| s.strip_prefix("<style>").unwrap_or(&s).strip_suffix("</style>").unwrap_or(&s).to_string())
6600 .unwrap_or_else(|_| MINIMAL_WHAT_CSS.clone())
6601});
6602
6603static MINIFIED_WHAT_JS: LazyLock<String> = LazyLock::new(|| {
6604 let cfg = minify_html::Cfg { minify_js: true, ..minify_html::Cfg::default() };
6605 let wrapped = format!("<script>{}</script>", EMBEDDED_WHAT_JS);
6606 let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6607 String::from_utf8(minified)
6608 .map(|s| s.strip_prefix("<script>").unwrap_or(&s).strip_suffix("</script>").unwrap_or(&s).to_string())
6609 .unwrap_or_else(|_| EMBEDDED_WHAT_JS.to_string())
6610});
6611
6612static WHAT_CSS_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6615 let hash = embedded_asset_hash(EMBEDDED_WHAT_CSS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6616 format!("/static/what.css?v={:08x}", hash)
6617});
6618static WHAT_CSS_MINIMAL_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6619 let hash =
6620 embedded_asset_hash(&MINIMAL_WHAT_CSS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6621 format!("/static/what.css?v={:08x}", hash)
6622});
6623static WHAT_JS_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6624 let hash = embedded_asset_hash(EMBEDDED_WHAT_JS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6625 format!("/static/what.js?v={:08x}", hash)
6626});
6627
6628pub fn embedded_what_css_for_mode(mode: CssMode) -> &'static str {
6630 match mode {
6631 CssMode::Minimal => MINIMAL_WHAT_CSS.as_str(),
6632 _ => EMBEDDED_WHAT_CSS,
6633 }
6634}
6635
6636fn embedded_asset_hash(content: &str) -> u32 {
6637 let mut hash: u32 = 0x811c9dc5;
6638 for byte in content.as_bytes() {
6639 hash ^= *byte as u32;
6640 hash = hash.wrapping_mul(0x0100_0193);
6641 }
6642 hash
6643}
6644
6645async fn handle_embedded_css(State(state): State<AppState>) -> impl IntoResponse {
6648 let minimal = state.css_mode == CssMode::Minimal;
6649 let (cache, content) = if state.dev_mode {
6650 let raw = if minimal { MINIMAL_WHAT_CSS.as_str() } else { EMBEDDED_WHAT_CSS };
6651 ("no-cache, no-store, must-revalidate", raw.to_string())
6652 } else {
6653 let min = if minimal { MINIFIED_MINIMAL_WHAT_CSS.clone() } else { MINIFIED_WHAT_CSS.clone() };
6654 ("public, max-age=31536000, immutable", min)
6655 };
6656 (
6657 StatusCode::OK,
6658 [
6659 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
6660 (header::CACHE_CONTROL, cache),
6661 ],
6662 content,
6663 )
6664}
6665
6666async fn handle_embedded_js(State(state): State<AppState>) -> impl IntoResponse {
6669 let (cache, content) = if state.dev_mode {
6670 ("no-cache, no-store, must-revalidate", EMBEDDED_WHAT_JS.to_string())
6671 } else {
6672 ("public, max-age=31536000, immutable", MINIFIED_WHAT_JS.clone())
6673 };
6674 (
6675 StatusCode::OK,
6676 [
6677 (
6678 header::CONTENT_TYPE,
6679 "application/javascript; charset=utf-8",
6680 ),
6681 (header::CACHE_CONTROL, cache),
6682 ],
6683 content,
6684 )
6685}
6686
6687async fn handle_page_source(
6689 State(state): State<AppState>,
6690 axum::extract::Path(path): axum::extract::Path<String>,
6691) -> impl IntoResponse {
6692 let not_available = axum::Json(json!({
6693 "files": [{ "label": "Page", "content": "Source not available" }]
6694 }));
6695
6696 let clean_path = path.trim_start_matches('/');
6698 let pages_dir = state.content_dir.clone();
6699 let file_path = pages_dir.join(clean_path);
6700
6701 let file_path = if file_path.extension().is_some() {
6703 file_path
6704 } else {
6705 let with_ext = file_path.with_extension("html");
6706 if with_ext.exists() {
6707 with_ext
6708 } else {
6709 file_path.join("index.html")
6710 }
6711 };
6712
6713 let canonical = match file_path.canonicalize() {
6715 Ok(p) => p,
6716 Err(_) => return not_available,
6717 };
6718 let pages_canonical = match pages_dir.canonicalize() {
6719 Ok(p) => p,
6720 Err(_) => return not_available,
6721 };
6722 if !canonical.starts_with(&pages_canonical) {
6723 return not_available;
6724 }
6725
6726 if std::fs::read_to_string(&canonical).is_err() {
6728 return not_available;
6729 }
6730
6731 let mut files: Vec<Value> = Vec::new();
6733 let mut scan_queue: Vec<(PathBuf, String)> = Vec::new();
6734
6735 static COMPONENT_RE: LazyLock<Regex> =
6737 LazyLock::new(|| Regex::new(r"<what-([a-zA-Z][a-zA-Z0-9_-]*)[\s/>]").unwrap());
6738 static INCLUDE_RE: LazyLock<Regex> =
6739 LazyLock::new(|| Regex::new(r#"<include\s+src="([^"]+)""#).unwrap());
6740 static PARTIAL_ROUTE_RE: LazyLock<Regex> =
6741 LazyLock::new(|| Regex::new(r#"w-get="/partials/([^"?#]+)""#).unwrap());
6742 static PARTIAL_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
6743 Regex::new(r#"name="w-partial"[^>]*value="([^"]+)"|value="([^"]+)"[^>]*name="w-partial""#)
6744 .unwrap()
6745 });
6746
6747 let project_root = pages_dir.parent().unwrap_or(&pages_dir);
6748 let project_root_canonical = match project_root.canonicalize() {
6749 Ok(p) => p,
6750 Err(_) => return not_available,
6751 };
6752 let components_dir = project_root.join("components");
6753 let mut seen_paths = std::collections::HashSet::new();
6754
6755 push_source_file(
6756 canonical.clone(),
6757 &project_root_canonical,
6758 &mut seen_paths,
6759 &mut files,
6760 &mut scan_queue,
6761 );
6762
6763 while let Some((current_path, current_content)) = scan_queue.pop() {
6768 for cap in COMPONENT_RE.captures_iter(¤t_content) {
6769 let name = cap[1].to_string();
6770 push_source_file(
6771 components_dir.join(format!("{}.html", name)),
6772 &project_root_canonical,
6773 &mut seen_paths,
6774 &mut files,
6775 &mut scan_queue,
6776 );
6777 }
6778
6779 for cap in INCLUDE_RE.captures_iter(¤t_content) {
6780 let src = cap[1].to_string();
6781 let file_dir = current_path.parent().unwrap_or(project_root);
6782 let candidates = [
6783 project_root.join(&src),
6784 pages_dir.join(&src),
6785 file_dir.join(&src),
6786 ];
6787 for candidate in candidates {
6788 if push_source_file(
6789 candidate,
6790 &project_root_canonical,
6791 &mut seen_paths,
6792 &mut files,
6793 &mut scan_queue,
6794 ) {
6795 break;
6796 }
6797 }
6798 }
6799
6800 for cap in PARTIAL_ROUTE_RE.captures_iter(¤t_content) {
6801 let partial = format!("{}.html", &cap[1]);
6802 push_source_file(
6803 pages_dir.join("partials").join(partial),
6804 &project_root_canonical,
6805 &mut seen_paths,
6806 &mut files,
6807 &mut scan_queue,
6808 );
6809 }
6810
6811 for cap in PARTIAL_NAME_RE.captures_iter(¤t_content) {
6812 if let Some(name) = cap.get(1).or_else(|| cap.get(2)) {
6813 push_source_file(
6814 pages_dir.join("partials").join(format!("{}.html", name.as_str())),
6815 &project_root_canonical,
6816 &mut seen_paths,
6817 &mut files,
6818 &mut scan_queue,
6819 );
6820 }
6821 }
6822 }
6823
6824 axum::Json(json!({ "files": files }))
6825}
6826
6827async fn handle_livereload_ws(
6829 ws: WebSocketUpgrade,
6830 State(state): State<AppState>,
6831) -> impl IntoResponse {
6832 ws.on_upgrade(|socket| handle_livereload_socket(socket, state))
6833}
6834
6835async fn handle_livereload_socket(socket: WebSocket, state: AppState) {
6837 let (mut sender, mut receiver) = socket.split();
6838
6839 let mut reload_rx = match state.live_reload_receiver() {
6841 Some(rx) => rx,
6842 None => {
6843 tracing::warn!("Live reload not enabled");
6844 return;
6845 }
6846 };
6847
6848 tracing::debug!("Live reload client connected");
6849
6850 let _ = sender
6852 .send(Message::Text("{\"type\":\"connected\"}".to_string()))
6853 .await;
6854
6855 let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
6857
6858 let recv_task = tokio::spawn(async move {
6860 while let Some(msg) = receiver.next().await {
6861 match msg {
6862 Ok(Message::Close(_)) => break,
6863 Ok(Message::Ping(_)) => {
6864 }
6866 Err(e) => {
6867 tracing::debug!("WebSocket receive error: {}", e);
6868 break;
6869 }
6870 _ => {}
6871 }
6872 }
6873 let _ = stop_tx.send(());
6875 });
6876
6877 loop {
6879 tokio::select! {
6880 result = reload_rx.recv() => {
6881 match result {
6882 Ok(LiveReloadMessage::Reload) => {
6883 if sender.send(Message::Text("{\"type\":\"reload\"}".to_string())).await.is_err() {
6884 break;
6885 }
6886 }
6887 Ok(LiveReloadMessage::CacheCleared) => {
6888 if sender.send(Message::Text("{\"type\":\"cache_cleared\"}".to_string())).await.is_err() {
6889 break;
6890 }
6891 }
6892 Err(broadcast::error::RecvError::Closed) => break,
6893 Err(broadcast::error::RecvError::Lagged(_)) => continue,
6894 }
6895 }
6896 _ = &mut stop_rx => {
6897 break;
6899 }
6900 }
6901 }
6902
6903 recv_task.abort();
6905
6906 tracing::debug!("Live reload client disconnected");
6907}
6908
6909async fn handle_wire_ws(
6911 ws: WebSocketUpgrade,
6912 headers: HeaderMap,
6913 State(state): State<AppState>,
6914) -> impl IntoResponse {
6915 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
6917 let (client_roles, client_user_id) = if state.auth.is_enabled() {
6918 if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
6919 match state.auth.decode_jwt(&token) {
6920 Ok(claims) if !claims.is_expired() => {
6921 let user =
6922 UserContext::from_claims(claims.to_context(state.auth.jwt_claims()));
6923 (user.roles(), claims.sub.clone())
6924 }
6925 _ => (Vec::new(), None),
6926 }
6927 } else {
6928 (Vec::new(), None)
6929 }
6930 } else {
6931 (Vec::new(), None)
6932 };
6933
6934 ws.on_upgrade(move |socket| handle_wire_socket(socket, state, client_roles, client_user_id))
6935}
6936
6937async fn handle_wire_socket(
6939 socket: WebSocket,
6940 state: AppState,
6941 client_roles: Vec<String>,
6942 client_user_id: Option<String>,
6943) {
6944 let (mut sender, mut receiver) = socket.split();
6945 let mut wired_rx = state.wired_tx.subscribe();
6946
6947 let count = state
6949 .wired_client_count
6950 .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6951 + 1;
6952 tracing::debug!(
6953 "Wired client connected (roles: {:?}, user: {:?}, total: {})",
6954 client_roles,
6955 client_user_id,
6956 count
6957 );
6958
6959 let connected_msg = format!(r#"{{"type":"connected","clients":{}}}"#, count);
6961 let _ = sender.send(Message::Text(connected_msg)).await;
6962
6963 let _ = state.wired_tx.send(WiredMessage {
6965 json: format!(r#"{{"wired._clients":"{}"}}"#, count),
6966 scope: WiredScope::Public,
6967 });
6968
6969 let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
6970
6971 let recv_task = tokio::spawn(async move {
6972 while let Some(msg) = receiver.next().await {
6973 match msg {
6974 Ok(Message::Close(_)) => break,
6975 Err(_) => break,
6976 _ => {}
6977 }
6978 }
6979 let _ = stop_tx.send(());
6980 });
6981
6982 loop {
6983 tokio::select! {
6984 result = wired_rx.recv() => {
6985 match result {
6986 Ok(msg) => {
6987 if msg.scope.allows(&client_roles, client_user_id.as_deref()) {
6989 if sender.send(Message::Text(msg.json)).await.is_err() {
6990 break;
6991 }
6992 }
6993 }
6994 Err(broadcast::error::RecvError::Closed) => break,
6995 Err(broadcast::error::RecvError::Lagged(_)) => continue,
6996 }
6997 }
6998 _ = &mut stop_rx => {
6999 break;
7000 }
7001 }
7002 }
7003
7004 recv_task.abort();
7005
7006 let count = state
7008 .wired_client_count
7009 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed)
7010 - 1;
7011 let _ = state.wired_tx.send(WiredMessage {
7012 json: format!(r#"{{"wired._clients":"{}"}}"#, count),
7013 scope: WiredScope::Public,
7014 });
7015 tracing::debug!("Wired client disconnected (total: {})", count);
7016}
7017
7018pub async fn serve(state: AppState) -> Result<()> {
7020 let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
7021 let listener = tokio::net::TcpListener::bind(&addr).await?;
7022
7023 tracing::info!("What server running at http://{}", addr);
7024
7025 let router = create_router(state);
7026 axum::serve(
7028 listener,
7029 router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
7030 )
7031 .await?;
7032
7033 Ok(())
7034}
7035
7036pub async fn serve_with_shutdown(
7039 state: AppState,
7040 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
7041) -> Result<()> {
7042 let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
7043 let listener = tokio::net::TcpListener::bind(&addr).await?;
7044 serve_on_listener(state, listener, shutdown).await
7045}
7046
7047pub async fn serve_on_listener(
7050 state: AppState,
7051 listener: tokio::net::TcpListener,
7052 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
7053) -> Result<()> {
7054 tracing::info!("What server running at http://{}", listener.local_addr()?);
7055
7056 let router = create_router(state);
7057 axum::serve(
7058 listener,
7059 router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
7060 )
7061 .with_graceful_shutdown(shutdown)
7062 .await?;
7063
7064 Ok(())
7065}
7066
7067#[cfg(test)]
7068mod tests {
7069 use super::*;
7070 use std::fs;
7071 use tempfile::TempDir;
7072
7073 #[test]
7074 fn resolve_client_ip_honors_trusted_hops() {
7075 use axum::body::Body;
7076 use axum::extract::ConnectInfo;
7077 let mk = |xff: Option<&str>, peer: Option<&str>| {
7078 let mut req = Request::builder().uri("/").body(Body::empty()).unwrap();
7079 if let Some(x) = xff {
7080 req.headers_mut()
7081 .insert("x-forwarded-for", x.parse().unwrap());
7082 }
7083 if let Some(p) = peer {
7084 req.extensions_mut()
7085 .insert(ConnectInfo(p.parse::<std::net::SocketAddr>().unwrap()));
7086 }
7087 req
7088 };
7089
7090 assert_eq!(
7092 resolve_client_ip(&mk(Some("1.2.3.4"), Some("10.0.0.9:5000")), 0),
7093 "10.0.0.9"
7094 );
7095 assert_eq!(
7097 resolve_client_ip(&mk(Some("1.2.3.4, 5.6.7.8"), Some("10.0.0.9:5000")), 1),
7098 "5.6.7.8"
7099 );
7100 assert_eq!(
7102 resolve_client_ip(&mk(None, Some("10.0.0.9:5000")), 1),
7103 "10.0.0.9"
7104 );
7105 assert_eq!(
7107 resolve_client_ip(&mk(Some("1.1.1.1, 2.2.2.2, 3.3.3.3"), None), 2),
7108 "2.2.2.2"
7109 );
7110 assert_eq!(resolve_client_ip(&mk(None, None), 0), "unknown");
7112 }
7113
7114 fn create_test_project() -> TempDir {
7116 let temp_dir = TempDir::new().unwrap();
7117 let root = temp_dir.path();
7118 let cd = content_dir_name(root);
7119
7120 fs::create_dir_all(root.join(cd).join("admin")).unwrap();
7122 fs::create_dir_all(root.join(cd).join("blog")).unwrap();
7123
7124 fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
7126 fs::write(root.join(cd).join("about.html"), "<h1>About</h1>").unwrap();
7127 fs::write(root.join(cd).join("admin/index.html"), "<h1>Admin</h1>").unwrap();
7128 fs::write(root.join(cd).join("admin/users.html"), "<h1>Users</h1>").unwrap();
7129 fs::write(root.join(cd).join("blog/[id].html"), "<h1>Blog Post</h1>").unwrap();
7130
7131 temp_dir
7132 }
7133
7134 #[test]
7135 fn test_resolve_page_path_exact_match() {
7136 let temp_dir = create_test_project();
7137 let root = temp_dir.path().to_path_buf();
7138
7139 let result = resolve_page_path(&root, "/about");
7141 assert!(result.is_some());
7142 let resolved = result.unwrap();
7143 assert!(resolved.path.ends_with("about.html"));
7144 assert!(resolved.params.is_empty());
7145 }
7146
7147 #[test]
7148 fn test_resolve_page_path_index() {
7149 let temp_dir = create_test_project();
7150 let root = temp_dir.path().to_path_buf();
7151
7152 let result = resolve_page_path(&root, "/");
7154 assert!(result.is_some());
7155 assert!(result.unwrap().path.ends_with("index.html"));
7156
7157 let result = resolve_page_path(&root, "/admin");
7159 assert!(result.is_some());
7160 let resolved = result.unwrap();
7161 assert!(resolved.path.to_string_lossy().contains("admin"));
7162 assert!(resolved.path.ends_with("index.html"));
7163 }
7164
7165 #[test]
7166 fn test_resolve_page_path_nested() {
7167 let temp_dir = create_test_project();
7168 let root = temp_dir.path().to_path_buf();
7169
7170 let result = resolve_page_path(&root, "/admin/users");
7172 assert!(result.is_some());
7173 assert!(result.unwrap().path.ends_with("users.html"));
7174 }
7175
7176 #[test]
7177 fn test_resolve_page_path_dynamic() {
7178 let temp_dir = create_test_project();
7179 let root = temp_dir.path().to_path_buf();
7180
7181 let result = resolve_page_path(&root, "/blog/123");
7183 assert!(result.is_some());
7184 let resolved = result.unwrap();
7185 assert!(resolved.path.ends_with("[id].html"));
7186 assert_eq!(resolved.params.get("id"), Some(&"123".to_string()));
7187 }
7188
7189 #[test]
7190 fn test_resolve_page_path_not_found() {
7191 let temp_dir = create_test_project();
7192 let root = temp_dir.path().to_path_buf();
7193
7194 let result = resolve_page_path(&root, "/nonexistent");
7196 assert!(result.is_none());
7197 }
7198
7199 fn create_test_project_with_config() -> TempDir {
7201 let temp_dir = TempDir::new().unwrap();
7202 let root = temp_dir.path();
7203 let cd = content_dir_name(root);
7204
7205 fs::create_dir_all(root.join(cd).join("admin")).unwrap();
7207 fs::create_dir_all(root.join(cd).join("admin/settings")).unwrap();
7208
7209 fs::write(
7211 root.join(cd).join("application.what"),
7212 r#"
7213title = "My App"
7214theme = "light"
7215auth = "all"
7216"#,
7217 )
7218 .unwrap();
7219
7220 fs::write(
7222 root.join(cd).join("admin/application.what"),
7223 r#"
7224title = "Admin Panel"
7225auth = "admin"
7226"#,
7227 )
7228 .unwrap();
7229
7230 fs::write(
7232 root.join(cd).join("admin/settings/application.what"),
7233 r#"
7234title = "Settings"
7235debug = true
7236"#,
7237 )
7238 .unwrap();
7239
7240 fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
7242 fs::write(root.join(cd).join("admin/index.html"), "<h1>Admin</h1>").unwrap();
7243 fs::write(
7244 root.join(cd).join("admin/settings/index.html"),
7245 "<h1>Settings</h1>",
7246 )
7247 .unwrap();
7248
7249 temp_dir
7250 }
7251
7252 #[test]
7253 fn test_load_application_config_root() {
7254 let temp_dir = create_test_project_with_config();
7255 let root = temp_dir.path().to_path_buf();
7256
7257 let config = load_application_config(&root, "/");
7259
7260 assert_eq!(config.get_string("title"), Some("My App"));
7261 assert_eq!(config.get_string("theme"), Some("light"));
7262 assert_eq!(config.directives.auth, crate::parser::AuthLevel::All);
7263 }
7264
7265 #[test]
7266 fn test_load_application_config_inheritance() {
7267 let temp_dir = create_test_project_with_config();
7268 let root = temp_dir.path().to_path_buf();
7269
7270 let config = load_application_config(&root, "/admin");
7272
7273 assert_eq!(config.get_string("title"), Some("Admin Panel"));
7274 assert_eq!(config.get_string("theme"), Some("light")); assert_eq!(
7276 config.directives.auth,
7277 crate::parser::AuthLevel::Roles(vec!["admin".to_string()])
7278 );
7279 }
7280
7281 #[test]
7282 fn test_load_application_config_deep_inheritance() {
7283 let temp_dir = create_test_project_with_config();
7284 let root = temp_dir.path().to_path_buf();
7285
7286 let config = load_application_config(&root, "/admin/settings");
7288
7289 assert_eq!(config.get_string("title"), Some("Settings"));
7290 assert_eq!(config.get_string("theme"), Some("light")); assert_eq!(config.get_bool("debug"), Some(true)); assert_eq!(
7294 config.directives.auth,
7295 crate::parser::AuthLevel::Roles(vec!["admin".to_string()])
7296 );
7297 }
7298
7299 #[test]
7300 fn test_load_application_config_no_config() {
7301 let temp_dir = create_test_project();
7302 let root = temp_dir.path().to_path_buf();
7303
7304 let config = load_application_config(&root, "/about");
7306
7307 assert!(config.values.is_empty());
7309 assert_eq!(config.directives.auth, crate::parser::AuthLevel::All);
7310 }
7311
7312 #[test]
7313 fn test_content_dir_name_prefers_site() {
7314 let temp = TempDir::new().unwrap();
7315 let root = temp.path();
7316
7317 assert_eq!(content_dir_name(root), "site");
7319
7320 fs::create_dir_all(root.join("pages")).unwrap();
7322 assert_eq!(content_dir_name(root), "pages");
7323
7324 fs::create_dir_all(root.join("site")).unwrap();
7326 assert_eq!(content_dir_name(root), "site");
7327 }
7328
7329 #[test]
7330 fn test_content_dir_name_only_site() {
7331 let temp = TempDir::new().unwrap();
7332 let root = temp.path();
7333 fs::create_dir_all(root.join("site")).unwrap();
7334 assert_eq!(content_dir_name(root), "site");
7335 }
7336
7337 #[test]
7338 fn test_app_state_dev_mode_disabled() {
7339 let temp_dir = create_test_project();
7340 let root = temp_dir.path().to_path_buf();
7341 let config = crate::Config::default();
7342
7343 let state = AppState::new(config, root).unwrap();
7344
7345 assert!(!state.dev_mode);
7346 assert!(state.live_reload_tx.is_none());
7347 assert!(state.live_reload_receiver().is_none());
7348 }
7349
7350 #[test]
7351 fn test_app_state_dev_mode_enabled() {
7352 let temp_dir = create_test_project();
7353 let root = temp_dir.path().to_path_buf();
7354 let config = crate::Config::default();
7355
7356 let state = AppState::with_dev_mode(config, root, true).unwrap();
7357
7358 assert!(state.dev_mode);
7359 assert!(state.live_reload_tx.is_some());
7360 assert!(state.live_reload_receiver().is_some());
7361 }
7362
7363 #[test]
7364 fn test_dev_mode_disables_secure_cookies() {
7365 let temp_dir = create_test_project();
7366 let root = temp_dir.path().to_path_buf();
7367 let config = crate::Config::default();
7368 assert!(config.session.secure);
7370
7371 let state = AppState::with_dev_mode(config, root, true).unwrap();
7373 assert!(!state.config.session.secure);
7374 }
7375
7376 #[test]
7377 fn test_production_mode_keeps_secure_cookies() {
7378 let temp_dir = create_test_project();
7379 let root = temp_dir.path().to_path_buf();
7380 let config = crate::Config::default();
7381
7382 let state = AppState::new(config, root).unwrap();
7384 assert!(state.config.session.secure);
7385 }
7386
7387 #[test]
7388 fn test_live_reload_broadcast() {
7389 let temp_dir = create_test_project();
7390 let root = temp_dir.path().to_path_buf();
7391 let config = crate::Config::default();
7392
7393 let state = AppState::with_dev_mode(config, root, true).unwrap();
7394
7395 let mut rx = state.live_reload_receiver().unwrap();
7397
7398 if let Some(ref tx) = state.live_reload_tx {
7400 tx.send(LiveReloadMessage::Reload).unwrap();
7401 }
7402
7403 let msg = rx.try_recv().unwrap();
7405 assert!(matches!(msg, LiveReloadMessage::Reload));
7406 }
7407
7408 #[test]
7409 fn test_live_reload_multiple_subscribers() {
7410 let temp_dir = create_test_project();
7411 let root = temp_dir.path().to_path_buf();
7412 let config = crate::Config::default();
7413
7414 let state = AppState::with_dev_mode(config, root, true).unwrap();
7415
7416 let mut rx1 = state.live_reload_receiver().unwrap();
7418 let mut rx2 = state.live_reload_receiver().unwrap();
7419
7420 if let Some(ref tx) = state.live_reload_tx {
7422 tx.send(LiveReloadMessage::Reload).unwrap();
7423 }
7424
7425 assert!(matches!(rx1.try_recv().unwrap(), LiveReloadMessage::Reload));
7427 assert!(matches!(rx2.try_recv().unwrap(), LiveReloadMessage::Reload));
7428 }
7429
7430 #[test]
7435 fn test_collect_fetch_directives_from_page() {
7436 let mut directives = crate::parser::PageDirectives::default();
7437 directives.custom.insert(
7438 "fetch.dog_facts".to_string(),
7439 "https://dogapi.dog/api/v2/facts?limit=3".to_string(),
7440 );
7441 directives.custom.insert(
7442 "fetch.dog_breeds".to_string(),
7443 "https://dogapi.dog/api/v2/breeds".to_string(),
7444 );
7445
7446 let fetches = collect_fetch_directives(None, Some(&directives));
7447
7448 assert_eq!(fetches.len(), 2);
7449 assert_eq!(
7450 fetches.get("dog_facts").map(|f| f.url.as_str()),
7451 Some("https://dogapi.dog/api/v2/facts?limit=3")
7452 );
7453 assert_eq!(
7454 fetches.get("dog_breeds").map(|f| f.url.as_str()),
7455 Some("https://dogapi.dog/api/v2/breeds")
7456 );
7457 }
7458
7459 #[test]
7460 fn test_collect_fetch_directives_excludes_non_fetch() {
7461 let mut directives = crate::parser::PageDirectives::default();
7462 directives
7463 .custom
7464 .insert("page".to_string(), "remote-data".to_string());
7465 directives.custom.insert(
7466 "fetch.api".to_string(),
7467 "https://example.com/api".to_string(),
7468 );
7469
7470 let fetches = collect_fetch_directives(None, Some(&directives));
7471
7472 assert_eq!(fetches.len(), 1);
7473 assert!(fetches.contains_key("api"));
7474 assert!(!fetches.contains_key("page"));
7475 }
7476
7477 #[test]
7478 fn test_collect_fetch_directives_empty() {
7479 let directives = crate::parser::PageDirectives::default();
7480 let fetches = collect_fetch_directives(None, Some(&directives));
7481 assert!(fetches.is_empty());
7482 }
7483
7484 #[test]
7485 fn test_collect_fetch_directives_enhanced() {
7486 let mut directives = crate::parser::PageDirectives::default();
7487 directives.custom.insert(
7488 "fetch.users.url".to_string(),
7489 "https://api.example.com/users".to_string(),
7490 );
7491 directives
7492 .custom
7493 .insert("fetch.users.method".to_string(), "POST".to_string());
7494 directives.custom.insert(
7495 "fetch.users.headers".to_string(),
7496 "Authorization: Bearer token".to_string(),
7497 );
7498 directives
7499 .custom
7500 .insert("fetch.users.path".to_string(), "data.results".to_string());
7501
7502 let fetches = collect_fetch_directives(None, Some(&directives));
7503 assert_eq!(fetches.len(), 1);
7504 let users = fetches.get("users").unwrap();
7505 assert_eq!(users.url, "https://api.example.com/users");
7506 assert_eq!(users.method, "POST");
7507 assert_eq!(users.headers.len(), 1);
7508 assert_eq!(
7509 users.headers[0],
7510 ("Authorization".to_string(), "Bearer token".to_string())
7511 );
7512 assert_eq!(users.path.as_deref(), Some("data.results"));
7513 }
7514
7515 #[test]
7516 fn test_extract_json_path() {
7517 let value = serde_json::json!({
7518 "data": {
7519 "results": [1, 2, 3],
7520 "meta": { "total": 100 }
7521 }
7522 });
7523 assert_eq!(
7524 extract_json_path(&value, "data.results"),
7525 serde_json::json!([1, 2, 3])
7526 );
7527 assert_eq!(
7528 extract_json_path(&value, "data.meta.total"),
7529 serde_json::json!(100)
7530 );
7531 assert_eq!(
7532 extract_json_path(&value, "data.missing"),
7533 serde_json::Value::Null
7534 );
7535 }
7536
7537 #[test]
7538 fn test_parse_header_string() {
7539 let headers = parse_header_string("Authorization: Bearer token, Accept: application/json");
7540 assert_eq!(headers.len(), 2);
7541 assert_eq!(
7542 headers[0],
7543 ("Authorization".to_string(), "Bearer token".to_string())
7544 );
7545 assert_eq!(
7546 headers[1],
7547 ("Accept".to_string(), "application/json".to_string())
7548 );
7549 }
7550
7551 #[test]
7556 fn test_parse_w_set_increment() {
7557 let result = parse_w_set_expr("session.counter += 1");
7558 assert!(result.is_some());
7559 let (scope, key, op) = result.unwrap();
7560 assert_eq!(scope, "session");
7561 assert_eq!(key, "counter");
7562 assert!(matches!(op, SetOp::Increment(1)));
7563 }
7564
7565 #[test]
7566 fn test_parse_w_set_decrement() {
7567 let result = parse_w_set_expr("session.counter -= 1");
7568 assert!(result.is_some());
7569 let (scope, key, op) = result.unwrap();
7570 assert_eq!(scope, "session");
7571 assert_eq!(key, "counter");
7572 assert!(matches!(op, SetOp::Decrement(1)));
7573 }
7574
7575 #[test]
7576 fn test_parse_w_set_assign_int() {
7577 let result = parse_w_set_expr("app.app_counter = 0");
7578 assert!(result.is_some());
7579 let (scope, key, op) = result.unwrap();
7580 assert_eq!(scope, "app");
7581 assert_eq!(key, "app_counter");
7582 assert!(matches!(op, SetOp::SetInt(0)));
7583 }
7584
7585 #[test]
7586 fn test_parse_w_set_assign_string() {
7587 let result = parse_w_set_expr("session.name = 'Jorge'");
7588 assert!(result.is_some());
7589 let (scope, key, op) = result.unwrap();
7590 assert_eq!(scope, "session");
7591 assert_eq!(key, "name");
7592 match op {
7593 SetOp::SetStr(s) => assert_eq!(s, "Jorge"),
7594 _ => panic!("Expected SetStr"),
7595 }
7596 }
7597
7598 #[test]
7599 fn test_parse_w_set_large_increment() {
7600 let result = parse_w_set_expr("app.views += 100");
7601 assert!(result.is_some());
7602 let (scope, key, op) = result.unwrap();
7603 assert_eq!(scope, "app");
7604 assert_eq!(key, "views");
7605 assert!(matches!(op, SetOp::Increment(100)));
7606 }
7607
7608 #[test]
7609 fn test_parse_w_set_whitespace() {
7610 let result = parse_w_set_expr(" session.counter += 5 ");
7611 assert!(result.is_some());
7612 let (scope, key, op) = result.unwrap();
7613 assert_eq!(scope, "session");
7614 assert_eq!(key, "counter");
7615 assert!(matches!(op, SetOp::Increment(5)));
7616 }
7617
7618 #[test]
7619 fn test_parse_w_set_invalid_no_scope() {
7620 assert!(parse_w_set_expr("counter += 1").is_none());
7621 }
7622
7623 #[test]
7624 fn test_parse_w_set_invalid_bad_value() {
7625 assert!(parse_w_set_expr("session.counter += abc").is_none());
7626 }
7627
7628 #[test]
7629 fn test_parse_w_set_invalid_empty() {
7630 assert!(parse_w_set_expr("").is_none());
7631 }
7632
7633 #[test]
7634 fn test_parse_w_set_multi_expression() {
7635 let expr_str = "session.counter += 1; app.views += 1";
7636 let parsed: Vec<_> = expr_str
7637 .split(';')
7638 .map(|s| s.trim())
7639 .filter(|s| !s.is_empty())
7640 .filter_map(|s| parse_w_set_expr(s))
7641 .collect();
7642 assert_eq!(parsed.len(), 2);
7643 assert_eq!(parsed[0].0, "session");
7644 assert_eq!(parsed[0].1, "counter");
7645 assert!(matches!(parsed[0].2, SetOp::Increment(1)));
7646 assert_eq!(parsed[1].0, "app");
7647 assert_eq!(parsed[1].1, "views");
7648 assert!(matches!(parsed[1].2, SetOp::Increment(1)));
7649 }
7650
7651 #[test]
7652 fn test_apply_set_op_increment_from_none() {
7653 let result = apply_set_op(None, &SetOp::Increment(1));
7654 assert_eq!(result, json!(1));
7655 }
7656
7657 #[test]
7658 fn test_apply_set_op_increment_existing() {
7659 let current = json!(5);
7660 let result = apply_set_op(Some(¤t), &SetOp::Increment(3));
7661 assert_eq!(result, json!(8));
7662 }
7663
7664 #[test]
7665 fn test_apply_set_op_set_string() {
7666 let result = apply_set_op(None, &SetOp::SetStr("hello".to_string()));
7667 assert_eq!(result, json!("hello"));
7668 }
7669
7670 #[test]
7671 fn test_parse_w_set_wired_scope() {
7672 let result = parse_w_set_expr("wired.total_dogs += 1");
7673 assert!(result.is_some());
7674 let (scope, key, op) = result.unwrap();
7675 assert_eq!(scope, "wired");
7676 assert_eq!(key, "total_dogs");
7677 assert!(matches!(op, SetOp::Increment(1)));
7678 }
7679
7680 #[test]
7681 fn test_parse_w_set_wired_reset() {
7682 let result = parse_w_set_expr("wired.counter = 0");
7683 assert!(result.is_some());
7684 let (scope, key, op) = result.unwrap();
7685 assert_eq!(scope, "wired");
7686 assert_eq!(key, "counter");
7687 assert!(matches!(op, SetOp::SetInt(0)));
7688 }
7689
7690 #[test]
7691 fn test_wired_broadcast_channel() {
7692 let temp_dir = create_test_project();
7693 let root = temp_dir.path().to_path_buf();
7694 let config = crate::Config::default();
7695 let state = AppState::new(config, root).unwrap();
7696
7697 let mut rx = state.wired_tx.subscribe();
7698 let _ = state.wired_tx.send(WiredMessage {
7699 json: r#"{"wired.counter":"5"}"#.to_string(),
7700 scope: WiredScope::Public,
7701 });
7702 let msg = rx.try_recv().unwrap();
7703 assert!(msg.json.contains("wired.counter"));
7704 assert!(msg.json.contains("5"));
7705 }
7706
7707 #[test]
7708 fn test_error_page_dev_mode_shows_detail() {
7709 let page = error_page_fallback(
7710 true,
7711 StatusCode::INTERNAL_SERVER_ERROR,
7712 "template parse failed at line 42",
7713 );
7714 assert!(page.contains("template parse failed at line 42"));
7715 assert!(page.contains("500"));
7716 }
7717
7718 #[test]
7719 fn test_error_page_production_hides_detail() {
7720 let page = error_page_fallback(
7721 false,
7722 StatusCode::INTERNAL_SERVER_ERROR,
7723 "template parse failed at line 42",
7724 );
7725 assert!(!page.contains("template parse failed"));
7726 assert!(!page.contains("line 42"));
7727 assert!(page.contains("Something went wrong"));
7728 }
7729
7730 #[test]
7731 fn test_error_page_404_production() {
7732 let page = error_page_fallback(false, StatusCode::NOT_FOUND, "/secret/path/file.html");
7733 assert!(!page.contains("/secret/path"));
7734 assert!(page.contains("Page Not Found"));
7735 }
7736
7737 #[test]
7738 fn test_error_page_403_production() {
7739 let page = error_page_fallback(false, StatusCode::FORBIDDEN, "user lacks admin role");
7740 assert!(!page.contains("admin role"));
7741 assert!(page.contains("Forbidden"));
7742 }
7743
7744 #[test]
7747 fn test_inject_csrf_into_post_form() {
7748 let html = r##"<html><head></head><body><form method="post" action="/submit"><input name="name"></form></body></html>"##;
7749 let result = inject_csrf_tokens(html, "test_token_abc");
7750 assert!(result.contains(r#"<input type="hidden" name="_csrf" value="test_token_abc">"#));
7751 assert!(result.contains(r#"<meta name="csrf-token" content="test_token_abc">"#));
7752 }
7753
7754 #[test]
7755 fn test_inject_csrf_skips_get_form() {
7756 let html = r##"<html><head></head><body><form method="get" action="/search"><input name="q"></form></body></html>"##;
7757 let result = inject_csrf_tokens(html, "test_token");
7758 assert!(!result.contains(r#"name="_csrf""#));
7760 assert!(result.contains(r#"<meta name="csrf-token" content="test_token">"#));
7762 }
7763
7764 #[test]
7765 fn test_inject_csrf_multiple_forms() {
7766 let html = r##"<html><head></head><body><form method="POST"><input></form><form method="post"><input></form></body></html>"##;
7767 let result = inject_csrf_tokens(html, "tok123");
7768 let count = result.matches(r#"name="_csrf""#).count();
7770 assert_eq!(count, 2, "Should inject into both POST forms");
7771 }
7772
7773 #[test]
7774 fn test_inject_what_js_before_body() {
7775 let html = "<html><head></head><body><p>Hello</p></body></html>";
7776 let result = inject_what_js(html);
7777 assert!(result.contains(WHAT_JS_ASSET_PATH.as_str()));
7778 assert!(result.contains("</body>"));
7779 }
7780
7781 #[test]
7782 fn test_inject_what_js_skips_if_present() {
7783 let html =
7784 r#"<html><head></head><body><script src="/static/what.js"></script></body></html>"#;
7785 let result = inject_what_js(html);
7786 assert_eq!(result.matches("what.js").count(), 1, "Should not duplicate");
7787 }
7788
7789 #[test]
7790 fn test_inject_what_js_no_body() {
7791 let html = "<p>just a fragment</p>";
7792 let result = inject_what_js(html);
7793 assert!(!result.contains("what.js"), "Should skip without </body>");
7794 assert_eq!(result, html);
7795 }
7796
7797 #[test]
7798 fn test_inject_theme_restore_after_head() {
7799 let html = "<html><head><title>T</title></head><body></body></html>";
7800 let result = inject_theme_restore(html);
7801 assert!(result.contains("data-w-theme"));
7802 let head_pos = result.find("<head>").unwrap();
7804 let script_pos = result.find("<script data-w-theme>").unwrap();
7805 let title_pos = result.find("<title>").unwrap();
7806 assert!(script_pos > head_pos && script_pos < title_pos);
7807 }
7808
7809 #[test]
7810 fn test_inject_theme_restore_idempotent() {
7811 let html = "<html><head></head><body></body></html>";
7812 let once = inject_theme_restore(html);
7813 let twice = inject_theme_restore(&once);
7814 assert_eq!(once, twice, "Should not duplicate");
7815 assert_eq!(twice.matches("data-w-theme").count(), 1);
7816 }
7817
7818 #[test]
7819 fn test_inject_theme_restore_head_with_attrs_and_fragment() {
7820 let html = r#"<html><head lang="en"><title>T</title></head><body></body></html>"#;
7821 let result = inject_theme_restore(html);
7822 assert!(result.contains("data-w-theme"));
7823
7824 let fragment = "<p>partial</p>";
7826 assert_eq!(inject_theme_restore(fragment), fragment);
7827 }
7828
7829 #[test]
7830 fn test_inject_what_css_into_head() {
7831 let html = "<html><head><title>Test</title></head><body></body></html>";
7832 let result = inject_what_css(html, CssMode::Full);
7833 assert!(result.contains(WHAT_CSS_ASSET_PATH.as_str()));
7834 assert!(result.contains("<head>\n"));
7835 }
7836
7837 #[test]
7838 fn test_inject_what_css_skips_if_present() {
7839 let html = r#"<html><head><link rel="stylesheet" href="/static/what.css"></head><body></body></html>"#;
7840 let result = inject_what_css(html, CssMode::Full);
7841 assert_eq!(
7842 result.matches("what.css").count(),
7843 1,
7844 "Should not duplicate"
7845 );
7846 }
7847
7848 #[test]
7849 fn test_inject_what_css_no_head() {
7850 let html = "<p>just a fragment</p>";
7851 let result = inject_what_css(html, CssMode::Full);
7852 assert!(!result.contains("what.css"), "Should skip without <head>");
7853 assert_eq!(result, html);
7854 }
7855
7856 #[test]
7857 fn test_inject_what_css_none_mode_skips() {
7858 let html = "<html><head><title>Test</title></head><body></body></html>";
7859 let result = inject_what_css(html, CssMode::None);
7860 assert_eq!(result, html, "none mode must not inject anything");
7861 }
7862
7863 #[test]
7864 fn test_inject_what_css_minimal_mode_uses_minimal_path() {
7865 let html = "<html><head><title>Test</title></head><body></body></html>";
7866 let result = inject_what_css(html, CssMode::Minimal);
7867 assert!(result.contains(WHAT_CSS_MINIMAL_ASSET_PATH.as_str()));
7868 assert_ne!(
7869 WHAT_CSS_MINIMAL_ASSET_PATH.as_str(),
7870 WHAT_CSS_ASSET_PATH.as_str(),
7871 "minimal and full variants must cache-bust independently"
7872 );
7873 }
7874
7875 #[test]
7876 fn test_minimal_css_slice() {
7877 let minimal = MINIMAL_WHAT_CSS.as_str();
7878 assert!(EMBEDDED_WHAT_CSS.contains(MINIMAL_CUT_START), "cut-start marker missing from what.css");
7881 assert!(EMBEDDED_WHAT_CSS.contains(MINIMAL_CUT_END), "cut-end marker missing from what.css");
7882 assert!(minimal.len() < EMBEDDED_WHAT_CSS.len());
7883 assert!(minimal.contains("@layer base"));
7885 assert!(minimal.contains("@layer utilities"));
7886 assert!(minimal.contains(".page-source"));
7887 assert!(!minimal.contains("@layer components"));
7889 assert!(!minimal.contains("@layer interactions {"));
7890 assert!(!minimal.contains(".what-pagination"));
7891 }
7892
7893 #[test]
7894 fn test_css_mode_from_config() {
7895 assert_eq!(CssMode::from_config("full").unwrap(), CssMode::Full);
7896 assert_eq!(CssMode::from_config("minimal").unwrap(), CssMode::Minimal);
7897 assert_eq!(CssMode::from_config("none").unwrap(), CssMode::None);
7898 let err = CssMode::from_config("compact").unwrap_err().to_string();
7899 assert!(err.contains("compact"), "error should echo the bad value: {err}");
7900 }
7901
7902 #[test]
7903 fn test_validate_csrf_token_valid() {
7904 let mut session = sessions::Session::new(3600);
7905 session
7906 .data
7907 .insert(CSRF_SESSION_KEY.to_string(), json!("correct_token"));
7908 assert!(validate_csrf_token(
7909 Some(&session),
7910 Some("correct_token"),
7911 None
7912 ));
7913 }
7914
7915 #[test]
7916 fn test_validate_csrf_token_invalid() {
7917 let mut session = sessions::Session::new(3600);
7918 session
7919 .data
7920 .insert(CSRF_SESSION_KEY.to_string(), json!("correct_token"));
7921 assert!(!validate_csrf_token(
7922 Some(&session),
7923 Some("wrong_token"),
7924 None
7925 ));
7926 }
7927
7928 #[test]
7929 fn test_validate_csrf_token_from_header() {
7930 let mut session = sessions::Session::new(3600);
7931 session
7932 .data
7933 .insert(CSRF_SESSION_KEY.to_string(), json!("header_token"));
7934 assert!(validate_csrf_token(
7935 Some(&session),
7936 None,
7937 Some("header_token")
7938 ));
7939 }
7940
7941 #[test]
7942 fn test_validate_csrf_token_no_session() {
7943 assert!(!validate_csrf_token(None, Some("any_token"), None));
7944 }
7945
7946 #[test]
7947 fn test_validate_csrf_token_missing_token() {
7948 let mut session = sessions::Session::new(3600);
7949 session
7950 .data
7951 .insert(CSRF_SESSION_KEY.to_string(), json!("token"));
7952 assert!(!validate_csrf_token(Some(&session), None, None));
7953 }
7954
7955 #[test]
7956 fn test_csrf_exempt_paths() {
7957 assert!(is_csrf_exempt("/w-livereload"));
7958 assert!(is_csrf_exempt("/w-wire"));
7959 assert!(!is_csrf_exempt("/w-set"));
7960 assert!(!is_csrf_exempt("/w-action/items"));
7961 assert!(!is_csrf_exempt("/w-auth/login"));
7962 }
7963
7964 #[test]
7965 fn test_session_new_has_csrf_token() {
7966 let session = sessions::Session::new(3600);
7967 let csrf_token = session.data.get(CSRF_SESSION_KEY);
7968 assert!(csrf_token.is_some(), "New session should have a CSRF token");
7969 let token_str = csrf_token.unwrap().as_str().unwrap();
7970 assert_eq!(
7971 token_str.len(),
7972 64,
7973 "CSRF token should be 64 hex chars (32 bytes)"
7974 );
7975 }
7976
7977 #[test]
7980 fn test_sanitize_extension_normal() {
7981 assert_eq!(sanitize_extension("jpg"), ".jpg");
7982 assert_eq!(sanitize_extension("png"), ".png");
7983 assert_eq!(sanitize_extension("PDF"), ".PDF");
7984 }
7985
7986 #[test]
7987 fn test_sanitize_extension_strips_path_separators() {
7988 let result = sanitize_extension("../../../etc/passwd");
7990 assert!(!result.contains('/'));
7991 assert!(!result.contains('\\'));
7992 assert!(result.ends_with("etcpasswd"));
7993
7994 let result = sanitize_extension("..\\..\\windows");
7995 assert!(!result.contains('\\'));
7996 }
7997
7998 #[test]
7999 fn test_sanitize_extension_strips_null_bytes() {
8000 assert_eq!(sanitize_extension("jpg\0exe"), ".jpgexe");
8001 }
8002
8003 #[test]
8004 fn test_sanitize_extension_strips_non_ascii() {
8005 assert_eq!(sanitize_extension("jp\u{00e9}g"), ".jpg");
8006 }
8007
8008 #[test]
8009 fn test_sanitize_extension_empty() {
8010 assert_eq!(sanitize_extension(""), "");
8011 assert_eq!(sanitize_extension("///"), "");
8012 }
8013
8014 #[test]
8015 fn test_collect_fetch_local_directive() {
8016 let mut directives = crate::parser::PageDirectives::default();
8017 directives
8018 .custom
8019 .insert("fetch.posts".to_string(), "local:posts".to_string());
8020 directives.custom.insert(
8021 "fetch.posts.sort".to_string(),
8022 "created_at:desc".to_string(),
8023 );
8024 directives.custom.insert(
8025 "fetch.posts.filter".to_string(),
8026 "status=published".to_string(),
8027 );
8028 directives
8029 .custom
8030 .insert("fetch.posts.limit".to_string(), "10".to_string());
8031 directives
8032 .custom
8033 .insert("fetch.posts.offset".to_string(), "20".to_string());
8034 directives
8035 .custom
8036 .insert("fetch.posts.search".to_string(), "rust".to_string());
8037 directives.custom.insert(
8038 "fetch.posts.search_fields".to_string(),
8039 "title,content".to_string(),
8040 );
8041
8042 let fetches = collect_fetch_directives(None, Some(&directives));
8043 assert_eq!(fetches.len(), 1);
8044 let posts = fetches.get("posts").unwrap();
8045 assert!(posts.is_local());
8046 assert_eq!(posts.local_collection(), Some("posts"));
8047 assert_eq!(posts.sort.as_deref(), Some("created_at:desc"));
8048 assert_eq!(posts.filter.as_deref(), Some("status=published"));
8049 assert_eq!(posts.limit, Some(10));
8050 assert_eq!(posts.offset, Some(20));
8051 assert_eq!(posts.search.as_deref(), Some("rust"));
8052 assert_eq!(posts.search_fields.as_deref(), Some("title,content"));
8053 }
8054
8055 #[test]
8056 fn test_local_collection_strips_query_string() {
8057 let mut directives = crate::parser::PageDirectives::default();
8060 directives.custom.insert(
8061 "fetch.posts".to_string(),
8062 "local:posts?sort=created_at:desc&limit=5&filter=status=published".to_string(),
8063 );
8064 let fetches = collect_fetch_directives(None, Some(&directives));
8065 let posts = fetches.get("posts").unwrap();
8066 assert!(posts.is_local());
8067 assert_eq!(posts.local_collection(), Some("posts"));
8068 assert_eq!(
8069 posts.local_query(),
8070 Some("sort=created_at:desc&limit=5&filter=status=published")
8071 );
8072
8073 let mut d2 = crate::parser::PageDirectives::default();
8075 d2.custom
8076 .insert("fetch.x".to_string(), "local:items".to_string());
8077 let f2 = collect_fetch_directives(None, Some(&d2));
8078 assert_eq!(f2.get("x").unwrap().local_collection(), Some("items"));
8079 assert_eq!(f2.get("x").unwrap().local_query(), None);
8080 }
8081
8082 #[test]
8083 fn test_is_framework_field() {
8084 assert!(is_framework_field("_csrf"));
8086 assert!(is_framework_field("w-rules"));
8087 assert!(is_framework_field("w-partial"));
8088 assert!(is_framework_field("redirect"));
8089 assert!(is_framework_field("cf-turnstile-response"));
8090 assert!(!is_framework_field("_session_id"));
8092 assert!(!is_framework_field("message"));
8093 assert!(!is_framework_field("name"));
8094 }
8095
8096 #[test]
8097 fn test_fetch_local_not_remote() {
8098 let d = FetchDirective::simple("posts".to_string(), "local:posts".to_string());
8099 assert!(d.is_local());
8100 assert_eq!(d.local_collection(), Some("posts"));
8101
8102 let d2 = FetchDirective::simple(
8103 "api".to_string(),
8104 "https://api.example.com/data".to_string(),
8105 );
8106 assert!(!d2.is_local());
8107 assert_eq!(d2.local_collection(), None);
8108 }
8109
8110 #[test]
8113 fn test_redirects_config_parsing() {
8114 let toml_str = r##"
8115[redirects]
8116"/old-blog" = "/blog"
8117"/legacy/*" = "/modern"
8118"/about-us" = "/about"
8119"##;
8120 let config: crate::Config = toml::from_str(toml_str).unwrap();
8121 assert_eq!(config.redirects.len(), 3);
8122 assert_eq!(config.redirects.get("/old-blog").unwrap(), "/blog");
8123 assert_eq!(config.redirects.get("/legacy/*").unwrap(), "/modern");
8124 assert_eq!(config.redirects.get("/about-us").unwrap(), "/about");
8125 }
8126
8127 #[test]
8128 fn test_redirects_config_empty_by_default() {
8129 let config = crate::Config::default();
8130 assert!(config.redirects.is_empty());
8131 }
8132
8133 #[test]
8134 fn test_custom_headers_in_application_what() {
8135 let content = r#"header.Access-Control-Allow-Origin = "*"
8136header.Cache-Control = "no-store"
8137header.X-Custom = "hello"
8138"#;
8139 let config = crate::parser::parse_what_file(content);
8140 assert_eq!(config.directives.headers.len(), 3);
8141 assert_eq!(
8142 config
8143 .directives
8144 .headers
8145 .get("access-control-allow-origin")
8146 .unwrap(),
8147 "*"
8148 );
8149 assert_eq!(
8150 config.directives.headers.get("cache-control").unwrap(),
8151 "no-store"
8152 );
8153 assert_eq!(config.directives.headers.get("x-custom").unwrap(), "hello");
8154 }
8155
8156 #[test]
8157 fn test_custom_headers_merge_in_config() {
8158 let parent_content = r#"header.X-Frame-Options = "DENY"
8159header.X-Custom = "parent"
8160"#;
8161 let child_content = r#"header.X-Custom = "child"
8162header.X-New = "added"
8163"#;
8164 let mut parent = crate::parser::parse_what_file(parent_content);
8165 let child = crate::parser::parse_what_file(child_content);
8166 parent.merge(&child);
8167
8168 assert_eq!(parent.directives.headers.get("x-custom").unwrap(), "child");
8170 assert_eq!(
8172 parent.directives.headers.get("x-frame-options").unwrap(),
8173 "DENY"
8174 );
8175 assert_eq!(parent.directives.headers.get("x-new").unwrap(), "added");
8177 }
8178
8179 #[test]
8180 fn test_custom_headers_in_page_directive_content() {
8181 let html = r##"<what>
8182header.Cache-Control: no-cache
8183header.X-Robots-Tag: noindex
8184</what>
8185<h1>Hello</h1>"##;
8186 let (directives, _cleaned) = crate::parser::parse_page_directives(html);
8187 assert_eq!(directives.headers.get("cache-control").unwrap(), "no-cache");
8188 assert_eq!(directives.headers.get("x-robots-tag").unwrap(), "noindex");
8189 }
8190
8191 #[test]
8192 fn test_custom_headers_not_in_template_context() {
8193 let content = r#"title = "My Page"
8194header.X-Custom = "value"
8195"#;
8196 let config = crate::parser::parse_what_file(content);
8197 assert!(config.values.contains_key("title"));
8199 assert!(!config.values.contains_key("header.x-custom"));
8200 }
8201
8202 #[tokio::test]
8203 async fn test_redirect_middleware_exact_match() {
8204 use axum::body::Body;
8205 use axum::http::Request;
8206 use std::collections::HashMap;
8207 use tower::ServiceExt;
8208
8209 let mut redirects = HashMap::new();
8210 redirects.insert("/old".to_string(), "/new".to_string());
8211
8212 let mut config = crate::Config::default();
8213 config.redirects = redirects;
8214 config.session.enabled = false;
8215
8216 let temp_dir = create_test_project();
8217 let root = temp_dir.path().to_path_buf();
8218 let state = AppState::with_dev_mode(config, root, true).unwrap();
8219
8220 let app = create_router(state);
8221
8222 let request = Request::builder().uri("/old").body(Body::empty()).unwrap();
8223
8224 let response = app.oneshot(request).await.unwrap();
8225 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
8226 assert_eq!(
8227 response
8228 .headers()
8229 .get("location")
8230 .unwrap()
8231 .to_str()
8232 .unwrap(),
8233 "/new"
8234 );
8235 }
8236
8237 #[tokio::test]
8238 async fn test_redirect_middleware_wildcard() {
8239 use axum::body::Body;
8240 use axum::http::Request;
8241 use std::collections::HashMap;
8242 use tower::ServiceExt;
8243
8244 let mut redirects = HashMap::new();
8245 redirects.insert("/legacy/*".to_string(), "/modern".to_string());
8246
8247 let mut config = crate::Config::default();
8248 config.redirects = redirects;
8249 config.session.enabled = false;
8250
8251 let temp_dir = create_test_project();
8252 let root = temp_dir.path().to_path_buf();
8253 let state = AppState::with_dev_mode(config, root, true).unwrap();
8254
8255 let app = create_router(state);
8256
8257 let request = Request::builder()
8258 .uri("/legacy/old-page")
8259 .body(Body::empty())
8260 .unwrap();
8261
8262 let response = app.oneshot(request).await.unwrap();
8263 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
8264 assert_eq!(
8265 response
8266 .headers()
8267 .get("location")
8268 .unwrap()
8269 .to_str()
8270 .unwrap(),
8271 "/modern"
8272 );
8273 }
8274
8275 #[tokio::test]
8276 async fn test_redirect_middleware_no_match_passes_through() {
8277 use axum::body::Body;
8278 use axum::http::Request;
8279 use std::collections::HashMap;
8280 use tower::ServiceExt;
8281
8282 let mut redirects = HashMap::new();
8283 redirects.insert("/old".to_string(), "/new".to_string());
8284
8285 let mut config = crate::Config::default();
8286 config.redirects = redirects;
8287 config.session.enabled = false;
8288
8289 let temp_dir = create_test_project();
8290 let root = temp_dir.path().to_path_buf();
8291 let state = AppState::with_dev_mode(config, root, true).unwrap();
8292
8293 let app = create_router(state);
8294
8295 let request = Request::builder()
8296 .uri("/unrelated")
8297 .body(Body::empty())
8298 .unwrap();
8299
8300 let response = app.oneshot(request).await.unwrap();
8301 assert_ne!(response.status(), StatusCode::PERMANENT_REDIRECT);
8303 }
8304
8305 #[tokio::test]
8306 async fn test_health_endpoint_returns_ok() {
8307 use axum::body::Body;
8308 use axum::http::Request;
8309 use tower::ServiceExt;
8310
8311 let mut config = crate::Config::default();
8312 config.session.enabled = false;
8313
8314 let temp_dir = create_test_project();
8315 let root = temp_dir.path().to_path_buf();
8316 let state = AppState::with_dev_mode(config, root, true).unwrap();
8317
8318 let app = create_router(state);
8319
8320 let request = Request::builder()
8321 .uri("/health")
8322 .body(Body::empty())
8323 .unwrap();
8324
8325 let response = app.oneshot(request).await.unwrap();
8326 assert_eq!(response.status(), StatusCode::OK);
8327
8328 let body = axum::body::to_bytes(response.into_body(), 1024)
8329 .await
8330 .unwrap();
8331 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8332 assert_eq!(json["status"], "ok");
8333 assert_eq!(json["version"], env!("CARGO_PKG_VERSION"));
8334 }
8335
8336 #[test]
8337 fn test_fetch_timeout_default() {
8338 let config = crate::Config::default();
8339 assert_eq!(config.server.fetch_timeout, 10);
8340 }
8341
8342 #[test]
8343 fn test_fetch_timeout_custom() {
8344 let toml_str = r#"
8345[server]
8346fetch_timeout = 30
8347"#;
8348 let config: crate::Config = toml::from_str(toml_str).unwrap();
8349 assert_eq!(config.server.fetch_timeout, 30);
8350 }
8351
8352 #[test]
8353 fn test_http_client_uses_configured_timeout() {
8354 let mut config = crate::Config::default();
8355 config.server.fetch_timeout = 5;
8356 config.session.enabled = false;
8357
8358 let temp_dir = create_test_project();
8359 let root = temp_dir.path().to_path_buf();
8360 let state = AppState::with_dev_mode(config, root, false).unwrap();
8361
8362 let _client = &state.http_client;
8365 }
8366
8367 #[test]
8370 fn wired_public_reaches_all() {
8371 let temp_dir = create_test_project();
8372 let root = temp_dir.path().to_path_buf();
8373 let config = crate::Config::default();
8374 let state = AppState::new(config, root).unwrap();
8375
8376 let mut rx = state.wired_tx.subscribe();
8377 let _ = state.wired_tx.send(WiredMessage {
8378 json: r#"{"wired.counter":"5"}"#.to_string(),
8379 scope: WiredScope::Public,
8380 });
8381 let msg = rx.try_recv().unwrap();
8382 assert!(msg.scope.allows(&[], None)); assert!(msg.scope.allows(&["admin".into()], Some("user1"))); }
8386
8387 #[test]
8388 fn wired_role_filters() {
8389 let temp_dir = create_test_project();
8390 let root = temp_dir.path().to_path_buf();
8391 let config = crate::Config::default();
8392 let state = AppState::new(config, root).unwrap();
8393
8394 let mut rx = state.wired_tx.subscribe();
8395 let _ = state.wired_tx.send(WiredMessage {
8396 json: r#"{"wired.revenue":"1000"}"#.to_string(),
8397 scope: WiredScope::Roles(vec!["admin".into()]),
8398 });
8399 let msg = rx.try_recv().unwrap();
8400 assert!(msg.scope.allows(&["admin".into()], None));
8401 assert!(!msg.scope.allows(&["viewer".into()], None));
8402 assert!(!msg.scope.allows(&[], None)); }
8404
8405 #[test]
8406 fn wired_multi_role() {
8407 let scope = WiredScope::Roles(vec!["admin".into(), "editor".into()]);
8408 assert!(scope.allows(&["editor".into()], None));
8409 assert!(scope.allows(&["admin".into()], None));
8410 assert!(!scope.allows(&["viewer".into()], None));
8411 }
8412
8413 #[test]
8414 fn wired_user_scope() {
8415 let scope = WiredScope::User("user42".into());
8416 assert!(scope.allows(&[], Some("user42")));
8417 assert!(!scope.allows(&["admin".into()], Some("other_user")));
8418 assert!(!scope.allows(&[], None));
8419 }
8420
8421 #[test]
8422 fn wired_unauthenticated_public_only() {
8423 let public = WiredScope::Public;
8425 let admin_only = WiredScope::Roles(vec!["admin".into()]);
8426 let user_only = WiredScope::User("user1".into());
8427
8428 assert!(public.allows(&[], None));
8429 assert!(!admin_only.allows(&[], None));
8430 assert!(!user_only.allows(&[], None));
8431 }
8432
8433 #[test]
8434 fn wired_undeclared_key_defaults_public() {
8435 let temp_dir = create_test_project();
8436 let root = temp_dir.path().to_path_buf();
8437 let config = crate::Config::default();
8438 let state = AppState::new(config, root).unwrap();
8439
8440 let rt = tokio::runtime::Runtime::new().unwrap();
8443 let scope = rt.block_on(state.get_wired_scope("nonexistent_key"));
8444 assert!(matches!(scope, WiredScope::Public));
8445 }
8446
8447 #[test]
8448 fn test_session_mutation_with_filters() {
8449 let mut session_data = HashMap::new();
8450 session_data.insert("name".to_string(), json!("alice"));
8451 session_data.insert("bio".to_string(), json!("Hello world from alice"));
8452
8453 let val = resolve_session_value(&json!("#session.name|uppercase#"), &session_data);
8455 assert_eq!(val, json!("ALICE"));
8456
8457 let val = resolve_session_value(&json!("#session.bio|truncate:10#"), &session_data);
8458 assert_eq!(val, json!("Hello worl..."));
8459
8460 let val = resolve_session_value(&json!("Hi #session.name|capitalize#!"), &session_data);
8461 assert_eq!(val, json!("Hi Alice!"));
8462 }
8463}