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