1use axum::Router;
4use axum::extract::State;
5use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
6use axum::response::Response;
7use axum::routing::{any, get, post};
8use std::net::SocketAddr;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use tokio::sync::broadcast;
12
13mod assets;
14mod build;
15mod config;
16mod dashboard;
17mod html;
18mod onboarding;
19mod pages;
20mod preview;
21mod sections;
22
23const DEFAULT_WEBAPP_PORT: u16 = 1112;
24const PREVIEW_MOUNT: &str = "/preview";
29const RELOAD_CHANNEL_CAPACITY: usize = 16;
30
31pub(crate) const LIVERELOAD_JS: &str = r#"
35<script>
36(function() {
37 var reconnectInterval = 1000;
38 var maxReconnect = 30000;
39 function connect() {
40 var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
41 var ws = new WebSocket(proto + '//' + location.host + '/__livereload');
42 ws.onmessage = function(event) {
43 if (event.data === 'reload') { location.reload(); }
44 };
45 ws.onclose = function() {
46 setTimeout(connect, reconnectInterval);
47 reconnectInterval = Math.min(reconnectInterval * 1.5, maxReconnect);
48 };
49 ws.onopen = function() { reconnectInterval = 1000; };
50 }
51 connect();
52})();
53</script>
54"#;
55
56pub(crate) struct AppState {
57 pub root: PathBuf,
58 pub output_dir: PathBuf,
59 pub sandbox: Option<PathBuf>,
60 pub reload_tx: broadcast::Sender<()>,
61 pub preview_base_url: String,
65}
66
67impl AppState {
68 fn site_title(&self) -> String {
69 let config_path = self.root.join("config.toml");
70 if let Ok(content) = std::fs::read_to_string(&config_path) {
71 if let Ok(config) = toml::from_str::<toml::Value>(&content) {
72 if let Some(title) = config.get("title").and_then(|v| v.as_str()) {
73 return title.to_string();
74 }
75 }
76 }
77 "Zorto Site".to_string()
78 }
79
80 fn site_exists(&self) -> bool {
81 self.root.join("config.toml").exists()
82 }
83
84 fn site_base_url(&self) -> String {
88 PREVIEW_MOUNT.to_string()
89 }
90}
91
92pub(crate) fn app(state: Arc<AppState>) -> Router {
94 Router::new()
95 .route("/", get(dashboard::index))
96 .route("/pages", get(pages::list))
97 .route("/pages/new", get(pages::new_form).post(pages::create))
98 .route("/pages/{*path}", get(pages::edit).post(pages::save))
99 .route("/pages/delete/{*path}", post(pages::delete))
100 .route("/sections", get(sections::list))
101 .route(
102 "/sections/new",
103 get(sections::new_form).post(sections::create),
104 )
105 .route("/sections/delete/{*path}", post(sections::delete))
106 .route(
107 "/sections/{*path}",
108 get(sections::edit).post(sections::save),
109 )
110 .route("/config", get(config::edit).post(config::save))
111 .route("/assets", get(assets::list))
112 .route("/assets/upload", post(assets::upload))
113 .route("/assets/delete", post(assets::delete))
114 .route("/build", post(build::trigger))
115 .route("/_render-markdown", post(build::render_preview))
116 .route("/preview", get(preview::serve))
117 .route("/preview/", get(preview::serve))
118 .route("/preview/{*path}", get(preview::serve))
119 .route("/static/htmx.min.js", get(serve_htmx))
120 .route("/__livereload", any(livereload_ws))
121 .route("/setup", get(onboarding::welcome))
123 .route(
124 "/setup/template",
125 get(onboarding::template).post(onboarding::template_submit),
126 )
127 .route(
128 "/setup/theme",
129 get(onboarding::theme).post(onboarding::theme_submit),
130 )
131 .route("/setup/configure", get(onboarding::configure))
132 .route("/setup/create", post(onboarding::create))
133 .with_state(state)
134}
135
136pub(crate) fn webapp_bind_addr(port: u16) -> SocketAddr {
137 SocketAddr::from(([127, 0, 0, 1], port))
138}
139
140pub fn run_webapp(root: &Path, output_dir: &Path, sandbox: Option<&Path>) -> anyhow::Result<()> {
146 let rt = tokio::runtime::Runtime::new()?;
147 rt.block_on(async {
148 let port: u16 = DEFAULT_WEBAPP_PORT;
151 let addr = webapp_bind_addr(port);
152 let listener = match tokio::net::TcpListener::bind(addr).await {
153 Ok(l) => l,
154 Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
155 eprintln!("Port {port} is in use, using a random available port...");
156 let fallback = webapp_bind_addr(0);
157 tokio::net::TcpListener::bind(fallback).await?
158 }
159 Err(e) => return Err(e.into()),
160 };
161 let actual_addr = listener.local_addr()?;
162 let actual_port = actual_addr.port();
163 let preview_base_url = format!("http://127.0.0.1:{actual_port}{PREVIEW_MOUNT}");
164
165 let (reload_tx, _) = broadcast::channel::<()>(RELOAD_CHANNEL_CAPACITY);
166
167 let state = Arc::new(AppState {
168 root: root.to_path_buf(),
169 output_dir: output_dir.to_path_buf(),
170 sandbox: sandbox.map(|p| p.to_path_buf()),
171 reload_tx,
172 preview_base_url,
173 });
174
175 if state.site_exists() {
178 if let Err(e) = rebuild_site(&state) {
179 eprintln!("initial rebuild failed: {e}");
180 }
181 }
182
183 let start_path = if state.site_exists() { "/" } else { "/setup" };
184
185 let app = app(state);
186
187 println!("zorto webapp: http://localhost:{actual_port}");
188 let _ = open::that(format!("http://localhost:{actual_port}{start_path}"));
189
190 axum::serve(listener, app)
191 .with_graceful_shutdown(async {
192 tokio::signal::ctrl_c().await.ok();
193 println!("\nshutting down...");
194 })
195 .await?;
196
197 Ok(())
198 })
199}
200
201async fn serve_htmx() -> impl axum::response::IntoResponse {
203 (
204 [("content-type", "application/javascript")],
205 include_str!("htmx.min.js"),
206 )
207}
208
209async fn livereload_ws(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> Response {
212 ws.on_upgrade(move |socket| handle_livereload(socket, state))
213}
214
215async fn handle_livereload(mut socket: WebSocket, state: Arc<AppState>) {
216 let mut rx = state.reload_tx.subscribe();
217 while rx.recv().await.is_ok() {
218 if socket
219 .send(Message::Text(String::from("reload").into()))
220 .await
221 .is_err()
222 {
223 break;
224 }
225 }
226}
227
228pub(crate) fn rebuild_site(state: &AppState) -> Result<(), String> {
229 match zorto_core::site::Site::load(&state.root, &state.output_dir, true) {
230 Ok(mut site) => {
231 site.sandbox = state.sandbox.clone();
232 site.set_base_url(state.preview_base_url.clone());
233 site.build().map_err(|e| e.to_string())?;
234 let _ = state.reload_tx.send(());
235 Ok(())
236 }
237 Err(e) => Err(e.to_string()),
238 }
239}
240
241pub(crate) fn escape(s: &str) -> String {
242 zorto_core::content::escape_html(s)
243}
244
245pub(crate) fn validate_path(base: &Path, user_path: &str) -> Result<PathBuf, String> {
249 let joined = base.join(user_path);
250
251 let canonical_base = base
253 .canonicalize()
254 .map_err(|e| format!("Base directory does not exist: {e}"))?;
255
256 let canonical = if joined.exists() {
259 joined
260 .canonicalize()
261 .map_err(|e| format!("Cannot resolve path: {e}"))?
262 } else {
263 let parent = joined.parent().ok_or("Invalid path")?;
265 let canonical_parent = parent
266 .canonicalize()
267 .map_err(|e| format!("Parent directory does not exist: {e}"))?;
268 canonical_parent.join(joined.file_name().ok_or("Invalid filename")?)
269 };
270
271 if !canonical.starts_with(&canonical_base) {
272 return Err("Path traversal detected".to_string());
273 }
274
275 Ok(canonical)
276}
277
278#[cfg(test)]
279mod integration_tests;
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use tempfile::TempDir;
285
286 #[test]
287 fn test_webapp_bind_addr_defaults_to_localhost() {
288 let addr = webapp_bind_addr(DEFAULT_WEBAPP_PORT);
289 assert_eq!(addr.ip().to_string(), "127.0.0.1");
290 assert_eq!(addr.port(), DEFAULT_WEBAPP_PORT);
291
292 let fallback = webapp_bind_addr(0);
293 assert_eq!(fallback.ip().to_string(), "127.0.0.1");
294 assert_eq!(fallback.port(), 0);
295 }
296
297 #[test]
298 fn test_validate_path_normal() {
299 let tmp = TempDir::new().unwrap();
300 let base = tmp.path();
301 std::fs::write(base.join("file.txt"), "hello").unwrap();
302 let result = validate_path(base, "file.txt");
303 assert!(result.is_ok());
304 }
305
306 #[test]
307 fn test_validate_path_traversal_blocked() {
308 let tmp = TempDir::new().unwrap();
309 let base = tmp.path().join("subdir");
310 std::fs::create_dir_all(&base).unwrap();
311 let result = validate_path(&base, "../../../etc/passwd");
312 let err = result.unwrap_err();
313 assert!(err.contains("Path traversal detected") || err.contains("does not exist"));
314 }
315
316 #[test]
317 fn test_validate_path_dotdot_traversal() {
318 let tmp = TempDir::new().unwrap();
319 let parent = tmp.path();
320 let base = parent.join("site");
321 let outside = parent.join("secret");
322 std::fs::create_dir_all(&base).unwrap();
323 std::fs::create_dir_all(&outside).unwrap();
324 std::fs::write(outside.join("data.txt"), "secret").unwrap();
325 let result = validate_path(&base, "../secret/data.txt");
326 assert!(result.is_err());
327 }
328
329 #[test]
330 fn test_validate_path_new_file_in_base() {
331 let tmp = TempDir::new().unwrap();
332 let base = tmp.path();
333 let result = validate_path(base, "new_file.txt");
335 assert!(result.is_ok());
336 }
337
338 #[test]
339 fn test_validate_path_subdirectory() {
340 let tmp = TempDir::new().unwrap();
341 let base = tmp.path();
342 let sub = base.join("sub");
343 std::fs::create_dir_all(&sub).unwrap();
344 std::fs::write(sub.join("file.txt"), "data").unwrap();
345 let result = validate_path(base, "sub/file.txt");
346 assert!(result.is_ok());
347 }
348
349 #[test]
350 fn test_validate_path_nonexistent_base() {
351 let result = validate_path(Path::new("/nonexistent/base/dir"), "file.txt");
352 assert!(result.is_err());
353 assert!(result.unwrap_err().contains("does not exist"));
354 }
355
356 #[test]
357 fn test_validate_path_symlink_escape() {
358 let tmp = TempDir::new().unwrap();
359 let base = tmp.path().join("site");
360 let outside = tmp.path().join("outside");
361 std::fs::create_dir_all(&base).unwrap();
362 std::fs::create_dir_all(&outside).unwrap();
363 std::fs::write(outside.join("secret.txt"), "secret data").unwrap();
364 #[cfg(unix)]
366 {
367 std::os::unix::fs::symlink(&outside, base.join("escape")).unwrap();
368 let result = validate_path(&base, "escape/secret.txt");
369 assert!(result.is_err(), "symlink escape should be blocked");
370 }
371 }
372}