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 let enforce_url = if action == "delete" {
4571 None
4572 } else {
4573 Some(action_url.clone())
4574 };
4575 if let Err(resp) = validate_form_data(
4576 &state,
4577 &form_data,
4578 &mut session,
4579 is_new_session,
4580 referer.as_deref(),
4581 enforce_url,
4582 )
4583 .await
4584 {
4585 return resp;
4586 }
4587
4588 let email_trigger = extract_email_trigger(&form_data);
4590 let partial_src = form_data.get("w-partial").cloned();
4591 let is_partial = headers
4592 .get("X-Requested-With")
4593 .and_then(|v| v.to_str().ok())
4594 .map(|v| v == "What")
4595 .unwrap_or(false);
4596
4597 let id_value: Value = if let Ok(num) = id.parse::<i64>() {
4598 Value::Number(num.into())
4599 } else {
4600 Value::String(id)
4601 };
4602
4603 let actor = extract_actor(&state, &headers, session.as_ref());
4607 let policy = state.policies.get(&collection);
4608 let kind = if action == "delete" {
4609 crate::policy::MutationKind::Delete
4610 } else {
4611 crate::policy::MutationKind::Update
4612 };
4613 let existing = state
4614 .store
4615 .find_by(&collection, "id", &id_value)
4616 .await
4617 .into_iter()
4618 .next();
4619 let mut filter_ctx: HashMap<String, Value> = HashMap::new();
4621 {
4622 let user = extract_user_context(&state, &headers);
4623 filter_ctx.insert("user".to_string(), user.to_context());
4624 if let Some(s) = session.as_ref() {
4625 filter_ctx.insert("session".to_string(), s.to_context());
4626 }
4627 }
4628 let resolved_filter = policy.resolved_filter(&filter_ctx);
4629 if action == "update" || action == "delete" {
4630 match policy.allows_mutation(&actor, existing.as_ref(), kind, resolved_filter.as_deref()) {
4631 crate::policy::Decision::Deny => {
4632 return deny_response(
4633 &state,
4634 &mut session,
4635 is_new_session,
4636 is_partial,
4637 referer.as_deref(),
4638 format!("You don't have permission to {} that item.", action),
4639 format!("collection={}; action={}; id={}", collection, action, id_value),
4640 )
4641 .await;
4642 }
4643 crate::policy::Decision::AllowLegacy => {
4644 warn_legacy_mutation_once(&collection);
4645 }
4646 crate::policy::Decision::Allow => {}
4647 }
4648 }
4649
4650 let result = match action {
4651 "update" => {
4652 let mut map: serde_json::Map<String, Value> = form_data
4653 .into_iter()
4654 .filter(|(k, _)| !is_framework_field(k))
4655 .map(|(k, v)| (k, Value::String(v)))
4656 .collect();
4657 policy.sanitize_input(&mut map);
4659 state
4660 .store
4661 .update(&collection, &id_value, Value::Object(map))
4662 .await
4663 .map(|_| ())
4664 }
4665 "delete" => {
4666 if state.config.uploads.enabled {
4668 if let Some(record) = existing.as_ref() {
4669 cleanup_uploaded_files(&state.root, &state.config.uploads.directory, record)
4670 .await;
4671 }
4672 }
4673 state.store.delete(&collection, &id_value).await.map(|_| ())
4674 }
4675 _ => Err(crate::Error::Action(format!("Unknown action: {}", action))),
4676 };
4677
4678 state.cache.invalidate_content_type(&collection).await;
4680
4681 let redirect_to = params.redirect.as_deref().unwrap_or("/");
4682
4683 match result {
4684 Ok(_) => {
4685 maybe_enqueue_email(&state, email_trigger).await;
4687
4688 if is_partial {
4690 if let Some(ref partial_path) = partial_src {
4691 if let Ok(html) = render_partial_for_action(
4692 &state,
4693 &headers,
4694 partial_path,
4695 ¶ms.extra,
4696 )
4697 .await
4698 {
4699 return build_partial_response(
4700 html,
4701 session.as_ref(),
4702 is_new_session,
4703 &state,
4704 );
4705 }
4706 }
4707 }
4708
4709 if let Some(ref mut sess) = session {
4711 let action_label = if action == "delete" {
4712 "deleted"
4713 } else {
4714 "updated"
4715 };
4716 let flash = FlashData {
4717 flash: HashMap::from([(
4718 "success".to_string(),
4719 format!("Item {} in {}", action_label, collection),
4720 )]),
4721 ..Default::default()
4722 };
4723 set_flash_data(sess, &flash);
4724 if let Some(ref sessions) = state.sessions {
4725 let _ = sessions.update(&sess.id, sess.data.clone()).await;
4726 }
4727 }
4728 redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
4729 }
4730 Err(e) => {
4731 tracing::error!("Action error: {}", e);
4732 if let Some(ref mut sess) = session {
4733 let flash = FlashData {
4734 flash: HashMap::from([("error".to_string(), format!("Action failed: {}", e))]),
4735 ..Default::default()
4736 };
4737 set_flash_data(sess, &flash);
4738 if let Some(ref sessions) = state.sessions {
4739 let _ = sessions.update(&sess.id, sess.data.clone()).await;
4740 }
4741 }
4742 let fallback = referer.as_deref().unwrap_or(redirect_to);
4743 redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
4744 }
4745 }
4746}
4747
4748fn partial_access_allowed(
4760 state: &AppState,
4761 clean_path: &str,
4762 inline: &PageDirectives,
4763 user_context: &UserContext,
4764) -> bool {
4765 let mut directives = inline.clone();
4766 let app_config = load_application_config(&state.root, &format!("partials/{clean_path}"));
4767 if !directives.requires_auth() && app_config.directives.requires_auth() {
4768 directives.auth = app_config.directives.auth.clone();
4769 directives.protected = app_config.directives.protected;
4770 directives.roles = app_config.directives.roles.clone();
4771 }
4772 if directives.requires_auth() && !user_context.authenticated {
4773 return false;
4774 }
4775 directives.check_access(user_context.authenticated, &user_context.roles())
4776}
4777
4778async fn render_partial_for_action(
4779 state: &AppState,
4780 headers: &HeaderMap,
4781 partial_path: &str,
4782 query_params: &HashMap<String, String>,
4783) -> Result<String> {
4784 let clean_path = partial_path.trim_start_matches('/');
4785 let partials_dir = state.content_dir.join("partials");
4786 let file_path = {
4787 let with_ext = partials_dir.join(format!("{}.html", clean_path));
4788 if with_ext.exists() {
4789 with_ext
4790 } else {
4791 partials_dir.join(clean_path).join("index.html")
4792 }
4793 };
4794
4795 let canonical = file_path
4797 .canonicalize()
4798 .map_err(|_| crate::Error::Action("Partial not found".into()))?;
4799 let partials_canonical = partials_dir
4800 .canonicalize()
4801 .map_err(|_| crate::Error::Action("Partials dir not found".into()))?;
4802 if !canonical.starts_with(&partials_canonical) {
4803 return Err(crate::Error::Action("Access denied".into()));
4804 }
4805
4806 let raw_content = tokio::fs::read_to_string(&canonical)
4807 .await
4808 .map_err(|_| crate::Error::Action("Partial not found".into()))?;
4809
4810 if state.dev_mode {
4811 engine::warn_template_lints_once(&canonical, &raw_content);
4812 }
4813
4814 let (directives, content) = parse_page_directives(&raw_content);
4815
4816 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
4818 let session = if let Some(ref sessions) = state.sessions {
4819 let session_id =
4820 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
4821 sessions.get_or_create(session_id.as_deref()).await.ok()
4822 } else {
4823 None
4824 };
4825
4826 let user_context = if state.auth.is_enabled() {
4827 if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
4828 match state.auth.decode_jwt(&token) {
4829 Ok(claims) if !claims.is_expired() => {
4830 UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
4831 }
4832 _ => UserContext::unauthenticated(),
4833 }
4834 } else {
4835 UserContext::unauthenticated()
4836 }
4837 } else {
4838 UserContext::unauthenticated()
4839 };
4840
4841 if !partial_access_allowed(state, clean_path, &directives, &user_context) {
4847 return Err(crate::Error::Action("Access denied".into()));
4848 }
4849
4850 let render_result = render_content_internal(
4851 state,
4852 &content,
4853 session.as_ref(),
4854 &user_context,
4855 None,
4856 Some(&directives),
4857 Some(query_params),
4858 &HashMap::new(),
4859 true,
4860 None,
4861 )
4862 .await?;
4863
4864 let mut html = render_result.html;
4865
4866 if let Some(ref s) = session {
4868 if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
4869 html = inject_csrf_tokens(&html, csrf);
4870 }
4871 }
4872
4873 Ok(html)
4874}
4875
4876fn build_partial_response(
4878 html: String,
4879 session: Option<&sessions::Session>,
4880 is_new_session: bool,
4881 state: &AppState,
4882) -> Response {
4883 let mut resp_headers: Vec<(header::HeaderName, String)> =
4884 vec![(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())];
4885 if is_new_session {
4886 if let Some(s) = session {
4887 resp_headers.push((
4888 header::SET_COOKIE,
4889 sessions::build_session_cookie(
4890 &s.id,
4891 &state.config.session.cookie_name,
4892 state.config.session.max_age,
4893 state.config.session.secure,
4894 ),
4895 ));
4896 }
4897 }
4898 build_response(StatusCode::OK, resp_headers, html)
4899}
4900
4901async fn handle_upload(
4903 State(state): State<AppState>,
4904 Path(collection): Path<String>,
4905 headers: HeaderMap,
4906 Query(params): Query<ActionParams>,
4907 mut multipart: Multipart,
4908) -> impl IntoResponse {
4909 let referer = headers
4910 .get(header::REFERER)
4911 .and_then(|v| v.to_str().ok())
4912 .map(|s| s.to_string());
4913 let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
4914 let redirect_to = params.redirect.as_deref().unwrap_or("/");
4915
4916 if !state.config.uploads.enabled {
4918 if let Some(ref mut sess) = session {
4919 let flash = FlashData {
4920 flash: HashMap::from([(
4921 "error".to_string(),
4922 "File uploads are not enabled".to_string(),
4923 )]),
4924 ..Default::default()
4925 };
4926 set_flash_data(sess, &flash);
4927 if let Some(ref sessions) = state.sessions {
4928 let _ = sessions.update(&sess.id, sess.data.clone()).await;
4929 }
4930 }
4931 let fallback = referer.as_deref().unwrap_or(redirect_to);
4932 return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4933 }
4934
4935 let upload_backend = match state.upload_backend {
4936 Some(ref backend) => backend.clone(),
4937 None => {
4938 if let Some(ref mut sess) = session {
4939 let flash = FlashData {
4940 flash: HashMap::from([(
4941 "error".to_string(),
4942 "Upload backend not configured".to_string(),
4943 )]),
4944 ..Default::default()
4945 };
4946 set_flash_data(sess, &flash);
4947 if let Some(ref sessions) = state.sessions {
4948 let _ = sessions.update(&sess.id, sess.data.clone()).await;
4949 }
4950 }
4951 let fallback = referer.as_deref().unwrap_or(redirect_to);
4952 return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4953 }
4954 };
4955 let actor = extract_actor(&state, &headers, session.as_ref());
4958 let policy = state.policies.get(&collection);
4959 if !policy.allows_create(&actor) {
4960 let is_partial = headers
4961 .get("X-Requested-With")
4962 .and_then(|v| v.to_str().ok())
4963 .map(|v| v == "What")
4964 .unwrap_or(false);
4965 return deny_response(
4966 &state,
4967 &mut session,
4968 is_new_session,
4969 is_partial,
4970 referer.as_deref(),
4971 format!("You don't have permission to upload to {}.", collection),
4972 format!("collection={}; action=upload", collection),
4973 )
4974 .await;
4975 }
4976
4977 let max_size = state.config.uploads.max_size_bytes();
4978 let mut form_fields: HashMap<String, String> = HashMap::new();
4979 let mut uploaded_files: Vec<(String, String)> = Vec::new(); while let Ok(Some(field)) = multipart.next_field().await {
4983 let field_name = field.name().unwrap_or("").to_string();
4984
4985 if let Some(file_name) = field.file_name().map(|s| s.to_string()) {
4986 if file_name.is_empty() {
4988 continue; }
4990
4991 let content_type = field
4992 .content_type()
4993 .unwrap_or("application/octet-stream")
4994 .to_string();
4995
4996 if !state.config.uploads.is_type_allowed(&content_type) {
4998 if let Some(ref mut sess) = session {
4999 let flash = FlashData {
5000 flash: HashMap::from([(
5001 "error".to_string(),
5002 format!("File type '{}' is not allowed", content_type),
5003 )]),
5004 ..Default::default()
5005 };
5006 set_flash_data(sess, &flash);
5007 if let Some(ref sessions) = state.sessions {
5008 let _ = sessions.update(&sess.id, sess.data.clone()).await;
5009 }
5010 }
5011 for (_, saved) in &uploaded_files {
5013 let _ = upload_backend.delete(saved).await;
5014 }
5015 let fallback = referer.as_deref().unwrap_or(redirect_to);
5016 return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
5017 }
5018
5019 let data = match field.bytes().await {
5021 Ok(bytes) => bytes,
5022 Err(e) => {
5023 tracing::error!("Failed to read upload field: {}", e);
5024 continue;
5025 }
5026 };
5027
5028 if data.len() > max_size {
5030 if let Some(ref mut sess) = session {
5031 let flash = FlashData {
5032 flash: HashMap::from([(
5033 "error".to_string(),
5034 format!(
5035 "File '{}' exceeds maximum size of {}",
5036 file_name, state.config.uploads.max_size
5037 ),
5038 )]),
5039 ..Default::default()
5040 };
5041 set_flash_data(sess, &flash);
5042 if let Some(ref sessions) = state.sessions {
5043 let _ = sessions.update(&sess.id, sess.data.clone()).await;
5044 }
5045 }
5046 for (_, saved) in &uploaded_files {
5047 let _ = upload_backend.delete(saved).await;
5048 }
5049 let fallback = referer.as_deref().unwrap_or(redirect_to);
5050 return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
5051 }
5052
5053 let extension = std::path::Path::new(&file_name)
5055 .extension()
5056 .and_then(|e| e.to_str())
5057 .map(|e| sanitize_extension(e))
5058 .unwrap_or_default();
5059 let saved_name = format!("{}{}", uuid::Uuid::new_v4(), extension);
5060
5061 match upload_backend.put(&saved_name, &data, &content_type).await {
5063 Ok(public_url) => {
5064 form_fields.insert(field_name.clone(), public_url);
5065 uploaded_files.push((field_name, saved_name));
5066 }
5067 Err(e) => {
5068 tracing::error!("Failed to save uploaded file: {}", e);
5069 if let Some(ref mut sess) = session {
5070 let flash = FlashData {
5071 flash: HashMap::from([(
5072 "error".to_string(),
5073 "Failed to save uploaded file".to_string(),
5074 )]),
5075 ..Default::default()
5076 };
5077 set_flash_data(sess, &flash);
5078 if let Some(ref sessions) = state.sessions {
5079 let _ = sessions.update(&sess.id, sess.data.clone()).await;
5080 }
5081 }
5082 for (_, saved) in &uploaded_files {
5083 let _ = upload_backend.delete(saved).await;
5084 }
5085 let fallback = referer.as_deref().unwrap_or(redirect_to);
5086 return redirect_with_session(
5087 &state,
5088 fallback,
5089 session.as_ref(),
5090 is_new_session,
5091 );
5092 }
5093 }
5094 } else {
5095 let value = field.text().await.unwrap_or_default();
5097 form_fields.insert(field_name, value);
5098 }
5099 }
5100
5101 let action_url = format!("/w-upload/{}", collection);
5102
5103 if let Err(resp) = validate_form_data(
5105 &state,
5106 &form_fields,
5107 &mut session,
5108 is_new_session,
5109 referer.as_deref(),
5110 Some(action_url.clone()),
5111 )
5112 .await
5113 {
5114 for (_, saved) in &uploaded_files {
5116 let _ = upload_backend.delete(saved).await;
5117 }
5118 return resp;
5119 }
5120
5121 let mut item_map: serde_json::Map<String, Value> = form_fields
5123 .into_iter()
5124 .filter(|(k, _)| !k.starts_with("w-"))
5125 .map(|(k, v)| (k, Value::String(v)))
5126 .collect();
5127 policy.sanitize_input(&mut item_map);
5128 policy.stamp_owner(&mut item_map, &actor);
5129 {
5130 let mut filter_ctx: HashMap<String, Value> = HashMap::new();
5131 let user = extract_user_context(&state, &headers);
5132 filter_ctx.insert("user".to_string(), user.to_context());
5133 if let Some(s) = session.as_ref() {
5134 filter_ctx.insert("session".to_string(), s.to_context());
5135 }
5136 policy.stamp_tenant(&mut item_map, &filter_ctx);
5137 }
5138
5139 let result = state.store.create(&collection, Value::Object(item_map)).await;
5140 state.cache.invalidate_content_type(&collection).await;
5141
5142 match result {
5143 Ok(_) => {
5144 if let Some(ref mut sess) = session {
5145 let flash = FlashData {
5146 flash: HashMap::from([(
5147 "success".to_string(),
5148 format!("Item created in {}", collection),
5149 )]),
5150 ..Default::default()
5151 };
5152 set_flash_data(sess, &flash);
5153 if let Some(ref sessions) = state.sessions {
5154 let _ = sessions.update(&sess.id, sess.data.clone()).await;
5155 }
5156 }
5157 redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
5158 }
5159 Err(e) => {
5160 tracing::error!("Upload action error: {}", e);
5161 for (_, saved) in &uploaded_files {
5163 let _ = upload_backend.delete(saved).await;
5164 }
5165 if let Some(ref mut sess) = session {
5166 let flash = FlashData {
5167 flash: HashMap::from([(
5168 "error".to_string(),
5169 format!("Failed to create: {}", e),
5170 )]),
5171 ..Default::default()
5172 };
5173 set_flash_data(sess, &flash);
5174 if let Some(ref sessions) = state.sessions {
5175 let _ = sessions.update(&sess.id, sess.data.clone()).await;
5176 }
5177 }
5178 let fallback = referer.as_deref().unwrap_or(redirect_to);
5179 redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
5180 }
5181 }
5182}
5183
5184async fn cleanup_uploaded_files(root: &std::path::Path, upload_dir: &str, record: &Value) {
5194 let Value::Object(map) = record else { return };
5195 let uploads_root = root.join(upload_dir);
5196 for (_key, value) in map {
5197 let Value::String(s) = value else { continue };
5198 let Some(filename) = s.strip_prefix("/uploads/") else {
5199 continue;
5200 };
5201 if filename.is_empty()
5203 || filename.contains("..")
5204 || filename.contains('/')
5205 || filename.contains('\\')
5206 || filename.contains('\0')
5207 {
5208 tracing::warn!("Skipping suspicious upload cleanup path: {:?}", s);
5209 continue;
5210 }
5211 let file_path = uploads_root.join(filename);
5212 if !file_path.exists() {
5213 continue;
5214 }
5215 let contained = match (uploads_root.canonicalize(), file_path.canonicalize()) {
5217 (Ok(base), Ok(resolved)) => resolved.starts_with(&base),
5218 _ => false,
5219 };
5220 if !contained {
5221 tracing::warn!(
5222 "Refusing to delete file outside uploads dir: {}",
5223 file_path.display()
5224 );
5225 continue;
5226 }
5227 if let Err(e) = tokio::fs::remove_file(&file_path).await {
5228 tracing::warn!("Failed to delete uploaded file {}: {}", file_path.display(), e);
5229 } else {
5230 tracing::debug!("Cleaned up uploaded file: {}", file_path.display());
5231 }
5232 }
5233}
5234
5235async fn handle_session_reset(
5237 State(state): State<AppState>,
5238 headers: HeaderMap,
5239 Form(form): Form<HashMap<String, String>>,
5240) -> impl IntoResponse {
5241 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5243
5244 if let Some(ref sessions) = state.sessions {
5246 if let Some(session_id) =
5247 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name)
5248 {
5249 let _ = sessions.delete(&session_id).await;
5250 }
5251
5252 match sessions.create().await {
5254 Ok(new_session) => {
5255 let cookie = sessions::build_session_cookie(
5256 &new_session.id,
5257 &state.config.session.cookie_name,
5258 state.config.session.max_age,
5259 state.config.session.secure,
5260 );
5261
5262 let raw_redirect = form
5266 .get("redirect")
5267 .map(|s| s.as_str())
5268 .or_else(|| headers.get(header::REFERER).and_then(|v| v.to_str().ok()))
5269 .unwrap_or("/state");
5270 let redirect_url = sanitize_redirect(raw_redirect, "/state");
5271
5272 build_response(
5274 StatusCode::SEE_OTHER,
5275 vec![
5276 (header::LOCATION, redirect_url),
5277 (header::SET_COOKIE, cookie),
5278 ],
5279 String::new(),
5280 )
5281 }
5282 Err(e) => {
5283 tracing::error!("Session reset error: {}", e);
5284 build_response(
5285 StatusCode::INTERNAL_SERVER_ERROR,
5286 vec![(header::CONTENT_TYPE, "text/plain".to_string())],
5287 "Failed to reset session".to_string(),
5288 )
5289 }
5290 }
5291 } else {
5292 Redirect::to("/state").into_response()
5294 }
5295}
5296
5297enum SetOp {
5299 Increment(i64),
5300 Decrement(i64),
5301 SetInt(i64),
5302 SetStr(String),
5303}
5304
5305fn sanitize_redirect(url: &str, fallback: &str) -> String {
5307 let trimmed = url.trim();
5308 if trimmed.starts_with('/') && !trimmed.starts_with("//") {
5309 trimmed.to_string()
5310 } else {
5311 fallback.to_string()
5312 }
5313}
5314
5315fn parse_w_set_expr(expr: &str) -> Option<(String, String, SetOp)> {
5317 let expr = expr.trim();
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::Increment(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 let val: i64 = right.parse().ok()?;
5334 return Some((scope.to_string(), key.to_string(), SetOp::Decrement(val)));
5335 }
5336
5337 if let Some((left, right)) = expr.split_once('=') {
5339 let left = left.trim();
5340 let right = right.trim();
5341 let (scope, key) = left.split_once('.')?;
5342 if let Ok(val) = right.parse::<i64>() {
5344 return Some((scope.to_string(), key.to_string(), SetOp::SetInt(val)));
5345 }
5346 let s = right.trim_matches(|c| c == '\'' || c == '"');
5348 return Some((
5349 scope.to_string(),
5350 key.to_string(),
5351 SetOp::SetStr(s.to_string()),
5352 ));
5353 }
5354
5355 None
5356}
5357
5358fn apply_set_op(current: Option<&Value>, op: &SetOp) -> Value {
5360 match op {
5361 SetOp::Increment(delta) => {
5362 let cur = current.and_then(|v| v.as_i64()).unwrap_or(0);
5363 json!(cur + delta)
5364 }
5365 SetOp::Decrement(delta) => {
5366 let cur = current.and_then(|v| v.as_i64()).unwrap_or(0);
5367 json!(cur - delta)
5368 }
5369 SetOp::SetInt(val) => json!(val),
5370 SetOp::SetStr(val) => json!(val),
5371 }
5372}
5373
5374fn value_display_string(v: &Value) -> String {
5376 match v {
5377 Value::String(s) => s.clone(),
5378 Value::Null => String::new(),
5379 other => other.to_string(),
5380 }
5381}
5382
5383async fn handle_w_set(
5387 State(state): State<AppState>,
5388 headers: HeaderMap,
5389 Form(params): Form<HashMap<String, String>>,
5390) -> impl IntoResponse {
5391 let expr_str = match params.get("expr") {
5392 Some(e) => e.as_str(),
5393 None => return (StatusCode::BAD_REQUEST, "Missing expr").into_response(),
5394 };
5395
5396 let mut parsed: Vec<(String, String, SetOp)> = Vec::new();
5398 for part in expr_str.split(';') {
5399 let part = part.trim();
5400 if part.is_empty() {
5401 continue;
5402 }
5403 match parse_w_set_expr(part) {
5404 Some((scope, key, op)) => {
5405 if scope != "session" && scope != "app" && scope != "wired" {
5406 return (
5407 StatusCode::BAD_REQUEST,
5408 "Invalid scope (use session, app, or wired)",
5409 )
5410 .into_response();
5411 }
5412 parsed.push((scope, key, op));
5413 }
5414 None => {
5415 return (
5416 StatusCode::BAD_REQUEST,
5417 format!("Invalid expression: {}", part),
5418 )
5419 .into_response();
5420 }
5421 }
5422 }
5423
5424 if parsed.is_empty() {
5425 return (StatusCode::BAD_REQUEST, "Empty expression").into_response();
5426 }
5427
5428 let mut oob_map = serde_json::Map::new();
5429 let mut wired_updates: Vec<(String, String)> = Vec::new(); let mut session_mutations: Vec<(String, SetOp)> = Vec::new();
5431
5432 let cookie_header_str = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5434 let mutator_user_id: Option<String> = if state.auth.is_enabled() {
5435 state
5436 .auth
5437 .parse_jwt_cookie(cookie_header_str)
5438 .and_then(|token| {
5439 state.auth.decode_jwt(&token).ok().and_then(|claims| {
5440 if claims.is_expired() {
5441 return None;
5442 }
5443 claims.sub.clone()
5444 })
5445 })
5446 } else {
5447 None
5448 };
5449
5450 let has_shared_mutation = parsed.iter().any(|(s, _, _)| s == "app" || s == "wired");
5453 if has_shared_mutation {
5454 let cookie_header_for_auth = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5455 let is_authenticated = if state.auth.is_enabled() {
5456 state
5457 .auth
5458 .parse_jwt_cookie(cookie_header_for_auth)
5459 .and_then(|token| state.auth.decode_jwt(&token).ok())
5460 .map(|claims| !claims.is_expired())
5461 .unwrap_or(false)
5462 } else {
5463 let session_id = sessions::parse_session_cookie(
5465 cookie_header_for_auth,
5466 &state.config.session.cookie_name,
5467 );
5468 session_id.is_some()
5469 };
5470 if !is_authenticated {
5471 return (
5472 StatusCode::FORBIDDEN,
5473 "Authentication required for app/wired mutations",
5474 )
5475 .into_response();
5476 }
5477
5478 let mutator_roles = extract_user_context(&state, &headers).roles();
5481 for (mscope, mkey, _) in &parsed {
5482 let var_scope = match mscope.as_str() {
5483 "wired" => state.get_wired_scope(mkey).await,
5484 "app" => state.get_app_scope(mkey).await,
5485 _ => continue,
5486 };
5487 let allowed = match &var_scope {
5488 WiredScope::Public => true,
5489 WiredScope::Roles(_) => {
5492 state.auth.is_enabled()
5493 && var_scope.allows(&mutator_roles, mutator_user_id.as_deref())
5494 }
5495 WiredScope::User(_) => state.auth.is_enabled() && mutator_user_id.is_some(),
5498 };
5499 if !allowed {
5500 tracing::info!(
5501 target: "what::policy",
5502 "w-set denied: {}.{} requires scope {:?}",
5503 mscope, mkey, var_scope
5504 );
5505 let mut resp = (
5506 StatusCode::FORBIDDEN,
5507 format!("Insufficient permissions for {}.{}", mscope, mkey),
5508 )
5509 .into_response();
5510 if state.dev_mode {
5511 if let Ok(hv) = format!("deny; {}.{}; scope={:?}", mscope, mkey, var_scope).parse()
5512 {
5513 resp.headers_mut()
5514 .insert(header::HeaderName::from_static("x-what-policy"), hv);
5515 }
5516 }
5517 return resp;
5518 }
5519 }
5520 }
5521
5522 for (scope, key, op) in parsed {
5524 if scope == "app" || scope == "wired" {
5525 match state
5526 .store
5527 .atomic_modify(&key, move |current| apply_set_op(current, &op))
5528 .await
5529 {
5530 Ok(val) => {
5531 let display = value_display_string(&val);
5532 if scope == "wired" {
5533 oob_map.insert(format!("wired.{}", key), Value::String(display.clone()));
5534 wired_updates.push((key, display));
5535 } else {
5536 oob_map.insert(format!("app.{}", key), Value::String(display));
5537 }
5538 }
5539 Err(e) => {
5540 tracing::error!("w-set {} mutation failed: {}", scope, e);
5541 return (StatusCode::INTERNAL_SERVER_ERROR, "Mutation failed").into_response();
5542 }
5543 }
5544 } else {
5545 if key.starts_with('_') {
5547 tracing::warn!("w-set blocked: reserved session key '{}'", key);
5548 continue;
5549 }
5550 session_mutations.push((key, op));
5551 }
5552 }
5553
5554 for (key, display) in wired_updates {
5556 let mut wired_map = serde_json::Map::new();
5557 wired_map.insert(format!("wired.{}", key), Value::String(display));
5558 let json = serde_json::to_string(&Value::Object(wired_map)).unwrap_or_default();
5559 let mut scope = state.get_wired_scope(&key).await;
5560 if matches!(scope, WiredScope::User(ref uid) if uid.is_empty()) {
5562 scope = WiredScope::User(mutator_user_id.clone().unwrap_or_default());
5563 }
5564 let _ = state.wired_tx.send(WiredMessage { json, scope });
5565 }
5566
5567 if !session_mutations.is_empty() {
5569 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5570
5571 if let Some(ref sessions) = state.sessions {
5572 let session_id =
5573 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5574
5575 if let Some(id) = session_id {
5576 for (key, op) in &session_mutations {
5577 let atomic_op = match op {
5578 SetOp::Increment(n) => sessions::AtomicMutation::Increment {
5579 key: key.clone(),
5580 value: *n,
5581 },
5582 SetOp::Decrement(n) => sessions::AtomicMutation::Increment {
5583 key: key.clone(),
5584 value: -*n,
5585 },
5586 SetOp::SetInt(n) => sessions::AtomicMutation::Set {
5587 key: key.clone(),
5588 value: serde_json::json!(*n),
5589 },
5590 SetOp::SetStr(s) => sessions::AtomicMutation::Set {
5591 key: key.clone(),
5592 value: serde_json::json!(s),
5593 },
5594 };
5595
5596 match sessions.apply_mutation(&id, &atomic_op).await {
5597 Ok(data) => {
5598 if let Some(val) = data.get(key) {
5599 oob_map.insert(
5600 format!("session.{}", key),
5601 Value::String(value_display_string(val)),
5602 );
5603 }
5604 }
5605 Err(e) => {
5606 tracing::error!("w-set session mutation failed: {}", e);
5607 }
5608 }
5609 }
5610 }
5611 }
5612 }
5613
5614 let is_partial = headers
5616 .get("X-Requested-With")
5617 .and_then(|v| v.to_str().ok())
5618 .map(|v| v == "What")
5619 .unwrap_or(false);
5620
5621 if is_partial {
5622 let json_str = serde_json::to_string(&Value::Object(oob_map)).unwrap_or_default();
5623 let oob = format!(r##"<template data-what-updates>{}</template>"##, json_str);
5624 return Html(oob).into_response();
5625 }
5626
5627 let redirect_url = headers
5629 .get(header::REFERER)
5630 .and_then(|v| v.to_str().ok())
5631 .and_then(|url| {
5632 if let Some(idx) = url.find("://") {
5634 url[idx + 3..].find('/').map(|i| &url[idx + 3 + i..])
5636 } else {
5637 Some(url)
5638 }
5639 })
5640 .map(|path| sanitize_redirect(path, "/"))
5641 .unwrap_or_else(|| "/".to_string());
5642
5643 Redirect::to(&redirect_url).into_response()
5644}
5645
5646async fn handle_session_clear_data(
5648 State(state): State<AppState>,
5649 headers: HeaderMap,
5650) -> impl IntoResponse {
5651 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5652
5653 let referer = headers
5654 .get(header::REFERER)
5655 .and_then(|v| v.to_str().ok())
5656 .unwrap_or("/");
5657 let redirect_to = sanitize_redirect(referer, "/");
5658
5659 if let Some(ref sessions) = state.sessions {
5660 let session_id =
5661 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5662
5663 if let Some(id) = session_id {
5664 if let Err(e) = sessions.update(&id, std::collections::HashMap::new()).await {
5666 tracing::error!("Failed to clear session data: {}", e);
5667 }
5668 }
5669 }
5670
5671 Redirect::to(&redirect_to).into_response()
5672}
5673
5674async fn handle_inject_notification(
5676 State(state): State<AppState>,
5677 headers: HeaderMap,
5678) -> impl IntoResponse {
5679 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5680
5681 let mut count: i64 = 1;
5682
5683 if let Some(ref sessions) = state.sessions {
5684 let session_id =
5685 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5686
5687 if let Some(id) = session_id {
5688 if let Ok(Some(mut session)) = sessions.get(&id).await {
5689 let current = session
5691 .data
5692 .get("inject_count")
5693 .and_then(|v| v.as_i64())
5694 .unwrap_or(0);
5695
5696 count = current + 1;
5698 session
5699 .data
5700 .insert("inject_count".to_string(), json!(count));
5701
5702 if let Err(e) = sessions.update(&id, session.data).await {
5704 tracing::error!("Failed to update inject count: {}", e);
5705 }
5706 }
5707 }
5708 }
5709
5710 let html = format!(
5712 r#"<div class="p-4 bg-blue-100 border border-blue-300 rounded flex items-center gap-3">
5713 <span class="text-2xl">🔔</span>
5714 <div>
5715 <strong>Notification {}</strong>
5716 <p class="text-sm text-gray-600">Injected without reloading the page</p>
5717 </div>
5718</div>"#,
5719 count
5720 );
5721
5722 axum::response::Html(html).into_response()
5723}
5724
5725async fn handle_login(
5728 State(state): State<AppState>,
5729 _headers: HeaderMap,
5730 Form(form_data): Form<HashMap<String, String>>,
5731) -> impl IntoResponse {
5732 let login_endpoint = match state.auth.login_endpoint() {
5734 Some(url) => url.to_string(),
5735 None => {
5736 tracing::error!("Login endpoint not configured");
5737 let msg = if state.dev_mode {
5738 "Login not configured"
5739 } else {
5740 "Something went wrong"
5741 };
5742 return (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response();
5743 }
5744 };
5745
5746 let fallback = state.auth.after_login_path().to_string();
5748 let redirect_url = form_data
5749 .get("redirect")
5750 .or_else(|| form_data.get("w-redirect"))
5751 .map(|s| sanitize_redirect(s, &fallback))
5752 .unwrap_or(fallback);
5753
5754 let login_data: HashMap<String, String> = form_data
5756 .into_iter()
5757 .filter(|(k, _)| !k.starts_with("w-") && k != "redirect")
5758 .collect();
5759
5760 let response = match state
5762 .http_client
5763 .post(&login_endpoint)
5764 .json(&login_data)
5765 .send()
5766 .await
5767 {
5768 Ok(resp) => resp,
5769 Err(e) => {
5770 tracing::error!("Login request failed: {}", e);
5771 return Redirect::to(&format!("{}?error=connection", state.auth.login_path()))
5772 .into_response();
5773 }
5774 };
5775
5776 if !response.status().is_success() {
5777 tracing::warn!("Login failed with status: {}", response.status());
5778 return Redirect::to(&format!("{}?error=invalid", state.auth.login_path())).into_response();
5779 }
5780
5781 let token = match response.json::<serde_json::Value>().await {
5784 Ok(json) => {
5785 json.get("token")
5787 .or_else(|| json.get("access_token"))
5788 .or_else(|| json.get("jwt"))
5789 .and_then(|v| v.as_str())
5790 .map(|s| s.to_string())
5791 }
5792 Err(e) => {
5793 tracing::error!("Failed to parse login response: {}", e);
5794 None
5795 }
5796 };
5797
5798 match token {
5799 Some(jwt) => {
5800 let cookie = state.auth.build_jwt_cookie(
5802 &jwt,
5803 state.config.session.max_age,
5804 state.config.session.secure,
5805 );
5806
5807 let mut response_headers = vec![
5808 (header::LOCATION, redirect_url),
5809 (header::SET_COOKIE, cookie),
5810 ];
5811
5812 if let Some(ref sessions) = state.sessions {
5814 let cookie_header = _headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5815 let old_id = sessions::parse_session_cookie(
5816 cookie_header,
5817 &state.config.session.cookie_name,
5818 );
5819
5820 if let Some(ref old) = old_id {
5821 if let Ok(Some(old_session)) = sessions.get(old).await {
5823 if let Ok(new_session) = sessions.create().await {
5824 let mut data = old_session.data;
5825 data.insert(
5827 sessions::CSRF_TOKEN_KEY.to_string(),
5828 serde_json::json!(sessions::generate_csrf_token()),
5829 );
5830 let _ = sessions.update(&new_session.id, data).await;
5831 let _ = sessions.delete(old).await;
5832 response_headers.push((
5833 header::SET_COOKIE,
5834 sessions::build_session_cookie(
5835 &new_session.id,
5836 &state.config.session.cookie_name,
5837 state.config.session.max_age,
5838 state.config.session.secure,
5839 ),
5840 ));
5841 }
5842 }
5843 }
5844 }
5845
5846 build_response(StatusCode::SEE_OTHER, response_headers, String::new())
5847 }
5848 None => {
5849 Redirect::to(&format!("{}?error=no_token", state.auth.login_path())).into_response()
5850 }
5851 }
5852}
5853
5854async fn handle_logout(
5856 State(state): State<AppState>,
5857 request_headers: HeaderMap,
5858 Form(form_data): Form<HashMap<String, String>>,
5859) -> impl IntoResponse {
5860 let redirect_url = form_data
5862 .get("redirect")
5863 .or_else(|| form_data.get("w-redirect"))
5864 .map(|s| sanitize_redirect(s, "/"))
5865 .unwrap_or_else(|| "/".to_string());
5866
5867 if let Some(logout_endpoint) = state.auth.logout_endpoint() {
5869 let cookie_header = request_headers
5871 .get(header::COOKIE)
5872 .and_then(|v| v.to_str().ok());
5873
5874 if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
5875 let _ = state
5876 .http_client
5877 .post(logout_endpoint)
5878 .bearer_auth(&token)
5879 .send()
5880 .await;
5881 }
5882 }
5883
5884 let clear_cookie = state.auth.build_clear_cookie();
5886
5887 let mut response_cookies = vec![
5892 (header::LOCATION, redirect_url),
5893 (header::SET_COOKIE, clear_cookie),
5894 ];
5895 if let Some(ref sessions) = state.sessions {
5896 let cookie_header = request_headers
5897 .get(header::COOKIE)
5898 .and_then(|v| v.to_str().ok());
5899 if let Some(session_id) =
5900 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name)
5901 {
5902 let _ = sessions.delete(&session_id).await;
5903 }
5904 response_cookies.push((
5905 header::SET_COOKIE,
5906 sessions::build_clear_session_cookie(&state.config.session.cookie_name),
5907 ));
5908 }
5909
5910 build_response(StatusCode::SEE_OTHER, response_cookies, String::new())
5912}
5913
5914async fn handle_cache_clear_all(State(state): State<AppState>) -> impl IntoResponse {
5916 state.cache.clear_all().await;
5918
5919 tracing::info!("All server caches cleared");
5920
5921 axum::Json(serde_json::json!({
5923 "success": true,
5924 "message": "All caches cleared"
5925 }))
5926}
5927
5928async fn handle_sessions_list(State(state): State<AppState>) -> impl IntoResponse {
5930 match &state.sessions {
5931 Some(sessions) => {
5932 let count = sessions.count().await.unwrap_or(0);
5933 let ids = sessions.list_session_ids().await.unwrap_or_default();
5934
5935 axum::Json(serde_json::json!({
5936 "count": count,
5937 "ids": ids
5938 }))
5939 }
5940 None => axum::Json(serde_json::json!({
5941 "error": "Sessions not enabled",
5942 "count": 0,
5943 "ids": []
5944 })),
5945 }
5946}
5947
5948async fn handle_data_info(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
5950 let session_data = if let Some(ref sessions) = state.sessions {
5952 let cookie_header = headers.get(header::COOKIE).and_then(|h| h.to_str().ok());
5953 let session_id =
5954 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5955
5956 if let Some(id) = session_id {
5957 if let Ok(Some(session)) = sessions.get(&id).await {
5958 session.data.clone()
5959 } else {
5960 std::collections::HashMap::new()
5961 }
5962 } else {
5963 std::collections::HashMap::new()
5964 }
5965 } else {
5966 std::collections::HashMap::new()
5967 };
5968
5969 let application_data = state.store.as_context().await;
5971
5972 axum::Json(serde_json::json!({
5973 "application": application_data,
5974 "session": session_data
5975 }))
5976}
5977
5978struct RouteMeta {
5984 url: String,
5985 dynamic: bool,
5986 auth: String,
5987 layout: String,
5988 missing: bool,
5989}
5990
5991fn inspector_auth_display(d: &PageDirectives) -> Option<String> {
5994 use crate::parser::AuthLevel;
5995 match &d.auth {
5996 AuthLevel::User => Some("user".to_string()),
5997 AuthLevel::Roles(v) => Some(format!("roles: {}", v.join(", "))),
5998 AuthLevel::All => {
5999 if d.protected {
6000 if d.roles.is_empty() {
6001 Some("user (protected)".to_string())
6002 } else {
6003 Some(format!("roles: {} (legacy)", d.roles.join(", ")))
6004 }
6005 } else {
6006 None
6007 }
6008 }
6009 }
6010}
6011
6012fn resolve_route_meta(root: &PathBuf, url_path: &str, dynamic: bool) -> RouteMeta {
6016 let Some(resolved) = resolve_page_path(root, url_path) else {
6017 return RouteMeta {
6018 url: url_path.to_string(),
6019 dynamic,
6020 auth: String::new(),
6021 layout: String::new(),
6022 missing: true,
6023 };
6024 };
6025 let raw = std::fs::read_to_string(&resolved.path).unwrap_or_default();
6026 let (directives, _) = parse_page_directives(&raw);
6027 let app_config = load_application_config(root, url_path);
6028
6029 let auth = inspector_auth_display(&directives)
6030 .or_else(|| inspector_auth_display(&app_config.directives))
6031 .unwrap_or_else(|| "all".to_string());
6032
6033 let layout = directives.layout.clone().or_else(|| app_config.layout.clone());
6034 let layout = match layout.as_deref() {
6035 Some("none") => "none (disabled)".to_string(),
6036 Some(l) => l.to_string(),
6037 None => "(default)".to_string(),
6038 };
6039
6040 RouteMeta { url: url_path.to_string(), dynamic, auth, layout, missing: false }
6041}
6042
6043fn collect_application_what_files(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<(String, WhatConfig)>) {
6046 let cfg = dir.join("application.what");
6047 if cfg.exists() {
6048 if let Ok(content) = std::fs::read_to_string(&cfg) {
6049 let rel = cfg.strip_prefix(root).unwrap_or(&cfg).to_string_lossy().to_string();
6050 out.push((rel, parse_what_file(&content)));
6051 }
6052 }
6053 if let Ok(entries) = std::fs::read_dir(dir) {
6054 let mut dirs: Vec<_> = entries
6055 .flatten()
6056 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
6057 .map(|e| e.path())
6058 .collect();
6059 dirs.sort();
6060 for d in dirs {
6061 collect_application_what_files(&d, root, out);
6062 }
6063 }
6064}
6065
6066async fn handle_inspector(
6071 State(state): State<AppState>,
6072 Query(params): Query<HashMap<String, String>>,
6073) -> axum::response::Response {
6074 let esc = engine::escape_html;
6075 let refresh: Option<u32> = params
6076 .get("refresh")
6077 .and_then(|v| v.parse().ok())
6078 .filter(|n| (1..=60).contains(n));
6079 let mut b = String::new();
6080
6081 b.push_str("<section id=\"overview\"><h2>Overview</h2><table>");
6083 let cfg = &state.config;
6084 let rows = [
6085 ("Framework version", env!("CARGO_PKG_VERSION").to_string()),
6086 ("Mode", if state.dev_mode { "development".into() } else { "production".into() }),
6087 ("Host : Port", format!("{}:{}", cfg.server.host, cfg.server.port)),
6088 ("CSS mode", format!("{:?}", state.css_mode).to_lowercase()),
6089 ("Sessions", if cfg.session.enabled { "enabled".into() } else { "disabled".into() }),
6090 ("Auth", if cfg.auth.enabled { "enabled".into() } else { "disabled".into() }),
6091 ("Uploads", if cfg.uploads.enabled { "enabled".into() } else { "disabled".into() }),
6092 ("Source viewer", if cfg.server.source_viewer { "on".into() } else { "off".into() }),
6093 ("Strict mode", if cfg.strict { "on".into() } else { "off".into() }),
6094 ];
6095 for (k, v) in rows {
6096 b.push_str(&format!("<tr><th>{}</th><td>{}</td></tr>", esc(k), esc(&v)));
6097 }
6098 b.push_str("</table></section>");
6099
6100 b.push_str("<section id=\"routes\"><h2>Routes</h2><table><tr><th>Path</th><th>Auth</th><th>Layout</th><th></th></tr>");
6102 let routes = discover_routes(&state.root);
6103 for (url, dynamic) in &routes {
6104 let m = resolve_route_meta(&state.root, url, *dynamic);
6105 let tags = format!(
6106 "{}{}",
6107 if m.dynamic { "<span class=\"tag\">dynamic</span>" } else { "" },
6108 if m.missing { "<span class=\"tag warn\">missing file</span>" } else { "" },
6109 );
6110 b.push_str(&format!(
6111 "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
6112 esc(&m.url), esc(&m.auth), esc(&m.layout), tags
6113 ));
6114 }
6115 b.push_str(&format!("</table><p class=\"muted\">{} route(s).</p></section>", routes.len()));
6116
6117 b.push_str("<section id=\"collections\"><h2>Collections & Policies</h2>");
6119 b.push_str("<table><tr><th>Collection</th><th>Rows</th><th>Policy</th></tr>");
6120 let ctx = state.store.as_context().await;
6121 let mut names: std::collections::BTreeSet<String> = ctx.keys().cloned().collect();
6122 for (name, _) in state.policies.configured() {
6123 names.insert(name.to_string());
6124 }
6125 if names.is_empty() {
6126 b.push_str("<tr><td colspan=\"3\" class=\"muted\">No collections yet.</td></tr>");
6127 }
6128 for name in &names {
6129 let count = ctx.get(name).and_then(|v| v.as_array()).map(|a| a.len());
6130 let count_str = count.map(|c| c.to_string()).unwrap_or_else(|| "—".to_string());
6131 let p = state.policies.get(name);
6132 let badge = if p.explicit { "<span class=\"tag\">explicit</span>" } else { "<span class=\"tag muted-tag\">default</span>" };
6133 let mut policy = format!(
6134 "create=<b>{}</b> · read=<b>{}</b> · update=<b>{}</b> · delete=<b>{}</b> · owner={}",
6135 esc(&p.create.to_string()), esc(&p.read.to_string()),
6136 esc(&p.update.to_string()), esc(&p.delete.to_string()), esc(&p.owner_mode.to_string()),
6137 );
6138 if let Some(f) = &p.filter {
6139 policy.push_str(&format!(" · filter=<code>{}</code>", esc(f)));
6140 }
6141 if !p.readonly_fields.is_empty() {
6142 policy.push_str(&format!(" · readonly=[{}]", esc(&p.readonly_fields.join(", "))));
6143 }
6144 if !p.private_fields.is_empty() {
6145 policy.push_str(&format!(" · private=[{}]", esc(&p.private_fields.join(", "))));
6146 }
6147 b.push_str(&format!(
6148 "<tr><td><code>{}</code> {}</td><td>{}</td><td class=\"policy\">{}</td></tr>",
6149 esc(name), badge, count_str, policy
6150 ));
6151 }
6152 b.push_str("</table></section>");
6153
6154 b.push_str("<section id=\"config\"><h2>application.what Inheritance</h2>");
6156 let mut app_files = Vec::new();
6157 collect_application_what_files(&state.content_dir, &state.root, &mut app_files);
6158 if app_files.is_empty() {
6159 b.push_str("<p class=\"muted\">No application.what files.</p>");
6160 } else {
6161 b.push_str("<table><tr><th>File</th><th>Declares</th></tr>");
6162 for (rel, wc) in &app_files {
6163 let mut parts = Vec::new();
6164 if let Some(a) = inspector_auth_display(&wc.directives) {
6165 parts.push(format!("auth={}", a));
6166 }
6167 let layout = wc.directives.layout.clone().or_else(|| wc.layout.clone());
6168 if let Some(l) = layout {
6169 parts.push(format!("layout={}", l));
6170 }
6171 if !wc.data_application.is_empty() {
6172 let ns: Vec<&str> = wc.data_application.iter().map(|d| d.name.as_str()).collect();
6173 parts.push(format!("data.application=[{}]", ns.join(", ")));
6174 }
6175 if !wc.data_wired.is_empty() {
6176 let ns: Vec<&str> = wc.data_wired.iter().map(|d| d.name.as_str()).collect();
6177 parts.push(format!("data.wired=[{}]", ns.join(", ")));
6178 }
6179 if !wc.data_session.is_empty() {
6180 parts.push(format!("data.session=[{}]", wc.data_session.join(", ")));
6181 }
6182 let desc = if parts.is_empty() { "(nothing auth/layout/data)".to_string() } else { parts.join(" · ") };
6183 b.push_str(&format!("<tr><td><code>{}</code></td><td>{}</td></tr>", esc(rel), esc(&desc)));
6184 }
6185 b.push_str("</table><p class=\"muted\">Child directories override parents.</p>");
6186 }
6187 b.push_str("</section>");
6188
6189 b.push_str("<section id=\"sessions\"><h2>Sessions</h2>");
6191 match &state.sessions {
6192 Some(s) => {
6193 let count = s.count().await.unwrap_or(0);
6194 let ids = s.list_session_ids().await.unwrap_or_default();
6195 b.push_str(&format!("<p><b>{}</b> active session(s).</p>", count));
6196 if !ids.is_empty() {
6197 b.push_str("<details><summary>Session IDs</summary><ul>");
6198 for id in ids.iter().take(200) {
6199 let short: String = id.chars().take(16).collect();
6200 b.push_str(&format!("<li><code>{}…</code></li>", esc(&short)));
6201 }
6202 b.push_str("</ul></details>");
6203 }
6204 }
6205 None => b.push_str("<p class=\"muted\">Sessions disabled.</p>"),
6206 }
6207 b.push_str("</section>");
6208
6209 b.push_str("<section id=\"scopes\"><h2>Write Scopes</h2>");
6211 let wired = state.wired_scopes.read().await;
6212 let app = state.app_scopes.read().await;
6213 if wired.is_empty() && app.is_empty() {
6214 b.push_str("<p class=\"muted\">No scoped wired/application variables.</p>");
6215 } else {
6216 b.push_str("<table><tr><th>Variable</th><th>Kind</th><th>Scope</th></tr>");
6217 let mut wk: Vec<_> = wired.iter().collect();
6218 wk.sort_by_key(|(k, _)| k.to_string());
6219 for (k, v) in wk {
6220 b.push_str(&format!("<tr><td><code>wired.{}</code></td><td>wired</td><td>{}</td></tr>", esc(k), esc(&v.to_string())));
6221 }
6222 let mut ak: Vec<_> = app.iter().collect();
6223 ak.sort_by_key(|(k, _)| k.to_string());
6224 for (k, v) in ak {
6225 b.push_str(&format!("<tr><td><code>app.{}</code></td><td>application</td><td>{}</td></tr>", esc(k), esc(&v.to_string())));
6226 }
6227 b.push_str("</table>");
6228 }
6229 b.push_str("</section>");
6230
6231 b.push_str("<section id=\"lints\"><h2>Template Lints</h2>");
6233 let mut total = 0;
6234 let mut lint_rows = String::new();
6235 for (url, dynamic) in &routes {
6236 if let Some(resolved) = resolve_page_path(&state.root, url) {
6237 let _ = dynamic;
6238 if let Ok(raw) = std::fs::read_to_string(&resolved.path) {
6239 let lints = engine::collect_template_lints(&raw);
6240 for l in lints {
6241 total += 1;
6242 lint_rows.push_str(&format!(
6243 "<tr><td><code>{}</code></td><td><span class=\"tag warn\">{}</span></td><td>{}</td></tr>",
6244 esc(url), esc(l.kind), esc(&l.message)
6245 ));
6246 }
6247 }
6248 }
6249 }
6250 if total == 0 {
6251 b.push_str("<p class=\"ok\">✓ No template issues found.</p>");
6252 } else {
6253 b.push_str(&format!("<table><tr><th>Page</th><th>Kind</th><th>Detail</th></tr>{}</table>", lint_rows));
6254 }
6255 b.push_str("</section>");
6256
6257 b.push_str("<section id=\"activity\"><h2>Activity");
6259 if refresh.is_some() {
6260 b.push_str(" <a class=\"refresh-link\" href=\"/w-inspector#activity\">⏸ stop auto-refresh</a>");
6261 } else {
6262 b.push_str(" <a class=\"refresh-link\" href=\"/w-inspector?refresh=2#activity\">↻ auto-refresh (2s)</a>");
6263 }
6264 b.push_str("</h2>");
6265 let events: Vec<ActivityEvent> = {
6266 let log = state.activity_log.lock().unwrap();
6267 log.iter().rev().cloned().collect()
6268 };
6269 if events.is_empty() {
6270 b.push_str("<p class=\"muted\">No activity yet — make some requests.</p>");
6271 } else {
6272 b.push_str("<table><tr><th>Time</th><th>Kind</th><th>Detail</th></tr>");
6273 for ev in &events {
6274 match ev {
6275 ActivityEvent::Request { time, method, path, status, duration_ms } => {
6276 let tag_class = if *status >= 400 { "tag warn" } else { "tag" };
6277 b.push_str(&format!(
6278 "<tr><td class=\"muted\">{}</td><td><span class=\"{}\">request</span></td><td><code>{} {}</code> → {} ({}ms)</td></tr>",
6279 time.format("%H:%M:%S"), tag_class, esc(method), esc(path), status, duration_ms
6280 ));
6281 }
6282 ActivityEvent::PolicyDenial { time, detail } => {
6283 b.push_str(&format!(
6284 "<tr><td class=\"muted\">{}</td><td><span class=\"tag warn\">deny</span></td><td>{}</td></tr>",
6285 time.format("%H:%M:%S"), esc(detail)
6286 ));
6287 }
6288 ActivityEvent::Fetch { time, key, url, elapsed_ms, result } => {
6289 b.push_str(&format!(
6290 "<tr><td class=\"muted\">{}</td><td><span class=\"tag muted-tag\">fetch</span></td><td><code>{}</code> ← <code>{}</code> → {} ({}ms)</td></tr>",
6291 time.format("%H:%M:%S"), esc(key), esc(url), esc(result), elapsed_ms
6292 ));
6293 }
6294 }
6295 }
6296 b.push_str("</table>");
6297 }
6298 b.push_str("</section>");
6299
6300 let html = render_inspector_shell(&b, refresh);
6301 build_response(
6302 StatusCode::OK,
6303 vec![(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())],
6304 html,
6305 )
6306}
6307
6308fn render_inspector_shell(body: &str, refresh: Option<u32>) -> String {
6311 let refresh_meta = refresh
6312 .map(|n| format!("<meta http-equiv=\"refresh\" content=\"{n}\">"))
6313 .unwrap_or_default();
6314 format!(
6315 r##"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">{refresh_meta}<title>What Inspector</title><style>
6316:root {{ color-scheme: light; }}
6317* {{ box-sizing: border-box; }}
6318body {{ margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; color: #1f2937; background: #f8fafc; }}
6319header {{ background: #0f172a; color: #e2e8f0; padding: 14px 20px; position: sticky; top: 0; z-index: 10; }}
6320header b {{ color: #fff; font-size: 15px; }}
6321header .badge {{ background: #334155; color: #cbd5e1; border-radius: 4px; padding: 2px 7px; font-size: 11px; margin-left: 8px; }}
6322nav {{ padding: 8px 20px; background: #1e293b; position: sticky; top: 46px; z-index: 9; }}
6323nav a {{ color: #93c5fd; text-decoration: none; margin-right: 14px; font-size: 12px; }}
6324nav a:hover {{ text-decoration: underline; }}
6325main {{ padding: 20px; max-width: 1100px; }}
6326section {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 18px; margin-bottom: 18px; }}
6327h2 {{ margin: 0 0 12px; font-size: 15px; color: #0f172a; }}
6328table {{ width: 100%; border-collapse: collapse; }}
6329th, td {{ text-align: left; padding: 6px 8px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }}
6330th {{ color: #64748b; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .03em; }}
6331td code, .policy code {{ background: #f1f5f9; padding: 1px 5px; border-radius: 3px; }}
6332.policy {{ font-size: 12px; }}
6333.muted {{ color: #94a3b8; }}
6334.ok {{ color: #16a34a; }}
6335.tag {{ display: inline-block; background: #dbeafe; color: #1e40af; border-radius: 3px; padding: 1px 6px; font-size: 11px; margin-left: 4px; }}
6336.tag.warn {{ background: #fef3c7; color: #92400e; }}
6337.tag.muted-tag {{ background: #f1f5f9; color: #64748b; }}
6338details summary {{ cursor: pointer; color: #2563eb; }}
6339ul {{ margin: 8px 0; padding-left: 20px; }}
6340.refresh-link {{ font-size: 11px; font-weight: 400; color: #2563eb; text-decoration: none; margin-left: 8px; }}
6341.refresh-link:hover {{ text-decoration: underline; }}
6342</style></head><body>
6343<header><b>What Inspector</b><span class="badge">dev</span></header>
6344<nav><a href="#overview">Overview</a><a href="#routes">Routes</a><a href="#collections">Collections</a><a href="#config">Config</a><a href="#sessions">Sessions</a><a href="#scopes">Scopes</a><a href="#lints">Lints</a><a href="#activity">Activity</a></nav>
6345<main>{body}</main>
6346</body></html>"##,
6347 body = body,
6348 refresh_meta = refresh_meta
6349 )
6350}
6351
6352async fn handle_partial(
6358 State(state): State<AppState>,
6359 headers: HeaderMap,
6360 axum::extract::Path(path): axum::extract::Path<String>,
6361 Query(query_params): Query<HashMap<String, String>>,
6362) -> Response {
6363 let clean_path = path.trim_start_matches('/');
6364
6365 let partials_dir = state.content_dir.join("partials");
6367 let file_path = if clean_path.is_empty() {
6368 partials_dir.join("index.html")
6369 } else {
6370 let with_ext = partials_dir.join(format!("{}.html", clean_path));
6371 if with_ext.exists() {
6372 with_ext
6373 } else {
6374 partials_dir.join(clean_path).join("index.html")
6375 }
6376 };
6377
6378 let canonical = match file_path.canonicalize() {
6380 Ok(p) => p,
6381 Err(_) => {
6382 return (StatusCode::NOT_FOUND, "Partial not found").into_response();
6383 }
6384 };
6385 let partials_canonical = match partials_dir.canonicalize() {
6386 Ok(p) => p,
6387 Err(_) => {
6388 return (StatusCode::NOT_FOUND, "Partial not found").into_response();
6389 }
6390 };
6391 if !canonical.starts_with(&partials_canonical) {
6392 return (StatusCode::FORBIDDEN, "Access denied").into_response();
6393 }
6394
6395 let raw_content = match tokio::fs::read_to_string(&canonical).await {
6397 Ok(c) => c,
6398 Err(_) => {
6399 return (StatusCode::NOT_FOUND, "Partial not found").into_response();
6400 }
6401 };
6402
6403 if state.dev_mode {
6404 engine::warn_template_lints_once(&canonical, &raw_content);
6405 }
6406
6407 let (directives, content) = parse_page_directives(&raw_content);
6409
6410 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
6412
6413 let (session, is_new_session) = if let Some(ref sessions) = state.sessions {
6414 let session_id =
6415 sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
6416 match sessions.get_or_create(session_id.as_deref()).await {
6417 Ok(session) => {
6418 let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
6419 (Some(session), is_new)
6420 }
6421 Err(_) => (None, false),
6422 }
6423 } else {
6424 (None, false)
6425 };
6426
6427 let user_context = if state.auth.is_enabled() {
6429 if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
6430 match state.auth.decode_jwt(&token) {
6431 Ok(claims) if !claims.is_expired() => {
6432 UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
6433 }
6434 _ => UserContext::unauthenticated(),
6435 }
6436 } else {
6437 UserContext::unauthenticated()
6438 }
6439 } else {
6440 UserContext::unauthenticated()
6441 };
6442
6443 if !partial_access_allowed(&state, clean_path, &directives, &user_context) {
6447 return (StatusCode::FORBIDDEN, "Access denied").into_response();
6448 }
6449
6450 match render_content_internal(
6452 &state,
6453 &content,
6454 session.as_ref(),
6455 &user_context,
6456 None,
6457 Some(&directives),
6458 Some(&query_params),
6459 &HashMap::new(),
6460 true,
6461 None,
6462 )
6463 .await
6464 {
6465 Ok(render_result) => {
6466 let mut html = render_result.html;
6467
6468 if !render_result.session_keys.is_empty() {
6470 if let Some(ref s) = session {
6471 let mut updates = serde_json::Map::new();
6472 for key in &render_result.session_keys {
6473 if let Some(value) = s.data.get(key) {
6474 updates.insert(format!("session.{}", key), value.clone());
6475 }
6476 }
6477 if !updates.is_empty() {
6478 let json_str = serde_json::to_string(&updates).unwrap_or_default();
6479 html.push_str(&format!(
6480 r#"<template data-what-updates>{}</template>"#,
6481 json_str
6482 ));
6483 }
6484 }
6485 }
6486
6487 if let Some(ref s) = session {
6489 if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
6490 html = inject_csrf_tokens(&html, csrf);
6491 }
6492 }
6493
6494 let mut resp_headers: Vec<(header::HeaderName, String)> = vec![
6496 (header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
6497 (
6498 header::HeaderName::from_static("x-robots-tag"),
6499 "noindex, nofollow".to_string(),
6500 ),
6501 ];
6502
6503 if is_new_session {
6505 if let Some(ref s) = session {
6506 let cookie = sessions::build_session_cookie(
6507 &s.id,
6508 &state.config.session.cookie_name,
6509 state.config.session.max_age,
6510 state.config.session.secure,
6511 );
6512 resp_headers.push((header::SET_COOKIE, cookie));
6513 }
6514 }
6515
6516 build_response(StatusCode::OK, resp_headers, html)
6517 }
6518 Err(e) => {
6519 tracing::error!("Partial render error: {}", e);
6520 (StatusCode::INTERNAL_SERVER_ERROR, "Render error").into_response()
6521 }
6522 }
6523}
6524
6525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6532pub enum CssMode {
6533 Full,
6535 Minimal,
6537 None,
6539}
6540
6541impl CssMode {
6542 pub fn from_config(value: &str) -> crate::Result<Self> {
6543 match value {
6544 "full" => Ok(Self::Full),
6545 "minimal" => Ok(Self::Minimal),
6546 "none" => Ok(Self::None),
6547 other => Err(crate::Error::Config(format!(
6548 "[server] css must be \"full\", \"minimal\", or \"none\" (got \"{}\")",
6549 other
6550 ))),
6551 }
6552 }
6553}
6554
6555const EMBEDDED_WHAT_CSS: &str = include_str!("../../assets/client/what.css");
6557
6558const MINIMAL_CUT_START: &str = "/*! what:minimal-cut-start */";
6562const MINIMAL_CUT_END: &str = "/*! what:minimal-cut-end */";
6563
6564static MINIMAL_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6565 match (
6566 EMBEDDED_WHAT_CSS.find(MINIMAL_CUT_START),
6567 EMBEDDED_WHAT_CSS.find(MINIMAL_CUT_END),
6568 ) {
6569 (Some(start), Some(end)) if end > start => format!(
6570 "{}{}",
6571 &EMBEDDED_WHAT_CSS[..start],
6572 &EMBEDDED_WHAT_CSS[end + MINIMAL_CUT_END.len()..]
6573 ),
6574 _ => EMBEDDED_WHAT_CSS.to_string(),
6575 }
6576});
6577
6578const EMBEDDED_WHAT_JS: &str = include_str!("../../assets/client/what.js");
6580
6581pub fn embedded_what_css() -> &'static str {
6584 EMBEDDED_WHAT_CSS
6585}
6586
6587pub fn embedded_what_js() -> &'static str {
6590 EMBEDDED_WHAT_JS
6591}
6592
6593static MINIFIED_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6595 let cfg = minify_html::Cfg { minify_css: true, ..minify_html::Cfg::default() };
6596 let wrapped = format!("<style>{}</style>", EMBEDDED_WHAT_CSS);
6597 let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6598 String::from_utf8(minified)
6599 .map(|s| s.strip_prefix("<style>").unwrap_or(&s).strip_suffix("</style>").unwrap_or(&s).to_string())
6600 .unwrap_or_else(|_| EMBEDDED_WHAT_CSS.to_string())
6601});
6602
6603static MINIFIED_MINIMAL_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6604 let cfg = minify_html::Cfg { minify_css: true, ..minify_html::Cfg::default() };
6605 let wrapped = format!("<style>{}</style>", MINIMAL_WHAT_CSS.as_str());
6606 let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6607 String::from_utf8(minified)
6608 .map(|s| s.strip_prefix("<style>").unwrap_or(&s).strip_suffix("</style>").unwrap_or(&s).to_string())
6609 .unwrap_or_else(|_| MINIMAL_WHAT_CSS.clone())
6610});
6611
6612static MINIFIED_WHAT_JS: LazyLock<String> = LazyLock::new(|| {
6613 let cfg = minify_html::Cfg { minify_js: true, ..minify_html::Cfg::default() };
6614 let wrapped = format!("<script>{}</script>", EMBEDDED_WHAT_JS);
6615 let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6616 String::from_utf8(minified)
6617 .map(|s| s.strip_prefix("<script>").unwrap_or(&s).strip_suffix("</script>").unwrap_or(&s).to_string())
6618 .unwrap_or_else(|_| EMBEDDED_WHAT_JS.to_string())
6619});
6620
6621static WHAT_CSS_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6624 let hash = embedded_asset_hash(EMBEDDED_WHAT_CSS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6625 format!("/static/what.css?v={:08x}", hash)
6626});
6627static WHAT_CSS_MINIMAL_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6628 let hash =
6629 embedded_asset_hash(&MINIMAL_WHAT_CSS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6630 format!("/static/what.css?v={:08x}", hash)
6631});
6632static WHAT_JS_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6633 let hash = embedded_asset_hash(EMBEDDED_WHAT_JS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6634 format!("/static/what.js?v={:08x}", hash)
6635});
6636
6637pub fn embedded_what_css_for_mode(mode: CssMode) -> &'static str {
6639 match mode {
6640 CssMode::Minimal => MINIMAL_WHAT_CSS.as_str(),
6641 _ => EMBEDDED_WHAT_CSS,
6642 }
6643}
6644
6645fn embedded_asset_hash(content: &str) -> u32 {
6646 let mut hash: u32 = 0x811c9dc5;
6647 for byte in content.as_bytes() {
6648 hash ^= *byte as u32;
6649 hash = hash.wrapping_mul(0x0100_0193);
6650 }
6651 hash
6652}
6653
6654async fn handle_embedded_css(State(state): State<AppState>) -> impl IntoResponse {
6657 let minimal = state.css_mode == CssMode::Minimal;
6658 let (cache, content) = if state.dev_mode {
6659 let raw = if minimal { MINIMAL_WHAT_CSS.as_str() } else { EMBEDDED_WHAT_CSS };
6660 ("no-cache, no-store, must-revalidate", raw.to_string())
6661 } else {
6662 let min = if minimal { MINIFIED_MINIMAL_WHAT_CSS.clone() } else { MINIFIED_WHAT_CSS.clone() };
6663 ("public, max-age=31536000, immutable", min)
6664 };
6665 (
6666 StatusCode::OK,
6667 [
6668 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
6669 (header::CACHE_CONTROL, cache),
6670 ],
6671 content,
6672 )
6673}
6674
6675async fn handle_embedded_js(State(state): State<AppState>) -> impl IntoResponse {
6678 let (cache, content) = if state.dev_mode {
6679 ("no-cache, no-store, must-revalidate", EMBEDDED_WHAT_JS.to_string())
6680 } else {
6681 ("public, max-age=31536000, immutable", MINIFIED_WHAT_JS.clone())
6682 };
6683 (
6684 StatusCode::OK,
6685 [
6686 (
6687 header::CONTENT_TYPE,
6688 "application/javascript; charset=utf-8",
6689 ),
6690 (header::CACHE_CONTROL, cache),
6691 ],
6692 content,
6693 )
6694}
6695
6696async fn handle_page_source(
6698 State(state): State<AppState>,
6699 axum::extract::Path(path): axum::extract::Path<String>,
6700) -> impl IntoResponse {
6701 let not_available = axum::Json(json!({
6702 "files": [{ "label": "Page", "content": "Source not available" }]
6703 }));
6704
6705 let clean_path = path.trim_start_matches('/');
6707 let pages_dir = state.content_dir.clone();
6708 let file_path = pages_dir.join(clean_path);
6709
6710 let file_path = if file_path.extension().is_some() {
6712 file_path
6713 } else {
6714 let with_ext = file_path.with_extension("html");
6715 if with_ext.exists() {
6716 with_ext
6717 } else {
6718 file_path.join("index.html")
6719 }
6720 };
6721
6722 let canonical = match file_path.canonicalize() {
6724 Ok(p) => p,
6725 Err(_) => return not_available,
6726 };
6727 let pages_canonical = match pages_dir.canonicalize() {
6728 Ok(p) => p,
6729 Err(_) => return not_available,
6730 };
6731 if !canonical.starts_with(&pages_canonical) {
6732 return not_available;
6733 }
6734
6735 if std::fs::read_to_string(&canonical).is_err() {
6737 return not_available;
6738 }
6739
6740 let mut files: Vec<Value> = Vec::new();
6742 let mut scan_queue: Vec<(PathBuf, String)> = Vec::new();
6743
6744 static COMPONENT_RE: LazyLock<Regex> =
6746 LazyLock::new(|| Regex::new(r"<what-([a-zA-Z][a-zA-Z0-9_-]*)[\s/>]").unwrap());
6747 static INCLUDE_RE: LazyLock<Regex> =
6748 LazyLock::new(|| Regex::new(r#"<include\s+src="([^"]+)""#).unwrap());
6749 static PARTIAL_ROUTE_RE: LazyLock<Regex> =
6750 LazyLock::new(|| Regex::new(r#"w-get="/partials/([^"?#]+)""#).unwrap());
6751 static PARTIAL_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
6752 Regex::new(r#"name="w-partial"[^>]*value="([^"]+)"|value="([^"]+)"[^>]*name="w-partial""#)
6753 .unwrap()
6754 });
6755
6756 let project_root = pages_dir.parent().unwrap_or(&pages_dir);
6757 let project_root_canonical = match project_root.canonicalize() {
6758 Ok(p) => p,
6759 Err(_) => return not_available,
6760 };
6761 let components_dir = project_root.join("components");
6762 let mut seen_paths = std::collections::HashSet::new();
6763
6764 push_source_file(
6765 canonical.clone(),
6766 &project_root_canonical,
6767 &mut seen_paths,
6768 &mut files,
6769 &mut scan_queue,
6770 );
6771
6772 while let Some((current_path, current_content)) = scan_queue.pop() {
6777 for cap in COMPONENT_RE.captures_iter(¤t_content) {
6778 let name = cap[1].to_string();
6779 push_source_file(
6780 components_dir.join(format!("{}.html", name)),
6781 &project_root_canonical,
6782 &mut seen_paths,
6783 &mut files,
6784 &mut scan_queue,
6785 );
6786 }
6787
6788 for cap in INCLUDE_RE.captures_iter(¤t_content) {
6789 let src = cap[1].to_string();
6790 let file_dir = current_path.parent().unwrap_or(project_root);
6791 let candidates = [
6792 project_root.join(&src),
6793 pages_dir.join(&src),
6794 file_dir.join(&src),
6795 ];
6796 for candidate in candidates {
6797 if push_source_file(
6798 candidate,
6799 &project_root_canonical,
6800 &mut seen_paths,
6801 &mut files,
6802 &mut scan_queue,
6803 ) {
6804 break;
6805 }
6806 }
6807 }
6808
6809 for cap in PARTIAL_ROUTE_RE.captures_iter(¤t_content) {
6810 let partial = format!("{}.html", &cap[1]);
6811 push_source_file(
6812 pages_dir.join("partials").join(partial),
6813 &project_root_canonical,
6814 &mut seen_paths,
6815 &mut files,
6816 &mut scan_queue,
6817 );
6818 }
6819
6820 for cap in PARTIAL_NAME_RE.captures_iter(¤t_content) {
6821 if let Some(name) = cap.get(1).or_else(|| cap.get(2)) {
6822 push_source_file(
6823 pages_dir.join("partials").join(format!("{}.html", name.as_str())),
6824 &project_root_canonical,
6825 &mut seen_paths,
6826 &mut files,
6827 &mut scan_queue,
6828 );
6829 }
6830 }
6831 }
6832
6833 axum::Json(json!({ "files": files }))
6834}
6835
6836async fn handle_livereload_ws(
6838 ws: WebSocketUpgrade,
6839 State(state): State<AppState>,
6840) -> impl IntoResponse {
6841 ws.on_upgrade(|socket| handle_livereload_socket(socket, state))
6842}
6843
6844async fn handle_livereload_socket(socket: WebSocket, state: AppState) {
6846 let (mut sender, mut receiver) = socket.split();
6847
6848 let mut reload_rx = match state.live_reload_receiver() {
6850 Some(rx) => rx,
6851 None => {
6852 tracing::warn!("Live reload not enabled");
6853 return;
6854 }
6855 };
6856
6857 tracing::debug!("Live reload client connected");
6858
6859 let _ = sender
6861 .send(Message::Text("{\"type\":\"connected\"}".to_string()))
6862 .await;
6863
6864 let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
6866
6867 let recv_task = tokio::spawn(async move {
6869 while let Some(msg) = receiver.next().await {
6870 match msg {
6871 Ok(Message::Close(_)) => break,
6872 Ok(Message::Ping(_)) => {
6873 }
6875 Err(e) => {
6876 tracing::debug!("WebSocket receive error: {}", e);
6877 break;
6878 }
6879 _ => {}
6880 }
6881 }
6882 let _ = stop_tx.send(());
6884 });
6885
6886 loop {
6888 tokio::select! {
6889 result = reload_rx.recv() => {
6890 match result {
6891 Ok(LiveReloadMessage::Reload) => {
6892 if sender.send(Message::Text("{\"type\":\"reload\"}".to_string())).await.is_err() {
6893 break;
6894 }
6895 }
6896 Ok(LiveReloadMessage::CacheCleared) => {
6897 if sender.send(Message::Text("{\"type\":\"cache_cleared\"}".to_string())).await.is_err() {
6898 break;
6899 }
6900 }
6901 Err(broadcast::error::RecvError::Closed) => break,
6902 Err(broadcast::error::RecvError::Lagged(_)) => continue,
6903 }
6904 }
6905 _ = &mut stop_rx => {
6906 break;
6908 }
6909 }
6910 }
6911
6912 recv_task.abort();
6914
6915 tracing::debug!("Live reload client disconnected");
6916}
6917
6918async fn handle_wire_ws(
6920 ws: WebSocketUpgrade,
6921 headers: HeaderMap,
6922 State(state): State<AppState>,
6923) -> impl IntoResponse {
6924 let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
6926 let (client_roles, client_user_id) = if state.auth.is_enabled() {
6927 if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
6928 match state.auth.decode_jwt(&token) {
6929 Ok(claims) if !claims.is_expired() => {
6930 let user =
6931 UserContext::from_claims(claims.to_context(state.auth.jwt_claims()));
6932 (user.roles(), claims.sub.clone())
6933 }
6934 _ => (Vec::new(), None),
6935 }
6936 } else {
6937 (Vec::new(), None)
6938 }
6939 } else {
6940 (Vec::new(), None)
6941 };
6942
6943 ws.on_upgrade(move |socket| handle_wire_socket(socket, state, client_roles, client_user_id))
6944}
6945
6946async fn handle_wire_socket(
6948 socket: WebSocket,
6949 state: AppState,
6950 client_roles: Vec<String>,
6951 client_user_id: Option<String>,
6952) {
6953 let (mut sender, mut receiver) = socket.split();
6954 let mut wired_rx = state.wired_tx.subscribe();
6955
6956 let count = state
6958 .wired_client_count
6959 .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6960 + 1;
6961 tracing::debug!(
6962 "Wired client connected (roles: {:?}, user: {:?}, total: {})",
6963 client_roles,
6964 client_user_id,
6965 count
6966 );
6967
6968 let connected_msg = format!(r#"{{"type":"connected","clients":{}}}"#, count);
6970 let _ = sender.send(Message::Text(connected_msg)).await;
6971
6972 let _ = state.wired_tx.send(WiredMessage {
6974 json: format!(r#"{{"wired._clients":"{}"}}"#, count),
6975 scope: WiredScope::Public,
6976 });
6977
6978 let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
6979
6980 let recv_task = tokio::spawn(async move {
6981 while let Some(msg) = receiver.next().await {
6982 match msg {
6983 Ok(Message::Close(_)) => break,
6984 Err(_) => break,
6985 _ => {}
6986 }
6987 }
6988 let _ = stop_tx.send(());
6989 });
6990
6991 loop {
6992 tokio::select! {
6993 result = wired_rx.recv() => {
6994 match result {
6995 Ok(msg) => {
6996 if msg.scope.allows(&client_roles, client_user_id.as_deref()) {
6998 if sender.send(Message::Text(msg.json)).await.is_err() {
6999 break;
7000 }
7001 }
7002 }
7003 Err(broadcast::error::RecvError::Closed) => break,
7004 Err(broadcast::error::RecvError::Lagged(_)) => continue,
7005 }
7006 }
7007 _ = &mut stop_rx => {
7008 break;
7009 }
7010 }
7011 }
7012
7013 recv_task.abort();
7014
7015 let count = state
7017 .wired_client_count
7018 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed)
7019 - 1;
7020 let _ = state.wired_tx.send(WiredMessage {
7021 json: format!(r#"{{"wired._clients":"{}"}}"#, count),
7022 scope: WiredScope::Public,
7023 });
7024 tracing::debug!("Wired client disconnected (total: {})", count);
7025}
7026
7027pub async fn serve(state: AppState) -> Result<()> {
7029 let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
7030 let listener = tokio::net::TcpListener::bind(&addr).await?;
7031
7032 tracing::info!("What server running at http://{}", addr);
7033
7034 let router = create_router(state);
7035 axum::serve(
7037 listener,
7038 router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
7039 )
7040 .await?;
7041
7042 Ok(())
7043}
7044
7045pub async fn serve_with_shutdown(
7048 state: AppState,
7049 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
7050) -> Result<()> {
7051 let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
7052 let listener = tokio::net::TcpListener::bind(&addr).await?;
7053 serve_on_listener(state, listener, shutdown).await
7054}
7055
7056pub async fn serve_on_listener(
7059 state: AppState,
7060 listener: tokio::net::TcpListener,
7061 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
7062) -> Result<()> {
7063 tracing::info!("What server running at http://{}", listener.local_addr()?);
7064
7065 let router = create_router(state);
7066 axum::serve(
7067 listener,
7068 router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
7069 )
7070 .with_graceful_shutdown(shutdown)
7071 .await?;
7072
7073 Ok(())
7074}
7075
7076#[cfg(test)]
7077mod tests {
7078 use super::*;
7079 use std::fs;
7080 use tempfile::TempDir;
7081
7082 #[test]
7083 fn resolve_client_ip_honors_trusted_hops() {
7084 use axum::body::Body;
7085 use axum::extract::ConnectInfo;
7086 let mk = |xff: Option<&str>, peer: Option<&str>| {
7087 let mut req = Request::builder().uri("/").body(Body::empty()).unwrap();
7088 if let Some(x) = xff {
7089 req.headers_mut()
7090 .insert("x-forwarded-for", x.parse().unwrap());
7091 }
7092 if let Some(p) = peer {
7093 req.extensions_mut()
7094 .insert(ConnectInfo(p.parse::<std::net::SocketAddr>().unwrap()));
7095 }
7096 req
7097 };
7098
7099 assert_eq!(
7101 resolve_client_ip(&mk(Some("1.2.3.4"), Some("10.0.0.9:5000")), 0),
7102 "10.0.0.9"
7103 );
7104 assert_eq!(
7106 resolve_client_ip(&mk(Some("1.2.3.4, 5.6.7.8"), Some("10.0.0.9:5000")), 1),
7107 "5.6.7.8"
7108 );
7109 assert_eq!(
7111 resolve_client_ip(&mk(None, Some("10.0.0.9:5000")), 1),
7112 "10.0.0.9"
7113 );
7114 assert_eq!(
7116 resolve_client_ip(&mk(Some("1.1.1.1, 2.2.2.2, 3.3.3.3"), None), 2),
7117 "2.2.2.2"
7118 );
7119 assert_eq!(resolve_client_ip(&mk(None, None), 0), "unknown");
7121 }
7122
7123 fn create_test_project() -> TempDir {
7125 let temp_dir = TempDir::new().unwrap();
7126 let root = temp_dir.path();
7127 let cd = content_dir_name(root);
7128
7129 fs::create_dir_all(root.join(cd).join("admin")).unwrap();
7131 fs::create_dir_all(root.join(cd).join("blog")).unwrap();
7132
7133 fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
7135 fs::write(root.join(cd).join("about.html"), "<h1>About</h1>").unwrap();
7136 fs::write(root.join(cd).join("admin/index.html"), "<h1>Admin</h1>").unwrap();
7137 fs::write(root.join(cd).join("admin/users.html"), "<h1>Users</h1>").unwrap();
7138 fs::write(root.join(cd).join("blog/[id].html"), "<h1>Blog Post</h1>").unwrap();
7139
7140 temp_dir
7141 }
7142
7143 #[test]
7144 fn test_resolve_page_path_exact_match() {
7145 let temp_dir = create_test_project();
7146 let root = temp_dir.path().to_path_buf();
7147
7148 let result = resolve_page_path(&root, "/about");
7150 assert!(result.is_some());
7151 let resolved = result.unwrap();
7152 assert!(resolved.path.ends_with("about.html"));
7153 assert!(resolved.params.is_empty());
7154 }
7155
7156 #[test]
7157 fn test_resolve_page_path_index() {
7158 let temp_dir = create_test_project();
7159 let root = temp_dir.path().to_path_buf();
7160
7161 let result = resolve_page_path(&root, "/");
7163 assert!(result.is_some());
7164 assert!(result.unwrap().path.ends_with("index.html"));
7165
7166 let result = resolve_page_path(&root, "/admin");
7168 assert!(result.is_some());
7169 let resolved = result.unwrap();
7170 assert!(resolved.path.to_string_lossy().contains("admin"));
7171 assert!(resolved.path.ends_with("index.html"));
7172 }
7173
7174 #[test]
7175 fn test_resolve_page_path_nested() {
7176 let temp_dir = create_test_project();
7177 let root = temp_dir.path().to_path_buf();
7178
7179 let result = resolve_page_path(&root, "/admin/users");
7181 assert!(result.is_some());
7182 assert!(result.unwrap().path.ends_with("users.html"));
7183 }
7184
7185 #[test]
7186 fn test_resolve_page_path_dynamic() {
7187 let temp_dir = create_test_project();
7188 let root = temp_dir.path().to_path_buf();
7189
7190 let result = resolve_page_path(&root, "/blog/123");
7192 assert!(result.is_some());
7193 let resolved = result.unwrap();
7194 assert!(resolved.path.ends_with("[id].html"));
7195 assert_eq!(resolved.params.get("id"), Some(&"123".to_string()));
7196 }
7197
7198 #[test]
7199 fn test_resolve_page_path_not_found() {
7200 let temp_dir = create_test_project();
7201 let root = temp_dir.path().to_path_buf();
7202
7203 let result = resolve_page_path(&root, "/nonexistent");
7205 assert!(result.is_none());
7206 }
7207
7208 fn create_test_project_with_config() -> TempDir {
7210 let temp_dir = TempDir::new().unwrap();
7211 let root = temp_dir.path();
7212 let cd = content_dir_name(root);
7213
7214 fs::create_dir_all(root.join(cd).join("admin")).unwrap();
7216 fs::create_dir_all(root.join(cd).join("admin/settings")).unwrap();
7217
7218 fs::write(
7220 root.join(cd).join("application.what"),
7221 r#"
7222title = "My App"
7223theme = "light"
7224auth = "all"
7225"#,
7226 )
7227 .unwrap();
7228
7229 fs::write(
7231 root.join(cd).join("admin/application.what"),
7232 r#"
7233title = "Admin Panel"
7234auth = "admin"
7235"#,
7236 )
7237 .unwrap();
7238
7239 fs::write(
7241 root.join(cd).join("admin/settings/application.what"),
7242 r#"
7243title = "Settings"
7244debug = true
7245"#,
7246 )
7247 .unwrap();
7248
7249 fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
7251 fs::write(root.join(cd).join("admin/index.html"), "<h1>Admin</h1>").unwrap();
7252 fs::write(
7253 root.join(cd).join("admin/settings/index.html"),
7254 "<h1>Settings</h1>",
7255 )
7256 .unwrap();
7257
7258 temp_dir
7259 }
7260
7261 #[test]
7262 fn test_load_application_config_root() {
7263 let temp_dir = create_test_project_with_config();
7264 let root = temp_dir.path().to_path_buf();
7265
7266 let config = load_application_config(&root, "/");
7268
7269 assert_eq!(config.get_string("title"), Some("My App"));
7270 assert_eq!(config.get_string("theme"), Some("light"));
7271 assert_eq!(config.directives.auth, crate::parser::AuthLevel::All);
7272 }
7273
7274 #[test]
7275 fn test_load_application_config_inheritance() {
7276 let temp_dir = create_test_project_with_config();
7277 let root = temp_dir.path().to_path_buf();
7278
7279 let config = load_application_config(&root, "/admin");
7281
7282 assert_eq!(config.get_string("title"), Some("Admin Panel"));
7283 assert_eq!(config.get_string("theme"), Some("light")); assert_eq!(
7285 config.directives.auth,
7286 crate::parser::AuthLevel::Roles(vec!["admin".to_string()])
7287 );
7288 }
7289
7290 #[test]
7291 fn test_load_application_config_deep_inheritance() {
7292 let temp_dir = create_test_project_with_config();
7293 let root = temp_dir.path().to_path_buf();
7294
7295 let config = load_application_config(&root, "/admin/settings");
7297
7298 assert_eq!(config.get_string("title"), Some("Settings"));
7299 assert_eq!(config.get_string("theme"), Some("light")); assert_eq!(config.get_bool("debug"), Some(true)); assert_eq!(
7303 config.directives.auth,
7304 crate::parser::AuthLevel::Roles(vec!["admin".to_string()])
7305 );
7306 }
7307
7308 #[test]
7309 fn test_load_application_config_no_config() {
7310 let temp_dir = create_test_project();
7311 let root = temp_dir.path().to_path_buf();
7312
7313 let config = load_application_config(&root, "/about");
7315
7316 assert!(config.values.is_empty());
7318 assert_eq!(config.directives.auth, crate::parser::AuthLevel::All);
7319 }
7320
7321 #[test]
7322 fn test_content_dir_name_prefers_site() {
7323 let temp = TempDir::new().unwrap();
7324 let root = temp.path();
7325
7326 assert_eq!(content_dir_name(root), "site");
7328
7329 fs::create_dir_all(root.join("pages")).unwrap();
7331 assert_eq!(content_dir_name(root), "pages");
7332
7333 fs::create_dir_all(root.join("site")).unwrap();
7335 assert_eq!(content_dir_name(root), "site");
7336 }
7337
7338 #[test]
7339 fn test_content_dir_name_only_site() {
7340 let temp = TempDir::new().unwrap();
7341 let root = temp.path();
7342 fs::create_dir_all(root.join("site")).unwrap();
7343 assert_eq!(content_dir_name(root), "site");
7344 }
7345
7346 #[test]
7347 fn test_app_state_dev_mode_disabled() {
7348 let temp_dir = create_test_project();
7349 let root = temp_dir.path().to_path_buf();
7350 let config = crate::Config::default();
7351
7352 let state = AppState::new(config, root).unwrap();
7353
7354 assert!(!state.dev_mode);
7355 assert!(state.live_reload_tx.is_none());
7356 assert!(state.live_reload_receiver().is_none());
7357 }
7358
7359 #[test]
7360 fn test_app_state_dev_mode_enabled() {
7361 let temp_dir = create_test_project();
7362 let root = temp_dir.path().to_path_buf();
7363 let config = crate::Config::default();
7364
7365 let state = AppState::with_dev_mode(config, root, true).unwrap();
7366
7367 assert!(state.dev_mode);
7368 assert!(state.live_reload_tx.is_some());
7369 assert!(state.live_reload_receiver().is_some());
7370 }
7371
7372 #[test]
7373 fn test_dev_mode_disables_secure_cookies() {
7374 let temp_dir = create_test_project();
7375 let root = temp_dir.path().to_path_buf();
7376 let config = crate::Config::default();
7377 assert!(config.session.secure);
7379
7380 let state = AppState::with_dev_mode(config, root, true).unwrap();
7382 assert!(!state.config.session.secure);
7383 }
7384
7385 #[test]
7386 fn test_production_mode_keeps_secure_cookies() {
7387 let temp_dir = create_test_project();
7388 let root = temp_dir.path().to_path_buf();
7389 let config = crate::Config::default();
7390
7391 let state = AppState::new(config, root).unwrap();
7393 assert!(state.config.session.secure);
7394 }
7395
7396 #[test]
7397 fn test_live_reload_broadcast() {
7398 let temp_dir = create_test_project();
7399 let root = temp_dir.path().to_path_buf();
7400 let config = crate::Config::default();
7401
7402 let state = AppState::with_dev_mode(config, root, true).unwrap();
7403
7404 let mut rx = state.live_reload_receiver().unwrap();
7406
7407 if let Some(ref tx) = state.live_reload_tx {
7409 tx.send(LiveReloadMessage::Reload).unwrap();
7410 }
7411
7412 let msg = rx.try_recv().unwrap();
7414 assert!(matches!(msg, LiveReloadMessage::Reload));
7415 }
7416
7417 #[test]
7418 fn test_live_reload_multiple_subscribers() {
7419 let temp_dir = create_test_project();
7420 let root = temp_dir.path().to_path_buf();
7421 let config = crate::Config::default();
7422
7423 let state = AppState::with_dev_mode(config, root, true).unwrap();
7424
7425 let mut rx1 = state.live_reload_receiver().unwrap();
7427 let mut rx2 = state.live_reload_receiver().unwrap();
7428
7429 if let Some(ref tx) = state.live_reload_tx {
7431 tx.send(LiveReloadMessage::Reload).unwrap();
7432 }
7433
7434 assert!(matches!(rx1.try_recv().unwrap(), LiveReloadMessage::Reload));
7436 assert!(matches!(rx2.try_recv().unwrap(), LiveReloadMessage::Reload));
7437 }
7438
7439 #[test]
7444 fn test_collect_fetch_directives_from_page() {
7445 let mut directives = crate::parser::PageDirectives::default();
7446 directives.custom.insert(
7447 "fetch.dog_facts".to_string(),
7448 "https://dogapi.dog/api/v2/facts?limit=3".to_string(),
7449 );
7450 directives.custom.insert(
7451 "fetch.dog_breeds".to_string(),
7452 "https://dogapi.dog/api/v2/breeds".to_string(),
7453 );
7454
7455 let fetches = collect_fetch_directives(None, Some(&directives));
7456
7457 assert_eq!(fetches.len(), 2);
7458 assert_eq!(
7459 fetches.get("dog_facts").map(|f| f.url.as_str()),
7460 Some("https://dogapi.dog/api/v2/facts?limit=3")
7461 );
7462 assert_eq!(
7463 fetches.get("dog_breeds").map(|f| f.url.as_str()),
7464 Some("https://dogapi.dog/api/v2/breeds")
7465 );
7466 }
7467
7468 #[test]
7469 fn test_collect_fetch_directives_excludes_non_fetch() {
7470 let mut directives = crate::parser::PageDirectives::default();
7471 directives
7472 .custom
7473 .insert("page".to_string(), "remote-data".to_string());
7474 directives.custom.insert(
7475 "fetch.api".to_string(),
7476 "https://example.com/api".to_string(),
7477 );
7478
7479 let fetches = collect_fetch_directives(None, Some(&directives));
7480
7481 assert_eq!(fetches.len(), 1);
7482 assert!(fetches.contains_key("api"));
7483 assert!(!fetches.contains_key("page"));
7484 }
7485
7486 #[test]
7487 fn test_collect_fetch_directives_empty() {
7488 let directives = crate::parser::PageDirectives::default();
7489 let fetches = collect_fetch_directives(None, Some(&directives));
7490 assert!(fetches.is_empty());
7491 }
7492
7493 #[test]
7494 fn test_collect_fetch_directives_enhanced() {
7495 let mut directives = crate::parser::PageDirectives::default();
7496 directives.custom.insert(
7497 "fetch.users.url".to_string(),
7498 "https://api.example.com/users".to_string(),
7499 );
7500 directives
7501 .custom
7502 .insert("fetch.users.method".to_string(), "POST".to_string());
7503 directives.custom.insert(
7504 "fetch.users.headers".to_string(),
7505 "Authorization: Bearer token".to_string(),
7506 );
7507 directives
7508 .custom
7509 .insert("fetch.users.path".to_string(), "data.results".to_string());
7510
7511 let fetches = collect_fetch_directives(None, Some(&directives));
7512 assert_eq!(fetches.len(), 1);
7513 let users = fetches.get("users").unwrap();
7514 assert_eq!(users.url, "https://api.example.com/users");
7515 assert_eq!(users.method, "POST");
7516 assert_eq!(users.headers.len(), 1);
7517 assert_eq!(
7518 users.headers[0],
7519 ("Authorization".to_string(), "Bearer token".to_string())
7520 );
7521 assert_eq!(users.path.as_deref(), Some("data.results"));
7522 }
7523
7524 #[test]
7525 fn test_extract_json_path() {
7526 let value = serde_json::json!({
7527 "data": {
7528 "results": [1, 2, 3],
7529 "meta": { "total": 100 }
7530 }
7531 });
7532 assert_eq!(
7533 extract_json_path(&value, "data.results"),
7534 serde_json::json!([1, 2, 3])
7535 );
7536 assert_eq!(
7537 extract_json_path(&value, "data.meta.total"),
7538 serde_json::json!(100)
7539 );
7540 assert_eq!(
7541 extract_json_path(&value, "data.missing"),
7542 serde_json::Value::Null
7543 );
7544 }
7545
7546 #[test]
7547 fn test_parse_header_string() {
7548 let headers = parse_header_string("Authorization: Bearer token, Accept: application/json");
7549 assert_eq!(headers.len(), 2);
7550 assert_eq!(
7551 headers[0],
7552 ("Authorization".to_string(), "Bearer token".to_string())
7553 );
7554 assert_eq!(
7555 headers[1],
7556 ("Accept".to_string(), "application/json".to_string())
7557 );
7558 }
7559
7560 #[test]
7565 fn test_parse_w_set_increment() {
7566 let result = parse_w_set_expr("session.counter += 1");
7567 assert!(result.is_some());
7568 let (scope, key, op) = result.unwrap();
7569 assert_eq!(scope, "session");
7570 assert_eq!(key, "counter");
7571 assert!(matches!(op, SetOp::Increment(1)));
7572 }
7573
7574 #[test]
7575 fn test_parse_w_set_decrement() {
7576 let result = parse_w_set_expr("session.counter -= 1");
7577 assert!(result.is_some());
7578 let (scope, key, op) = result.unwrap();
7579 assert_eq!(scope, "session");
7580 assert_eq!(key, "counter");
7581 assert!(matches!(op, SetOp::Decrement(1)));
7582 }
7583
7584 #[test]
7585 fn test_parse_w_set_assign_int() {
7586 let result = parse_w_set_expr("app.app_counter = 0");
7587 assert!(result.is_some());
7588 let (scope, key, op) = result.unwrap();
7589 assert_eq!(scope, "app");
7590 assert_eq!(key, "app_counter");
7591 assert!(matches!(op, SetOp::SetInt(0)));
7592 }
7593
7594 #[test]
7595 fn test_parse_w_set_assign_string() {
7596 let result = parse_w_set_expr("session.name = 'Jorge'");
7597 assert!(result.is_some());
7598 let (scope, key, op) = result.unwrap();
7599 assert_eq!(scope, "session");
7600 assert_eq!(key, "name");
7601 match op {
7602 SetOp::SetStr(s) => assert_eq!(s, "Jorge"),
7603 _ => panic!("Expected SetStr"),
7604 }
7605 }
7606
7607 #[test]
7608 fn test_parse_w_set_large_increment() {
7609 let result = parse_w_set_expr("app.views += 100");
7610 assert!(result.is_some());
7611 let (scope, key, op) = result.unwrap();
7612 assert_eq!(scope, "app");
7613 assert_eq!(key, "views");
7614 assert!(matches!(op, SetOp::Increment(100)));
7615 }
7616
7617 #[test]
7618 fn test_parse_w_set_whitespace() {
7619 let result = parse_w_set_expr(" session.counter += 5 ");
7620 assert!(result.is_some());
7621 let (scope, key, op) = result.unwrap();
7622 assert_eq!(scope, "session");
7623 assert_eq!(key, "counter");
7624 assert!(matches!(op, SetOp::Increment(5)));
7625 }
7626
7627 #[test]
7628 fn test_parse_w_set_invalid_no_scope() {
7629 assert!(parse_w_set_expr("counter += 1").is_none());
7630 }
7631
7632 #[test]
7633 fn test_parse_w_set_invalid_bad_value() {
7634 assert!(parse_w_set_expr("session.counter += abc").is_none());
7635 }
7636
7637 #[test]
7638 fn test_parse_w_set_invalid_empty() {
7639 assert!(parse_w_set_expr("").is_none());
7640 }
7641
7642 #[test]
7643 fn test_parse_w_set_multi_expression() {
7644 let expr_str = "session.counter += 1; app.views += 1";
7645 let parsed: Vec<_> = expr_str
7646 .split(';')
7647 .map(|s| s.trim())
7648 .filter(|s| !s.is_empty())
7649 .filter_map(|s| parse_w_set_expr(s))
7650 .collect();
7651 assert_eq!(parsed.len(), 2);
7652 assert_eq!(parsed[0].0, "session");
7653 assert_eq!(parsed[0].1, "counter");
7654 assert!(matches!(parsed[0].2, SetOp::Increment(1)));
7655 assert_eq!(parsed[1].0, "app");
7656 assert_eq!(parsed[1].1, "views");
7657 assert!(matches!(parsed[1].2, SetOp::Increment(1)));
7658 }
7659
7660 #[test]
7661 fn test_apply_set_op_increment_from_none() {
7662 let result = apply_set_op(None, &SetOp::Increment(1));
7663 assert_eq!(result, json!(1));
7664 }
7665
7666 #[test]
7667 fn test_apply_set_op_increment_existing() {
7668 let current = json!(5);
7669 let result = apply_set_op(Some(¤t), &SetOp::Increment(3));
7670 assert_eq!(result, json!(8));
7671 }
7672
7673 #[test]
7674 fn test_apply_set_op_set_string() {
7675 let result = apply_set_op(None, &SetOp::SetStr("hello".to_string()));
7676 assert_eq!(result, json!("hello"));
7677 }
7678
7679 #[test]
7680 fn test_parse_w_set_wired_scope() {
7681 let result = parse_w_set_expr("wired.total_dogs += 1");
7682 assert!(result.is_some());
7683 let (scope, key, op) = result.unwrap();
7684 assert_eq!(scope, "wired");
7685 assert_eq!(key, "total_dogs");
7686 assert!(matches!(op, SetOp::Increment(1)));
7687 }
7688
7689 #[test]
7690 fn test_parse_w_set_wired_reset() {
7691 let result = parse_w_set_expr("wired.counter = 0");
7692 assert!(result.is_some());
7693 let (scope, key, op) = result.unwrap();
7694 assert_eq!(scope, "wired");
7695 assert_eq!(key, "counter");
7696 assert!(matches!(op, SetOp::SetInt(0)));
7697 }
7698
7699 #[test]
7700 fn test_wired_broadcast_channel() {
7701 let temp_dir = create_test_project();
7702 let root = temp_dir.path().to_path_buf();
7703 let config = crate::Config::default();
7704 let state = AppState::new(config, root).unwrap();
7705
7706 let mut rx = state.wired_tx.subscribe();
7707 let _ = state.wired_tx.send(WiredMessage {
7708 json: r#"{"wired.counter":"5"}"#.to_string(),
7709 scope: WiredScope::Public,
7710 });
7711 let msg = rx.try_recv().unwrap();
7712 assert!(msg.json.contains("wired.counter"));
7713 assert!(msg.json.contains("5"));
7714 }
7715
7716 #[test]
7717 fn test_error_page_dev_mode_shows_detail() {
7718 let page = error_page_fallback(
7719 true,
7720 StatusCode::INTERNAL_SERVER_ERROR,
7721 "template parse failed at line 42",
7722 );
7723 assert!(page.contains("template parse failed at line 42"));
7724 assert!(page.contains("500"));
7725 }
7726
7727 #[test]
7728 fn test_error_page_production_hides_detail() {
7729 let page = error_page_fallback(
7730 false,
7731 StatusCode::INTERNAL_SERVER_ERROR,
7732 "template parse failed at line 42",
7733 );
7734 assert!(!page.contains("template parse failed"));
7735 assert!(!page.contains("line 42"));
7736 assert!(page.contains("Something went wrong"));
7737 }
7738
7739 #[test]
7740 fn test_error_page_404_production() {
7741 let page = error_page_fallback(false, StatusCode::NOT_FOUND, "/secret/path/file.html");
7742 assert!(!page.contains("/secret/path"));
7743 assert!(page.contains("Page Not Found"));
7744 }
7745
7746 #[test]
7747 fn test_error_page_403_production() {
7748 let page = error_page_fallback(false, StatusCode::FORBIDDEN, "user lacks admin role");
7749 assert!(!page.contains("admin role"));
7750 assert!(page.contains("Forbidden"));
7751 }
7752
7753 #[test]
7756 fn test_inject_csrf_into_post_form() {
7757 let html = r##"<html><head></head><body><form method="post" action="/submit"><input name="name"></form></body></html>"##;
7758 let result = inject_csrf_tokens(html, "test_token_abc");
7759 assert!(result.contains(r#"<input type="hidden" name="_csrf" value="test_token_abc">"#));
7760 assert!(result.contains(r#"<meta name="csrf-token" content="test_token_abc">"#));
7761 }
7762
7763 #[test]
7764 fn test_inject_csrf_skips_get_form() {
7765 let html = r##"<html><head></head><body><form method="get" action="/search"><input name="q"></form></body></html>"##;
7766 let result = inject_csrf_tokens(html, "test_token");
7767 assert!(!result.contains(r#"name="_csrf""#));
7769 assert!(result.contains(r#"<meta name="csrf-token" content="test_token">"#));
7771 }
7772
7773 #[test]
7774 fn test_inject_csrf_multiple_forms() {
7775 let html = r##"<html><head></head><body><form method="POST"><input></form><form method="post"><input></form></body></html>"##;
7776 let result = inject_csrf_tokens(html, "tok123");
7777 let count = result.matches(r#"name="_csrf""#).count();
7779 assert_eq!(count, 2, "Should inject into both POST forms");
7780 }
7781
7782 #[test]
7783 fn test_inject_what_js_before_body() {
7784 let html = "<html><head></head><body><p>Hello</p></body></html>";
7785 let result = inject_what_js(html);
7786 assert!(result.contains(WHAT_JS_ASSET_PATH.as_str()));
7787 assert!(result.contains("</body>"));
7788 }
7789
7790 #[test]
7791 fn test_inject_what_js_skips_if_present() {
7792 let html =
7793 r#"<html><head></head><body><script src="/static/what.js"></script></body></html>"#;
7794 let result = inject_what_js(html);
7795 assert_eq!(result.matches("what.js").count(), 1, "Should not duplicate");
7796 }
7797
7798 #[test]
7799 fn test_inject_what_js_no_body() {
7800 let html = "<p>just a fragment</p>";
7801 let result = inject_what_js(html);
7802 assert!(!result.contains("what.js"), "Should skip without </body>");
7803 assert_eq!(result, html);
7804 }
7805
7806 #[test]
7807 fn test_inject_theme_restore_after_head() {
7808 let html = "<html><head><title>T</title></head><body></body></html>";
7809 let result = inject_theme_restore(html);
7810 assert!(result.contains("data-w-theme"));
7811 let head_pos = result.find("<head>").unwrap();
7813 let script_pos = result.find("<script data-w-theme>").unwrap();
7814 let title_pos = result.find("<title>").unwrap();
7815 assert!(script_pos > head_pos && script_pos < title_pos);
7816 }
7817
7818 #[test]
7819 fn test_inject_theme_restore_idempotent() {
7820 let html = "<html><head></head><body></body></html>";
7821 let once = inject_theme_restore(html);
7822 let twice = inject_theme_restore(&once);
7823 assert_eq!(once, twice, "Should not duplicate");
7824 assert_eq!(twice.matches("data-w-theme").count(), 1);
7825 }
7826
7827 #[test]
7828 fn test_inject_theme_restore_head_with_attrs_and_fragment() {
7829 let html = r#"<html><head lang="en"><title>T</title></head><body></body></html>"#;
7830 let result = inject_theme_restore(html);
7831 assert!(result.contains("data-w-theme"));
7832
7833 let fragment = "<p>partial</p>";
7835 assert_eq!(inject_theme_restore(fragment), fragment);
7836 }
7837
7838 #[test]
7839 fn test_inject_what_css_into_head() {
7840 let html = "<html><head><title>Test</title></head><body></body></html>";
7841 let result = inject_what_css(html, CssMode::Full);
7842 assert!(result.contains(WHAT_CSS_ASSET_PATH.as_str()));
7843 assert!(result.contains("<head>\n"));
7844 }
7845
7846 #[test]
7847 fn test_inject_what_css_skips_if_present() {
7848 let html = r#"<html><head><link rel="stylesheet" href="/static/what.css"></head><body></body></html>"#;
7849 let result = inject_what_css(html, CssMode::Full);
7850 assert_eq!(
7851 result.matches("what.css").count(),
7852 1,
7853 "Should not duplicate"
7854 );
7855 }
7856
7857 #[test]
7858 fn test_inject_what_css_no_head() {
7859 let html = "<p>just a fragment</p>";
7860 let result = inject_what_css(html, CssMode::Full);
7861 assert!(!result.contains("what.css"), "Should skip without <head>");
7862 assert_eq!(result, html);
7863 }
7864
7865 #[test]
7866 fn test_inject_what_css_none_mode_skips() {
7867 let html = "<html><head><title>Test</title></head><body></body></html>";
7868 let result = inject_what_css(html, CssMode::None);
7869 assert_eq!(result, html, "none mode must not inject anything");
7870 }
7871
7872 #[test]
7873 fn test_inject_what_css_minimal_mode_uses_minimal_path() {
7874 let html = "<html><head><title>Test</title></head><body></body></html>";
7875 let result = inject_what_css(html, CssMode::Minimal);
7876 assert!(result.contains(WHAT_CSS_MINIMAL_ASSET_PATH.as_str()));
7877 assert_ne!(
7878 WHAT_CSS_MINIMAL_ASSET_PATH.as_str(),
7879 WHAT_CSS_ASSET_PATH.as_str(),
7880 "minimal and full variants must cache-bust independently"
7881 );
7882 }
7883
7884 #[test]
7885 fn test_minimal_css_slice() {
7886 let minimal = MINIMAL_WHAT_CSS.as_str();
7887 assert!(EMBEDDED_WHAT_CSS.contains(MINIMAL_CUT_START), "cut-start marker missing from what.css");
7890 assert!(EMBEDDED_WHAT_CSS.contains(MINIMAL_CUT_END), "cut-end marker missing from what.css");
7891 assert!(minimal.len() < EMBEDDED_WHAT_CSS.len());
7892 assert!(minimal.contains("@layer base"));
7894 assert!(minimal.contains("@layer utilities"));
7895 assert!(minimal.contains(".page-source"));
7896 assert!(!minimal.contains("@layer components"));
7898 assert!(!minimal.contains("@layer interactions {"));
7899 assert!(!minimal.contains(".what-pagination"));
7900 }
7901
7902 #[test]
7903 fn test_css_mode_from_config() {
7904 assert_eq!(CssMode::from_config("full").unwrap(), CssMode::Full);
7905 assert_eq!(CssMode::from_config("minimal").unwrap(), CssMode::Minimal);
7906 assert_eq!(CssMode::from_config("none").unwrap(), CssMode::None);
7907 let err = CssMode::from_config("compact").unwrap_err().to_string();
7908 assert!(err.contains("compact"), "error should echo the bad value: {err}");
7909 }
7910
7911 #[test]
7912 fn test_validate_csrf_token_valid() {
7913 let mut session = sessions::Session::new(3600);
7914 session
7915 .data
7916 .insert(CSRF_SESSION_KEY.to_string(), json!("correct_token"));
7917 assert!(validate_csrf_token(
7918 Some(&session),
7919 Some("correct_token"),
7920 None
7921 ));
7922 }
7923
7924 #[test]
7925 fn test_validate_csrf_token_invalid() {
7926 let mut session = sessions::Session::new(3600);
7927 session
7928 .data
7929 .insert(CSRF_SESSION_KEY.to_string(), json!("correct_token"));
7930 assert!(!validate_csrf_token(
7931 Some(&session),
7932 Some("wrong_token"),
7933 None
7934 ));
7935 }
7936
7937 #[test]
7938 fn test_validate_csrf_token_from_header() {
7939 let mut session = sessions::Session::new(3600);
7940 session
7941 .data
7942 .insert(CSRF_SESSION_KEY.to_string(), json!("header_token"));
7943 assert!(validate_csrf_token(
7944 Some(&session),
7945 None,
7946 Some("header_token")
7947 ));
7948 }
7949
7950 #[test]
7951 fn test_validate_csrf_token_no_session() {
7952 assert!(!validate_csrf_token(None, Some("any_token"), None));
7953 }
7954
7955 #[test]
7956 fn test_validate_csrf_token_missing_token() {
7957 let mut session = sessions::Session::new(3600);
7958 session
7959 .data
7960 .insert(CSRF_SESSION_KEY.to_string(), json!("token"));
7961 assert!(!validate_csrf_token(Some(&session), None, None));
7962 }
7963
7964 #[test]
7965 fn test_csrf_exempt_paths() {
7966 assert!(is_csrf_exempt("/w-livereload"));
7967 assert!(is_csrf_exempt("/w-wire"));
7968 assert!(!is_csrf_exempt("/w-set"));
7969 assert!(!is_csrf_exempt("/w-action/items"));
7970 assert!(!is_csrf_exempt("/w-auth/login"));
7971 }
7972
7973 #[test]
7974 fn test_session_new_has_csrf_token() {
7975 let session = sessions::Session::new(3600);
7976 let csrf_token = session.data.get(CSRF_SESSION_KEY);
7977 assert!(csrf_token.is_some(), "New session should have a CSRF token");
7978 let token_str = csrf_token.unwrap().as_str().unwrap();
7979 assert_eq!(
7980 token_str.len(),
7981 64,
7982 "CSRF token should be 64 hex chars (32 bytes)"
7983 );
7984 }
7985
7986 #[test]
7989 fn test_sanitize_extension_normal() {
7990 assert_eq!(sanitize_extension("jpg"), ".jpg");
7991 assert_eq!(sanitize_extension("png"), ".png");
7992 assert_eq!(sanitize_extension("PDF"), ".PDF");
7993 }
7994
7995 #[test]
7996 fn test_sanitize_extension_strips_path_separators() {
7997 let result = sanitize_extension("../../../etc/passwd");
7999 assert!(!result.contains('/'));
8000 assert!(!result.contains('\\'));
8001 assert!(result.ends_with("etcpasswd"));
8002
8003 let result = sanitize_extension("..\\..\\windows");
8004 assert!(!result.contains('\\'));
8005 }
8006
8007 #[test]
8008 fn test_sanitize_extension_strips_null_bytes() {
8009 assert_eq!(sanitize_extension("jpg\0exe"), ".jpgexe");
8010 }
8011
8012 #[test]
8013 fn test_sanitize_extension_strips_non_ascii() {
8014 assert_eq!(sanitize_extension("jp\u{00e9}g"), ".jpg");
8015 }
8016
8017 #[test]
8018 fn test_sanitize_extension_empty() {
8019 assert_eq!(sanitize_extension(""), "");
8020 assert_eq!(sanitize_extension("///"), "");
8021 }
8022
8023 #[test]
8024 fn test_collect_fetch_local_directive() {
8025 let mut directives = crate::parser::PageDirectives::default();
8026 directives
8027 .custom
8028 .insert("fetch.posts".to_string(), "local:posts".to_string());
8029 directives.custom.insert(
8030 "fetch.posts.sort".to_string(),
8031 "created_at:desc".to_string(),
8032 );
8033 directives.custom.insert(
8034 "fetch.posts.filter".to_string(),
8035 "status=published".to_string(),
8036 );
8037 directives
8038 .custom
8039 .insert("fetch.posts.limit".to_string(), "10".to_string());
8040 directives
8041 .custom
8042 .insert("fetch.posts.offset".to_string(), "20".to_string());
8043 directives
8044 .custom
8045 .insert("fetch.posts.search".to_string(), "rust".to_string());
8046 directives.custom.insert(
8047 "fetch.posts.search_fields".to_string(),
8048 "title,content".to_string(),
8049 );
8050
8051 let fetches = collect_fetch_directives(None, Some(&directives));
8052 assert_eq!(fetches.len(), 1);
8053 let posts = fetches.get("posts").unwrap();
8054 assert!(posts.is_local());
8055 assert_eq!(posts.local_collection(), Some("posts"));
8056 assert_eq!(posts.sort.as_deref(), Some("created_at:desc"));
8057 assert_eq!(posts.filter.as_deref(), Some("status=published"));
8058 assert_eq!(posts.limit, Some(10));
8059 assert_eq!(posts.offset, Some(20));
8060 assert_eq!(posts.search.as_deref(), Some("rust"));
8061 assert_eq!(posts.search_fields.as_deref(), Some("title,content"));
8062 }
8063
8064 #[test]
8065 fn test_local_collection_strips_query_string() {
8066 let mut directives = crate::parser::PageDirectives::default();
8069 directives.custom.insert(
8070 "fetch.posts".to_string(),
8071 "local:posts?sort=created_at:desc&limit=5&filter=status=published".to_string(),
8072 );
8073 let fetches = collect_fetch_directives(None, Some(&directives));
8074 let posts = fetches.get("posts").unwrap();
8075 assert!(posts.is_local());
8076 assert_eq!(posts.local_collection(), Some("posts"));
8077 assert_eq!(
8078 posts.local_query(),
8079 Some("sort=created_at:desc&limit=5&filter=status=published")
8080 );
8081
8082 let mut d2 = crate::parser::PageDirectives::default();
8084 d2.custom
8085 .insert("fetch.x".to_string(), "local:items".to_string());
8086 let f2 = collect_fetch_directives(None, Some(&d2));
8087 assert_eq!(f2.get("x").unwrap().local_collection(), Some("items"));
8088 assert_eq!(f2.get("x").unwrap().local_query(), None);
8089 }
8090
8091 #[test]
8092 fn test_is_framework_field() {
8093 assert!(is_framework_field("_csrf"));
8095 assert!(is_framework_field("w-rules"));
8096 assert!(is_framework_field("w-partial"));
8097 assert!(is_framework_field("redirect"));
8098 assert!(is_framework_field("cf-turnstile-response"));
8099 assert!(!is_framework_field("_session_id"));
8101 assert!(!is_framework_field("message"));
8102 assert!(!is_framework_field("name"));
8103 }
8104
8105 #[test]
8106 fn test_fetch_local_not_remote() {
8107 let d = FetchDirective::simple("posts".to_string(), "local:posts".to_string());
8108 assert!(d.is_local());
8109 assert_eq!(d.local_collection(), Some("posts"));
8110
8111 let d2 = FetchDirective::simple(
8112 "api".to_string(),
8113 "https://api.example.com/data".to_string(),
8114 );
8115 assert!(!d2.is_local());
8116 assert_eq!(d2.local_collection(), None);
8117 }
8118
8119 #[test]
8122 fn test_redirects_config_parsing() {
8123 let toml_str = r##"
8124[redirects]
8125"/old-blog" = "/blog"
8126"/legacy/*" = "/modern"
8127"/about-us" = "/about"
8128"##;
8129 let config: crate::Config = toml::from_str(toml_str).unwrap();
8130 assert_eq!(config.redirects.len(), 3);
8131 assert_eq!(config.redirects.get("/old-blog").unwrap(), "/blog");
8132 assert_eq!(config.redirects.get("/legacy/*").unwrap(), "/modern");
8133 assert_eq!(config.redirects.get("/about-us").unwrap(), "/about");
8134 }
8135
8136 #[test]
8137 fn test_redirects_config_empty_by_default() {
8138 let config = crate::Config::default();
8139 assert!(config.redirects.is_empty());
8140 }
8141
8142 #[test]
8143 fn test_custom_headers_in_application_what() {
8144 let content = r#"header.Access-Control-Allow-Origin = "*"
8145header.Cache-Control = "no-store"
8146header.X-Custom = "hello"
8147"#;
8148 let config = crate::parser::parse_what_file(content);
8149 assert_eq!(config.directives.headers.len(), 3);
8150 assert_eq!(
8151 config
8152 .directives
8153 .headers
8154 .get("access-control-allow-origin")
8155 .unwrap(),
8156 "*"
8157 );
8158 assert_eq!(
8159 config.directives.headers.get("cache-control").unwrap(),
8160 "no-store"
8161 );
8162 assert_eq!(config.directives.headers.get("x-custom").unwrap(), "hello");
8163 }
8164
8165 #[test]
8166 fn test_custom_headers_merge_in_config() {
8167 let parent_content = r#"header.X-Frame-Options = "DENY"
8168header.X-Custom = "parent"
8169"#;
8170 let child_content = r#"header.X-Custom = "child"
8171header.X-New = "added"
8172"#;
8173 let mut parent = crate::parser::parse_what_file(parent_content);
8174 let child = crate::parser::parse_what_file(child_content);
8175 parent.merge(&child);
8176
8177 assert_eq!(parent.directives.headers.get("x-custom").unwrap(), "child");
8179 assert_eq!(
8181 parent.directives.headers.get("x-frame-options").unwrap(),
8182 "DENY"
8183 );
8184 assert_eq!(parent.directives.headers.get("x-new").unwrap(), "added");
8186 }
8187
8188 #[test]
8189 fn test_custom_headers_in_page_directive_content() {
8190 let html = r##"<what>
8191header.Cache-Control: no-cache
8192header.X-Robots-Tag: noindex
8193</what>
8194<h1>Hello</h1>"##;
8195 let (directives, _cleaned) = crate::parser::parse_page_directives(html);
8196 assert_eq!(directives.headers.get("cache-control").unwrap(), "no-cache");
8197 assert_eq!(directives.headers.get("x-robots-tag").unwrap(), "noindex");
8198 }
8199
8200 #[test]
8201 fn test_custom_headers_not_in_template_context() {
8202 let content = r#"title = "My Page"
8203header.X-Custom = "value"
8204"#;
8205 let config = crate::parser::parse_what_file(content);
8206 assert!(config.values.contains_key("title"));
8208 assert!(!config.values.contains_key("header.x-custom"));
8209 }
8210
8211 #[tokio::test]
8212 async fn test_redirect_middleware_exact_match() {
8213 use axum::body::Body;
8214 use axum::http::Request;
8215 use std::collections::HashMap;
8216 use tower::ServiceExt;
8217
8218 let mut redirects = HashMap::new();
8219 redirects.insert("/old".to_string(), "/new".to_string());
8220
8221 let mut config = crate::Config::default();
8222 config.redirects = redirects;
8223 config.session.enabled = false;
8224
8225 let temp_dir = create_test_project();
8226 let root = temp_dir.path().to_path_buf();
8227 let state = AppState::with_dev_mode(config, root, true).unwrap();
8228
8229 let app = create_router(state);
8230
8231 let request = Request::builder().uri("/old").body(Body::empty()).unwrap();
8232
8233 let response = app.oneshot(request).await.unwrap();
8234 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
8235 assert_eq!(
8236 response
8237 .headers()
8238 .get("location")
8239 .unwrap()
8240 .to_str()
8241 .unwrap(),
8242 "/new"
8243 );
8244 }
8245
8246 #[tokio::test]
8247 async fn test_redirect_middleware_wildcard() {
8248 use axum::body::Body;
8249 use axum::http::Request;
8250 use std::collections::HashMap;
8251 use tower::ServiceExt;
8252
8253 let mut redirects = HashMap::new();
8254 redirects.insert("/legacy/*".to_string(), "/modern".to_string());
8255
8256 let mut config = crate::Config::default();
8257 config.redirects = redirects;
8258 config.session.enabled = false;
8259
8260 let temp_dir = create_test_project();
8261 let root = temp_dir.path().to_path_buf();
8262 let state = AppState::with_dev_mode(config, root, true).unwrap();
8263
8264 let app = create_router(state);
8265
8266 let request = Request::builder()
8267 .uri("/legacy/old-page")
8268 .body(Body::empty())
8269 .unwrap();
8270
8271 let response = app.oneshot(request).await.unwrap();
8272 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
8273 assert_eq!(
8274 response
8275 .headers()
8276 .get("location")
8277 .unwrap()
8278 .to_str()
8279 .unwrap(),
8280 "/modern"
8281 );
8282 }
8283
8284 #[tokio::test]
8285 async fn test_redirect_middleware_no_match_passes_through() {
8286 use axum::body::Body;
8287 use axum::http::Request;
8288 use std::collections::HashMap;
8289 use tower::ServiceExt;
8290
8291 let mut redirects = HashMap::new();
8292 redirects.insert("/old".to_string(), "/new".to_string());
8293
8294 let mut config = crate::Config::default();
8295 config.redirects = redirects;
8296 config.session.enabled = false;
8297
8298 let temp_dir = create_test_project();
8299 let root = temp_dir.path().to_path_buf();
8300 let state = AppState::with_dev_mode(config, root, true).unwrap();
8301
8302 let app = create_router(state);
8303
8304 let request = Request::builder()
8305 .uri("/unrelated")
8306 .body(Body::empty())
8307 .unwrap();
8308
8309 let response = app.oneshot(request).await.unwrap();
8310 assert_ne!(response.status(), StatusCode::PERMANENT_REDIRECT);
8312 }
8313
8314 #[tokio::test]
8315 async fn test_health_endpoint_returns_ok() {
8316 use axum::body::Body;
8317 use axum::http::Request;
8318 use tower::ServiceExt;
8319
8320 let mut config = crate::Config::default();
8321 config.session.enabled = false;
8322
8323 let temp_dir = create_test_project();
8324 let root = temp_dir.path().to_path_buf();
8325 let state = AppState::with_dev_mode(config, root, true).unwrap();
8326
8327 let app = create_router(state);
8328
8329 let request = Request::builder()
8330 .uri("/health")
8331 .body(Body::empty())
8332 .unwrap();
8333
8334 let response = app.oneshot(request).await.unwrap();
8335 assert_eq!(response.status(), StatusCode::OK);
8336
8337 let body = axum::body::to_bytes(response.into_body(), 1024)
8338 .await
8339 .unwrap();
8340 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8341 assert_eq!(json["status"], "ok");
8342 assert_eq!(json["version"], env!("CARGO_PKG_VERSION"));
8343 }
8344
8345 #[test]
8346 fn test_fetch_timeout_default() {
8347 let config = crate::Config::default();
8348 assert_eq!(config.server.fetch_timeout, 10);
8349 }
8350
8351 #[test]
8352 fn test_fetch_timeout_custom() {
8353 let toml_str = r#"
8354[server]
8355fetch_timeout = 30
8356"#;
8357 let config: crate::Config = toml::from_str(toml_str).unwrap();
8358 assert_eq!(config.server.fetch_timeout, 30);
8359 }
8360
8361 #[test]
8362 fn test_http_client_uses_configured_timeout() {
8363 let mut config = crate::Config::default();
8364 config.server.fetch_timeout = 5;
8365 config.session.enabled = false;
8366
8367 let temp_dir = create_test_project();
8368 let root = temp_dir.path().to_path_buf();
8369 let state = AppState::with_dev_mode(config, root, false).unwrap();
8370
8371 let _client = &state.http_client;
8374 }
8375
8376 #[test]
8379 fn wired_public_reaches_all() {
8380 let temp_dir = create_test_project();
8381 let root = temp_dir.path().to_path_buf();
8382 let config = crate::Config::default();
8383 let state = AppState::new(config, root).unwrap();
8384
8385 let mut rx = state.wired_tx.subscribe();
8386 let _ = state.wired_tx.send(WiredMessage {
8387 json: r#"{"wired.counter":"5"}"#.to_string(),
8388 scope: WiredScope::Public,
8389 });
8390 let msg = rx.try_recv().unwrap();
8391 assert!(msg.scope.allows(&[], None)); assert!(msg.scope.allows(&["admin".into()], Some("user1"))); }
8395
8396 #[test]
8397 fn wired_role_filters() {
8398 let temp_dir = create_test_project();
8399 let root = temp_dir.path().to_path_buf();
8400 let config = crate::Config::default();
8401 let state = AppState::new(config, root).unwrap();
8402
8403 let mut rx = state.wired_tx.subscribe();
8404 let _ = state.wired_tx.send(WiredMessage {
8405 json: r#"{"wired.revenue":"1000"}"#.to_string(),
8406 scope: WiredScope::Roles(vec!["admin".into()]),
8407 });
8408 let msg = rx.try_recv().unwrap();
8409 assert!(msg.scope.allows(&["admin".into()], None));
8410 assert!(!msg.scope.allows(&["viewer".into()], None));
8411 assert!(!msg.scope.allows(&[], None)); }
8413
8414 #[test]
8415 fn wired_multi_role() {
8416 let scope = WiredScope::Roles(vec!["admin".into(), "editor".into()]);
8417 assert!(scope.allows(&["editor".into()], None));
8418 assert!(scope.allows(&["admin".into()], None));
8419 assert!(!scope.allows(&["viewer".into()], None));
8420 }
8421
8422 #[test]
8423 fn wired_user_scope() {
8424 let scope = WiredScope::User("user42".into());
8425 assert!(scope.allows(&[], Some("user42")));
8426 assert!(!scope.allows(&["admin".into()], Some("other_user")));
8427 assert!(!scope.allows(&[], None));
8428 }
8429
8430 #[test]
8431 fn wired_unauthenticated_public_only() {
8432 let public = WiredScope::Public;
8434 let admin_only = WiredScope::Roles(vec!["admin".into()]);
8435 let user_only = WiredScope::User("user1".into());
8436
8437 assert!(public.allows(&[], None));
8438 assert!(!admin_only.allows(&[], None));
8439 assert!(!user_only.allows(&[], None));
8440 }
8441
8442 #[test]
8443 fn wired_undeclared_key_defaults_public() {
8444 let temp_dir = create_test_project();
8445 let root = temp_dir.path().to_path_buf();
8446 let config = crate::Config::default();
8447 let state = AppState::new(config, root).unwrap();
8448
8449 let rt = tokio::runtime::Runtime::new().unwrap();
8452 let scope = rt.block_on(state.get_wired_scope("nonexistent_key"));
8453 assert!(matches!(scope, WiredScope::Public));
8454 }
8455
8456 #[test]
8457 fn test_session_mutation_with_filters() {
8458 let mut session_data = HashMap::new();
8459 session_data.insert("name".to_string(), json!("alice"));
8460 session_data.insert("bio".to_string(), json!("Hello world from alice"));
8461
8462 let val = resolve_session_value(&json!("#session.name|uppercase#"), &session_data);
8464 assert_eq!(val, json!("ALICE"));
8465
8466 let val = resolve_session_value(&json!("#session.bio|truncate:10#"), &session_data);
8467 assert_eq!(val, json!("Hello worl..."));
8468
8469 let val = resolve_session_value(&json!("Hi #session.name|capitalize#!"), &session_data);
8470 assert_eq!(val, json!("Hi Alice!"));
8471 }
8472}