use axum::{
Router,
body::Body,
extract::{
Form, Multipart, Path, Query, State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
http::{HeaderMap, HeaderValue, Request, StatusCode, header},
middleware::Next,
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
use tower::ServiceBuilder;
use tower_http::services::ServeDir;
use tower_http::set_header::SetResponseHeaderLayer;
use regex::Regex;
use std::sync::LazyLock;
use crate::auth::{AuthHandler, UserContext};
use crate::cache::{CacheKey, CachedValue, WhatCache};
use crate::components::ComponentRegistry;
use crate::config::DataSource;
use crate::database::DatabaseAdapter;
use crate::parser::{
PageDirectives, SessionMutation, WhatConfig, WiredScope, parse_page_directives, parse_what_file,
};
use crate::sessions::{self, AtomicMutation, KvSessionStore, SessionBackend, SqliteSessionStore};
use crate::validation;
use crate::{Config, Result};
const FLASH_SESSION_KEY: &str = "_flash";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct FlashData {
#[serde(default)]
flash: HashMap<String, String>,
#[serde(default)]
errors: HashMap<String, String>,
#[serde(default)]
old: HashMap<String, String>,
}
fn set_flash_data(session: &mut sessions::Session, flash: &FlashData) {
if let Ok(json) = serde_json::to_value(flash) {
session.data.insert(FLASH_SESSION_KEY.to_string(), json);
}
}
fn consume_flash_data(session: &mut sessions::Session) -> Option<FlashData> {
let value = session.data.remove(FLASH_SESSION_KEY)?;
serde_json::from_value(value).ok()
}
fn inject_flash_into_context(flash: &FlashData, context: &mut HashMap<String, Value>) {
if !flash.flash.is_empty() {
let flash_obj: serde_json::Map<String, Value> = flash
.flash
.iter()
.map(|(k, v)| (k.clone(), json!(v)))
.collect();
context.insert("flash".to_string(), Value::Object(flash_obj));
}
if !flash.errors.is_empty() {
let errors_obj: serde_json::Map<String, Value> = flash
.errors
.iter()
.map(|(k, v)| (k.clone(), json!(v)))
.collect();
context.insert("errors".to_string(), Value::Object(errors_obj));
context.insert("has_errors".to_string(), json!(true));
} else {
context.insert("has_errors".to_string(), json!(false));
}
if !flash.old.is_empty() {
let old_obj: serde_json::Map<String, Value> = flash
.old
.iter()
.map(|(k, v)| (k.clone(), json!(v)))
.collect();
context.insert("old".to_string(), Value::Object(old_obj));
}
}
mod actions;
mod engine;
pub use actions::ActionHandler;
pub use engine::RenderEngine;
pub fn content_dir_name(root: &std::path::Path) -> &'static str {
use std::sync::Once;
static DEPRECATION_WARNED: Once = Once::new();
static BOTH_WARNED: Once = Once::new();
let has_site = root.join("site").is_dir();
let has_pages = root.join("pages").is_dir();
match (has_site, has_pages) {
(true, true) => {
BOTH_WARNED.call_once(|| {
tracing::warn!(
"Both site/ and pages/ directories found. Using site/. \
Remove pages/ to silence this warning."
);
});
"site"
}
(true, false) => "site",
(false, true) => {
DEPRECATION_WARNED.call_once(|| {
tracing::warn!("pages/ is deprecated — rename to site/: mv pages site");
});
"pages"
}
(false, false) => "site",
}
}
pub fn content_dir(root: &std::path::Path) -> PathBuf {
root.join(content_dir_name(root))
}
#[derive(Clone, Debug)]
pub enum LiveReloadMessage {
Reload,
CacheCleared,
}
#[derive(Clone, Debug)]
pub struct WiredMessage {
pub json: String,
pub scope: WiredScope,
}
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
pub store: DatabaseAdapter,
pub cache: WhatCache,
pub components: Arc<ComponentRegistry>,
pub engine: Arc<RenderEngine>,
pub root: PathBuf,
pub content_dir: PathBuf,
pub sessions: Option<SessionBackend>,
pub auth: AuthHandler,
pub dev_mode: bool,
pub css_mode: CssMode,
pub live_reload_tx: Option<broadcast::Sender<LiveReloadMessage>>,
pub wired_tx: broadcast::Sender<WiredMessage>,
pub wired_scopes: Arc<tokio::sync::RwLock<HashMap<String, WiredScope>>>,
pub app_scopes: Arc<tokio::sync::RwLock<HashMap<String, WiredScope>>>,
pub policies: Arc<crate::policy::PolicyRegistry>,
pub data_source_loaded: Arc<tokio::sync::RwLock<HashMap<String, Instant>>>,
pub rate_limiters: Option<RateLimiters>,
pub log_level: String,
pub jobs: crate::jobs::JobQueue,
pub http_client: reqwest::Client,
pub upload_backend: Option<crate::uploads::UploadBackend>,
pub datasources: HashMap<String, crate::datasource::Datasource>,
pub wired_client_count: Arc<std::sync::atomic::AtomicUsize>,
pub validated_actions: Arc<std::sync::RwLock<HashSet<String>>>,
pub activity_log: Arc<std::sync::Mutex<VecDeque<ActivityEvent>>>,
}
const ACTIVITY_LOG_CAPACITY: usize = 200;
#[derive(Clone)]
pub enum ActivityEvent {
Request {
time: chrono::DateTime<chrono::Local>,
method: String,
path: String,
status: u16,
duration_ms: u64,
},
PolicyDenial {
time: chrono::DateTime<chrono::Local>,
detail: String,
},
Fetch {
time: chrono::DateTime<chrono::Local>,
key: String,
url: String,
elapsed_ms: u64,
result: String,
},
}
impl AppState {
pub fn record_activity(&self, event: ActivityEvent) {
if !self.dev_mode {
return;
}
let mut log = self.activity_log.lock().unwrap();
if log.len() >= ACTIVITY_LOG_CAPACITY {
log.pop_front();
}
log.push_back(event);
}
}
#[derive(Clone)]
pub struct RateLimiters {
pub login: moka::future::Cache<String, u32>,
pub login_max: u32,
pub upload: moka::future::Cache<String, u32>,
pub upload_max: u32,
pub action: moka::future::Cache<String, u32>,
pub action_max: u32,
}
impl AppState {
pub fn new(config: Config, root: PathBuf) -> Result<Self> {
Self::with_dev_mode(config, root, false)
}
pub fn with_dev_mode(mut config: Config, root: PathBuf, dev_mode: bool) -> Result<Self> {
let css_mode = CssMode::from_config(&config.server.css)?;
let policies = Arc::new(crate::policy::PolicyRegistry::from_config(&config.collections)?);
if !config.session.enabled && !config.auth.enabled {
for (name, policy) in policies.configured() {
if policy.is_read_scoped() || policy.owner_mode == crate::policy::OwnerMode::Auto {
tracing::warn!(
target: "what::policy",
"collection '{}' has an identity-based policy but both sessions and auth are disabled — ownership/read scoping will deny everything",
name
);
break;
}
}
}
if dev_mode && config.session.secure {
config.session.secure = false;
tracing::info!("Dev mode: disabled Secure flag on cookies (no TLS on localhost)");
}
let env_path = root.join(".env");
if env_path.exists() {
let _ = dotenvy::from_path(&env_path);
tracing::info!("Loaded .env from {}", env_path.display());
}
let mut components = ComponentRegistry::new();
components.register_builtins();
let components_dir = root.join("components");
if components_dir.exists() {
components.load_from_directory(&components_dir)?;
}
let store = if let Some(ref db_config) = config.database {
match db_config.r#type.as_str() {
"d1" => {
let cf = config.cloudflare.as_ref()
.ok_or_else(|| crate::Error::Config("[database] type = \"d1\" requires [cloudflare] section with account_id and api_token".to_string()))?;
let db_id = cf.d1_database_id.as_ref().ok_or_else(|| {
crate::Error::Config(
"[cloudflare] d1_database_id is required for type = \"d1\"".to_string(),
)
})?;
let account_id = resolve_env_value(&cf.account_id)?;
let api_token = resolve_env_value(&cf.api_token)?;
let database_id = resolve_env_value(db_id)?;
let db =
crate::database::D1Database::new(&account_id, &database_id, &api_token);
tracing::info!("Database: Cloudflare D1 ({})", database_id);
DatabaseAdapter::D1(db)
}
"supabase" => {
let sb = config.supabase.as_ref()
.ok_or_else(|| crate::Error::Config("[database] type = \"supabase\" requires [supabase] section with project_url and api_key".to_string()))?;
let project_url = resolve_env_value(&sb.project_url)?;
let api_key = resolve_env_value(&sb.api_key)?;
let db = crate::database::SupabaseDatabase::new(&project_url, &api_key);
tracing::info!("Database: Supabase ({})", project_url);
DatabaseAdapter::Supabase(db)
}
"sqlite" | _ => {
let db_path = root.join(&db_config.path);
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let db = crate::database::SqliteDatabase::open(&db_path)?;
if db_config.r#type != "sqlite"
&& db_config.r#type != "d1"
&& db_config.r#type != "supabase"
{
tracing::warn!(
"Unknown database type '{}', falling back to sqlite",
db_config.r#type
);
}
tracing::info!("Database: SQLite ({})", db_path.display());
DatabaseAdapter::Sqlite(db)
}
}
} else {
let db_path = root.join("data").join("app.db");
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let db = crate::database::SqliteDatabase::open(&db_path)?;
let store_json_path = root.join("data").join("store.json");
if store_json_path.exists() {
if let Ok(content) = std::fs::read_to_string(&store_json_path) {
if let Ok(store_data) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(collections) =
store_data.get("collections").and_then(|c| c.as_object())
{
for (name, items) in collections {
if let Some(items_arr) = items.as_array() {
db.import_json_collection(name, items_arr);
}
}
}
}
}
}
tracing::info!("Database: SQLite auto-default ({})", db_path.display());
DatabaseAdapter::Sqlite(db)
};
let sessions: Option<SessionBackend> = if config.session.enabled {
match config.session.store.as_str() {
"cloudflare-kv" => {
if let Some(ref cf_config) = config.session.cloudflare {
let resolved = crate::config::CloudflareKvConfig {
account_id: resolve_env_value(&cf_config.account_id)?,
namespace_id: resolve_env_value(&cf_config.namespace_id)?,
api_token: resolve_env_value(&cf_config.api_token)?,
};
let store = KvSessionStore::new(&resolved, config.session.max_age);
tracing::info!(
"Session store initialized: Cloudflare KV (namespace: {})",
resolved.namespace_id
);
Some(SessionBackend::CloudflareKv(store))
} else {
tracing::warn!(
"Session store set to cloudflare-kv but [session.cloudflare] config is missing"
);
None
}
}
_ => {
let db_path = root.join(&config.session.database);
match SqliteSessionStore::new(&db_path, config.session.max_age) {
Ok(store) => {
tracing::info!(
"Session store initialized: SQLite ({})",
db_path.display()
);
Some(SessionBackend::Sqlite(store))
}
Err(e) => {
tracing::warn!("Failed to initialize session store: {}", e);
None
}
}
}
}
} else {
None
};
let upload_backend = if config.uploads.enabled {
match config.uploads.provider.as_str() {
"r2" => {
let cf = config.cloudflare.as_ref().ok_or_else(|| {
crate::Error::Config(
"[uploads] provider = \"r2\" requires [cloudflare] section".to_string(),
)
})?;
let bucket = cf.r2_bucket.as_ref().ok_or_else(|| {
crate::Error::Config(
"[cloudflare] r2_bucket is required for provider = \"r2\"".to_string(),
)
})?;
let public_url = cf.r2_public_url.as_ref().ok_or_else(|| {
crate::Error::Config(
"[cloudflare] r2_public_url is required for provider = \"r2\""
.to_string(),
)
})?;
tracing::info!("Uploads: Cloudflare R2 (bucket: {})", bucket);
Some(crate::uploads::UploadBackend::R2 {
client: crate::http_client::build_http_client(None).map_err(|e| {
crate::Error::Upload(format!("Failed to build R2 HTTP client: {}", e))
})?,
account_id: resolve_env_value(&cf.account_id)?,
bucket: resolve_env_value(bucket)?,
api_token: resolve_env_value(&cf.api_token)?,
public_url: resolve_env_value(public_url)?,
})
}
_ => {
let uploads_dir = root.join(&config.uploads.directory);
if !uploads_dir.exists() {
std::fs::create_dir_all(&uploads_dir).map_err(|e| {
crate::Error::Upload(format!(
"Failed to create uploads directory: {}",
e
))
})?;
tracing::info!("Created uploads directory: {}", uploads_dir.display());
}
Some(crate::uploads::UploadBackend::Local {
directory: uploads_dir,
})
}
}
} else {
None
};
let engine = RenderEngine::new(components.clone());
let auth = AuthHandler::from_config_with_env(config.auth.clone());
if auth.is_enabled() {
tracing::info!("Authentication enabled");
if let Some(endpoint) = auth.login_endpoint() {
tracing::info!("Login endpoint: {}", endpoint);
}
}
let live_reload_tx = if dev_mode {
let (tx, _) = broadcast::channel(16);
Some(tx)
} else {
None
};
let (wired_tx, _) = broadcast::channel::<WiredMessage>(256);
let rate_limiters = if config.rate_limit.enabled {
use crate::config::RateLimitConfig;
let (login_max, login_window) = RateLimitConfig::parse_limit(&config.rate_limit.login);
let (upload_max, upload_window) =
RateLimitConfig::parse_limit(&config.rate_limit.upload);
let (action_max, action_window) =
RateLimitConfig::parse_limit(&config.rate_limit.action);
Some(RateLimiters {
login: moka::future::Cache::builder()
.time_to_live(Duration::from_secs(login_window))
.build(),
login_max,
upload: moka::future::Cache::builder()
.time_to_live(Duration::from_secs(upload_window))
.build(),
upload_max,
action: moka::future::Cache::builder()
.time_to_live(Duration::from_secs(action_window))
.build(),
action_max,
})
} else {
None
};
let jobs = crate::jobs::start(sessions.clone(), config.email.clone());
let http_client = crate::http_client::build_http_client(Some(Duration::from_secs(
config.server.fetch_timeout,
)))
.map_err(|e| crate::Error::Server(format!("Failed to build HTTP client: {}", e)))?;
let resolved_content_dir = content_dir(&root);
let mut datasources = HashMap::new();
for (name, ds_config) in &config.datasources {
let datasource = match ds_config.r#type {
crate::config::DatasourceType::D1 => {
let account_id = ds_config.account_id.as_deref().ok_or_else(|| {
crate::Error::Config(format!(
"[datasources.{}] type = \"d1\" requires account_id",
name
))
})?;
let api_token = ds_config.api_token.as_deref().ok_or_else(|| {
crate::Error::Config(format!(
"[datasources.{}] type = \"d1\" requires api_token",
name
))
})?;
let db_id = ds_config.d1_database_id.as_deref().ok_or_else(|| {
crate::Error::Config(format!(
"[datasources.{}] type = \"d1\" requires d1_database_id",
name
))
})?;
let db = crate::database::D1Database::new(
&resolve_env_value(account_id)?,
&resolve_env_value(db_id)?,
&resolve_env_value(api_token)?,
);
tracing::info!("Datasource '{}': Cloudflare D1", name);
crate::datasource::Datasource::Database(DatabaseAdapter::D1(db))
}
crate::config::DatasourceType::Supabase => {
let project_url = ds_config.project_url.as_deref().ok_or_else(|| {
crate::Error::Config(format!(
"[datasources.{}] type = \"supabase\" requires project_url",
name
))
})?;
let api_key = ds_config.api_key.as_deref().ok_or_else(|| {
crate::Error::Config(format!(
"[datasources.{}] type = \"supabase\" requires api_key",
name
))
})?;
let db = crate::database::SupabaseDatabase::new(
&resolve_env_value(project_url)?,
&resolve_env_value(api_key)?,
);
tracing::info!("Datasource '{}': Supabase", name);
crate::datasource::Datasource::Database(DatabaseAdapter::Supabase(db))
}
crate::config::DatasourceType::Sqlite => {
let path = ds_config.path.as_deref().unwrap_or("data/app.db");
let db_path = root.join(path);
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let db = crate::database::SqliteDatabase::open(&db_path)?;
tracing::info!("Datasource '{}': SQLite ({})", name, db_path.display());
crate::datasource::Datasource::Database(DatabaseAdapter::Sqlite(db))
}
crate::config::DatasourceType::Api => {
let url = ds_config.url.as_deref().ok_or_else(|| {
crate::Error::Config(format!(
"[datasources.{}] type = \"api\" requires url",
name
))
})?;
let base_url = resolve_env_value(url)?;
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
return Err(crate::Error::Config(format!(
"[datasources.{}] url must start with http:// or https://, got: {}",
name, base_url
)));
}
let mut headers: Vec<(String, String)> = Vec::new();
if let Some(ref h) = ds_config.headers {
for (k, v) in h {
headers.push((k.clone(), resolve_env_value(v)?));
}
}
tracing::info!("Datasource '{}': API ({})", name, base_url);
crate::datasource::Datasource::Api { base_url, headers }
}
};
datasources.insert(name.clone(), datasource);
}
Ok(Self {
config: Arc::new(config),
store,
cache: WhatCache::new(),
components: Arc::new(components),
engine: Arc::new(engine),
content_dir: resolved_content_dir,
root,
sessions,
auth,
dev_mode,
css_mode,
live_reload_tx,
wired_tx,
wired_scopes: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
app_scopes: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
policies,
data_source_loaded: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
rate_limiters,
log_level: "info".to_string(),
jobs,
http_client,
upload_backend,
datasources,
wired_client_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
validated_actions: Arc::new(std::sync::RwLock::new(HashSet::new())),
activity_log: Arc::new(std::sync::Mutex::new(VecDeque::new())),
})
}
pub async fn init_datasources(&self) -> crate::Result<()> {
if let DatabaseAdapter::D1(ref db) = self.store {
db.init().await.map_err(|e| {
crate::Error::Config(format!("D1 primary database init failed: {}", e))
})?;
}
for (name, ds) in &self.datasources {
if let crate::datasource::Datasource::Database(DatabaseAdapter::D1(db)) = ds {
db.init().await.map_err(|e| {
crate::Error::Config(format!("D1 datasource '{}' init failed: {}", name, e))
})?;
}
}
self.rebuild_wired_scopes().await;
Ok(())
}
pub fn trigger_reload(&self) {
let cache = self.cache.clone();
tokio::spawn(async move {
cache.clear_all().await;
});
if let Some(ref tx) = self.live_reload_tx {
let _ = tx.send(LiveReloadMessage::Reload);
tracing::debug!("Live reload triggered");
}
}
pub async fn trigger_reload_async(&self) {
self.cache.clear_all().await;
self.rebuild_wired_scopes().await;
if let Some(ref tx) = self.live_reload_tx {
let _ = tx.send(LiveReloadMessage::Reload);
tracing::debug!("Live reload triggered");
}
}
pub fn live_reload_receiver(&self) -> Option<broadcast::Receiver<LiveReloadMessage>> {
self.live_reload_tx.as_ref().map(|tx| tx.subscribe())
}
pub async fn rebuild_wired_scopes(&self) {
let mut wired = HashMap::new();
let mut app = HashMap::new();
self.scan_wired_scopes_dir(&self.content_dir, &mut wired, &mut app);
let count = wired.len();
let app_count = app.len();
*self.wired_scopes.write().await = wired;
*self.app_scopes.write().await = app;
if count > 0 || app_count > 0 {
tracing::info!(
"Scopes rebuilt: {} wired, {} application variable(s)",
count,
app_count
);
}
}
fn scan_wired_scopes_dir(
&self,
dir: &std::path::Path,
wired: &mut HashMap<String, WiredScope>,
app: &mut HashMap<String, WiredScope>,
) {
let config_path = dir.join("application.what");
if config_path.exists() {
if let Ok(content) = std::fs::read_to_string(&config_path) {
let config = parse_what_file(&content);
for decl in &config.data_wired {
wired.insert(decl.name.clone(), decl.scope.clone());
}
for decl in &config.data_application {
if !matches!(decl.scope, WiredScope::Public) {
app.insert(decl.name.clone(), decl.scope.clone());
}
}
}
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
self.scan_wired_scopes_dir(&entry.path(), wired, app);
}
}
}
}
pub async fn get_wired_scope(&self, key: &str) -> WiredScope {
self.wired_scopes
.read()
.await
.get(key)
.cloned()
.unwrap_or_default()
}
pub async fn get_app_scope(&self, key: &str) -> WiredScope {
self.app_scopes
.read()
.await
.get(key)
.cloned()
.unwrap_or_default()
}
}
async fn redirect_middleware(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
if !state.config.redirects.is_empty() {
let path = request.uri().path();
if let Some(target) = state.config.redirects.get(path) {
tracing::debug!("Redirect: {} -> {}", path, target);
return Redirect::permanent(target).into_response();
}
for (pattern, target) in &state.config.redirects {
if let Some(prefix) = pattern.strip_suffix("/*") {
if path.starts_with(prefix)
&& (path.len() == prefix.len()
|| path.as_bytes().get(prefix.len()) == Some(&b'/'))
{
tracing::debug!(
"Redirect (wildcard): {} -> {} (pattern: {})",
path,
target,
pattern
);
return Redirect::permanent(target).into_response();
}
}
}
}
next.run(request).await
}
async fn security_headers_middleware(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
header::HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
);
headers.insert(
header::HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("SAMEORIGIN"),
);
headers.insert(
header::HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
headers.insert(
header::HeaderName::from_static("x-xss-protection"),
HeaderValue::from_static("1; mode=block"),
);
headers.insert(
header::HeaderName::from_static("content-security-policy"),
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:"),
);
response
}
async fn rate_limit_middleware(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
if let Some(ref limiters) = state.rate_limiters {
let path = request.uri().path().to_string();
let (cache, max) = if path == "/w-auth/login" {
Some((&limiters.login, limiters.login_max))
} else if path.starts_with("/w-upload/") {
Some((&limiters.upload, limiters.upload_max))
} else if path.starts_with("/w-action/") {
Some((&limiters.action, limiters.action_max))
} else {
None
}
.unzip();
if let (Some(cache), Some(max)) = (cache, max) {
let ip = request
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
let key = format!(
"{}:{}",
ip,
path.split('/').take(3).collect::<Vec<_>>().join("/")
);
let count = cache.get(&key).await.unwrap_or(0) + 1;
cache.insert(key.clone(), count).await;
if count > max {
return (
StatusCode::TOO_MANY_REQUESTS,
"Rate limit exceeded. Try again later.",
)
.into_response();
}
}
}
next.run(request).await
}
async fn request_logging_middleware(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let start = Instant::now();
let response = next.run(request).await;
let status = response.status().as_u16();
let duration = start.elapsed();
let duration_ms = duration.as_millis();
if !path.starts_with("/w-livereload") && !path.starts_with("/w-wire") {
if status >= 500 {
tracing::error!("{method} {path} → {status} ({duration_ms}ms)");
} else if status >= 400 {
tracing::warn!("{method} {path} → {status} ({duration_ms}ms)");
} else {
tracing::info!("{method} {path} → {status} ({duration_ms}ms)");
}
if !path.starts_with("/w-inspector") {
state.record_activity(ActivityEvent::Request {
time: chrono::Local::now(),
method: method.to_string(),
path,
status,
duration_ms: duration_ms as u64,
});
}
}
response
}
async fn csrf_middleware(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
if request.method() != axum::http::Method::POST {
return next.run(request).await;
}
let path = request.uri().path().to_string();
if is_csrf_exempt(&path) {
return next.run(request).await;
}
if state.sessions.is_none() {
return next.run(request).await;
}
let header_token = request
.headers()
.get("X-CSRF-Token")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let cookie_header = request
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let session_id =
sessions::parse_session_cookie(cookie_header.as_deref(), &state.config.session.cookie_name);
let session = if let (Some(sessions), Some(id)) = (&state.sessions, &session_id) {
sessions.get(id).await.ok().flatten()
} else {
None
};
let session_has_csrf = session
.as_ref()
.and_then(|s| s.data.get(CSRF_SESSION_KEY))
.and_then(|v| v.as_str())
.is_some();
if !session_has_csrf {
return next.run(request).await;
}
if let Some(ref token) = header_token {
if validate_csrf_token(session.as_ref(), None, Some(token)) {
return next.run(request).await;
}
return (StatusCode::FORBIDDEN, "CSRF token mismatch").into_response();
}
let content_type = request
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if content_type.starts_with("application/x-www-form-urlencoded")
|| content_type.starts_with("multipart/form-data")
{
let (parts, body) = request.into_parts();
let max_body = crate::config::parse_size_string(&state.config.server.max_body_size);
let bytes = match axum::body::to_bytes(body, max_body).await {
Ok(b) => b,
Err(_) => {
return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response();
}
};
let body_str = String::from_utf8_lossy(&bytes);
let form_csrf = serde_urlencoded::from_str::<Vec<(String, String)>>(&body_str)
.ok()
.and_then(|pairs| {
pairs
.into_iter()
.find(|(k, _)| k == "_csrf")
.map(|(_, v)| v)
});
if validate_csrf_token(session.as_ref(), form_csrf.as_deref(), None) {
let request = Request::from_parts(parts, Body::from(bytes));
return next.run(request).await;
}
return (StatusCode::FORBIDDEN, "CSRF token mismatch").into_response();
}
(StatusCode::FORBIDDEN, "CSRF token mismatch").into_response()
}
pub fn create_router(state: AppState) -> Router {
let static_dir = state.root.join("static");
let dev_mode = state.dev_mode;
let cache_control = if dev_mode {
HeaderValue::from_static("no-cache, no-store, must-revalidate")
} else {
HeaderValue::from_static("public, max-age=3600")
};
let static_service = ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
cache_control,
))
.service(ServeDir::new(static_dir));
let health_router = Router::new().route("/health", get(handle_health));
let mut router = Router::new()
.route("/static/what.css", get(handle_embedded_css))
.route("/static/what.js", get(handle_embedded_js))
.nest_service("/static", static_service);
if state.config.uploads.enabled {
let uploads_dir = state.root.join(&state.config.uploads.directory);
let uploads_service = ServeDir::new(uploads_dir);
router = router.nest_service("/uploads", uploads_service);
}
router = router
.route("/w-session/reset", post(handle_session_reset))
.route("/w-auth/login", post(handle_login))
.route("/w-auth/logout", post(handle_logout))
.route("/w-action/:collection", post(handle_action))
.route("/w-action/:collection/:id", post(handle_action_with_id))
.route("/w-upload/:collection", post(handle_upload))
.route("/w-set", post(handle_w_set))
.route("/w-wire", get(handle_wire_ws))
.route("/w-session/clear-data", post(handle_session_clear_data))
.route("/w-partial/*path", get(handle_partial));
if dev_mode {
router = router
.route("/w-source/*path", get(handle_page_source))
.route("/w-inject/notification", get(handle_inject_notification))
.route("/w-livereload", get(handle_livereload_ws))
.route("/w-cache/clear-all", post(handle_cache_clear_all))
.route("/w-sessions/list", get(handle_sessions_list))
.route("/w-data/info", get(handle_data_info))
.route("/w-inspector", get(handle_inspector));
} else if state.config.server.source_viewer {
router = router.route("/w-source/*path", get(handle_page_source));
}
let app = router
.fallback(handle_page)
.with_state(state.clone())
.layer(axum::extract::DefaultBodyLimit::max(
crate::config::parse_size_string(&state.config.server.max_body_size),
))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
rate_limit_middleware,
))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
csrf_middleware,
))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
redirect_middleware,
))
.layer(axum::middleware::from_fn(security_headers_middleware))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
request_logging_middleware,
));
app.merge(health_router)
}
async fn handle_health() -> impl IntoResponse {
axum::Json(json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION")
}))
}
async fn handle_page(State(state): State<AppState>, request: Request<Body>) -> impl IntoResponse {
let path = request.uri().path().to_string();
let query_params = decode_query_params(request.uri().query());
let is_partial_request = request
.headers()
.get("X-Requested-With")
.and_then(|v| v.to_str().ok())
.map(|v| v == "What")
.unwrap_or(false);
let cookie_header = request
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok());
let (mut session, is_new_session) = if let Some(ref sessions) = state.sessions {
let session_id =
sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
match sessions.get_or_create(session_id.as_deref()).await {
Ok(session) => {
let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
(Some(session), is_new)
}
Err(e) => {
tracing::warn!("Session error: {}", e);
(None, false)
}
}
} else {
(None, false)
};
let user_context = if state.auth.is_enabled() {
if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
match state.auth.decode_jwt(&token) {
Ok(claims) => {
if claims.is_expired() {
tracing::debug!("JWT token expired");
UserContext::unauthenticated()
} else {
let user_claims = claims.to_context(state.auth.jwt_claims());
UserContext::from_claims(user_claims)
}
}
Err(e) => {
tracing::debug!("Failed to decode JWT: {}", e);
UserContext::unauthenticated()
}
}
} else {
UserContext::unauthenticated()
}
} else {
UserContext::unauthenticated()
};
let resolved = resolve_page_path(&state.root, &path);
let route_params = resolved
.as_ref()
.map(|r| r.params.clone())
.unwrap_or_default();
let page_path = resolved.map(|r| r.path);
let (app_config, page_content, mut directives) = {
let root = state.root.clone();
let url_path = path.clone();
let file_path = page_path.clone();
let dev_mode = state.dev_mode;
let load = tokio::task::spawn_blocking(move || {
let app_config = load_application_config(&root, &url_path);
let (content, dirs) = match file_path {
Some(file_path) => match std::fs::read_to_string(&file_path) {
Ok(content) => {
if dev_mode {
engine::warn_template_lints_once(&file_path, &content);
}
let (dirs, cleaned) = parse_page_directives(&content);
(Some(cleaned), dirs)
}
Err(_) => (None, PageDirectives::default()),
},
None => (None, PageDirectives::default()),
};
(app_config, content, dirs)
})
.await;
match load {
Ok(loaded) => loaded,
Err(e) => {
tracing::error!("Page load task failed: {}", e);
(WhatConfig::default(), None, PageDirectives::default())
}
}
};
if !directives.requires_auth() && app_config.directives.requires_auth() {
directives.auth = app_config.directives.auth.clone();
directives.protected = app_config.directives.protected;
directives.roles = app_config.directives.roles.clone();
}
if directives.title.is_none() {
directives.title = app_config.directives.title.clone();
}
if directives.redirect.is_none() {
directives.redirect = app_config.directives.redirect.clone();
}
if directives.cache_ttl.is_none() {
directives.cache_ttl = app_config.directives.cache_ttl;
}
if let Some(ref redirect_to) = directives.redirect {
return Redirect::to(redirect_to).into_response();
}
if directives.exclude {
let html = render_custom_error_page(
&state,
StatusCode::NOT_FOUND,
&path,
"Page not found",
session.as_ref(),
&user_context,
)
.await;
return build_response(
StatusCode::NOT_FOUND,
vec![(header::CONTENT_TYPE, "text/html".to_string())],
html,
);
}
let user_roles: Vec<String> = user_context.roles();
let requires_auth = directives.requires_auth() || state.auth.is_protected(&path);
if requires_auth && !user_context.authenticated {
let login_path = state.auth.login_path();
let redirect_url = format!("{}?redirect={}", login_path, urlencoding::encode(&path));
return Redirect::to(&redirect_url).into_response();
}
if !directives.check_access(user_context.authenticated, &user_roles) {
let html = render_custom_error_page(
&state,
StatusCode::FORBIDDEN,
&path,
"You don't have permission to access this page.",
session.as_ref(),
&user_context,
)
.await;
return build_response(
StatusCode::FORBIDDEN,
vec![(header::CONTENT_TYPE, "text/html".to_string())],
html,
);
}
let mut headers = vec![(header::CONTENT_TYPE, "text/html".to_string())];
if is_new_session {
if let Some(ref s) = session {
let cookie = sessions::build_session_cookie(
&s.id,
&state.config.session.cookie_name,
state.config.session.max_age,
state.config.session.secure,
);
headers.push((header::SET_COOKIE, cookie));
}
}
for (name, value) in &app_config.directives.headers {
match header::HeaderName::from_bytes(name.as_bytes()) {
Ok(header_name) => match HeaderValue::from_str(value) {
Ok(header_value) => headers.push((
header_name,
header_value.to_str().unwrap_or(value).to_string(),
)),
Err(_) => tracing::warn!(
"Invalid custom header value for '{}': could not parse value",
name
),
},
Err(_) => tracing::warn!(
"Invalid custom header name '{}': could not parse as HTTP header",
name
),
}
}
for (name, value) in &directives.headers {
match header::HeaderName::from_bytes(name.as_bytes()) {
Ok(header_name) => match HeaderValue::from_str(value) {
Ok(header_value) => headers.push((
header_name,
header_value.to_str().unwrap_or(value).to_string(),
)),
Err(_) => tracing::warn!(
"Invalid custom header value for '{}': could not parse value",
name
),
},
Err(_) => tracing::warn!(
"Invalid custom header name '{}': could not parse as HTTP header",
name
),
}
}
let flash_data = if let Some(ref mut sess) = session {
let flash = consume_flash_data(sess);
if flash.is_some() {
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
flash
} else {
None
};
match (page_path, page_content) {
(Some(_file_path), Some(content)) => {
if !directives.session_mutations.is_empty() {
if let (Some(sess), Some(sessions)) = (&mut session, &state.sessions) {
for mutation in &directives.session_mutations {
let atomic = to_atomic_mutation(mutation, &sess.data);
match sessions.apply_mutation(&sess.id, &atomic).await {
Ok(updated_data) => sess.data = updated_data,
Err(e) => tracing::error!("Failed to apply session mutation: {}", e),
}
}
}
}
let use_cache = session.is_none() && !user_context.authenticated;
if use_cache {
let cache_key = CacheKey::page(&path);
if let Some(cached) = state.cache.get(&cache_key).await {
return build_response(StatusCode::OK, headers, cached.content);
}
}
match render_content_internal(
&state,
&content,
session.as_ref(),
&user_context,
Some(&app_config),
Some(&directives),
Some(&query_params),
&route_params,
is_partial_request,
flash_data.as_ref(),
)
.await
{
Ok(render_result) => {
let mut html = render_result.html;
if is_partial_request && !render_result.session_keys.is_empty() {
if let Some(ref s) = session {
let mut updates = serde_json::Map::new();
for key in &render_result.session_keys {
if let Some(value) = s.data.get(key) {
updates.insert(format!("session.{}", key), value.clone());
}
}
if !updates.is_empty() {
let json_str = serde_json::to_string(&updates).unwrap_or_default();
html.push_str(&format!(
r#"<template data-what-updates>{}</template>"#,
json_str
));
}
}
}
if state.dev_mode && is_partial_request && !render_result.fetch_debug.is_empty()
{
if let Ok(debug_json) = serde_json::to_string(&render_result.fetch_debug) {
headers.push((
header::HeaderName::from_static("x-what-debug"),
debug_json,
));
}
}
if let Some(ref s) = session {
if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
html = inject_csrf_tokens(&html, csrf);
}
}
if !is_partial_request {
html = inject_seo_meta(&html, &directives.custom, &directives.vars);
}
if state.dev_mode && !is_partial_request {
html = inject_debug_meta(&html, &state.log_level);
}
register_validated_actions(&html, &state);
html = inject_what_css(&html, state.css_mode);
if !is_partial_request {
html = inject_what_js(&html);
html = inject_theme_restore(&html);
}
if use_cache && !is_partial_request {
let cache_key = CacheKey::page(&path);
state
.cache
.set(&cache_key, CachedValue::html(html.clone()))
.await;
}
build_response(StatusCode::OK, headers, html)
}
Err(e) => {
tracing::error!("Render error: {}", e);
let html = render_custom_error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
&path,
&e.to_string(),
session.as_ref(),
&user_context,
)
.await;
build_response(StatusCode::INTERNAL_SERVER_ERROR, headers, html)
}
}
}
_ => {
let html = render_custom_error_page(
&state,
StatusCode::NOT_FOUND,
&path,
"Page not found",
session.as_ref(),
&user_context,
)
.await;
build_response(StatusCode::NOT_FOUND, headers, html)
}
}
}
fn to_atomic_mutation(
mutation: &SessionMutation,
session_data: &HashMap<String, Value>,
) -> AtomicMutation {
match mutation {
SessionMutation::Increment { key, value } => AtomicMutation::Increment {
key: key.clone(),
value: *value,
},
SessionMutation::Set { key, value } => {
let resolved = resolve_session_value(value, session_data);
AtomicMutation::Set {
key: key.clone(),
value: resolved,
}
}
SessionMutation::Push { key, value } => {
let resolved = resolve_session_value(value, session_data);
AtomicMutation::Push {
key: key.clone(),
value: resolved,
}
}
SessionMutation::PushMax { key, max, value } => {
let resolved = resolve_session_value(value, session_data);
AtomicMutation::PushMax {
key: key.clone(),
max: *max,
value: resolved,
}
}
SessionMutation::Unshift { key, value } => {
let resolved = resolve_session_value(value, session_data);
AtomicMutation::Unshift {
key: key.clone(),
value: resolved,
}
}
SessionMutation::Clear { key } => AtomicMutation::Clear { key: key.clone() },
}
}
fn resolve_session_value(value: &Value, session_data: &HashMap<String, Value>) -> Value {
if let Some(s) = value.as_str() {
if s.contains('#') {
let mut context = HashMap::new();
let session_obj: serde_json::Map<String, Value> = session_data
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
context.insert("session".to_string(), Value::Object(session_obj));
let resolved = crate::parser::replace_variables(s, &context);
if let Some(n) = crate::parser::evaluate_arithmetic(&resolved) {
return if n == n.trunc() && n.abs() < i64::MAX as f64 {
json!(n as i64)
} else {
json!(n)
};
}
if let Ok(n) = resolved.parse::<i64>() {
return json!(n);
}
if let Ok(n) = resolved.parse::<f64>() {
return json!(n);
}
return json!(resolved);
}
if let Some(n) = crate::parser::evaluate_arithmetic(s) {
return if n == n.trunc() && n.abs() < i64::MAX as f64 {
json!(n as i64)
} else {
json!(n)
};
}
}
value.clone()
}
fn build_response(
status: StatusCode,
headers: Vec<(header::HeaderName, String)>,
body: String,
) -> axum::response::Response {
let mut response = axum::response::Response::builder().status(status);
for (name, value) in headers {
response = response.header(name, value);
}
response.body(Body::from(body)).unwrap_or_else(|_| {
axum::response::Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("Internal Server Error"))
.expect("fallback response must build")
})
}
async fn render_custom_error_page(
state: &AppState,
status: StatusCode,
request_path: &str,
detail: &str,
session: Option<&sessions::Session>,
user: &UserContext,
) -> String {
let error_file = state.content_dir.join(format!("{}.html", status.as_u16()));
if error_file.exists() {
if let Ok(raw_content) = tokio::fs::read_to_string(&error_file).await {
if state.dev_mode {
engine::warn_template_lints_once(&error_file, &raw_content);
}
let (_directives, content) = parse_page_directives(&raw_content);
let mut content_with_error = content.clone();
content_with_error =
content_with_error.replace("#error.status#", &status.as_u16().to_string());
content_with_error = content_with_error.replace("#error.path#", request_path);
if state.dev_mode {
content_with_error = content_with_error.replace("#error.message#", detail);
} else {
content_with_error = content_with_error.replace("#error.message#", "");
}
match render_content(
state,
&content_with_error,
session,
user,
None,
Some(&_directives),
None,
)
.await
{
Ok(html) => return html,
Err(e) => tracing::warn!("Failed to render custom {} page: {}", status.as_u16(), e),
}
}
}
error_page_fallback(state.dev_mode, status, detail)
}
fn error_page_fallback(dev_mode: bool, status: StatusCode, detail: &str) -> String {
if dev_mode {
format!("<h1>Error {}</h1><pre>{}</pre>", status.as_u16(), detail)
} else {
match status {
StatusCode::NOT_FOUND => "<h1>404 - Page Not Found</h1>".to_string(),
StatusCode::FORBIDDEN => "<h1>403 - Forbidden</h1>".to_string(),
_ => "<h1>Something went wrong</h1><p>An internal error occurred.</p>".to_string(),
}
}
}
const CSRF_SESSION_KEY: &str = "_csrf_token";
fn inject_csrf_tokens(html: &str, csrf_token: &str) -> String {
static FORM_POST_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(<form\b[^>]*\bmethod\s*=\s*["']?post["']?[^>]*>)"#).unwrap()
});
let hidden_input = format!(
r#"<input type="hidden" name="_csrf" value="{}">"#,
csrf_token
);
let output = FORM_POST_RE.replace_all(html, |caps: ®ex::Captures| {
format!("{}{}", &caps[0], hidden_input)
});
let meta_tag = format!(r#"<meta name="csrf-token" content="{}">"#, csrf_token);
let output = if let Some(pos) = output.find("</head>") {
format!("{}{}\n{}", &output[..pos], meta_tag, &output[pos..])
} else if let Some(pos) = output.find("</HEAD>") {
format!("{}{}\n{}", &output[..pos], meta_tag, &output[pos..])
} else {
format!("{}\n{}", meta_tag, output)
};
output
}
fn inject_what_js(html: &str) -> String {
let script_tag = format!(r#"<script src="{}"></script>"#, WHAT_JS_ASSET_PATH.as_str());
if html.contains(r#"/static/what.js"#) {
return html.to_string();
}
if let Some(pos) = html.find("</body>") {
format!("{}{}\n{}", &html[..pos], script_tag, &html[pos..])
} else if let Some(pos) = html.find("</BODY>") {
format!("{}{}\n{}", &html[..pos], script_tag, &html[pos..])
} else {
html.to_string()
}
}
fn inject_theme_restore(html: &str) -> String {
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>"#;
if html.contains("data-w-theme") {
return html.to_string();
}
for open in ["<head>", "<HEAD>"] {
if let Some(pos) = html.find(open) {
let insert_at = pos + open.len();
return format!("{}{}{}", &html[..insert_at], THEME_SCRIPT, &html[insert_at..]);
}
}
if let Some(pos) = html.find("<head ") {
if let Some(end) = html[pos..].find('>') {
let insert_at = pos + end + 1;
return format!("{}{}{}", &html[..insert_at], THEME_SCRIPT, &html[insert_at..]);
}
}
html.to_string()
}
fn register_validated_actions(html: &str, state: &AppState) {
static FORM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?si)<form\b[^>]*\bw-validate\b[^>]*>"#).unwrap()
});
static ACTION_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)action="([^"]+)""#).unwrap()
});
let mut found = Vec::new();
for mat in FORM_RE.find_iter(html) {
if let Some(cap) = ACTION_RE.captures(mat.as_str()) {
if let Some(action) = cap.get(1) {
found.push(action.as_str().to_string());
}
}
}
if !found.is_empty() {
if let Ok(mut registry) = state.validated_actions.write() {
for url in found {
registry.insert(url);
}
}
}
}
fn inject_what_css(html: &str, mode: CssMode) -> String {
if mode == CssMode::None {
return html.to_string();
}
let asset_path = match mode {
CssMode::Minimal => WHAT_CSS_MINIMAL_ASSET_PATH.as_str(),
_ => WHAT_CSS_ASSET_PATH.as_str(),
};
let link_tag = format!(r#"<link rel="stylesheet" href="{}">"#, asset_path);
let head_start = html.find("<head>").or_else(|| html.find("<HEAD>"));
if let Some(hp) = head_start {
let head_section_end = html[hp..].find("</head>").map(|p| p + hp).unwrap_or(html.len());
if html[hp..head_section_end].contains("/static/what.css") {
return html.to_string();
}
}
if let Some(head_pos) = head_start {
let after_head = head_pos + 6; let head_end = html[after_head..]
.find("</head>")
.or_else(|| html[after_head..].find("</HEAD>"))
.map(|p| p + after_head)
.unwrap_or(html.len());
if let Some(rel) = html[after_head..head_end].find("<link ").or_else(|| html[after_head..head_end].find("<link\n")) {
let insert_at = after_head + rel;
format!(
"{}{}\n {}",
&html[..insert_at],
link_tag,
&html[insert_at..]
)
} else {
format!(
"{}\n {}{}",
&html[..after_head],
link_tag,
&html[after_head..]
)
}
} else {
html.to_string()
}
}
fn inject_seo_meta(
html: &str,
custom: &HashMap<String, String>,
vars: &HashMap<String, Value>,
) -> String {
let get = |key: &str| -> Option<String> {
custom
.get(key)
.cloned()
.or_else(|| vars.get(key).and_then(|v| v.as_str().map(String::from)))
};
let mut tags = Vec::new();
if let Some(desc) = get("description") {
tags.push(format!(
r#"<meta name="description" content="{}">"#,
html_escape(&desc)
));
}
if let Some(robots) = get("robots") {
tags.push(format!(
r#"<meta name="robots" content="{}">"#,
html_escape(&robots)
));
}
for (key, prop) in [
("og.title", "og:title"),
("og.description", "og:description"),
("og.image", "og:image"),
("og.url", "og:url"),
("og.type", "og:type"),
] {
if let Some(val) = get(key) {
tags.push(format!(
r#"<meta property="{}" content="{}">"#,
prop,
html_escape(&val)
));
}
}
for (key, name) in [
("twitter.card", "twitter:card"),
("twitter.title", "twitter:title"),
("twitter.description", "twitter:description"),
("twitter.image", "twitter:image"),
("twitter.creator", "twitter:creator"),
] {
if let Some(val) = get(key) {
tags.push(format!(
r#"<meta name="{}" content="{}">"#,
name,
html_escape(&val)
));
}
}
if let Some(canonical) = get("canonical") {
tags.push(format!(
r#"<link rel="canonical" href="{}">"#,
html_escape(&canonical)
));
}
if tags.is_empty() {
return html.to_string();
}
let meta_block = tags.join("\n");
inject_before_head_close(html, &meta_block)
}
fn inject_debug_meta(html: &str, log_level: &str) -> String {
let meta = format!(r#"<meta name="what-debug" content="{}">"#, log_level);
inject_before_head_close(html, &meta)
}
fn inject_before_head_close(html: &str, content: &str) -> String {
if let Some(pos) = html.find("</head>") {
format!("{}{}\n{}", &html[..pos], content, &html[pos..])
} else if let Some(pos) = html.find("</HEAD>") {
format!("{}{}\n{}", &html[..pos], content, &html[pos..])
} else {
format!("{}\n{}", content, html)
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
fn validate_csrf_token(
session: Option<&sessions::Session>,
form_token: Option<&str>,
header_token: Option<&str>,
) -> bool {
let session_token = session
.and_then(|s| s.data.get(CSRF_SESSION_KEY))
.and_then(|v| v.as_str());
let session_token = match session_token {
Some(t) => t,
None => return false, };
let submitted_token = form_token.or(header_token);
match submitted_token {
Some(t) => t == session_token,
None => false,
}
}
fn is_csrf_exempt(path: &str) -> bool {
path.starts_with("/w-livereload") || path.starts_with("/w-wire")
}
struct ResolvedPage {
path: PathBuf,
params: HashMap<String, String>,
}
fn resolve_page_path(root: &PathBuf, url_path: &str) -> Option<ResolvedPage> {
let pages_dir = content_dir(root);
let clean_path = url_path.trim_start_matches('/');
let exact = pages_dir.join(format!("{}.html", clean_path));
if exact.exists() {
return Some(ResolvedPage {
path: exact,
params: HashMap::new(),
});
}
let index = pages_dir.join(clean_path).join("index.html");
if index.exists() {
return Some(ResolvedPage {
path: index,
params: HashMap::new(),
});
}
if clean_path.is_empty() || clean_path == "/" {
let root_index = pages_dir.join("index.html");
if root_index.exists() {
return Some(ResolvedPage {
path: root_index,
params: HashMap::new(),
});
}
}
let parts: Vec<&str> = clean_path.split('/').collect();
if parts.len() >= 2 {
let parent = parts[..parts.len() - 1].join("/");
let dynamic_value = parts[parts.len() - 1];
let dynamic = pages_dir.join(&parent).join("[id].html");
if dynamic.exists() {
let mut params = HashMap::new();
params.insert("id".to_string(), dynamic_value.to_string());
return Some(ResolvedPage {
path: dynamic,
params,
});
}
}
None
}
fn collect_application_config_paths(root: &PathBuf, url_path: &str) -> Vec<PathBuf> {
let pages_dir = content_dir(root);
let clean_path = url_path.trim_start_matches('/');
let mut dirs_to_check = vec![pages_dir.clone()];
if !clean_path.is_empty() {
let parts: Vec<&str> = clean_path.split('/').collect();
let mut current = pages_dir.clone();
for part in &parts[..parts.len().saturating_sub(1)] {
current = current.join(part);
if current.is_dir() {
dirs_to_check.push(current.clone());
}
}
let last_dir = pages_dir.join(clean_path);
if last_dir.is_dir() {
dirs_to_check.push(last_dir);
}
}
dirs_to_check
.into_iter()
.map(|dir| dir.join("application.what"))
.filter(|path| path.exists())
.collect()
}
fn load_application_config(root: &PathBuf, url_path: &str) -> WhatConfig {
let mut merged = WhatConfig::default();
for config_path in collect_application_config_paths(root, url_path) {
if let Ok(content) = std::fs::read_to_string(&config_path) {
let config = parse_what_file(&content);
merged.merge(&config);
tracing::debug!("Loaded application.what from {:?}", config_path);
}
}
merged
}
fn push_source_file(
path: PathBuf,
project_root_canonical: &std::path::Path,
seen_paths: &mut std::collections::HashSet<PathBuf>,
files: &mut Vec<Value>,
scan_queue: &mut Vec<(PathBuf, String)>,
) -> bool {
let canonical_path = match path.canonicalize() {
Ok(p) => p,
Err(_) => return false,
};
if !canonical_path.starts_with(project_root_canonical)
|| !seen_paths.insert(canonical_path.clone())
{
return false;
}
let content = match std::fs::read_to_string(&canonical_path) {
Ok(c) => c,
Err(_) => return false,
};
let label = canonical_path
.strip_prefix(project_root_canonical)
.unwrap_or(&canonical_path)
.display()
.to_string();
files.push(json!({ "label": label, "content": content }));
scan_queue.push((canonical_path, content));
true
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
struct FetchDirective {
key: String,
url: String,
method: String,
headers: Vec<(String, String)>,
body: Option<String>,
path: Option<String>,
sort: Option<String>,
filter: Option<String>,
search: Option<String>,
search_fields: Option<String>,
limit: Option<usize>,
offset: Option<usize>,
}
impl FetchDirective {
#[allow(dead_code)]
fn simple(key: String, url: String) -> Self {
Self {
key,
url,
method: "GET".to_string(),
headers: Vec::new(),
body: None,
path: None,
sort: None,
filter: None,
search: None,
search_fields: None,
limit: None,
offset: None,
}
}
fn is_local(&self) -> bool {
self.url.starts_with("local:")
}
fn local_collection(&self) -> Option<&str> {
self.url
.strip_prefix("local:")
.map(|rest| rest.split('?').next().unwrap_or(rest))
}
fn local_query(&self) -> Option<&str> {
self.url
.strip_prefix("local:")
.and_then(|rest| rest.split_once('?').map(|(_, q)| q))
}
}
fn extract_json_path(value: &Value, path: &str) -> Value {
let mut current = value;
for part in path.split('.') {
match current {
Value::Object(obj) => {
if let Some(v) = obj.get(part) {
current = v;
} else {
return Value::Null;
}
}
Value::Array(arr) => {
if let Ok(idx) = part.parse::<usize>() {
if let Some(v) = arr.get(idx) {
current = v;
} else {
return Value::Null;
}
} else {
return Value::Null;
}
}
_ => return Value::Null,
}
}
current.clone()
}
fn parse_header_string(s: &str) -> Vec<(String, String)> {
let mut result = Vec::new();
for part in s.split(',') {
let part = part.trim();
if let Some(colon) = part.find(':') {
let key = part[..colon].trim().to_string();
let value = part[colon + 1..].trim().to_string();
result.push((key, value));
}
}
result
}
#[derive(Clone, Serialize)]
pub struct FetchDebugEntry {
pub key: String,
pub url: String,
pub elapsed_ms: u64,
pub result: String,
}
pub struct RenderResult {
pub html: String,
pub session_keys: std::collections::HashSet<String>,
pub fetch_debug: Vec<FetchDebugEntry>,
}
fn resolve_env_value(value: &str) -> crate::Result<String> {
if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
std::env::var(var_name).map_err(|_| {
crate::Error::Config(format!("Environment variable {} is not set", var_name))
})
} else {
Ok(value.to_string())
}
}
struct EmailTrigger {
to: String,
subject: String,
template: Option<String>,
}
fn extract_email_trigger(form_data: &HashMap<String, String>) -> Option<EmailTrigger> {
let to = form_data.get("w-email-to")?;
if to.is_empty() {
return None;
}
Some(EmailTrigger {
to: to.clone(),
subject: form_data
.get("w-email-subject")
.cloned()
.unwrap_or_else(|| "Notification".to_string()),
template: form_data.get("w-email-template").cloned(),
})
}
async fn maybe_enqueue_email(state: &AppState, trigger: Option<EmailTrigger>) {
let trigger = match trigger {
Some(t) => t,
None => return,
};
if state.config.email.is_none() {
tracing::warn!("Form has w-email-to but no [email] config in what.toml — skipping");
return;
}
let html_body = if let Some(ref tpl_name) = trigger.template {
let email_config = state.config.email.as_ref().unwrap();
match crate::email::render_email_template(
&state.root,
&email_config.template_dir,
tpl_name,
&HashMap::new(),
) {
Ok(html) => html,
Err(e) => {
tracing::error!("Failed to render email template '{}': {}", tpl_name, e);
return;
}
}
} else {
format!("<p>{}</p>", trigger.subject)
};
let message = crate::email::EmailMessage {
to: trigger.to,
subject: trigger.subject,
html_body,
text_body: None,
};
let _ = state
.jobs
.enqueue(crate::jobs::Job::SendEmail { message })
.await;
}
const DEFAULT_DATA_CACHE_TTL_SECS: u64 = 300;
fn data_source_cache_ttl(source: &DataSource) -> u64 {
match source {
DataSource::Url { cache, .. } => *cache,
DataSource::File { cache, .. } => *cache,
DataSource::SimplePath(_) => DEFAULT_DATA_CACHE_TTL_SECS,
}
}
fn resolve_data_source_path(root: &PathBuf, path: &str) -> PathBuf {
let candidate = PathBuf::from(path);
if candidate.is_absolute() {
candidate
} else {
root.join(candidate)
}
}
async fn store_data_value(state: &AppState, name: &str, value: Value) -> Result<()> {
match value {
Value::Array(items) => state.store.set_collection(name, items).await,
other => state.store.set(name, other).await,
}
}
async fn verify_turnstile(
state: &AppState,
token: Option<&str>,
) -> std::result::Result<(), String> {
let secret = match state
.config
.cloudflare
.as_ref()
.and_then(|cf| cf.turnstile_secret_key.as_ref())
{
Some(s) => resolve_env_value(s).map_err(|e| e.to_string())?,
None => return Ok(()), };
let token = match token {
Some(t) if !t.is_empty() => t,
_ => return Err("Turnstile verification required".to_string()),
};
let resp = state
.http_client
.post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
.form(&[("secret", secret.as_str()), ("response", token)])
.send()
.await
.map_err(|_| "Turnstile verification failed: network error".to_string())?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|_| "Turnstile verification failed: invalid response".to_string())?;
if body
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
Ok(())
} else {
let codes = body
.get("error-codes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(|| "unknown".to_string());
Err(format!("Turnstile verification failed: {}", codes))
}
}
fn sanitize_extension(ext: &str) -> String {
let clean: String = ext
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '.')
.collect();
if clean.is_empty() {
String::new()
} else {
format!(".{}", clean)
}
}
fn describe_json(value: &Value) -> String {
match value {
Value::Array(arr) => format!("array[{}]", arr.len()),
Value::Object(obj) => {
let summaries: Vec<String> = obj
.iter()
.take(5)
.map(|(k, v)| {
let v_desc = match v {
Value::Array(a) => format!("array[{}]", a.len()),
Value::Object(o) => format!("object{{{}}}", o.len()),
Value::String(s) => format!("str({})", s.len()),
Value::Number(_) => "num".to_string(),
Value::Bool(_) => "bool".to_string(),
Value::Null => "null".to_string(),
};
format!("{}: {}", k, v_desc)
})
.collect();
if obj.len() > 5 {
format!("object{{{}... +{}}}", summaries.join(", "), obj.len() - 5)
} else {
format!("object{{{}}}", summaries.join(", "))
}
}
Value::String(s) => format!("string({}b)", s.len()),
Value::Number(n) => format!("number({})", n),
Value::Bool(b) => format!("bool({})", b),
Value::Null => "null".to_string(),
}
}
async fn fetch_remote_json(state: &AppState, url: &str, use_cache: bool) -> Result<Value> {
if use_cache && state.config.cache.enabled {
if let Some(cached) = state.cache.get_api(url).await {
tracing::info!(" cache hit: {}", url);
return Ok(serde_json::from_str(&cached)?);
}
}
let start = std::time::Instant::now();
let response = state.http_client.get(url).send().await.map_err(|e| {
tracing::warn!(" fetch failed ({}): {}", start.elapsed().as_millis(), e);
crate::Error::Data(format!("HTTP request failed: {}", e))
})?;
let status = response.status();
let body = response.text().await?;
let elapsed = start.elapsed();
if !status.is_success() {
tracing::warn!(" {} -> {} {}ms", url, status, elapsed.as_millis());
return Err(crate::Error::Data(format!(
"Remote fetch failed ({}): {}",
status, url
)));
}
let value: Value = serde_json::from_str(&body)?;
tracing::info!(
" {} -> {} {}ms {}",
url,
status,
elapsed.as_millis(),
describe_json(&value)
);
if use_cache && state.config.cache.enabled {
state.cache.set_api(url, body).await;
}
Ok(value)
}
async fn load_data_source(state: &AppState, name: &str, source: &DataSource) -> Result<()> {
let value = match source {
DataSource::File { file, .. } => {
let path = resolve_data_source_path(&state.root, file);
let content = tokio::fs::read_to_string(&path).await?;
serde_json::from_str(&content)?
}
DataSource::SimplePath(path) => {
let full_path = resolve_data_source_path(&state.root, path);
let content = tokio::fs::read_to_string(&full_path).await?;
serde_json::from_str(&content)?
}
DataSource::Url { url, .. } => fetch_remote_json(state, url, true).await?,
};
store_data_value(state, name, value).await
}
async fn hydrate_config_data_sources(state: &AppState) -> Result<()> {
if state.config.data.is_empty() {
return Ok(());
}
let mut to_refresh: Vec<(String, DataSource)> = Vec::new();
let use_cache = state.config.cache.enabled;
{
let last_loaded = state.data_source_loaded.read().await;
for (name, source) in &state.config.data {
let ttl = if use_cache {
data_source_cache_ttl(source)
} else {
0
};
let should_refresh = if ttl == 0 {
true
} else {
last_loaded
.get(name)
.map(|t| t.elapsed() >= Duration::from_secs(ttl))
.unwrap_or(true)
};
if should_refresh {
to_refresh.push((name.clone(), source.clone()));
}
}
}
if to_refresh.is_empty() {
return Ok(());
}
for (name, source) in to_refresh {
match load_data_source(state, &name, &source).await {
Ok(()) => {
let mut last_loaded = state.data_source_loaded.write().await;
last_loaded.insert(name, Instant::now());
}
Err(e) => {
tracing::warn!("Failed to load data source {}: {}", name, e);
}
}
}
Ok(())
}
fn collect_fetch_directives(
app_config: Option<&WhatConfig>,
page_directives: Option<&PageDirectives>,
) -> HashMap<String, FetchDirective> {
let mut all_entries: HashMap<String, HashMap<String, String>> = HashMap::new();
let mut raw_pairs: Vec<(String, String)> = Vec::new();
if let Some(config) = app_config {
for (key, value) in &config.values {
if let Some(rest) = key.strip_prefix("fetch.") {
if let Some(url) = value.as_str() {
raw_pairs.push((rest.to_string(), url.to_string()));
}
}
}
}
if let Some(directives) = page_directives {
for (key, value) in &directives.custom {
if let Some(rest) = key.strip_prefix("fetch.") {
if !value.is_empty() {
raw_pairs.push((rest.to_string(), value.to_string()));
}
}
}
}
for (rest, value) in raw_pairs {
if let Some(dot_pos) = rest.find('.') {
let fetch_key = rest[..dot_pos].to_string();
let property = rest[dot_pos + 1..].to_string();
all_entries
.entry(fetch_key)
.or_default()
.insert(property, value);
} else {
all_entries
.entry(rest)
.or_default()
.insert("url".to_string(), value);
}
}
let mut result = HashMap::new();
for (key, props) in all_entries {
let url = match props.get("url") {
Some(u) => u.clone(),
None => continue, };
let method = props
.get("method")
.cloned()
.unwrap_or_else(|| "GET".to_string())
.to_uppercase();
let headers = props
.get("headers")
.map(|h| parse_header_string(h))
.unwrap_or_default();
let body = props.get("body").cloned();
let path = props.get("path").cloned();
if props.contains_key("poll") {
tracing::warn!(
"fetch.{}.poll was removed in v1.3 — move the markup into a partial and wrap it in <what-fetch url=\"/w-partial/...\" poll=\"...\"> instead",
key
);
}
let sort = props.get("sort").cloned();
let filter = props.get("filter").cloned();
let search = props.get("search").cloned();
let search_fields = props.get("search_fields").cloned();
let limit = props.get("limit").and_then(|l| l.parse().ok());
let offset = props.get("offset").and_then(|o| o.parse().ok());
result.insert(
key.clone(),
FetchDirective {
key,
url,
method,
headers,
body,
path,
sort,
filter,
search,
search_fields,
limit,
offset,
},
);
}
result
}
async fn fetch_enhanced(
state: &AppState,
directive: &FetchDirective,
resolved_url: &str,
) -> Result<Value> {
let start = std::time::Instant::now();
let method = match directive.method.as_str() {
"POST" => reqwest::Method::POST,
"PUT" => reqwest::Method::PUT,
"DELETE" => reqwest::Method::DELETE,
"PATCH" => reqwest::Method::PATCH,
_ => reqwest::Method::GET,
};
let mut request = state.http_client.request(method, resolved_url);
for (key, value) in &directive.headers {
request = request.header(key.as_str(), value.as_str());
}
if let Some(ref body) = directive.body {
request = request.body(body.clone());
if !directive
.headers
.iter()
.any(|(k, _)| k.to_lowercase() == "content-type")
{
request = request.header("Content-Type", "application/json");
}
}
let response = request
.send()
.await
.map_err(|e| crate::Error::Data(format!("HTTP request failed: {}", e)))?;
let status = response.status();
let body_text = response.text().await?;
let elapsed = start.elapsed();
if !status.is_success() {
tracing::warn!(" {} -> {} {}ms", resolved_url, status, elapsed.as_millis());
return Err(crate::Error::Data(format!(
"Remote fetch failed ({}): {}",
status, resolved_url
)));
}
let mut value: Value = serde_json::from_str(&body_text)?;
tracing::info!(
" {} -> {} {}ms {}",
resolved_url,
status,
elapsed.as_millis(),
describe_json(&value)
);
if let Some(ref path) = directive.path {
value = extract_json_path(&value, path);
}
Ok(value)
}
async fn apply_fetch_directives(
state: &AppState,
context: &mut HashMap<String, Value>,
app_config: Option<&WhatConfig>,
page_directives: Option<&PageDirectives>,
actor: &crate::policy::Actor,
) -> Vec<FetchDebugEntry> {
let fetches = collect_fetch_directives(app_config, page_directives);
if fetches.is_empty() {
return Vec::new();
}
let total_start = std::time::Instant::now();
let mut local_fetches = Vec::new();
let mut dsn_fetches = Vec::new();
let mut remote_fetches = Vec::new();
for (key, directive) in fetches {
if directive.is_local() {
local_fetches.push((key, directive));
} else if directive.url.starts_with("dsn:") {
dsn_fetches.push((key, directive));
} else {
remote_fetches.push((key, directive));
}
}
if !remote_fetches.is_empty() {
tracing::info!("Fetching {} remote source(s):", remote_fetches.len());
}
if !local_fetches.is_empty() {
tracing::info!("Querying {} local collection(s):", local_fetches.len());
}
if !dsn_fetches.is_empty() {
tracing::info!("Querying {} datasource(s):", dsn_fetches.len());
}
let mut fetch_errors = serde_json::Map::new();
let mut debug_entries = Vec::new();
for (key, directive) in local_fetches {
let start = std::time::Instant::now();
if let Some(collection) = directive.local_collection() {
let (mut q_sort, mut q_filter, mut q_search, mut q_limit, mut q_offset) =
(None, None, None, None, None);
if let Some(qs) = directive.local_query() {
for pair in qs.split('&') {
if let Some((k, v)) = pair.split_once('=') {
match k {
"sort" => q_sort = Some(v.to_string()),
"filter" => q_filter = Some(v.to_string()),
"search" => q_search = Some(v.to_string()),
"limit" => q_limit = v.parse::<usize>().ok(),
"offset" => q_offset = v.parse::<usize>().ok(),
_ => {}
}
}
}
}
let sort = directive
.sort
.clone()
.or(q_sort)
.map(|s| crate::parser::replace_variables(&s, context));
let filter = directive
.filter
.clone()
.or(q_filter)
.map(|s| crate::parser::replace_variables(&s, context));
let search = directive
.search
.clone()
.or(q_search)
.map(|s| crate::parser::replace_variables(&s, context));
let search_fields = directive.search_fields.clone();
let offset = directive.offset.or(q_offset);
let limit = directive.limit.or(q_limit);
let policy = state.policies.get(collection);
let forced_filters = match policy.read_scope(actor, context) {
crate::policy::ReadScope::Deny => {
tracing::debug!(target: "what::policy", "read denied for '{}' (no matching identity)", collection);
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: format!("local:{}", collection),
elapsed_ms: start.elapsed().as_millis() as u64,
result: "0 items (policy: denied)".to_string(),
});
context.insert(key, json!([]));
continue;
}
crate::policy::ReadScope::All => Vec::new(),
crate::policy::ReadScope::Filters(f) => f,
};
let scoped = !forced_filters.is_empty();
let query = crate::database::CollectionQuery {
sort,
filter,
search,
search_fields,
limit,
offset,
forced_filters,
};
let has_query = query.sort.is_some()
|| query.filter.is_some()
|| query.search.is_some()
|| query.limit.is_some()
|| query.offset.is_some()
|| !query.forced_filters.is_empty();
let value = if has_query {
state.store.query_collection(collection, &query).await
} else {
state.store.get_collection(collection).await
};
let elapsed_ms = start.elapsed().as_millis() as u64;
match value {
Some(mut items) => {
let count = items.len();
let mut items_val = json!(items.drain(..).collect::<Vec<_>>());
crate::policy::strip_private_fields(&mut items_val, &policy.private_fields);
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: format!("local:{}", collection),
elapsed_ms,
result: if scoped {
format!("{} items (policy-scoped)", count)
} else {
format!("{} items", count)
},
});
context.insert(key, items_val);
}
None => {
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: format!("local:{}", collection),
elapsed_ms,
result: "empty collection".to_string(),
});
context.insert(key, json!([]));
}
}
}
}
for (key, directive) in dsn_fetches {
let start = std::time::Instant::now();
let resolved_url = crate::parser::replace_variables(&directive.url, context);
if let Some((ds_name, target)) = crate::datasource::parse_dsn(&resolved_url) {
if let Some(datasource) = state.datasources.get(ds_name) {
match (datasource, &target) {
(
crate::datasource::Datasource::Database(adapter),
crate::datasource::DsnTarget::Collection(collection),
) => {
let sort = directive
.sort
.as_ref()
.map(|s| crate::parser::replace_variables(s, context));
let filter = directive
.filter
.as_ref()
.map(|s| crate::parser::replace_variables(s, context));
let search = directive
.search
.as_ref()
.map(|s| crate::parser::replace_variables(s, context));
let search_fields = directive.search_fields.clone();
let policy = state.policies.get(collection);
let forced_filters = match policy.read_scope(actor, context) {
crate::policy::ReadScope::Deny => {
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: resolved_url.clone(),
elapsed_ms: start.elapsed().as_millis() as u64,
result: "0 items (policy: denied)".to_string(),
});
context.insert(key, json!([]));
continue;
}
crate::policy::ReadScope::All => Vec::new(),
crate::policy::ReadScope::Filters(f) => f,
};
let scoped = !forced_filters.is_empty();
let query = crate::database::CollectionQuery {
sort,
filter,
search,
search_fields,
limit: directive.limit,
offset: directive.offset,
forced_filters,
};
let has_query = query.sort.is_some()
|| query.filter.is_some()
|| query.search.is_some()
|| query.limit.is_some()
|| query.offset.is_some()
|| !query.forced_filters.is_empty();
let value = if has_query {
adapter.query_collection(collection, &query).await
} else {
adapter.get_collection(collection).await
};
let elapsed_ms = start.elapsed().as_millis() as u64;
match value {
Some(mut items) => {
let count = items.len();
let mut items_val = json!(items.drain(..).collect::<Vec<_>>());
crate::policy::strip_private_fields(&mut items_val, &policy.private_fields);
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: resolved_url.clone(),
elapsed_ms,
result: if scoped {
format!("{} items (policy-scoped)", count)
} else {
format!("{} items", count)
},
});
context.insert(key, items_val);
}
None => {
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: resolved_url.clone(),
elapsed_ms,
result: "empty collection".to_string(),
});
context.insert(key, json!([]));
}
}
}
(
crate::datasource::Datasource::Database(adapter),
crate::datasource::DsnTarget::Root,
) => {
let ctx = adapter.as_context().await;
let elapsed_ms = start.elapsed().as_millis() as u64;
let count = ctx.len();
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: resolved_url.clone(),
elapsed_ms,
result: format!("{} entries", count),
});
context.insert(key, json!(ctx));
}
(
crate::datasource::Datasource::Api { base_url, headers },
crate::datasource::DsnTarget::Path(path),
) => {
let full_url = format!("{}{}", base_url, path);
let mut request = state.http_client.get(&full_url);
for (hk, hv) in headers {
request = request.header(hk.as_str(), hv.as_str());
}
for (hk, hv) in &directive.headers {
request = request.header(hk.as_str(), hv.as_str());
}
match request.send().await {
Ok(resp) if resp.status().is_success() => {
let elapsed_ms = start.elapsed().as_millis() as u64;
if let Ok(body) = resp.text().await {
if let Ok(mut value) = serde_json::from_str::<Value>(&body) {
if let Some(ref extract_path) = directive.path {
value = extract_json_path(&value, extract_path);
}
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: full_url,
elapsed_ms,
result: describe_json(&value),
});
context.insert(key, value);
} else {
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: full_url,
elapsed_ms,
result: "error: invalid JSON".to_string(),
});
}
}
}
Ok(resp) => {
let elapsed_ms = start.elapsed().as_millis() as u64;
let status = resp.status();
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: full_url,
elapsed_ms,
result: format!("error: HTTP {}", status),
});
fetch_errors.insert(key, json!(format!("HTTP {}", status)));
}
Err(e) => {
let elapsed_ms = start.elapsed().as_millis() as u64;
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: full_url,
elapsed_ms,
result: format!("error: {}", e),
});
fetch_errors.insert(key, json!(e.to_string()));
}
}
}
(
crate::datasource::Datasource::Api { base_url, headers },
crate::datasource::DsnTarget::Root,
) => {
let mut request = state.http_client.get(base_url.as_str());
for (hk, hv) in headers {
request = request.header(hk.as_str(), hv.as_str());
}
match request.send().await {
Ok(resp) if resp.status().is_success() => {
let elapsed_ms = start.elapsed().as_millis() as u64;
if let Ok(body) = resp.text().await {
if let Ok(value) = serde_json::from_str::<Value>(&body) {
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: base_url.clone(),
elapsed_ms,
result: describe_json(&value),
});
context.insert(key, value);
}
}
}
_ => {
let elapsed_ms = start.elapsed().as_millis() as u64;
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: base_url.clone(),
elapsed_ms,
result: "error: request failed".to_string(),
});
}
}
}
(
crate::datasource::Datasource::Database(adapter),
crate::datasource::DsnTarget::Path(path),
) => {
let collection = path.trim_start_matches('/');
let value = adapter.get_collection(collection).await;
let elapsed_ms = start.elapsed().as_millis() as u64;
match value {
Some(items) => {
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: resolved_url.clone(),
elapsed_ms,
result: format!("{} items", items.len()),
});
context.insert(key, json!(items));
}
None => {
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: resolved_url.clone(),
elapsed_ms,
result: "empty collection".to_string(),
});
context.insert(key, json!([]));
}
}
}
(
crate::datasource::Datasource::Api { base_url, headers },
crate::datasource::DsnTarget::Collection(collection),
) => {
let full_url = format!("{}/{}", base_url, collection);
let mut request = state.http_client.get(&full_url);
for (hk, hv) in headers {
request = request.header(hk.as_str(), hv.as_str());
}
match request.send().await {
Ok(resp) if resp.status().is_success() => {
let elapsed_ms = start.elapsed().as_millis() as u64;
if let Ok(body) = resp.text().await {
if let Ok(value) = serde_json::from_str::<Value>(&body) {
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: full_url,
elapsed_ms,
result: describe_json(&value),
});
context.insert(key, value);
}
}
}
_ => {
let elapsed_ms = start.elapsed().as_millis() as u64;
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: full_url,
elapsed_ms,
result: "error: request failed".to_string(),
});
}
}
}
}
} else {
let elapsed_ms = start.elapsed().as_millis() as u64;
let ds_name_owned = ds_name.to_string();
tracing::warn!(" dsn '{}' not found in [datasources]", ds_name_owned);
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: resolved_url.clone(),
elapsed_ms,
result: format!("error: datasource '{}' not configured", ds_name_owned),
});
fetch_errors.insert(
key,
json!(format!("datasource '{}' not configured", ds_name_owned)),
);
}
} else {
let elapsed_ms = start.elapsed().as_millis() as u64;
tracing::warn!(" invalid dsn URL: {}", resolved_url);
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url: resolved_url.clone(),
elapsed_ms,
result: "error: invalid dsn URL format".to_string(),
});
fetch_errors.insert(key, json!("invalid dsn URL format"));
}
}
let handles: Vec<_> = remote_fetches
.into_iter()
.map(|(key, directive)| {
let state = state.clone();
let resolved_url = crate::parser::replace_variables(&directive.url, context);
let directive = directive.clone();
tokio::spawn(async move {
let start = std::time::Instant::now();
let result = if directive.method == "GET"
&& directive.headers.is_empty()
&& directive.body.is_none()
&& directive.path.is_none()
{
fetch_remote_json(&state, &resolved_url, false).await
} else {
fetch_enhanced(&state, &directive, &resolved_url).await
};
let elapsed_ms = start.elapsed().as_millis() as u64;
(
key,
resolved_url,
elapsed_ms,
result,
directive.path.clone(),
)
})
})
.collect();
for handle in handles {
match handle.await {
Ok((key, url, elapsed_ms, Ok(value), _path)) => {
let desc = describe_json(&value);
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url,
elapsed_ms,
result: desc,
});
context.insert(key, value);
}
Ok((key, url, elapsed_ms, Err(e), _)) => {
let err_str = e.to_string();
debug_entries.push(FetchDebugEntry {
key: key.clone(),
url,
elapsed_ms,
result: format!("error: {}", err_str),
});
fetch_errors.insert(key, json!(err_str));
}
Err(e) => {
tracing::warn!(" fetch task panicked: {}", e);
}
}
}
tracing::info!(
"All fetches done in {}ms",
total_start.elapsed().as_millis()
);
if !fetch_errors.is_empty() {
context.insert("fetch_errors".to_string(), Value::Object(fetch_errors));
}
debug_entries
}
async fn render_content_internal(
state: &AppState,
content: &str,
session: Option<&sessions::Session>,
user: &UserContext,
app_config: Option<&WhatConfig>,
page_directives: Option<&PageDirectives>,
query_params: Option<&HashMap<String, String>>,
route_params: &HashMap<String, String>,
_is_partial: bool,
flash: Option<&FlashData>,
) -> Result<RenderResult> {
if let Err(e) = hydrate_config_data_sources(state).await {
tracing::warn!("Failed to hydrate configured data sources: {}", e);
}
let mut context = state.store.as_context().await;
crate::policy::scrub_base_context(&state.policies, &mut context);
context.insert(
"_base_path".to_string(),
json!(state.root.to_string_lossy()),
);
context.insert(
"_content_dir".to_string(),
json!(state.content_dir.to_string_lossy()),
);
context.insert("_dev_mode".to_string(), json!(state.dev_mode));
context.insert("_strict".to_string(), json!(state.config.strict));
if let Some(ref cf) = state.config.cloudflare {
if let Some(ref site_key) = cf.turnstile_site_key {
context.insert("_turnstile_site_key".to_string(), json!(site_key));
}
}
context.insert("flash".to_string(), Value::Object(serde_json::Map::new()));
context.insert("errors".to_string(), Value::Object(serde_json::Map::new()));
context.insert("old".to_string(), Value::Object(serde_json::Map::new()));
context.insert("has_errors".to_string(), json!(false));
if let Some(flash) = flash {
inject_flash_into_context(flash, &mut context);
}
for (key, value) in route_params {
context.insert(key.clone(), json!(value));
}
if let Some(params) = query_params {
let query_obj: serde_json::Map<String, Value> =
params.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
context.insert("query".to_string(), Value::Object(query_obj));
} else {
context.insert("query".to_string(), Value::Object(serde_json::Map::new()));
}
if let Some(config) = app_config {
for (key, value) in &config.values {
if !key.starts_with("fetch.") {
context.insert(key.clone(), value.clone());
}
}
}
if let Some(directives) = page_directives {
if let Some(ref title) = directives.title {
context.insert("title".to_string(), json!(title));
}
for (key, value) in &directives.custom {
if !key.starts_with("fetch.") {
context.insert(key.clone(), json!(value));
}
}
for (key, value) in &directives.vars {
if let Some((root, child)) = key.split_once('.') {
if let Some(Value::Object(obj)) = context.get_mut(root) {
obj.entry(child.to_string())
.or_insert_with(|| value.clone());
} else {
let mut obj = serde_json::Map::new();
obj.insert(child.to_string(), value.clone());
context.insert(root.to_string(), Value::Object(obj));
}
} else {
context.insert(key.clone(), value.clone());
}
}
}
if let Some(s) = session {
context.insert("session".to_string(), s.to_context());
}
let actor = crate::policy::Actor::from_parts(user, session);
let mut user_ctx = user.to_context();
if let (Value::Object(map), Some(owner)) = (&mut user_ctx, actor.primary_owner_key()) {
map.insert("owner".to_string(), json!(owner));
}
context.insert("user".to_string(), user_ctx);
context.insert(
"now".to_string(),
json!(chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()),
);
let fetch_debug =
apply_fetch_directives(state, &mut context, app_config, page_directives, &actor).await;
for entry in &fetch_debug {
state.record_activity(ActivityEvent::Fetch {
time: chrono::Local::now(),
key: entry.key.clone(),
url: entry.url.clone(),
elapsed_ms: entry.elapsed_ms,
result: entry.result.clone(),
});
}
tracing::debug!(
"Context keys after fetch: {:?}",
context.keys().collect::<Vec<_>>()
);
let mut data_obj = serde_json::Map::new();
if let Some(config) = app_config {
let mut app_data = serde_json::Map::new();
for decl in &config.data_application {
let key = &decl.name;
if let Some(value) = state.store.get(key).await {
app_data.insert(key.clone(), value);
} else if state.policies.is_read_scoped(key) {
tracing::debug!(target: "what::policy", "data.application '{}' is read-scoped — exposing empty", key);
app_data.insert(key.clone(), json!([]));
} else if let Some(value) = state.store.get_collection(key).await {
let mut v = json!(value);
crate::policy::strip_private_fields(&mut v, &state.policies.get(key).private_fields);
app_data.insert(key.clone(), v);
} else {
app_data.insert(key.clone(), json!(0));
}
}
data_obj.insert("application".to_string(), Value::Object(app_data));
let mut wired_data = serde_json::Map::new();
for decl in &config.data_wired {
if let Some(value) = state.store.get(&decl.name).await {
wired_data.insert(decl.name.clone(), value);
} else {
wired_data.insert(decl.name.clone(), json!(0));
}
}
if !wired_data.is_empty() {
context.insert("wired".to_string(), Value::Object(wired_data));
}
}
if let Some(directives) = page_directives {
for (key, value_template) in &directives.custom {
if let Some(wired_key) = key.strip_prefix("set.wired.") {
let resolved = crate::parser::replace_variables(value_template, &context);
tracing::info!("set.wired.{} = {}", wired_key, &resolved);
if let Err(e) = state.store.set(wired_key, json!(&resolved)).await {
tracing::error!("Failed to set wired.{}: {}", wired_key, e);
}
if let Some(Value::Object(wired_data)) = context.get_mut("wired") {
wired_data.insert(wired_key.to_string(), json!(&resolved));
} else {
let mut wired_data = serde_json::Map::new();
wired_data.insert(wired_key.to_string(), json!(&resolved));
context.insert("wired".to_string(), Value::Object(wired_data));
}
let mut wired_map = serde_json::Map::new();
wired_map.insert(format!("wired.{}", wired_key), Value::String(resolved));
let json_str = serde_json::to_string(&Value::Object(wired_map)).unwrap_or_default();
let mut scope = state.get_wired_scope(wired_key).await;
if matches!(scope, WiredScope::User(ref uid) if uid.is_empty()) {
let uid = user
.claims
.get("sub")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
scope = WiredScope::User(uid);
}
let _ = state.wired_tx.send(WiredMessage {
json: json_str,
scope,
});
}
}
}
if let Some(directives) = page_directives {
let mut mutation_data = serde_json::Map::new();
for (key, value) in &directives.custom {
if let Some(name) = key.strip_prefix("mutation.") {
mutation_data.insert(name.to_string(), Value::String(value.clone()));
}
}
if !mutation_data.is_empty() {
context.insert("mutation".to_string(), Value::Object(mutation_data));
}
}
if let Some(s) = session {
let mut session_data = serde_json::Map::new();
if let Some(config) = app_config {
for key in &config.data_session {
if let Some(value) = s.data.get(key) {
session_data.insert(key.clone(), value.clone());
} else {
session_data.insert(key.clone(), json!(0));
}
}
}
data_obj.insert("session".to_string(), Value::Object(session_data));
}
context.insert("data".to_string(), Value::Object(data_obj));
if let Some(directives) = page_directives {
if !directives.computed.is_empty() {
crate::parser::resolve_computed_variables(&directives.computed, &mut context);
}
}
let layout_path = page_directives
.and_then(|d| d.layout.as_ref())
.or_else(|| app_config.and_then(|c| c.layout.as_ref()));
let validation_secret = state
.config
.auth
.jwt_secret
.as_deref()
.unwrap_or("wwwhat-validation-secret");
let render_result = state
.engine
.render_reactive_with_secret(content, &context, Some(validation_secret))
.await?;
let (page_html, session_keys) = (render_result.html, render_result.session_keys);
if let Some(layout) = layout_path {
if layout.to_lowercase() != "none" {
let layout_file = state.root.join(layout);
if layout_file.exists() {
let raw_layout = tokio::fs::read_to_string(&layout_file).await?;
if state.dev_mode {
engine::warn_template_lints_once(&layout_file, &raw_layout);
}
let (_, layout_content) = parse_page_directives(&raw_layout);
let wrapped = layout_content
.replace("<slot/>", &page_html)
.replace("<slot />", &page_html);
let layout_result = state
.engine
.render_reactive_with_secret(&wrapped, &context, Some(validation_secret))
.await?;
let (final_html, layout_session_keys) =
(layout_result.html, layout_result.session_keys);
let mut all_keys = session_keys;
all_keys.extend(layout_session_keys);
return Ok(RenderResult {
html: final_html,
session_keys: all_keys,
fetch_debug,
});
} else {
tracing::warn!("Layout file not found: {:?}", layout_file);
}
}
}
Ok(RenderResult {
html: page_html,
session_keys,
fetch_debug,
})
}
async fn render_content(
state: &AppState,
content: &str,
session: Option<&sessions::Session>,
user: &UserContext,
app_config: Option<&WhatConfig>,
page_directives: Option<&PageDirectives>,
query_params: Option<&HashMap<String, String>>,
) -> Result<String> {
let result = render_content_internal(
state,
content,
session,
user,
app_config,
page_directives,
query_params,
&HashMap::new(),
false,
None,
)
.await?;
Ok(result.html)
}
pub fn discover_routes(root: &std::path::Path) -> Vec<(String, bool)> {
let pages_dir = content_dir(root);
if !pages_dir.exists() {
return Vec::new();
}
let mut routes = Vec::new();
collect_routes(&pages_dir, &pages_dir, &mut routes);
routes.sort_by(|a, b| a.0.cmp(&b.0));
routes
}
fn collect_routes(base: &std::path::Path, dir: &std::path::Path, routes: &mut Vec<(String, bool)>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if path.file_name().map_or(false, |n| n == "partials") {
continue;
}
collect_routes(base, &path, routes);
} else if path.extension().map_or(false, |e| e == "html") {
let file_name = path.file_stem().unwrap_or_default().to_string_lossy();
let relative = path.strip_prefix(base).unwrap_or(&path);
let is_dynamic = file_name.starts_with('[') && file_name.ends_with(']');
let parent = relative.parent().unwrap_or(std::path::Path::new(""));
let parent_str = parent.to_string_lossy();
let url_path = if file_name == "index" {
if parent_str.is_empty() {
"/".to_string()
} else {
format!("/{}", parent_str)
}
} else if parent_str.is_empty() {
format!("/{}", file_name)
} else {
format!("/{}/{}", parent_str, file_name)
};
routes.push((url_path, is_dynamic));
}
}
}
pub async fn render_page_to_html(state: &AppState, url_path: &str) -> Result<Option<String>> {
let resolved = resolve_page_path(&state.root, url_path);
let page_path = match resolved {
Some(r) if r.params.is_empty() => r.path,
_ => return Ok(None),
};
let raw_content = tokio::fs::read_to_string(&page_path).await?;
let (mut directives, content) = parse_page_directives(&raw_content);
let app_config = load_application_config(&state.root, url_path);
if !directives.requires_auth() && app_config.directives.requires_auth() {
directives.auth = app_config.directives.auth.clone();
directives.protected = app_config.directives.protected;
directives.roles = app_config.directives.roles.clone();
}
if directives.requires_auth() || directives.exclude {
return Ok(None);
}
let user = UserContext::unauthenticated();
let result = render_content_internal(
state,
&content,
None,
&user,
Some(&app_config),
Some(&directives),
None,
&HashMap::new(),
false,
None,
)
.await?;
register_validated_actions(&result.html, state);
let html = inject_what_css(&result.html, state.css_mode);
let html = inject_what_js(&html);
let html = inject_theme_restore(&html);
Ok(Some(html))
}
#[derive(Deserialize)]
struct ActionParams {
#[serde(rename = "w-action")]
action: Option<String>,
#[serde(rename = "w-redirect")]
redirect: Option<String>,
#[serde(flatten)]
extra: HashMap<String, String>,
}
fn decode_query_params(query: Option<&str>) -> HashMap<String, String> {
query
.map(|q| {
q.split('&')
.filter_map(|pair| {
let mut parts = pair.splitn(2, '=');
let key = parts.next()?;
let value = parts.next().unwrap_or("");
Some((
urlencoding::decode(key).unwrap_or_default().into_owned(),
urlencoding::decode(value).unwrap_or_default().into_owned(),
))
})
.collect()
})
.unwrap_or_default()
}
async fn extract_session_from_headers(
state: &AppState,
headers: &HeaderMap,
) -> (Option<sessions::Session>, bool) {
let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
if let Some(ref sessions) = state.sessions {
let session_id =
sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
match sessions.get_or_create(session_id.as_deref()).await {
Ok(session) => {
let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
(Some(session), is_new)
}
Err(_) => (None, false),
}
} else {
(None, false)
}
}
fn extract_user_context(state: &AppState, headers: &HeaderMap) -> UserContext {
if !state.auth.is_enabled() {
return UserContext::unauthenticated();
}
let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
match state.auth.decode_jwt(&token) {
Ok(claims) if !claims.is_expired() => {
UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
}
_ => UserContext::unauthenticated(),
}
} else {
UserContext::unauthenticated()
}
}
fn extract_actor(
state: &AppState,
headers: &HeaderMap,
session: Option<&sessions::Session>,
) -> crate::policy::Actor {
let user = extract_user_context(state, headers);
crate::policy::Actor::from_parts(&user, session)
}
async fn deny_response(
state: &AppState,
session: &mut Option<sessions::Session>,
is_new_session: bool,
is_partial: bool,
referer: Option<&str>,
msg: String,
dev_detail: String,
) -> axum::response::Response {
tracing::info!(target: "what::policy", "denied: {}", dev_detail);
state.record_activity(ActivityEvent::PolicyDenial {
time: chrono::Local::now(),
detail: dev_detail.clone(),
});
if is_partial {
let mut headers = vec![(header::CONTENT_TYPE, "text/plain".to_string())];
if state.dev_mode {
headers.push((
header::HeaderName::from_static("x-what-policy"),
format!("deny; {}", dev_detail),
));
}
return build_response(StatusCode::FORBIDDEN, headers, msg);
}
if let Some(sess) = session {
let flash = FlashData {
flash: HashMap::from([("error".to_string(), msg)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
let fallback = referer.unwrap_or("/");
redirect_with_session(state, fallback, session.as_ref(), is_new_session)
}
fn redirect_with_session(
state: &AppState,
redirect_to: &str,
session: Option<&sessions::Session>,
is_new_session: bool,
) -> axum::response::Response {
let mut headers = vec![(header::LOCATION, redirect_to.to_string())];
if is_new_session {
if let Some(s) = session {
headers.push((
header::SET_COOKIE,
sessions::build_session_cookie(
&s.id,
&state.config.session.cookie_name,
state.config.session.max_age,
state.config.session.secure,
),
));
}
}
build_response(StatusCode::SEE_OTHER, headers, String::new())
}
async fn validate_form_data(
state: &AppState,
form_data: &HashMap<String, String>,
session: &mut Option<sessions::Session>,
is_new_session: bool,
referer: Option<&str>,
action_url: Option<String>,
) -> std::result::Result<(), axum::response::Response> {
let rules_token = match form_data.get("w-rules") {
Some(t) => t,
None => {
if let Some(ref url) = action_url {
let requires_validation = {
let registry = state.validated_actions.read().unwrap_or_else(|e| e.into_inner());
registry.contains(url.as_str())
|| registry.iter().any(|r| {
let r_path = r.split('?').next().unwrap_or(r);
r_path == url.as_str()
})
};
if requires_validation {
tracing::warn!(
"Validation bypass rejected: w-rules missing for registered action '{}'",
url
);
let redirect_to = referer.unwrap_or("/");
let flash = FlashData {
flash: HashMap::from([(
"error".to_string(),
"Form validation required. Please reload and try again.".to_string(),
)]),
errors: HashMap::new(),
old: HashMap::new(),
};
if let Some(sess) = session {
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
return Err(redirect_with_session(
state,
redirect_to,
session.as_ref(),
is_new_session,
));
}
}
return Ok(());
}
};
let secret = state
.config
.auth
.jwt_secret
.as_deref()
.unwrap_or("wwwhat-validation-secret");
let rules = match validation::decode_rules(rules_token, secret) {
Some(r) => r,
None => {
tracing::warn!("Invalid w-rules token — possible tampering, rejecting submission");
let redirect_to = referer.unwrap_or("/");
let flash = FlashData {
flash: HashMap::from([(
"error".to_string(),
"Form validation failed. Please reload and try again.".to_string(),
)]),
errors: HashMap::new(),
old: HashMap::new(),
};
if let Some(sess) = session {
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
return Err(redirect_with_session(
state,
redirect_to,
session.as_ref(),
is_new_session,
));
}
};
let mut result = validation::validate_form(form_data, &rules);
for (field_name, field_rules) in &rules.fields {
if let Some(ref unique_spec) = field_rules.unique {
let value = form_data.get(field_name).map(|s| s.as_str()).unwrap_or("");
if !value.is_empty() {
let parts: Vec<&str> = unique_spec.split('.').collect();
if parts.len() == 2 {
let collection = parts[0];
let check_field = parts[1];
if let Some(items) = state.store.get_collection(collection).await {
let exists = items.iter().any(|item| {
item.get(check_field)
.and_then(|v| v.as_str())
.map(|v| v == value)
.unwrap_or(false)
});
if exists {
let msg = field_rules
.error_message
.clone()
.unwrap_or_else(|| format!("{} already exists", field_name));
result.errors.insert(field_name.clone(), msg);
result.is_valid = false;
}
}
}
}
}
}
if result.is_valid {
return Ok(());
}
let redirect_to = referer.unwrap_or("/");
let flash = FlashData {
flash: HashMap::from([(
"error".to_string(),
"Please fix the errors below".to_string(),
)]),
errors: result.errors,
old: form_data
.iter()
.filter(|(k, _)| !k.starts_with("w-"))
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
};
if let Some(sess) = session {
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
Err(redirect_with_session(
state,
redirect_to,
session.as_ref(),
is_new_session,
))
}
fn is_framework_field(key: &str) -> bool {
key.starts_with("w-")
|| key == "_csrf"
|| key == "redirect"
|| key == "cf-turnstile-response"
}
fn warn_legacy_mutation_once(collection: &str) {
static WARNED: LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
let mut warned = WARNED.lock().unwrap_or_else(|e| e.into_inner());
if warned.insert(collection.to_string()) {
tracing::warn!(
target: "what::policy",
"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.",
collection
);
}
}
async fn handle_action(
State(state): State<AppState>,
Path(collection): Path<String>,
headers: HeaderMap,
Query(params): Query<ActionParams>,
Form(form_data): Form<HashMap<String, String>>,
) -> impl IntoResponse {
let action = params.action.as_deref().unwrap_or("create");
let referer = headers
.get(header::REFERER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
let action_url = format!("/w-action/{}", collection);
if let Err(resp) = validate_form_data(
&state,
&form_data,
&mut session,
is_new_session,
referer.as_deref(),
Some(action_url.clone()),
)
.await
{
return resp;
}
let turnstile_token = form_data.get("cf-turnstile-response").map(|s| s.as_str());
if let Err(msg) = verify_turnstile(&state, turnstile_token).await {
tracing::warn!("Turnstile verification failed: {}", msg);
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([("error".to_string(), msg)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
let fallback = referer.as_deref().unwrap_or("/");
return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
}
let email_trigger = extract_email_trigger(&form_data);
let partial_src = form_data.get("w-partial").cloned();
let is_partial = headers
.get("X-Requested-With")
.and_then(|v| v.to_str().ok())
.map(|v| v == "What")
.unwrap_or(false);
let actor = extract_actor(&state, &headers, session.as_ref());
let policy = state.policies.get(&collection);
if action == "create" && !policy.allows_create(&actor) {
return deny_response(
&state,
&mut session,
is_new_session,
is_partial,
referer.as_deref(),
format!("You don't have permission to add to {}.", collection),
format!("collection={}; action=create; rule={:?}", collection, policy.create),
)
.await;
}
let result = match action {
"create" => {
let mut map: serde_json::Map<String, Value> = form_data
.into_iter()
.filter(|(k, _)| !is_framework_field(k))
.map(|(k, v)| (k, Value::String(v)))
.collect();
policy.sanitize_input(&mut map);
policy.stamp_owner(&mut map, &actor);
state.store.create(&collection, Value::Object(map)).await
}
_ => Err(crate::Error::Action(format!("Unknown action: {}", action))),
};
state.cache.invalidate_content_type(&collection).await;
let redirect_to = params.redirect.as_deref().unwrap_or("/");
match result {
Ok(_) => {
maybe_enqueue_email(&state, email_trigger).await;
if is_partial {
if let Some(ref partial_path) = partial_src {
if let Ok(html) = render_partial_for_action(
&state,
&headers,
partial_path,
¶ms.extra,
)
.await
{
return build_partial_response(
html,
session.as_ref(),
is_new_session,
&state,
);
}
}
}
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([(
"success".to_string(),
format!("Item created in {}", collection),
)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
}
Err(e) => {
tracing::error!("Action error: {}", e);
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([(
"error".to_string(),
format!("Failed to create: {}", e),
)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
let fallback = referer.as_deref().unwrap_or(redirect_to);
redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
}
}
}
async fn handle_action_with_id(
State(state): State<AppState>,
Path((collection, id)): Path<(String, String)>,
headers: HeaderMap,
Query(params): Query<ActionParams>,
Form(form_data): Form<HashMap<String, String>>,
) -> impl IntoResponse {
let action = params.action.as_deref().unwrap_or("update");
let referer = headers
.get(header::REFERER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
let action_url = format!("/w-action/{}/{}", collection, id);
if let Err(resp) = validate_form_data(
&state,
&form_data,
&mut session,
is_new_session,
referer.as_deref(),
Some(action_url.clone()),
)
.await
{
return resp;
}
let email_trigger = extract_email_trigger(&form_data);
let partial_src = form_data.get("w-partial").cloned();
let is_partial = headers
.get("X-Requested-With")
.and_then(|v| v.to_str().ok())
.map(|v| v == "What")
.unwrap_or(false);
let id_value: Value = if let Ok(num) = id.parse::<i64>() {
Value::Number(num.into())
} else {
Value::String(id)
};
let actor = extract_actor(&state, &headers, session.as_ref());
let policy = state.policies.get(&collection);
let kind = if action == "delete" {
crate::policy::MutationKind::Delete
} else {
crate::policy::MutationKind::Update
};
let existing = state
.store
.find_by(&collection, "id", &id_value)
.await
.into_iter()
.next();
let mut filter_ctx: HashMap<String, Value> = HashMap::new();
{
let user = extract_user_context(&state, &headers);
filter_ctx.insert("user".to_string(), user.to_context());
if let Some(s) = session.as_ref() {
filter_ctx.insert("session".to_string(), s.to_context());
}
}
let resolved_filter = policy.resolved_filter(&filter_ctx);
if action == "update" || action == "delete" {
match policy.allows_mutation(&actor, existing.as_ref(), kind, resolved_filter.as_deref()) {
crate::policy::Decision::Deny => {
return deny_response(
&state,
&mut session,
is_new_session,
is_partial,
referer.as_deref(),
format!("You don't have permission to {} that item.", action),
format!("collection={}; action={}; id={}", collection, action, id_value),
)
.await;
}
crate::policy::Decision::AllowLegacy => {
warn_legacy_mutation_once(&collection);
}
crate::policy::Decision::Allow => {}
}
}
let result = match action {
"update" => {
let mut map: serde_json::Map<String, Value> = form_data
.into_iter()
.filter(|(k, _)| !is_framework_field(k))
.map(|(k, v)| (k, Value::String(v)))
.collect();
policy.sanitize_input(&mut map);
state
.store
.update(&collection, &id_value, Value::Object(map))
.await
.map(|_| ())
}
"delete" => {
if state.config.uploads.enabled {
if let Some(record) = existing.as_ref() {
cleanup_uploaded_files(&state.root, &state.config.uploads.directory, record)
.await;
}
}
state.store.delete(&collection, &id_value).await.map(|_| ())
}
_ => Err(crate::Error::Action(format!("Unknown action: {}", action))),
};
state.cache.invalidate_content_type(&collection).await;
let redirect_to = params.redirect.as_deref().unwrap_or("/");
match result {
Ok(_) => {
maybe_enqueue_email(&state, email_trigger).await;
if is_partial {
if let Some(ref partial_path) = partial_src {
if let Ok(html) = render_partial_for_action(
&state,
&headers,
partial_path,
¶ms.extra,
)
.await
{
return build_partial_response(
html,
session.as_ref(),
is_new_session,
&state,
);
}
}
}
if let Some(ref mut sess) = session {
let action_label = if action == "delete" {
"deleted"
} else {
"updated"
};
let flash = FlashData {
flash: HashMap::from([(
"success".to_string(),
format!("Item {} in {}", action_label, collection),
)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
}
Err(e) => {
tracing::error!("Action error: {}", e);
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([("error".to_string(), format!("Action failed: {}", e))]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
let fallback = referer.as_deref().unwrap_or(redirect_to);
redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
}
}
}
async fn render_partial_for_action(
state: &AppState,
headers: &HeaderMap,
partial_path: &str,
query_params: &HashMap<String, String>,
) -> Result<String> {
let clean_path = partial_path.trim_start_matches('/');
let partials_dir = state.content_dir.join("partials");
let file_path = {
let with_ext = partials_dir.join(format!("{}.html", clean_path));
if with_ext.exists() {
with_ext
} else {
partials_dir.join(clean_path).join("index.html")
}
};
let canonical = file_path
.canonicalize()
.map_err(|_| crate::Error::Action("Partial not found".into()))?;
let partials_canonical = partials_dir
.canonicalize()
.map_err(|_| crate::Error::Action("Partials dir not found".into()))?;
if !canonical.starts_with(&partials_canonical) {
return Err(crate::Error::Action("Access denied".into()));
}
let raw_content = tokio::fs::read_to_string(&canonical)
.await
.map_err(|_| crate::Error::Action("Partial not found".into()))?;
if state.dev_mode {
engine::warn_template_lints_once(&canonical, &raw_content);
}
let (directives, content) = parse_page_directives(&raw_content);
let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
let session = if let Some(ref sessions) = state.sessions {
let session_id =
sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
sessions.get_or_create(session_id.as_deref()).await.ok()
} else {
None
};
let user_context = if state.auth.is_enabled() {
if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
match state.auth.decode_jwt(&token) {
Ok(claims) if !claims.is_expired() => {
UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
}
_ => UserContext::unauthenticated(),
}
} else {
UserContext::unauthenticated()
}
} else {
UserContext::unauthenticated()
};
let render_result = render_content_internal(
state,
&content,
session.as_ref(),
&user_context,
None,
Some(&directives),
Some(query_params),
&HashMap::new(),
true,
None,
)
.await?;
let mut html = render_result.html;
if let Some(ref s) = session {
if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
html = inject_csrf_tokens(&html, csrf);
}
}
Ok(html)
}
fn build_partial_response(
html: String,
session: Option<&sessions::Session>,
is_new_session: bool,
state: &AppState,
) -> Response {
let mut resp_headers: Vec<(header::HeaderName, String)> =
vec![(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())];
if is_new_session {
if let Some(s) = session {
resp_headers.push((
header::SET_COOKIE,
sessions::build_session_cookie(
&s.id,
&state.config.session.cookie_name,
state.config.session.max_age,
state.config.session.secure,
),
));
}
}
build_response(StatusCode::OK, resp_headers, html)
}
async fn handle_upload(
State(state): State<AppState>,
Path(collection): Path<String>,
headers: HeaderMap,
Query(params): Query<ActionParams>,
mut multipart: Multipart,
) -> impl IntoResponse {
let referer = headers
.get(header::REFERER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
let redirect_to = params.redirect.as_deref().unwrap_or("/");
if !state.config.uploads.enabled {
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([(
"error".to_string(),
"File uploads are not enabled".to_string(),
)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
let fallback = referer.as_deref().unwrap_or(redirect_to);
return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
}
let upload_backend = match state.upload_backend {
Some(ref backend) => backend.clone(),
None => {
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([(
"error".to_string(),
"Upload backend not configured".to_string(),
)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
let fallback = referer.as_deref().unwrap_or(redirect_to);
return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
}
};
let actor = extract_actor(&state, &headers, session.as_ref());
let policy = state.policies.get(&collection);
if !policy.allows_create(&actor) {
let is_partial = headers
.get("X-Requested-With")
.and_then(|v| v.to_str().ok())
.map(|v| v == "What")
.unwrap_or(false);
return deny_response(
&state,
&mut session,
is_new_session,
is_partial,
referer.as_deref(),
format!("You don't have permission to upload to {}.", collection),
format!("collection={}; action=upload", collection),
)
.await;
}
let max_size = state.config.uploads.max_size_bytes();
let mut form_fields: HashMap<String, String> = HashMap::new();
let mut uploaded_files: Vec<(String, String)> = Vec::new();
while let Ok(Some(field)) = multipart.next_field().await {
let field_name = field.name().unwrap_or("").to_string();
if let Some(file_name) = field.file_name().map(|s| s.to_string()) {
if file_name.is_empty() {
continue; }
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
if !state.config.uploads.is_type_allowed(&content_type) {
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([(
"error".to_string(),
format!("File type '{}' is not allowed", content_type),
)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
for (_, saved) in &uploaded_files {
let _ = upload_backend.delete(saved).await;
}
let fallback = referer.as_deref().unwrap_or(redirect_to);
return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
}
let data = match field.bytes().await {
Ok(bytes) => bytes,
Err(e) => {
tracing::error!("Failed to read upload field: {}", e);
continue;
}
};
if data.len() > max_size {
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([(
"error".to_string(),
format!(
"File '{}' exceeds maximum size of {}",
file_name, state.config.uploads.max_size
),
)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
for (_, saved) in &uploaded_files {
let _ = upload_backend.delete(saved).await;
}
let fallback = referer.as_deref().unwrap_or(redirect_to);
return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
}
let extension = std::path::Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.map(|e| sanitize_extension(e))
.unwrap_or_default();
let saved_name = format!("{}{}", uuid::Uuid::new_v4(), extension);
match upload_backend.put(&saved_name, &data, &content_type).await {
Ok(public_url) => {
form_fields.insert(field_name.clone(), public_url);
uploaded_files.push((field_name, saved_name));
}
Err(e) => {
tracing::error!("Failed to save uploaded file: {}", e);
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([(
"error".to_string(),
"Failed to save uploaded file".to_string(),
)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
for (_, saved) in &uploaded_files {
let _ = upload_backend.delete(saved).await;
}
let fallback = referer.as_deref().unwrap_or(redirect_to);
return redirect_with_session(
&state,
fallback,
session.as_ref(),
is_new_session,
);
}
}
} else {
let value = field.text().await.unwrap_or_default();
form_fields.insert(field_name, value);
}
}
let action_url = format!("/w-upload/{}", collection);
if let Err(resp) = validate_form_data(
&state,
&form_fields,
&mut session,
is_new_session,
referer.as_deref(),
Some(action_url.clone()),
)
.await
{
for (_, saved) in &uploaded_files {
let _ = upload_backend.delete(saved).await;
}
return resp;
}
let mut item_map: serde_json::Map<String, Value> = form_fields
.into_iter()
.filter(|(k, _)| !k.starts_with("w-"))
.map(|(k, v)| (k, Value::String(v)))
.collect();
policy.sanitize_input(&mut item_map);
policy.stamp_owner(&mut item_map, &actor);
let result = state.store.create(&collection, Value::Object(item_map)).await;
state.cache.invalidate_content_type(&collection).await;
match result {
Ok(_) => {
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([(
"success".to_string(),
format!("Item created in {}", collection),
)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
}
Err(e) => {
tracing::error!("Upload action error: {}", e);
for (_, saved) in &uploaded_files {
let _ = upload_backend.delete(saved).await;
}
if let Some(ref mut sess) = session {
let flash = FlashData {
flash: HashMap::from([(
"error".to_string(),
format!("Failed to create: {}", e),
)]),
..Default::default()
};
set_flash_data(sess, &flash);
if let Some(ref sessions) = state.sessions {
let _ = sessions.update(&sess.id, sess.data.clone()).await;
}
}
let fallback = referer.as_deref().unwrap_or(redirect_to);
redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
}
}
}
async fn cleanup_uploaded_files(root: &std::path::Path, upload_dir: &str, record: &Value) {
if let Value::Object(map) = record {
for (_key, value) in map {
if let Value::String(s) = value {
if s.starts_with("/uploads/") {
let filename = &s["/uploads/".len()..];
let file_path = root.join(upload_dir).join(filename);
if file_path.exists() {
if let Err(e) = tokio::fs::remove_file(&file_path).await {
tracing::warn!(
"Failed to delete uploaded file {}: {}",
file_path.display(),
e
);
} else {
tracing::debug!("Cleaned up uploaded file: {}", file_path.display());
}
}
}
}
}
}
}
async fn handle_session_reset(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> impl IntoResponse {
let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
if let Some(ref sessions) = state.sessions {
if let Some(session_id) =
sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name)
{
let _ = sessions.delete(&session_id).await;
}
match sessions.create().await {
Ok(new_session) => {
let cookie = sessions::build_session_cookie(
&new_session.id,
&state.config.session.cookie_name,
state.config.session.max_age,
state.config.session.secure,
);
let redirect_url = form
.get("redirect")
.map(|s| s.as_str())
.or_else(|| headers.get(header::REFERER).and_then(|v| v.to_str().ok()))
.unwrap_or("/state");
build_response(
StatusCode::SEE_OTHER,
vec![
(header::LOCATION, redirect_url.to_string()),
(header::SET_COOKIE, cookie),
],
String::new(),
)
}
Err(e) => {
tracing::error!("Session reset error: {}", e);
build_response(
StatusCode::INTERNAL_SERVER_ERROR,
vec![(header::CONTENT_TYPE, "text/plain".to_string())],
"Failed to reset session".to_string(),
)
}
}
} else {
Redirect::to("/state").into_response()
}
}
enum SetOp {
Increment(i64),
Decrement(i64),
SetInt(i64),
SetStr(String),
}
fn sanitize_redirect(url: &str, fallback: &str) -> String {
let trimmed = url.trim();
if trimmed.starts_with('/') && !trimmed.starts_with("//") {
trimmed.to_string()
} else {
fallback.to_string()
}
}
fn parse_w_set_expr(expr: &str) -> Option<(String, String, SetOp)> {
let expr = expr.trim();
if let Some((left, right)) = expr.split_once("+=") {
let left = left.trim();
let right = right.trim();
let (scope, key) = left.split_once('.')?;
let val: i64 = right.parse().ok()?;
return Some((scope.to_string(), key.to_string(), SetOp::Increment(val)));
}
if let Some((left, right)) = expr.split_once("-=") {
let left = left.trim();
let right = right.trim();
let (scope, key) = left.split_once('.')?;
let val: i64 = right.parse().ok()?;
return Some((scope.to_string(), key.to_string(), SetOp::Decrement(val)));
}
if let Some((left, right)) = expr.split_once('=') {
let left = left.trim();
let right = right.trim();
let (scope, key) = left.split_once('.')?;
if let Ok(val) = right.parse::<i64>() {
return Some((scope.to_string(), key.to_string(), SetOp::SetInt(val)));
}
let s = right.trim_matches(|c| c == '\'' || c == '"');
return Some((
scope.to_string(),
key.to_string(),
SetOp::SetStr(s.to_string()),
));
}
None
}
fn apply_set_op(current: Option<&Value>, op: &SetOp) -> Value {
match op {
SetOp::Increment(delta) => {
let cur = current.and_then(|v| v.as_i64()).unwrap_or(0);
json!(cur + delta)
}
SetOp::Decrement(delta) => {
let cur = current.and_then(|v| v.as_i64()).unwrap_or(0);
json!(cur - delta)
}
SetOp::SetInt(val) => json!(val),
SetOp::SetStr(val) => json!(val),
}
}
fn value_display_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
async fn handle_w_set(
State(state): State<AppState>,
headers: HeaderMap,
Form(params): Form<HashMap<String, String>>,
) -> impl IntoResponse {
let expr_str = match params.get("expr") {
Some(e) => e.as_str(),
None => return (StatusCode::BAD_REQUEST, "Missing expr").into_response(),
};
let mut parsed: Vec<(String, String, SetOp)> = Vec::new();
for part in expr_str.split(';') {
let part = part.trim();
if part.is_empty() {
continue;
}
match parse_w_set_expr(part) {
Some((scope, key, op)) => {
if scope != "session" && scope != "app" && scope != "wired" {
return (
StatusCode::BAD_REQUEST,
"Invalid scope (use session, app, or wired)",
)
.into_response();
}
parsed.push((scope, key, op));
}
None => {
return (
StatusCode::BAD_REQUEST,
format!("Invalid expression: {}", part),
)
.into_response();
}
}
}
if parsed.is_empty() {
return (StatusCode::BAD_REQUEST, "Empty expression").into_response();
}
let mut oob_map = serde_json::Map::new();
let mut wired_updates: Vec<(String, String)> = Vec::new(); let mut session_mutations: Vec<(String, SetOp)> = Vec::new();
let cookie_header_str = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
let mutator_user_id: Option<String> = if state.auth.is_enabled() {
state
.auth
.parse_jwt_cookie(cookie_header_str)
.and_then(|token| {
state.auth.decode_jwt(&token).ok().and_then(|claims| {
if claims.is_expired() {
return None;
}
claims.sub.clone()
})
})
} else {
None
};
let has_shared_mutation = parsed.iter().any(|(s, _, _)| s == "app" || s == "wired");
if has_shared_mutation {
let cookie_header_for_auth = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
let is_authenticated = if state.auth.is_enabled() {
state
.auth
.parse_jwt_cookie(cookie_header_for_auth)
.and_then(|token| state.auth.decode_jwt(&token).ok())
.map(|claims| !claims.is_expired())
.unwrap_or(false)
} else {
let session_id = sessions::parse_session_cookie(
cookie_header_for_auth,
&state.config.session.cookie_name,
);
session_id.is_some()
};
if !is_authenticated {
return (
StatusCode::FORBIDDEN,
"Authentication required for app/wired mutations",
)
.into_response();
}
let mutator_roles = extract_user_context(&state, &headers).roles();
for (mscope, mkey, _) in &parsed {
let var_scope = match mscope.as_str() {
"wired" => state.get_wired_scope(mkey).await,
"app" => state.get_app_scope(mkey).await,
_ => continue,
};
let allowed = match &var_scope {
WiredScope::Public => true,
WiredScope::Roles(_) => {
state.auth.is_enabled()
&& var_scope.allows(&mutator_roles, mutator_user_id.as_deref())
}
WiredScope::User(_) => state.auth.is_enabled() && mutator_user_id.is_some(),
};
if !allowed {
tracing::info!(
target: "what::policy",
"w-set denied: {}.{} requires scope {:?}",
mscope, mkey, var_scope
);
let mut resp = (
StatusCode::FORBIDDEN,
format!("Insufficient permissions for {}.{}", mscope, mkey),
)
.into_response();
if state.dev_mode {
if let Ok(hv) = format!("deny; {}.{}; scope={:?}", mscope, mkey, var_scope).parse()
{
resp.headers_mut()
.insert(header::HeaderName::from_static("x-what-policy"), hv);
}
}
return resp;
}
}
}
for (scope, key, op) in parsed {
if scope == "app" || scope == "wired" {
match state
.store
.atomic_modify(&key, move |current| apply_set_op(current, &op))
.await
{
Ok(val) => {
let display = value_display_string(&val);
if scope == "wired" {
oob_map.insert(format!("wired.{}", key), Value::String(display.clone()));
wired_updates.push((key, display));
} else {
oob_map.insert(format!("app.{}", key), Value::String(display));
}
}
Err(e) => {
tracing::error!("w-set {} mutation failed: {}", scope, e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Mutation failed").into_response();
}
}
} else {
if key.starts_with('_') {
tracing::warn!("w-set blocked: reserved session key '{}'", key);
continue;
}
session_mutations.push((key, op));
}
}
for (key, display) in wired_updates {
let mut wired_map = serde_json::Map::new();
wired_map.insert(format!("wired.{}", key), Value::String(display));
let json = serde_json::to_string(&Value::Object(wired_map)).unwrap_or_default();
let mut scope = state.get_wired_scope(&key).await;
if matches!(scope, WiredScope::User(ref uid) if uid.is_empty()) {
scope = WiredScope::User(mutator_user_id.clone().unwrap_or_default());
}
let _ = state.wired_tx.send(WiredMessage { json, scope });
}
if !session_mutations.is_empty() {
let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
if let Some(ref sessions) = state.sessions {
let session_id =
sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
if let Some(id) = session_id {
for (key, op) in &session_mutations {
let atomic_op = match op {
SetOp::Increment(n) => sessions::AtomicMutation::Increment {
key: key.clone(),
value: *n,
},
SetOp::Decrement(n) => sessions::AtomicMutation::Increment {
key: key.clone(),
value: -*n,
},
SetOp::SetInt(n) => sessions::AtomicMutation::Set {
key: key.clone(),
value: serde_json::json!(*n),
},
SetOp::SetStr(s) => sessions::AtomicMutation::Set {
key: key.clone(),
value: serde_json::json!(s),
},
};
match sessions.apply_mutation(&id, &atomic_op).await {
Ok(data) => {
if let Some(val) = data.get(key) {
oob_map.insert(
format!("session.{}", key),
Value::String(value_display_string(val)),
);
}
}
Err(e) => {
tracing::error!("w-set session mutation failed: {}", e);
}
}
}
}
}
}
let is_partial = headers
.get("X-Requested-With")
.and_then(|v| v.to_str().ok())
.map(|v| v == "What")
.unwrap_or(false);
if is_partial {
let json_str = serde_json::to_string(&Value::Object(oob_map)).unwrap_or_default();
let oob = format!(r##"<template data-what-updates>{}</template>"##, json_str);
return Html(oob).into_response();
}
let redirect_url = headers
.get(header::REFERER)
.and_then(|v| v.to_str().ok())
.and_then(|url| {
if let Some(idx) = url.find("://") {
url[idx + 3..].find('/').map(|i| &url[idx + 3 + i..])
} else {
Some(url)
}
})
.map(|path| sanitize_redirect(path, "/"))
.unwrap_or_else(|| "/".to_string());
Redirect::to(&redirect_url).into_response()
}
async fn handle_session_clear_data(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
let referer = headers
.get(header::REFERER)
.and_then(|v| v.to_str().ok())
.unwrap_or("/");
let redirect_to = sanitize_redirect(referer, "/");
if let Some(ref sessions) = state.sessions {
let session_id =
sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
if let Some(id) = session_id {
if let Err(e) = sessions.update(&id, std::collections::HashMap::new()).await {
tracing::error!("Failed to clear session data: {}", e);
}
}
}
Redirect::to(&redirect_to).into_response()
}
async fn handle_inject_notification(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
let mut count: i64 = 1;
if let Some(ref sessions) = state.sessions {
let session_id =
sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
if let Some(id) = session_id {
if let Ok(Some(mut session)) = sessions.get(&id).await {
let current = session
.data
.get("inject_count")
.and_then(|v| v.as_i64())
.unwrap_or(0);
count = current + 1;
session
.data
.insert("inject_count".to_string(), json!(count));
if let Err(e) = sessions.update(&id, session.data).await {
tracing::error!("Failed to update inject count: {}", e);
}
}
}
}
let html = format!(
r#"<div class="p-4 bg-blue-100 border border-blue-300 rounded flex items-center gap-3">
<span class="text-2xl">🔔</span>
<div>
<strong>Notification {}</strong>
<p class="text-sm text-gray-600">Injected without reloading the page</p>
</div>
</div>"#,
count
);
axum::response::Html(html).into_response()
}
async fn handle_login(
State(state): State<AppState>,
_headers: HeaderMap,
Form(form_data): Form<HashMap<String, String>>,
) -> impl IntoResponse {
let login_endpoint = match state.auth.login_endpoint() {
Some(url) => url.to_string(),
None => {
tracing::error!("Login endpoint not configured");
let msg = if state.dev_mode {
"Login not configured"
} else {
"Something went wrong"
};
return (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response();
}
};
let fallback = state.auth.after_login_path().to_string();
let redirect_url = form_data
.get("redirect")
.or_else(|| form_data.get("w-redirect"))
.map(|s| sanitize_redirect(s, &fallback))
.unwrap_or(fallback);
let login_data: HashMap<String, String> = form_data
.into_iter()
.filter(|(k, _)| !k.starts_with("w-") && k != "redirect")
.collect();
let response = match state
.http_client
.post(&login_endpoint)
.json(&login_data)
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
tracing::error!("Login request failed: {}", e);
return Redirect::to(&format!("{}?error=connection", state.auth.login_path()))
.into_response();
}
};
if !response.status().is_success() {
tracing::warn!("Login failed with status: {}", response.status());
return Redirect::to(&format!("{}?error=invalid", state.auth.login_path())).into_response();
}
let token = match response.json::<serde_json::Value>().await {
Ok(json) => {
json.get("token")
.or_else(|| json.get("access_token"))
.or_else(|| json.get("jwt"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
Err(e) => {
tracing::error!("Failed to parse login response: {}", e);
None
}
};
match token {
Some(jwt) => {
let cookie = state.auth.build_jwt_cookie(
&jwt,
state.config.session.max_age,
state.config.session.secure,
);
let mut response_headers = vec![
(header::LOCATION, redirect_url),
(header::SET_COOKIE, cookie),
];
if let Some(ref sessions) = state.sessions {
let cookie_header = _headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
let old_id = sessions::parse_session_cookie(
cookie_header,
&state.config.session.cookie_name,
);
if let Some(ref old) = old_id {
if let Ok(Some(old_session)) = sessions.get(old).await {
if let Ok(new_session) = sessions.create().await {
let mut data = old_session.data;
data.insert(
sessions::CSRF_TOKEN_KEY.to_string(),
serde_json::json!(sessions::generate_csrf_token()),
);
let _ = sessions.update(&new_session.id, data).await;
let _ = sessions.delete(old).await;
response_headers.push((
header::SET_COOKIE,
sessions::build_session_cookie(
&new_session.id,
&state.config.session.cookie_name,
state.config.session.max_age,
state.config.session.secure,
),
));
}
}
}
}
build_response(StatusCode::SEE_OTHER, response_headers, String::new())
}
None => {
Redirect::to(&format!("{}?error=no_token", state.auth.login_path())).into_response()
}
}
}
async fn handle_logout(
State(state): State<AppState>,
request_headers: HeaderMap,
Form(form_data): Form<HashMap<String, String>>,
) -> impl IntoResponse {
let redirect_url = form_data
.get("redirect")
.or_else(|| form_data.get("w-redirect"))
.map(|s| sanitize_redirect(s, "/"))
.unwrap_or_else(|| "/".to_string());
if let Some(logout_endpoint) = state.auth.logout_endpoint() {
let cookie_header = request_headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok());
if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
let _ = state
.http_client
.post(logout_endpoint)
.bearer_auth(&token)
.send()
.await;
}
}
let clear_cookie = state.auth.build_clear_cookie();
build_response(
StatusCode::SEE_OTHER,
vec![
(header::LOCATION, redirect_url),
(header::SET_COOKIE, clear_cookie),
],
String::new(),
)
}
async fn handle_cache_clear_all(State(state): State<AppState>) -> impl IntoResponse {
state.cache.clear_all().await;
tracing::info!("All server caches cleared");
axum::Json(serde_json::json!({
"success": true,
"message": "All caches cleared"
}))
}
async fn handle_sessions_list(State(state): State<AppState>) -> impl IntoResponse {
match &state.sessions {
Some(sessions) => {
let count = sessions.count().await.unwrap_or(0);
let ids = sessions.list_session_ids().await.unwrap_or_default();
axum::Json(serde_json::json!({
"count": count,
"ids": ids
}))
}
None => axum::Json(serde_json::json!({
"error": "Sessions not enabled",
"count": 0,
"ids": []
})),
}
}
async fn handle_data_info(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
let session_data = if let Some(ref sessions) = state.sessions {
let cookie_header = headers.get(header::COOKIE).and_then(|h| h.to_str().ok());
let session_id =
sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
if let Some(id) = session_id {
if let Ok(Some(session)) = sessions.get(&id).await {
session.data.clone()
} else {
std::collections::HashMap::new()
}
} else {
std::collections::HashMap::new()
}
} else {
std::collections::HashMap::new()
};
let application_data = state.store.as_context().await;
axum::Json(serde_json::json!({
"application": application_data,
"session": session_data
}))
}
struct RouteMeta {
url: String,
dynamic: bool,
auth: String,
layout: String,
missing: bool,
}
fn inspector_auth_display(d: &PageDirectives) -> Option<String> {
use crate::parser::AuthLevel;
match &d.auth {
AuthLevel::User => Some("user".to_string()),
AuthLevel::Roles(v) => Some(format!("roles: {}", v.join(", "))),
AuthLevel::All => {
if d.protected {
if d.roles.is_empty() {
Some("user (protected)".to_string())
} else {
Some(format!("roles: {} (legacy)", d.roles.join(", ")))
}
} else {
None
}
}
}
}
fn resolve_route_meta(root: &PathBuf, url_path: &str, dynamic: bool) -> RouteMeta {
let Some(resolved) = resolve_page_path(root, url_path) else {
return RouteMeta {
url: url_path.to_string(),
dynamic,
auth: String::new(),
layout: String::new(),
missing: true,
};
};
let raw = std::fs::read_to_string(&resolved.path).unwrap_or_default();
let (directives, _) = parse_page_directives(&raw);
let app_config = load_application_config(root, url_path);
let auth = inspector_auth_display(&directives)
.or_else(|| inspector_auth_display(&app_config.directives))
.unwrap_or_else(|| "all".to_string());
let layout = directives.layout.clone().or_else(|| app_config.layout.clone());
let layout = match layout.as_deref() {
Some("none") => "none (disabled)".to_string(),
Some(l) => l.to_string(),
None => "(default)".to_string(),
};
RouteMeta { url: url_path.to_string(), dynamic, auth, layout, missing: false }
}
fn collect_application_what_files(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<(String, WhatConfig)>) {
let cfg = dir.join("application.what");
if cfg.exists() {
if let Ok(content) = std::fs::read_to_string(&cfg) {
let rel = cfg.strip_prefix(root).unwrap_or(&cfg).to_string_lossy().to_string();
out.push((rel, parse_what_file(&content)));
}
}
if let Ok(entries) = std::fs::read_dir(dir) {
let mut dirs: Vec<_> = entries
.flatten()
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.map(|e| e.path())
.collect();
dirs.sort();
for d in dirs {
collect_application_what_files(&d, root, out);
}
}
}
async fn handle_inspector(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> axum::response::Response {
let esc = engine::escape_html;
let refresh: Option<u32> = params
.get("refresh")
.and_then(|v| v.parse().ok())
.filter(|n| (1..=60).contains(n));
let mut b = String::new();
b.push_str("<section id=\"overview\"><h2>Overview</h2><table>");
let cfg = &state.config;
let rows = [
("Framework version", env!("CARGO_PKG_VERSION").to_string()),
("Mode", if state.dev_mode { "development".into() } else { "production".into() }),
("Host : Port", format!("{}:{}", cfg.server.host, cfg.server.port)),
("CSS mode", format!("{:?}", state.css_mode).to_lowercase()),
("Sessions", if cfg.session.enabled { "enabled".into() } else { "disabled".into() }),
("Auth", if cfg.auth.enabled { "enabled".into() } else { "disabled".into() }),
("Uploads", if cfg.uploads.enabled { "enabled".into() } else { "disabled".into() }),
("Source viewer", if cfg.server.source_viewer { "on".into() } else { "off".into() }),
("Strict mode", if cfg.strict { "on".into() } else { "off".into() }),
];
for (k, v) in rows {
b.push_str(&format!("<tr><th>{}</th><td>{}</td></tr>", esc(k), esc(&v)));
}
b.push_str("</table></section>");
b.push_str("<section id=\"routes\"><h2>Routes</h2><table><tr><th>Path</th><th>Auth</th><th>Layout</th><th></th></tr>");
let routes = discover_routes(&state.root);
for (url, dynamic) in &routes {
let m = resolve_route_meta(&state.root, url, *dynamic);
let tags = format!(
"{}{}",
if m.dynamic { "<span class=\"tag\">dynamic</span>" } else { "" },
if m.missing { "<span class=\"tag warn\">missing file</span>" } else { "" },
);
b.push_str(&format!(
"<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
esc(&m.url), esc(&m.auth), esc(&m.layout), tags
));
}
b.push_str(&format!("</table><p class=\"muted\">{} route(s).</p></section>", routes.len()));
b.push_str("<section id=\"collections\"><h2>Collections & Policies</h2>");
b.push_str("<table><tr><th>Collection</th><th>Rows</th><th>Policy</th></tr>");
let ctx = state.store.as_context().await;
let mut names: std::collections::BTreeSet<String> = ctx.keys().cloned().collect();
for (name, _) in state.policies.configured() {
names.insert(name.to_string());
}
if names.is_empty() {
b.push_str("<tr><td colspan=\"3\" class=\"muted\">No collections yet.</td></tr>");
}
for name in &names {
let count = ctx.get(name).and_then(|v| v.as_array()).map(|a| a.len());
let count_str = count.map(|c| c.to_string()).unwrap_or_else(|| "—".to_string());
let p = state.policies.get(name);
let badge = if p.explicit { "<span class=\"tag\">explicit</span>" } else { "<span class=\"tag muted-tag\">default</span>" };
let mut policy = format!(
"create=<b>{}</b> · read=<b>{}</b> · update=<b>{}</b> · delete=<b>{}</b> · owner={}",
esc(&p.create.to_string()), esc(&p.read.to_string()),
esc(&p.update.to_string()), esc(&p.delete.to_string()), esc(&p.owner_mode.to_string()),
);
if let Some(f) = &p.filter {
policy.push_str(&format!(" · filter=<code>{}</code>", esc(f)));
}
if !p.readonly_fields.is_empty() {
policy.push_str(&format!(" · readonly=[{}]", esc(&p.readonly_fields.join(", "))));
}
if !p.private_fields.is_empty() {
policy.push_str(&format!(" · private=[{}]", esc(&p.private_fields.join(", "))));
}
b.push_str(&format!(
"<tr><td><code>{}</code> {}</td><td>{}</td><td class=\"policy\">{}</td></tr>",
esc(name), badge, count_str, policy
));
}
b.push_str("</table></section>");
b.push_str("<section id=\"config\"><h2>application.what Inheritance</h2>");
let mut app_files = Vec::new();
collect_application_what_files(&state.content_dir, &state.root, &mut app_files);
if app_files.is_empty() {
b.push_str("<p class=\"muted\">No application.what files.</p>");
} else {
b.push_str("<table><tr><th>File</th><th>Declares</th></tr>");
for (rel, wc) in &app_files {
let mut parts = Vec::new();
if let Some(a) = inspector_auth_display(&wc.directives) {
parts.push(format!("auth={}", a));
}
let layout = wc.directives.layout.clone().or_else(|| wc.layout.clone());
if let Some(l) = layout {
parts.push(format!("layout={}", l));
}
if !wc.data_application.is_empty() {
let ns: Vec<&str> = wc.data_application.iter().map(|d| d.name.as_str()).collect();
parts.push(format!("data.application=[{}]", ns.join(", ")));
}
if !wc.data_wired.is_empty() {
let ns: Vec<&str> = wc.data_wired.iter().map(|d| d.name.as_str()).collect();
parts.push(format!("data.wired=[{}]", ns.join(", ")));
}
if !wc.data_session.is_empty() {
parts.push(format!("data.session=[{}]", wc.data_session.join(", ")));
}
let desc = if parts.is_empty() { "(nothing auth/layout/data)".to_string() } else { parts.join(" · ") };
b.push_str(&format!("<tr><td><code>{}</code></td><td>{}</td></tr>", esc(rel), esc(&desc)));
}
b.push_str("</table><p class=\"muted\">Child directories override parents.</p>");
}
b.push_str("</section>");
b.push_str("<section id=\"sessions\"><h2>Sessions</h2>");
match &state.sessions {
Some(s) => {
let count = s.count().await.unwrap_or(0);
let ids = s.list_session_ids().await.unwrap_or_default();
b.push_str(&format!("<p><b>{}</b> active session(s).</p>", count));
if !ids.is_empty() {
b.push_str("<details><summary>Session IDs</summary><ul>");
for id in ids.iter().take(200) {
let short: String = id.chars().take(16).collect();
b.push_str(&format!("<li><code>{}…</code></li>", esc(&short)));
}
b.push_str("</ul></details>");
}
}
None => b.push_str("<p class=\"muted\">Sessions disabled.</p>"),
}
b.push_str("</section>");
b.push_str("<section id=\"scopes\"><h2>Write Scopes</h2>");
let wired = state.wired_scopes.read().await;
let app = state.app_scopes.read().await;
if wired.is_empty() && app.is_empty() {
b.push_str("<p class=\"muted\">No scoped wired/application variables.</p>");
} else {
b.push_str("<table><tr><th>Variable</th><th>Kind</th><th>Scope</th></tr>");
let mut wk: Vec<_> = wired.iter().collect();
wk.sort_by_key(|(k, _)| k.clone());
for (k, v) in wk {
b.push_str(&format!("<tr><td><code>wired.{}</code></td><td>wired</td><td>{}</td></tr>", esc(k), esc(&v.to_string())));
}
let mut ak: Vec<_> = app.iter().collect();
ak.sort_by_key(|(k, _)| k.clone());
for (k, v) in ak {
b.push_str(&format!("<tr><td><code>app.{}</code></td><td>application</td><td>{}</td></tr>", esc(k), esc(&v.to_string())));
}
b.push_str("</table>");
}
b.push_str("</section>");
b.push_str("<section id=\"lints\"><h2>Template Lints</h2>");
let mut total = 0;
let mut lint_rows = String::new();
for (url, dynamic) in &routes {
if let Some(resolved) = resolve_page_path(&state.root, url) {
let _ = dynamic;
if let Ok(raw) = std::fs::read_to_string(&resolved.path) {
let lints = engine::collect_template_lints(&raw);
for l in lints {
total += 1;
lint_rows.push_str(&format!(
"<tr><td><code>{}</code></td><td><span class=\"tag warn\">{}</span></td><td>{}</td></tr>",
esc(url), esc(l.kind), esc(&l.message)
));
}
}
}
}
if total == 0 {
b.push_str("<p class=\"ok\">✓ No template issues found.</p>");
} else {
b.push_str(&format!("<table><tr><th>Page</th><th>Kind</th><th>Detail</th></tr>{}</table>", lint_rows));
}
b.push_str("</section>");
b.push_str("<section id=\"activity\"><h2>Activity");
if refresh.is_some() {
b.push_str(" <a class=\"refresh-link\" href=\"/w-inspector#activity\">⏸ stop auto-refresh</a>");
} else {
b.push_str(" <a class=\"refresh-link\" href=\"/w-inspector?refresh=2#activity\">↻ auto-refresh (2s)</a>");
}
b.push_str("</h2>");
let events: Vec<ActivityEvent> = {
let log = state.activity_log.lock().unwrap();
log.iter().rev().cloned().collect()
};
if events.is_empty() {
b.push_str("<p class=\"muted\">No activity yet — make some requests.</p>");
} else {
b.push_str("<table><tr><th>Time</th><th>Kind</th><th>Detail</th></tr>");
for ev in &events {
match ev {
ActivityEvent::Request { time, method, path, status, duration_ms } => {
let tag_class = if *status >= 400 { "tag warn" } else { "tag" };
b.push_str(&format!(
"<tr><td class=\"muted\">{}</td><td><span class=\"{}\">request</span></td><td><code>{} {}</code> → {} ({}ms)</td></tr>",
time.format("%H:%M:%S"), tag_class, esc(method), esc(path), status, duration_ms
));
}
ActivityEvent::PolicyDenial { time, detail } => {
b.push_str(&format!(
"<tr><td class=\"muted\">{}</td><td><span class=\"tag warn\">deny</span></td><td>{}</td></tr>",
time.format("%H:%M:%S"), esc(detail)
));
}
ActivityEvent::Fetch { time, key, url, elapsed_ms, result } => {
b.push_str(&format!(
"<tr><td class=\"muted\">{}</td><td><span class=\"tag muted-tag\">fetch</span></td><td><code>{}</code> ← <code>{}</code> → {} ({}ms)</td></tr>",
time.format("%H:%M:%S"), esc(key), esc(url), esc(result), elapsed_ms
));
}
}
}
b.push_str("</table>");
}
b.push_str("</section>");
let html = render_inspector_shell(&b, refresh);
build_response(
StatusCode::OK,
vec![(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())],
html,
)
}
fn render_inspector_shell(body: &str, refresh: Option<u32>) -> String {
let refresh_meta = refresh
.map(|n| format!("<meta http-equiv=\"refresh\" content=\"{n}\">"))
.unwrap_or_default();
format!(
r##"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">{refresh_meta}<title>What Inspector</title><style>
:root {{ color-scheme: light; }}
* {{ box-sizing: border-box; }}
body {{ margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; color: #1f2937; background: #f8fafc; }}
header {{ background: #0f172a; color: #e2e8f0; padding: 14px 20px; position: sticky; top: 0; z-index: 10; }}
header b {{ color: #fff; font-size: 15px; }}
header .badge {{ background: #334155; color: #cbd5e1; border-radius: 4px; padding: 2px 7px; font-size: 11px; margin-left: 8px; }}
nav {{ padding: 8px 20px; background: #1e293b; position: sticky; top: 46px; z-index: 9; }}
nav a {{ color: #93c5fd; text-decoration: none; margin-right: 14px; font-size: 12px; }}
nav a:hover {{ text-decoration: underline; }}
main {{ padding: 20px; max-width: 1100px; }}
section {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 18px; margin-bottom: 18px; }}
h2 {{ margin: 0 0 12px; font-size: 15px; color: #0f172a; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ text-align: left; padding: 6px 8px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }}
th {{ color: #64748b; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .03em; }}
td code, .policy code {{ background: #f1f5f9; padding: 1px 5px; border-radius: 3px; }}
.policy {{ font-size: 12px; }}
.muted {{ color: #94a3b8; }}
.ok {{ color: #16a34a; }}
.tag {{ display: inline-block; background: #dbeafe; color: #1e40af; border-radius: 3px; padding: 1px 6px; font-size: 11px; margin-left: 4px; }}
.tag.warn {{ background: #fef3c7; color: #92400e; }}
.tag.muted-tag {{ background: #f1f5f9; color: #64748b; }}
details summary {{ cursor: pointer; color: #2563eb; }}
ul {{ margin: 8px 0; padding-left: 20px; }}
.refresh-link {{ font-size: 11px; font-weight: 400; color: #2563eb; text-decoration: none; margin-left: 8px; }}
.refresh-link:hover {{ text-decoration: underline; }}
</style></head><body>
<header><b>What Inspector</b><span class="badge">dev</span></header>
<nav><a href="#overview">Overview</a><a href="#routes">Routes</a><a href="#collections">Collections</a><a href="#config">Config</a><a href="#sessions">Sessions</a><a href="#scopes">Scopes</a><a href="#lints">Lints</a><a href="#activity">Activity</a></nav>
<main>{body}</main>
</body></html>"##,
body = body,
refresh_meta = refresh_meta
)
}
async fn handle_partial(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Path(path): axum::extract::Path<String>,
Query(query_params): Query<HashMap<String, String>>,
) -> Response {
let clean_path = path.trim_start_matches('/');
let partials_dir = state.content_dir.join("partials");
let file_path = if clean_path.is_empty() {
partials_dir.join("index.html")
} else {
let with_ext = partials_dir.join(format!("{}.html", clean_path));
if with_ext.exists() {
with_ext
} else {
partials_dir.join(clean_path).join("index.html")
}
};
let canonical = match file_path.canonicalize() {
Ok(p) => p,
Err(_) => {
return (StatusCode::NOT_FOUND, "Partial not found").into_response();
}
};
let partials_canonical = match partials_dir.canonicalize() {
Ok(p) => p,
Err(_) => {
return (StatusCode::NOT_FOUND, "Partial not found").into_response();
}
};
if !canonical.starts_with(&partials_canonical) {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
let raw_content = match tokio::fs::read_to_string(&canonical).await {
Ok(c) => c,
Err(_) => {
return (StatusCode::NOT_FOUND, "Partial not found").into_response();
}
};
if state.dev_mode {
engine::warn_template_lints_once(&canonical, &raw_content);
}
let (directives, content) = parse_page_directives(&raw_content);
let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
let (session, is_new_session) = if let Some(ref sessions) = state.sessions {
let session_id =
sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
match sessions.get_or_create(session_id.as_deref()).await {
Ok(session) => {
let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
(Some(session), is_new)
}
Err(_) => (None, false),
}
} else {
(None, false)
};
let user_context = if state.auth.is_enabled() {
if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
match state.auth.decode_jwt(&token) {
Ok(claims) if !claims.is_expired() => {
UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
}
_ => UserContext::unauthenticated(),
}
} else {
UserContext::unauthenticated()
}
} else {
UserContext::unauthenticated()
};
match render_content_internal(
&state,
&content,
session.as_ref(),
&user_context,
None,
Some(&directives),
Some(&query_params),
&HashMap::new(),
true,
None,
)
.await
{
Ok(render_result) => {
let mut html = render_result.html;
if !render_result.session_keys.is_empty() {
if let Some(ref s) = session {
let mut updates = serde_json::Map::new();
for key in &render_result.session_keys {
if let Some(value) = s.data.get(key) {
updates.insert(format!("session.{}", key), value.clone());
}
}
if !updates.is_empty() {
let json_str = serde_json::to_string(&updates).unwrap_or_default();
html.push_str(&format!(
r#"<template data-what-updates>{}</template>"#,
json_str
));
}
}
}
if let Some(ref s) = session {
if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
html = inject_csrf_tokens(&html, csrf);
}
}
let mut resp_headers: Vec<(header::HeaderName, String)> = vec![
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
(
header::HeaderName::from_static("x-robots-tag"),
"noindex, nofollow".to_string(),
),
];
if is_new_session {
if let Some(ref s) = session {
let cookie = sessions::build_session_cookie(
&s.id,
&state.config.session.cookie_name,
state.config.session.max_age,
state.config.session.secure,
);
resp_headers.push((header::SET_COOKIE, cookie));
}
}
build_response(StatusCode::OK, resp_headers, html)
}
Err(e) => {
tracing::error!("Partial render error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Render error").into_response()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CssMode {
Full,
Minimal,
None,
}
impl CssMode {
pub fn from_config(value: &str) -> crate::Result<Self> {
match value {
"full" => Ok(Self::Full),
"minimal" => Ok(Self::Minimal),
"none" => Ok(Self::None),
other => Err(crate::Error::Config(format!(
"[server] css must be \"full\", \"minimal\", or \"none\" (got \"{}\")",
other
))),
}
}
}
const EMBEDDED_WHAT_CSS: &str = include_str!("../../assets/client/what.css");
const MINIMAL_CUT_START: &str = "/*! what:minimal-cut-start */";
const MINIMAL_CUT_END: &str = "/*! what:minimal-cut-end */";
static MINIMAL_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
match (
EMBEDDED_WHAT_CSS.find(MINIMAL_CUT_START),
EMBEDDED_WHAT_CSS.find(MINIMAL_CUT_END),
) {
(Some(start), Some(end)) if end > start => format!(
"{}{}",
&EMBEDDED_WHAT_CSS[..start],
&EMBEDDED_WHAT_CSS[end + MINIMAL_CUT_END.len()..]
),
_ => EMBEDDED_WHAT_CSS.to_string(),
}
});
const EMBEDDED_WHAT_JS: &str = include_str!("../../assets/client/what.js");
pub fn embedded_what_css() -> &'static str {
EMBEDDED_WHAT_CSS
}
pub fn embedded_what_js() -> &'static str {
EMBEDDED_WHAT_JS
}
static MINIFIED_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
let cfg = minify_html::Cfg { minify_css: true, ..minify_html::Cfg::default() };
let wrapped = format!("<style>{}</style>", EMBEDDED_WHAT_CSS);
let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
String::from_utf8(minified)
.map(|s| s.strip_prefix("<style>").unwrap_or(&s).strip_suffix("</style>").unwrap_or(&s).to_string())
.unwrap_or_else(|_| EMBEDDED_WHAT_CSS.to_string())
});
static MINIFIED_MINIMAL_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
let cfg = minify_html::Cfg { minify_css: true, ..minify_html::Cfg::default() };
let wrapped = format!("<style>{}</style>", MINIMAL_WHAT_CSS.as_str());
let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
String::from_utf8(minified)
.map(|s| s.strip_prefix("<style>").unwrap_or(&s).strip_suffix("</style>").unwrap_or(&s).to_string())
.unwrap_or_else(|_| MINIMAL_WHAT_CSS.clone())
});
static MINIFIED_WHAT_JS: LazyLock<String> = LazyLock::new(|| {
let cfg = minify_html::Cfg { minify_js: true, ..minify_html::Cfg::default() };
let wrapped = format!("<script>{}</script>", EMBEDDED_WHAT_JS);
let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
String::from_utf8(minified)
.map(|s| s.strip_prefix("<script>").unwrap_or(&s).strip_suffix("</script>").unwrap_or(&s).to_string())
.unwrap_or_else(|_| EMBEDDED_WHAT_JS.to_string())
});
static WHAT_CSS_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
let hash = embedded_asset_hash(EMBEDDED_WHAT_CSS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
format!("/static/what.css?v={:08x}", hash)
});
static WHAT_CSS_MINIMAL_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
let hash =
embedded_asset_hash(&MINIMAL_WHAT_CSS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
format!("/static/what.css?v={:08x}", hash)
});
static WHAT_JS_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
let hash = embedded_asset_hash(EMBEDDED_WHAT_JS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
format!("/static/what.js?v={:08x}", hash)
});
pub fn embedded_what_css_for_mode(mode: CssMode) -> &'static str {
match mode {
CssMode::Minimal => MINIMAL_WHAT_CSS.as_str(),
_ => EMBEDDED_WHAT_CSS,
}
}
fn embedded_asset_hash(content: &str) -> u32 {
let mut hash: u32 = 0x811c9dc5;
for byte in content.as_bytes() {
hash ^= *byte as u32;
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
async fn handle_embedded_css(State(state): State<AppState>) -> impl IntoResponse {
let minimal = state.css_mode == CssMode::Minimal;
let (cache, content) = if state.dev_mode {
let raw = if minimal { MINIMAL_WHAT_CSS.as_str() } else { EMBEDDED_WHAT_CSS };
("no-cache, no-store, must-revalidate", raw.to_string())
} else {
let min = if minimal { MINIFIED_MINIMAL_WHAT_CSS.clone() } else { MINIFIED_WHAT_CSS.clone() };
("public, max-age=31536000, immutable", min)
};
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, cache),
],
content,
)
}
async fn handle_embedded_js(State(state): State<AppState>) -> impl IntoResponse {
let (cache, content) = if state.dev_mode {
("no-cache, no-store, must-revalidate", EMBEDDED_WHAT_JS.to_string())
} else {
("public, max-age=31536000, immutable", MINIFIED_WHAT_JS.clone())
};
(
StatusCode::OK,
[
(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
),
(header::CACHE_CONTROL, cache),
],
content,
)
}
async fn handle_page_source(
State(state): State<AppState>,
axum::extract::Path(path): axum::extract::Path<String>,
) -> impl IntoResponse {
let not_available = axum::Json(json!({
"files": [{ "label": "Page", "content": "Source not available" }]
}));
let clean_path = path.trim_start_matches('/');
let pages_dir = state.content_dir.clone();
let file_path = pages_dir.join(clean_path);
let file_path = if file_path.extension().is_some() {
file_path
} else {
let with_ext = file_path.with_extension("html");
if with_ext.exists() {
with_ext
} else {
file_path.join("index.html")
}
};
let canonical = match file_path.canonicalize() {
Ok(p) => p,
Err(_) => return not_available,
};
let pages_canonical = match pages_dir.canonicalize() {
Ok(p) => p,
Err(_) => return not_available,
};
if !canonical.starts_with(&pages_canonical) {
return not_available;
}
if std::fs::read_to_string(&canonical).is_err() {
return not_available;
}
let mut files: Vec<Value> = Vec::new();
let mut scan_queue: Vec<(PathBuf, String)> = Vec::new();
static COMPONENT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<what-([a-zA-Z][a-zA-Z0-9_-]*)[\s/>]").unwrap());
static INCLUDE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"<include\s+src="([^"]+)""#).unwrap());
static PARTIAL_ROUTE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"w-get="/partials/([^"?#]+)""#).unwrap());
static PARTIAL_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"name="w-partial"[^>]*value="([^"]+)"|value="([^"]+)"[^>]*name="w-partial""#)
.unwrap()
});
let project_root = pages_dir.parent().unwrap_or(&pages_dir);
let project_root_canonical = match project_root.canonicalize() {
Ok(p) => p,
Err(_) => return not_available,
};
let components_dir = project_root.join("components");
let mut seen_paths = std::collections::HashSet::new();
push_source_file(
canonical.clone(),
&project_root_canonical,
&mut seen_paths,
&mut files,
&mut scan_queue,
);
while let Some((current_path, current_content)) = scan_queue.pop() {
for cap in COMPONENT_RE.captures_iter(¤t_content) {
let name = cap[1].to_string();
push_source_file(
components_dir.join(format!("{}.html", name)),
&project_root_canonical,
&mut seen_paths,
&mut files,
&mut scan_queue,
);
}
for cap in INCLUDE_RE.captures_iter(¤t_content) {
let src = cap[1].to_string();
let file_dir = current_path.parent().unwrap_or(project_root);
let candidates = [
project_root.join(&src),
pages_dir.join(&src),
file_dir.join(&src),
];
for candidate in candidates {
if push_source_file(
candidate,
&project_root_canonical,
&mut seen_paths,
&mut files,
&mut scan_queue,
) {
break;
}
}
}
for cap in PARTIAL_ROUTE_RE.captures_iter(¤t_content) {
let partial = format!("{}.html", &cap[1]);
push_source_file(
pages_dir.join("partials").join(partial),
&project_root_canonical,
&mut seen_paths,
&mut files,
&mut scan_queue,
);
}
for cap in PARTIAL_NAME_RE.captures_iter(¤t_content) {
if let Some(name) = cap.get(1).or_else(|| cap.get(2)) {
push_source_file(
pages_dir.join("partials").join(format!("{}.html", name.as_str())),
&project_root_canonical,
&mut seen_paths,
&mut files,
&mut scan_queue,
);
}
}
}
axum::Json(json!({ "files": files }))
}
async fn handle_livereload_ws(
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_livereload_socket(socket, state))
}
async fn handle_livereload_socket(socket: WebSocket, state: AppState) {
let (mut sender, mut receiver) = socket.split();
let mut reload_rx = match state.live_reload_receiver() {
Some(rx) => rx,
None => {
tracing::warn!("Live reload not enabled");
return;
}
};
tracing::debug!("Live reload client connected");
let _ = sender
.send(Message::Text("{\"type\":\"connected\"}".to_string()))
.await;
let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
let recv_task = tokio::spawn(async move {
while let Some(msg) = receiver.next().await {
match msg {
Ok(Message::Close(_)) => break,
Ok(Message::Ping(_)) => {
}
Err(e) => {
tracing::debug!("WebSocket receive error: {}", e);
break;
}
_ => {}
}
}
let _ = stop_tx.send(());
});
loop {
tokio::select! {
result = reload_rx.recv() => {
match result {
Ok(LiveReloadMessage::Reload) => {
if sender.send(Message::Text("{\"type\":\"reload\"}".to_string())).await.is_err() {
break;
}
}
Ok(LiveReloadMessage::CacheCleared) => {
if sender.send(Message::Text("{\"type\":\"cache_cleared\"}".to_string())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
}
}
_ = &mut stop_rx => {
break;
}
}
}
recv_task.abort();
tracing::debug!("Live reload client disconnected");
}
async fn handle_wire_ws(
ws: WebSocketUpgrade,
headers: HeaderMap,
State(state): State<AppState>,
) -> impl IntoResponse {
let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
let (client_roles, client_user_id) = if state.auth.is_enabled() {
if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
match state.auth.decode_jwt(&token) {
Ok(claims) if !claims.is_expired() => {
let user =
UserContext::from_claims(claims.to_context(state.auth.jwt_claims()));
(user.roles(), claims.sub.clone())
}
_ => (Vec::new(), None),
}
} else {
(Vec::new(), None)
}
} else {
(Vec::new(), None)
};
ws.on_upgrade(move |socket| handle_wire_socket(socket, state, client_roles, client_user_id))
}
async fn handle_wire_socket(
socket: WebSocket,
state: AppState,
client_roles: Vec<String>,
client_user_id: Option<String>,
) {
let (mut sender, mut receiver) = socket.split();
let mut wired_rx = state.wired_tx.subscribe();
let count = state
.wired_client_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
tracing::debug!(
"Wired client connected (roles: {:?}, user: {:?}, total: {})",
client_roles,
client_user_id,
count
);
let connected_msg = format!(r#"{{"type":"connected","clients":{}}}"#, count);
let _ = sender.send(Message::Text(connected_msg)).await;
let _ = state.wired_tx.send(WiredMessage {
json: format!(r#"{{"wired._clients":"{}"}}"#, count),
scope: WiredScope::Public,
});
let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
let recv_task = tokio::spawn(async move {
while let Some(msg) = receiver.next().await {
match msg {
Ok(Message::Close(_)) => break,
Err(_) => break,
_ => {}
}
}
let _ = stop_tx.send(());
});
loop {
tokio::select! {
result = wired_rx.recv() => {
match result {
Ok(msg) => {
if msg.scope.allows(&client_roles, client_user_id.as_deref()) {
if sender.send(Message::Text(msg.json)).await.is_err() {
break;
}
}
}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
}
}
_ = &mut stop_rx => {
break;
}
}
}
recv_task.abort();
let count = state
.wired_client_count
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed)
- 1;
let _ = state.wired_tx.send(WiredMessage {
json: format!(r#"{{"wired._clients":"{}"}}"#, count),
scope: WiredScope::Public,
});
tracing::debug!("Wired client disconnected (total: {})", count);
}
pub async fn serve(state: AppState) -> Result<()> {
let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("What server running at http://{}", addr);
let router = create_router(state);
axum::serve(listener, router).await?;
Ok(())
}
pub async fn serve_with_shutdown(
state: AppState,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> Result<()> {
let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
serve_on_listener(state, listener, shutdown).await
}
pub async fn serve_on_listener(
state: AppState,
listener: tokio::net::TcpListener,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> Result<()> {
tracing::info!("What server running at http://{}", listener.local_addr()?);
let router = create_router(state);
axum::serve(listener, router)
.with_graceful_shutdown(shutdown)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_project() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let cd = content_dir_name(root);
fs::create_dir_all(root.join(cd).join("admin")).unwrap();
fs::create_dir_all(root.join(cd).join("blog")).unwrap();
fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
fs::write(root.join(cd).join("about.html"), "<h1>About</h1>").unwrap();
fs::write(root.join(cd).join("admin/index.html"), "<h1>Admin</h1>").unwrap();
fs::write(root.join(cd).join("admin/users.html"), "<h1>Users</h1>").unwrap();
fs::write(root.join(cd).join("blog/[id].html"), "<h1>Blog Post</h1>").unwrap();
temp_dir
}
#[test]
fn test_resolve_page_path_exact_match() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let result = resolve_page_path(&root, "/about");
assert!(result.is_some());
let resolved = result.unwrap();
assert!(resolved.path.ends_with("about.html"));
assert!(resolved.params.is_empty());
}
#[test]
fn test_resolve_page_path_index() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let result = resolve_page_path(&root, "/");
assert!(result.is_some());
assert!(result.unwrap().path.ends_with("index.html"));
let result = resolve_page_path(&root, "/admin");
assert!(result.is_some());
let resolved = result.unwrap();
assert!(resolved.path.to_string_lossy().contains("admin"));
assert!(resolved.path.ends_with("index.html"));
}
#[test]
fn test_resolve_page_path_nested() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let result = resolve_page_path(&root, "/admin/users");
assert!(result.is_some());
assert!(result.unwrap().path.ends_with("users.html"));
}
#[test]
fn test_resolve_page_path_dynamic() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let result = resolve_page_path(&root, "/blog/123");
assert!(result.is_some());
let resolved = result.unwrap();
assert!(resolved.path.ends_with("[id].html"));
assert_eq!(resolved.params.get("id"), Some(&"123".to_string()));
}
#[test]
fn test_resolve_page_path_not_found() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let result = resolve_page_path(&root, "/nonexistent");
assert!(result.is_none());
}
fn create_test_project_with_config() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let cd = content_dir_name(root);
fs::create_dir_all(root.join(cd).join("admin")).unwrap();
fs::create_dir_all(root.join(cd).join("admin/settings")).unwrap();
fs::write(
root.join(cd).join("application.what"),
r#"
title = "My App"
theme = "light"
auth = "all"
"#,
)
.unwrap();
fs::write(
root.join(cd).join("admin/application.what"),
r#"
title = "Admin Panel"
auth = "admin"
"#,
)
.unwrap();
fs::write(
root.join(cd).join("admin/settings/application.what"),
r#"
title = "Settings"
debug = true
"#,
)
.unwrap();
fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
fs::write(root.join(cd).join("admin/index.html"), "<h1>Admin</h1>").unwrap();
fs::write(
root.join(cd).join("admin/settings/index.html"),
"<h1>Settings</h1>",
)
.unwrap();
temp_dir
}
#[test]
fn test_load_application_config_root() {
let temp_dir = create_test_project_with_config();
let root = temp_dir.path().to_path_buf();
let config = load_application_config(&root, "/");
assert_eq!(config.get_string("title"), Some("My App"));
assert_eq!(config.get_string("theme"), Some("light"));
assert_eq!(config.directives.auth, crate::parser::AuthLevel::All);
}
#[test]
fn test_load_application_config_inheritance() {
let temp_dir = create_test_project_with_config();
let root = temp_dir.path().to_path_buf();
let config = load_application_config(&root, "/admin");
assert_eq!(config.get_string("title"), Some("Admin Panel"));
assert_eq!(config.get_string("theme"), Some("light")); assert_eq!(
config.directives.auth,
crate::parser::AuthLevel::Roles(vec!["admin".to_string()])
);
}
#[test]
fn test_load_application_config_deep_inheritance() {
let temp_dir = create_test_project_with_config();
let root = temp_dir.path().to_path_buf();
let config = load_application_config(&root, "/admin/settings");
assert_eq!(config.get_string("title"), Some("Settings"));
assert_eq!(config.get_string("theme"), Some("light")); assert_eq!(config.get_bool("debug"), Some(true)); assert_eq!(
config.directives.auth,
crate::parser::AuthLevel::Roles(vec!["admin".to_string()])
);
}
#[test]
fn test_load_application_config_no_config() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = load_application_config(&root, "/about");
assert!(config.values.is_empty());
assert_eq!(config.directives.auth, crate::parser::AuthLevel::All);
}
#[test]
fn test_content_dir_name_prefers_site() {
let temp = TempDir::new().unwrap();
let root = temp.path();
assert_eq!(content_dir_name(root), "site");
fs::create_dir_all(root.join("pages")).unwrap();
assert_eq!(content_dir_name(root), "pages");
fs::create_dir_all(root.join("site")).unwrap();
assert_eq!(content_dir_name(root), "site");
}
#[test]
fn test_content_dir_name_only_site() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("site")).unwrap();
assert_eq!(content_dir_name(root), "site");
}
#[test]
fn test_app_state_dev_mode_disabled() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = crate::Config::default();
let state = AppState::new(config, root).unwrap();
assert!(!state.dev_mode);
assert!(state.live_reload_tx.is_none());
assert!(state.live_reload_receiver().is_none());
}
#[test]
fn test_app_state_dev_mode_enabled() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = crate::Config::default();
let state = AppState::with_dev_mode(config, root, true).unwrap();
assert!(state.dev_mode);
assert!(state.live_reload_tx.is_some());
assert!(state.live_reload_receiver().is_some());
}
#[test]
fn test_dev_mode_disables_secure_cookies() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = crate::Config::default();
assert!(config.session.secure);
let state = AppState::with_dev_mode(config, root, true).unwrap();
assert!(!state.config.session.secure);
}
#[test]
fn test_production_mode_keeps_secure_cookies() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = crate::Config::default();
let state = AppState::new(config, root).unwrap();
assert!(state.config.session.secure);
}
#[test]
fn test_live_reload_broadcast() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = crate::Config::default();
let state = AppState::with_dev_mode(config, root, true).unwrap();
let mut rx = state.live_reload_receiver().unwrap();
if let Some(ref tx) = state.live_reload_tx {
tx.send(LiveReloadMessage::Reload).unwrap();
}
let msg = rx.try_recv().unwrap();
assert!(matches!(msg, LiveReloadMessage::Reload));
}
#[test]
fn test_live_reload_multiple_subscribers() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = crate::Config::default();
let state = AppState::with_dev_mode(config, root, true).unwrap();
let mut rx1 = state.live_reload_receiver().unwrap();
let mut rx2 = state.live_reload_receiver().unwrap();
if let Some(ref tx) = state.live_reload_tx {
tx.send(LiveReloadMessage::Reload).unwrap();
}
assert!(matches!(rx1.try_recv().unwrap(), LiveReloadMessage::Reload));
assert!(matches!(rx2.try_recv().unwrap(), LiveReloadMessage::Reload));
}
#[test]
fn test_collect_fetch_directives_from_page() {
let mut directives = crate::parser::PageDirectives::default();
directives.custom.insert(
"fetch.dog_facts".to_string(),
"https://dogapi.dog/api/v2/facts?limit=3".to_string(),
);
directives.custom.insert(
"fetch.dog_breeds".to_string(),
"https://dogapi.dog/api/v2/breeds".to_string(),
);
let fetches = collect_fetch_directives(None, Some(&directives));
assert_eq!(fetches.len(), 2);
assert_eq!(
fetches.get("dog_facts").map(|f| f.url.as_str()),
Some("https://dogapi.dog/api/v2/facts?limit=3")
);
assert_eq!(
fetches.get("dog_breeds").map(|f| f.url.as_str()),
Some("https://dogapi.dog/api/v2/breeds")
);
}
#[test]
fn test_collect_fetch_directives_excludes_non_fetch() {
let mut directives = crate::parser::PageDirectives::default();
directives
.custom
.insert("page".to_string(), "remote-data".to_string());
directives.custom.insert(
"fetch.api".to_string(),
"https://example.com/api".to_string(),
);
let fetches = collect_fetch_directives(None, Some(&directives));
assert_eq!(fetches.len(), 1);
assert!(fetches.contains_key("api"));
assert!(!fetches.contains_key("page"));
}
#[test]
fn test_collect_fetch_directives_empty() {
let directives = crate::parser::PageDirectives::default();
let fetches = collect_fetch_directives(None, Some(&directives));
assert!(fetches.is_empty());
}
#[test]
fn test_collect_fetch_directives_enhanced() {
let mut directives = crate::parser::PageDirectives::default();
directives.custom.insert(
"fetch.users.url".to_string(),
"https://api.example.com/users".to_string(),
);
directives
.custom
.insert("fetch.users.method".to_string(), "POST".to_string());
directives.custom.insert(
"fetch.users.headers".to_string(),
"Authorization: Bearer token".to_string(),
);
directives
.custom
.insert("fetch.users.path".to_string(), "data.results".to_string());
let fetches = collect_fetch_directives(None, Some(&directives));
assert_eq!(fetches.len(), 1);
let users = fetches.get("users").unwrap();
assert_eq!(users.url, "https://api.example.com/users");
assert_eq!(users.method, "POST");
assert_eq!(users.headers.len(), 1);
assert_eq!(
users.headers[0],
("Authorization".to_string(), "Bearer token".to_string())
);
assert_eq!(users.path.as_deref(), Some("data.results"));
}
#[test]
fn test_extract_json_path() {
let value = serde_json::json!({
"data": {
"results": [1, 2, 3],
"meta": { "total": 100 }
}
});
assert_eq!(
extract_json_path(&value, "data.results"),
serde_json::json!([1, 2, 3])
);
assert_eq!(
extract_json_path(&value, "data.meta.total"),
serde_json::json!(100)
);
assert_eq!(
extract_json_path(&value, "data.missing"),
serde_json::Value::Null
);
}
#[test]
fn test_parse_header_string() {
let headers = parse_header_string("Authorization: Bearer token, Accept: application/json");
assert_eq!(headers.len(), 2);
assert_eq!(
headers[0],
("Authorization".to_string(), "Bearer token".to_string())
);
assert_eq!(
headers[1],
("Accept".to_string(), "application/json".to_string())
);
}
#[test]
fn test_parse_w_set_increment() {
let result = parse_w_set_expr("session.counter += 1");
assert!(result.is_some());
let (scope, key, op) = result.unwrap();
assert_eq!(scope, "session");
assert_eq!(key, "counter");
assert!(matches!(op, SetOp::Increment(1)));
}
#[test]
fn test_parse_w_set_decrement() {
let result = parse_w_set_expr("session.counter -= 1");
assert!(result.is_some());
let (scope, key, op) = result.unwrap();
assert_eq!(scope, "session");
assert_eq!(key, "counter");
assert!(matches!(op, SetOp::Decrement(1)));
}
#[test]
fn test_parse_w_set_assign_int() {
let result = parse_w_set_expr("app.app_counter = 0");
assert!(result.is_some());
let (scope, key, op) = result.unwrap();
assert_eq!(scope, "app");
assert_eq!(key, "app_counter");
assert!(matches!(op, SetOp::SetInt(0)));
}
#[test]
fn test_parse_w_set_assign_string() {
let result = parse_w_set_expr("session.name = 'Jorge'");
assert!(result.is_some());
let (scope, key, op) = result.unwrap();
assert_eq!(scope, "session");
assert_eq!(key, "name");
match op {
SetOp::SetStr(s) => assert_eq!(s, "Jorge"),
_ => panic!("Expected SetStr"),
}
}
#[test]
fn test_parse_w_set_large_increment() {
let result = parse_w_set_expr("app.views += 100");
assert!(result.is_some());
let (scope, key, op) = result.unwrap();
assert_eq!(scope, "app");
assert_eq!(key, "views");
assert!(matches!(op, SetOp::Increment(100)));
}
#[test]
fn test_parse_w_set_whitespace() {
let result = parse_w_set_expr(" session.counter += 5 ");
assert!(result.is_some());
let (scope, key, op) = result.unwrap();
assert_eq!(scope, "session");
assert_eq!(key, "counter");
assert!(matches!(op, SetOp::Increment(5)));
}
#[test]
fn test_parse_w_set_invalid_no_scope() {
assert!(parse_w_set_expr("counter += 1").is_none());
}
#[test]
fn test_parse_w_set_invalid_bad_value() {
assert!(parse_w_set_expr("session.counter += abc").is_none());
}
#[test]
fn test_parse_w_set_invalid_empty() {
assert!(parse_w_set_expr("").is_none());
}
#[test]
fn test_parse_w_set_multi_expression() {
let expr_str = "session.counter += 1; app.views += 1";
let parsed: Vec<_> = expr_str
.split(';')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.filter_map(|s| parse_w_set_expr(s))
.collect();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].0, "session");
assert_eq!(parsed[0].1, "counter");
assert!(matches!(parsed[0].2, SetOp::Increment(1)));
assert_eq!(parsed[1].0, "app");
assert_eq!(parsed[1].1, "views");
assert!(matches!(parsed[1].2, SetOp::Increment(1)));
}
#[test]
fn test_apply_set_op_increment_from_none() {
let result = apply_set_op(None, &SetOp::Increment(1));
assert_eq!(result, json!(1));
}
#[test]
fn test_apply_set_op_increment_existing() {
let current = json!(5);
let result = apply_set_op(Some(¤t), &SetOp::Increment(3));
assert_eq!(result, json!(8));
}
#[test]
fn test_apply_set_op_set_string() {
let result = apply_set_op(None, &SetOp::SetStr("hello".to_string()));
assert_eq!(result, json!("hello"));
}
#[test]
fn test_parse_w_set_wired_scope() {
let result = parse_w_set_expr("wired.total_dogs += 1");
assert!(result.is_some());
let (scope, key, op) = result.unwrap();
assert_eq!(scope, "wired");
assert_eq!(key, "total_dogs");
assert!(matches!(op, SetOp::Increment(1)));
}
#[test]
fn test_parse_w_set_wired_reset() {
let result = parse_w_set_expr("wired.counter = 0");
assert!(result.is_some());
let (scope, key, op) = result.unwrap();
assert_eq!(scope, "wired");
assert_eq!(key, "counter");
assert!(matches!(op, SetOp::SetInt(0)));
}
#[test]
fn test_wired_broadcast_channel() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = crate::Config::default();
let state = AppState::new(config, root).unwrap();
let mut rx = state.wired_tx.subscribe();
let _ = state.wired_tx.send(WiredMessage {
json: r#"{"wired.counter":"5"}"#.to_string(),
scope: WiredScope::Public,
});
let msg = rx.try_recv().unwrap();
assert!(msg.json.contains("wired.counter"));
assert!(msg.json.contains("5"));
}
#[test]
fn test_error_page_dev_mode_shows_detail() {
let page = error_page_fallback(
true,
StatusCode::INTERNAL_SERVER_ERROR,
"template parse failed at line 42",
);
assert!(page.contains("template parse failed at line 42"));
assert!(page.contains("500"));
}
#[test]
fn test_error_page_production_hides_detail() {
let page = error_page_fallback(
false,
StatusCode::INTERNAL_SERVER_ERROR,
"template parse failed at line 42",
);
assert!(!page.contains("template parse failed"));
assert!(!page.contains("line 42"));
assert!(page.contains("Something went wrong"));
}
#[test]
fn test_error_page_404_production() {
let page = error_page_fallback(false, StatusCode::NOT_FOUND, "/secret/path/file.html");
assert!(!page.contains("/secret/path"));
assert!(page.contains("Page Not Found"));
}
#[test]
fn test_error_page_403_production() {
let page = error_page_fallback(false, StatusCode::FORBIDDEN, "user lacks admin role");
assert!(!page.contains("admin role"));
assert!(page.contains("Forbidden"));
}
#[test]
fn test_inject_csrf_into_post_form() {
let html = r##"<html><head></head><body><form method="post" action="/submit"><input name="name"></form></body></html>"##;
let result = inject_csrf_tokens(html, "test_token_abc");
assert!(result.contains(r#"<input type="hidden" name="_csrf" value="test_token_abc">"#));
assert!(result.contains(r#"<meta name="csrf-token" content="test_token_abc">"#));
}
#[test]
fn test_inject_csrf_skips_get_form() {
let html = r##"<html><head></head><body><form method="get" action="/search"><input name="q"></form></body></html>"##;
let result = inject_csrf_tokens(html, "test_token");
assert!(!result.contains(r#"name="_csrf""#));
assert!(result.contains(r#"<meta name="csrf-token" content="test_token">"#));
}
#[test]
fn test_inject_csrf_multiple_forms() {
let html = r##"<html><head></head><body><form method="POST"><input></form><form method="post"><input></form></body></html>"##;
let result = inject_csrf_tokens(html, "tok123");
let count = result.matches(r#"name="_csrf""#).count();
assert_eq!(count, 2, "Should inject into both POST forms");
}
#[test]
fn test_inject_what_js_before_body() {
let html = "<html><head></head><body><p>Hello</p></body></html>";
let result = inject_what_js(html);
assert!(result.contains(WHAT_JS_ASSET_PATH.as_str()));
assert!(result.contains("</body>"));
}
#[test]
fn test_inject_what_js_skips_if_present() {
let html =
r#"<html><head></head><body><script src="/static/what.js"></script></body></html>"#;
let result = inject_what_js(html);
assert_eq!(result.matches("what.js").count(), 1, "Should not duplicate");
}
#[test]
fn test_inject_what_js_no_body() {
let html = "<p>just a fragment</p>";
let result = inject_what_js(html);
assert!(!result.contains("what.js"), "Should skip without </body>");
assert_eq!(result, html);
}
#[test]
fn test_inject_theme_restore_after_head() {
let html = "<html><head><title>T</title></head><body></body></html>";
let result = inject_theme_restore(html);
assert!(result.contains("data-w-theme"));
let head_pos = result.find("<head>").unwrap();
let script_pos = result.find("<script data-w-theme>").unwrap();
let title_pos = result.find("<title>").unwrap();
assert!(script_pos > head_pos && script_pos < title_pos);
}
#[test]
fn test_inject_theme_restore_idempotent() {
let html = "<html><head></head><body></body></html>";
let once = inject_theme_restore(html);
let twice = inject_theme_restore(&once);
assert_eq!(once, twice, "Should not duplicate");
assert_eq!(twice.matches("data-w-theme").count(), 1);
}
#[test]
fn test_inject_theme_restore_head_with_attrs_and_fragment() {
let html = r#"<html><head lang="en"><title>T</title></head><body></body></html>"#;
let result = inject_theme_restore(html);
assert!(result.contains("data-w-theme"));
let fragment = "<p>partial</p>";
assert_eq!(inject_theme_restore(fragment), fragment);
}
#[test]
fn test_inject_what_css_into_head() {
let html = "<html><head><title>Test</title></head><body></body></html>";
let result = inject_what_css(html, CssMode::Full);
assert!(result.contains(WHAT_CSS_ASSET_PATH.as_str()));
assert!(result.contains("<head>\n"));
}
#[test]
fn test_inject_what_css_skips_if_present() {
let html = r#"<html><head><link rel="stylesheet" href="/static/what.css"></head><body></body></html>"#;
let result = inject_what_css(html, CssMode::Full);
assert_eq!(
result.matches("what.css").count(),
1,
"Should not duplicate"
);
}
#[test]
fn test_inject_what_css_no_head() {
let html = "<p>just a fragment</p>";
let result = inject_what_css(html, CssMode::Full);
assert!(!result.contains("what.css"), "Should skip without <head>");
assert_eq!(result, html);
}
#[test]
fn test_inject_what_css_none_mode_skips() {
let html = "<html><head><title>Test</title></head><body></body></html>";
let result = inject_what_css(html, CssMode::None);
assert_eq!(result, html, "none mode must not inject anything");
}
#[test]
fn test_inject_what_css_minimal_mode_uses_minimal_path() {
let html = "<html><head><title>Test</title></head><body></body></html>";
let result = inject_what_css(html, CssMode::Minimal);
assert!(result.contains(WHAT_CSS_MINIMAL_ASSET_PATH.as_str()));
assert_ne!(
WHAT_CSS_MINIMAL_ASSET_PATH.as_str(),
WHAT_CSS_ASSET_PATH.as_str(),
"minimal and full variants must cache-bust independently"
);
}
#[test]
fn test_minimal_css_slice() {
let minimal = MINIMAL_WHAT_CSS.as_str();
assert!(EMBEDDED_WHAT_CSS.contains(MINIMAL_CUT_START), "cut-start marker missing from what.css");
assert!(EMBEDDED_WHAT_CSS.contains(MINIMAL_CUT_END), "cut-end marker missing from what.css");
assert!(minimal.len() < EMBEDDED_WHAT_CSS.len());
assert!(minimal.contains("@layer base"));
assert!(minimal.contains("@layer utilities"));
assert!(minimal.contains(".page-source"));
assert!(!minimal.contains("@layer components"));
assert!(!minimal.contains("@layer interactions {"));
assert!(!minimal.contains(".what-pagination"));
}
#[test]
fn test_css_mode_from_config() {
assert_eq!(CssMode::from_config("full").unwrap(), CssMode::Full);
assert_eq!(CssMode::from_config("minimal").unwrap(), CssMode::Minimal);
assert_eq!(CssMode::from_config("none").unwrap(), CssMode::None);
let err = CssMode::from_config("compact").unwrap_err().to_string();
assert!(err.contains("compact"), "error should echo the bad value: {err}");
}
#[test]
fn test_validate_csrf_token_valid() {
let mut session = sessions::Session::new(3600);
session
.data
.insert(CSRF_SESSION_KEY.to_string(), json!("correct_token"));
assert!(validate_csrf_token(
Some(&session),
Some("correct_token"),
None
));
}
#[test]
fn test_validate_csrf_token_invalid() {
let mut session = sessions::Session::new(3600);
session
.data
.insert(CSRF_SESSION_KEY.to_string(), json!("correct_token"));
assert!(!validate_csrf_token(
Some(&session),
Some("wrong_token"),
None
));
}
#[test]
fn test_validate_csrf_token_from_header() {
let mut session = sessions::Session::new(3600);
session
.data
.insert(CSRF_SESSION_KEY.to_string(), json!("header_token"));
assert!(validate_csrf_token(
Some(&session),
None,
Some("header_token")
));
}
#[test]
fn test_validate_csrf_token_no_session() {
assert!(!validate_csrf_token(None, Some("any_token"), None));
}
#[test]
fn test_validate_csrf_token_missing_token() {
let mut session = sessions::Session::new(3600);
session
.data
.insert(CSRF_SESSION_KEY.to_string(), json!("token"));
assert!(!validate_csrf_token(Some(&session), None, None));
}
#[test]
fn test_csrf_exempt_paths() {
assert!(is_csrf_exempt("/w-livereload"));
assert!(is_csrf_exempt("/w-wire"));
assert!(!is_csrf_exempt("/w-set"));
assert!(!is_csrf_exempt("/w-action/items"));
assert!(!is_csrf_exempt("/w-auth/login"));
}
#[test]
fn test_session_new_has_csrf_token() {
let session = sessions::Session::new(3600);
let csrf_token = session.data.get(CSRF_SESSION_KEY);
assert!(csrf_token.is_some(), "New session should have a CSRF token");
let token_str = csrf_token.unwrap().as_str().unwrap();
assert_eq!(
token_str.len(),
64,
"CSRF token should be 64 hex chars (32 bytes)"
);
}
#[test]
fn test_sanitize_extension_normal() {
assert_eq!(sanitize_extension("jpg"), ".jpg");
assert_eq!(sanitize_extension("png"), ".png");
assert_eq!(sanitize_extension("PDF"), ".PDF");
}
#[test]
fn test_sanitize_extension_strips_path_separators() {
let result = sanitize_extension("../../../etc/passwd");
assert!(!result.contains('/'));
assert!(!result.contains('\\'));
assert!(result.ends_with("etcpasswd"));
let result = sanitize_extension("..\\..\\windows");
assert!(!result.contains('\\'));
}
#[test]
fn test_sanitize_extension_strips_null_bytes() {
assert_eq!(sanitize_extension("jpg\0exe"), ".jpgexe");
}
#[test]
fn test_sanitize_extension_strips_non_ascii() {
assert_eq!(sanitize_extension("jp\u{00e9}g"), ".jpg");
}
#[test]
fn test_sanitize_extension_empty() {
assert_eq!(sanitize_extension(""), "");
assert_eq!(sanitize_extension("///"), "");
}
#[test]
fn test_collect_fetch_local_directive() {
let mut directives = crate::parser::PageDirectives::default();
directives
.custom
.insert("fetch.posts".to_string(), "local:posts".to_string());
directives.custom.insert(
"fetch.posts.sort".to_string(),
"created_at:desc".to_string(),
);
directives.custom.insert(
"fetch.posts.filter".to_string(),
"status=published".to_string(),
);
directives
.custom
.insert("fetch.posts.limit".to_string(), "10".to_string());
directives
.custom
.insert("fetch.posts.offset".to_string(), "20".to_string());
directives
.custom
.insert("fetch.posts.search".to_string(), "rust".to_string());
directives.custom.insert(
"fetch.posts.search_fields".to_string(),
"title,content".to_string(),
);
let fetches = collect_fetch_directives(None, Some(&directives));
assert_eq!(fetches.len(), 1);
let posts = fetches.get("posts").unwrap();
assert!(posts.is_local());
assert_eq!(posts.local_collection(), Some("posts"));
assert_eq!(posts.sort.as_deref(), Some("created_at:desc"));
assert_eq!(posts.filter.as_deref(), Some("status=published"));
assert_eq!(posts.limit, Some(10));
assert_eq!(posts.offset, Some(20));
assert_eq!(posts.search.as_deref(), Some("rust"));
assert_eq!(posts.search_fields.as_deref(), Some("title,content"));
}
#[test]
fn test_local_collection_strips_query_string() {
let mut directives = crate::parser::PageDirectives::default();
directives.custom.insert(
"fetch.posts".to_string(),
"local:posts?sort=created_at:desc&limit=5&filter=status=published".to_string(),
);
let fetches = collect_fetch_directives(None, Some(&directives));
let posts = fetches.get("posts").unwrap();
assert!(posts.is_local());
assert_eq!(posts.local_collection(), Some("posts"));
assert_eq!(
posts.local_query(),
Some("sort=created_at:desc&limit=5&filter=status=published")
);
let mut d2 = crate::parser::PageDirectives::default();
d2.custom
.insert("fetch.x".to_string(), "local:items".to_string());
let f2 = collect_fetch_directives(None, Some(&d2));
assert_eq!(f2.get("x").unwrap().local_collection(), Some("items"));
assert_eq!(f2.get("x").unwrap().local_query(), None);
}
#[test]
fn test_is_framework_field() {
assert!(is_framework_field("_csrf"));
assert!(is_framework_field("w-rules"));
assert!(is_framework_field("w-partial"));
assert!(is_framework_field("redirect"));
assert!(is_framework_field("cf-turnstile-response"));
assert!(!is_framework_field("_session_id"));
assert!(!is_framework_field("message"));
assert!(!is_framework_field("name"));
}
#[test]
fn test_fetch_local_not_remote() {
let d = FetchDirective::simple("posts".to_string(), "local:posts".to_string());
assert!(d.is_local());
assert_eq!(d.local_collection(), Some("posts"));
let d2 = FetchDirective::simple(
"api".to_string(),
"https://api.example.com/data".to_string(),
);
assert!(!d2.is_local());
assert_eq!(d2.local_collection(), None);
}
#[test]
fn test_redirects_config_parsing() {
let toml_str = r##"
[redirects]
"/old-blog" = "/blog"
"/legacy/*" = "/modern"
"/about-us" = "/about"
"##;
let config: crate::Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.redirects.len(), 3);
assert_eq!(config.redirects.get("/old-blog").unwrap(), "/blog");
assert_eq!(config.redirects.get("/legacy/*").unwrap(), "/modern");
assert_eq!(config.redirects.get("/about-us").unwrap(), "/about");
}
#[test]
fn test_redirects_config_empty_by_default() {
let config = crate::Config::default();
assert!(config.redirects.is_empty());
}
#[test]
fn test_custom_headers_in_application_what() {
let content = r#"header.Access-Control-Allow-Origin = "*"
header.Cache-Control = "no-store"
header.X-Custom = "hello"
"#;
let config = crate::parser::parse_what_file(content);
assert_eq!(config.directives.headers.len(), 3);
assert_eq!(
config
.directives
.headers
.get("access-control-allow-origin")
.unwrap(),
"*"
);
assert_eq!(
config.directives.headers.get("cache-control").unwrap(),
"no-store"
);
assert_eq!(config.directives.headers.get("x-custom").unwrap(), "hello");
}
#[test]
fn test_custom_headers_merge_in_config() {
let parent_content = r#"header.X-Frame-Options = "DENY"
header.X-Custom = "parent"
"#;
let child_content = r#"header.X-Custom = "child"
header.X-New = "added"
"#;
let mut parent = crate::parser::parse_what_file(parent_content);
let child = crate::parser::parse_what_file(child_content);
parent.merge(&child);
assert_eq!(parent.directives.headers.get("x-custom").unwrap(), "child");
assert_eq!(
parent.directives.headers.get("x-frame-options").unwrap(),
"DENY"
);
assert_eq!(parent.directives.headers.get("x-new").unwrap(), "added");
}
#[test]
fn test_custom_headers_in_page_directive_content() {
let html = r##"<what>
header.Cache-Control: no-cache
header.X-Robots-Tag: noindex
</what>
<h1>Hello</h1>"##;
let (directives, _cleaned) = crate::parser::parse_page_directives(html);
assert_eq!(directives.headers.get("cache-control").unwrap(), "no-cache");
assert_eq!(directives.headers.get("x-robots-tag").unwrap(), "noindex");
}
#[test]
fn test_custom_headers_not_in_template_context() {
let content = r#"title = "My Page"
header.X-Custom = "value"
"#;
let config = crate::parser::parse_what_file(content);
assert!(config.values.contains_key("title"));
assert!(!config.values.contains_key("header.x-custom"));
}
#[tokio::test]
async fn test_redirect_middleware_exact_match() {
use axum::body::Body;
use axum::http::Request;
use std::collections::HashMap;
use tower::ServiceExt;
let mut redirects = HashMap::new();
redirects.insert("/old".to_string(), "/new".to_string());
let mut config = crate::Config::default();
config.redirects = redirects;
config.session.enabled = false;
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let state = AppState::with_dev_mode(config, root, true).unwrap();
let app = create_router(state);
let request = Request::builder().uri("/old").body(Body::empty()).unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap(),
"/new"
);
}
#[tokio::test]
async fn test_redirect_middleware_wildcard() {
use axum::body::Body;
use axum::http::Request;
use std::collections::HashMap;
use tower::ServiceExt;
let mut redirects = HashMap::new();
redirects.insert("/legacy/*".to_string(), "/modern".to_string());
let mut config = crate::Config::default();
config.redirects = redirects;
config.session.enabled = false;
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let state = AppState::with_dev_mode(config, root, true).unwrap();
let app = create_router(state);
let request = Request::builder()
.uri("/legacy/old-page")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap(),
"/modern"
);
}
#[tokio::test]
async fn test_redirect_middleware_no_match_passes_through() {
use axum::body::Body;
use axum::http::Request;
use std::collections::HashMap;
use tower::ServiceExt;
let mut redirects = HashMap::new();
redirects.insert("/old".to_string(), "/new".to_string());
let mut config = crate::Config::default();
config.redirects = redirects;
config.session.enabled = false;
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let state = AppState::with_dev_mode(config, root, true).unwrap();
let app = create_router(state);
let request = Request::builder()
.uri("/unrelated")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_ne!(response.status(), StatusCode::PERMANENT_REDIRECT);
}
#[tokio::test]
async fn test_health_endpoint_returns_ok() {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
let mut config = crate::Config::default();
config.session.enabled = false;
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let state = AppState::with_dev_mode(config, root, true).unwrap();
let app = create_router(state);
let request = Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert_eq!(json["version"], env!("CARGO_PKG_VERSION"));
}
#[test]
fn test_fetch_timeout_default() {
let config = crate::Config::default();
assert_eq!(config.server.fetch_timeout, 10);
}
#[test]
fn test_fetch_timeout_custom() {
let toml_str = r#"
[server]
fetch_timeout = 30
"#;
let config: crate::Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.server.fetch_timeout, 30);
}
#[test]
fn test_http_client_uses_configured_timeout() {
let mut config = crate::Config::default();
config.server.fetch_timeout = 5;
config.session.enabled = false;
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let state = AppState::with_dev_mode(config, root, false).unwrap();
let _client = &state.http_client;
}
#[test]
fn wired_public_reaches_all() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = crate::Config::default();
let state = AppState::new(config, root).unwrap();
let mut rx = state.wired_tx.subscribe();
let _ = state.wired_tx.send(WiredMessage {
json: r#"{"wired.counter":"5"}"#.to_string(),
scope: WiredScope::Public,
});
let msg = rx.try_recv().unwrap();
assert!(msg.scope.allows(&[], None)); assert!(msg.scope.allows(&["admin".into()], Some("user1"))); }
#[test]
fn wired_role_filters() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = crate::Config::default();
let state = AppState::new(config, root).unwrap();
let mut rx = state.wired_tx.subscribe();
let _ = state.wired_tx.send(WiredMessage {
json: r#"{"wired.revenue":"1000"}"#.to_string(),
scope: WiredScope::Roles(vec!["admin".into()]),
});
let msg = rx.try_recv().unwrap();
assert!(msg.scope.allows(&["admin".into()], None));
assert!(!msg.scope.allows(&["viewer".into()], None));
assert!(!msg.scope.allows(&[], None)); }
#[test]
fn wired_multi_role() {
let scope = WiredScope::Roles(vec!["admin".into(), "editor".into()]);
assert!(scope.allows(&["editor".into()], None));
assert!(scope.allows(&["admin".into()], None));
assert!(!scope.allows(&["viewer".into()], None));
}
#[test]
fn wired_user_scope() {
let scope = WiredScope::User("user42".into());
assert!(scope.allows(&[], Some("user42")));
assert!(!scope.allows(&["admin".into()], Some("other_user")));
assert!(!scope.allows(&[], None));
}
#[test]
fn wired_unauthenticated_public_only() {
let public = WiredScope::Public;
let admin_only = WiredScope::Roles(vec!["admin".into()]);
let user_only = WiredScope::User("user1".into());
assert!(public.allows(&[], None));
assert!(!admin_only.allows(&[], None));
assert!(!user_only.allows(&[], None));
}
#[test]
fn wired_undeclared_key_defaults_public() {
let temp_dir = create_test_project();
let root = temp_dir.path().to_path_buf();
let config = crate::Config::default();
let state = AppState::new(config, root).unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let scope = rt.block_on(state.get_wired_scope("nonexistent_key"));
assert!(matches!(scope, WiredScope::Public));
}
#[test]
fn test_session_mutation_with_filters() {
let mut session_data = HashMap::new();
session_data.insert("name".to_string(), json!("alice"));
session_data.insert("bio".to_string(), json!("Hello world from alice"));
let val = resolve_session_value(&json!("#session.name|uppercase#"), &session_data);
assert_eq!(val, json!("ALICE"));
let val = resolve_session_value(&json!("#session.bio|truncate:10#"), &session_data);
assert_eq!(val, json!("Hello worl..."));
let val = resolve_session_value(&json!("Hi #session.name|capitalize#!"), &session_data);
assert_eq!(val, json!("Hi Alice!"));
}
}