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