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