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