Skip to main content

zorto_webapp/
lib.rs

1//! Zorto webapp — HTMX-based local CMS for managing zorto sites.
2
3use 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;
24/// Origin-relative mount point for the embedded preview server. Every CMS
25/// page passes this to the sidebar "View Site" link; every rebuilt site
26/// gets its `base_url` pinned here so internal links resolve against the
27/// same origin.
28const PREVIEW_MOUNT: &str = "/preview";
29const RELOAD_CHANNEL_CAPACITY: usize = 16;
30
31/// Client-side livereload snippet. Opens a WebSocket to `/__livereload`
32/// and reloads the page on each `"reload"` message. Reconnects with backoff
33/// so that webapp restarts do not leave the page dead.
34pub(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    /// Absolute base URL used when rebuilding the site for preview, e.g.
62    /// `http://127.0.0.1:1112/preview`. Bakes the webapp's actual port so
63    /// links in the generated HTML route back through the preview mount.
64    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    /// URL for the CMS's sidebar "View Site" link. Always the embedded
85    /// preview mount — the user's config.toml `base_url` remains the deploy
86    /// URL but isn't what we want for "look at what I just edited".
87    fn site_base_url(&self) -> String {
88        PREVIEW_MOUNT.to_string()
89    }
90}
91
92/// Build the axum router with the given shared state.
93pub(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        // Onboarding wizard routes
122        .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
140/// Run the zorto webapp server.
141///
142/// Starts an HTMX-based CMS webapp for managing the site at the given root directory.
143/// The optional `sandbox` path allows file operations (like include shortcodes) to
144/// access files outside the site root within the sandbox boundary.
145pub 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        // Bind listener first so we know the actual port. The preview base
149        // URL bakes the port into every rebuilt site's internal links.
150        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 a site already exists, rebuild once at startup so `/preview`
176        // serves fresh output pinned to the webapp's port.
177        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
201/// Serve the embedded HTMX library.
202async fn serve_htmx() -> impl axum::response::IntoResponse {
203    (
204        [("content-type", "application/javascript")],
205        include_str!("htmx.min.js"),
206    )
207}
208
209/// WebSocket upgrade handler for live reload. Subscribes to
210/// [`AppState::reload_tx`] and emits `"reload"` on each broadcast.
211async 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
245/// Validate that a user-supplied path, when joined to a base directory, stays
246/// within that directory. Returns the canonical path on success, or an error
247/// message suitable for display.
248pub(crate) fn validate_path(base: &Path, user_path: &str) -> Result<PathBuf, String> {
249    let joined = base.join(user_path);
250
251    // Canonicalize base (must exist)
252    let canonical_base = base
253        .canonicalize()
254        .map_err(|e| format!("Base directory does not exist: {e}"))?;
255
256    // For existence-checking operations, canonicalize the joined path.
257    // For creation, canonicalize the parent and verify.
258    let canonical = if joined.exists() {
259        joined
260            .canonicalize()
261            .map_err(|e| format!("Cannot resolve path: {e}"))?
262    } else {
263        // File doesn't exist yet (creation). Canonicalize the parent dir.
264        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        // File doesn't exist yet, but parent does — should succeed
334        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        // Create a symlink inside base pointing outside
365        #[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}