1mod api;
9mod assets;
10mod registry;
11mod watch;
12
13use std::net::{Ipv4Addr, SocketAddr};
14use std::path::PathBuf;
15use std::time::Duration;
16
17use axum::routing::{get, patch, post, put};
18use axum::Router;
19use tokio::sync::broadcast;
20use tower_http::cors::CorsLayer;
21
22use wipe_core::model::Exposure;
23
24pub use api::AppState;
25pub use registry::{list as list_projects, ProjectEntry};
26
27#[derive(Debug, Clone)]
29pub struct ServeConfig {
30 pub root: Option<PathBuf>,
34 pub port: u16,
36 pub expose: Exposure,
38 pub open: bool,
40 pub idle_timeout: Option<std::time::Duration>,
43}
44
45fn router(state: AppState) -> Router {
47 Router::new()
48 .route("/api/health", get(api::health))
49 .route("/api/config", get(api::app_config).patch(api::patch_config))
50 .route("/api/scan", post(api::rescan))
51 .route("/api/projects", get(api::projects))
52 .route("/api/board", get(api::board))
53 .route("/api/history", get(api::history))
54 .route("/api/board/at", get(api::board_at))
55 .route("/api/definitions", get(api::definitions))
56 .route("/api/graph", get(api::graph))
57 .route("/api/labels", post(api::create_label))
58 .route(
59 "/api/labels/{name}",
60 patch(api::recolor_label).delete(api::delete_label),
61 )
62 .route("/api/lists", post(api::add_list))
63 .route(
64 "/api/lists/{id}",
65 patch(api::rename_list).delete(api::remove_list),
66 )
67 .route("/api/lists/{id}/move", post(api::move_list))
68 .route("/api/identities", get(api::identities))
69 .route(
70 "/api/identities/{id}",
71 put(api::put_identity).delete(api::delete_identity),
72 )
73 .route("/api/tickets", post(api::create_ticket))
74 .route("/api/tickets/{id}", patch(api::patch_ticket))
75 .route("/api/tickets/{id}/move", post(api::move_ticket))
76 .route("/api/tickets/{id}/comments", post(api::add_comment))
77 .route(
78 "/api/tickets/{id}/attachments",
79 post(api::upload_attachment).delete(api::delete_attachment),
80 )
81 .route("/api/media/{*path}", get(api::serve_media))
82 .route("/api/forum", get(api::forum_list).post(api::forum_create))
83 .route("/api/forum/search", get(api::forum_search))
84 .route(
85 "/api/forum/{id}",
86 get(api::forum_thread)
87 .patch(api::forum_edit)
88 .delete(api::forum_delete),
89 )
90 .route("/api/forum/{id}/reply", post(api::forum_reply))
91 .route("/ws", get(api::ws_handler))
92 .fallback(assets::static_handler)
93 .layer(CorsLayer::permissive())
94 .with_state(state)
95}
96
97pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> {
99 if let Some(root) = &cfg.root {
100 registry::register(root);
101 }
102
103 let (tx, _rx) = broadcast::channel::<String>(64);
104 let clients = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
105 let state = AppState {
106 current: cfg.root.clone(),
107 tx: tx.clone(),
108 clients: clients.clone(),
109 };
110
111 let _watcher = cfg
115 .root
116 .as_ref()
117 .map(|root| watch::spawn(&root.join(".wipe"), tx.clone()));
118 if matches!(_watcher, Some(Err(_))) {
119 eprintln!("warning: file watching unavailable; live updates disabled");
120 }
121
122 let ip = match cfg.expose {
123 Exposure::None => Ipv4Addr::LOCALHOST,
124 Exposure::Tailscale | Exposure::Proxy => Ipv4Addr::UNSPECIFIED,
125 };
126 let addr = SocketAddr::from((ip, cfg.port));
127 let listener = tokio::net::TcpListener::bind(addr).await?;
128 let bound = listener.local_addr()?;
129
130 let shown = if bound.ip().is_unspecified() {
131 SocketAddr::from((Ipv4Addr::LOCALHOST, bound.port()))
132 } else {
133 bound
134 };
135 match cfg.idle_timeout {
136 Some(d) => println!(
137 "wipe UI serving on http://{shown} (Ctrl-C to stop; auto-stops after {}s idle)",
138 d.as_secs()
139 ),
140 None => println!("wipe UI serving on http://{shown} (Ctrl-C to stop)"),
141 }
142 if cfg.open {
143 open_browser(&format!("http://{shown}"));
144 }
145
146 let app = router(state);
147 let idle = cfg.idle_timeout;
148 axum::serve(listener, app)
149 .with_graceful_shutdown(async move { shutdown_signal(clients, idle).await })
150 .await?;
151 Ok(())
152}
153
154async fn shutdown_signal(
157 clients: std::sync::Arc<std::sync::atomic::AtomicUsize>,
158 idle: Option<Duration>,
159) {
160 tokio::select! {
161 _ = async { let _ = tokio::signal::ctrl_c().await; } => {}
162 _ = idle_watcher(clients, idle) => {
163 println!("wipe: idle with no viewers; shutting down.");
164 }
165 }
166}
167
168async fn idle_watcher(
171 clients: std::sync::Arc<std::sync::atomic::AtomicUsize>,
172 timeout: Option<Duration>,
173) {
174 use std::sync::atomic::Ordering;
175 let Some(timeout) = timeout else {
176 std::future::pending::<()>().await;
177 return;
178 };
179 let mut idle_since = Some(std::time::Instant::now());
180 let mut tick = tokio::time::interval(Duration::from_secs(5));
181 loop {
182 tick.tick().await;
183 if clients.load(Ordering::SeqCst) > 0 {
184 idle_since = None;
185 } else {
186 let since = idle_since.get_or_insert_with(std::time::Instant::now);
187 if since.elapsed() >= timeout {
188 return;
189 }
190 }
191 }
192}
193
194fn open_browser(url: &str) {
196 #[cfg(target_os = "windows")]
197 let _ = std::process::Command::new("cmd")
198 .args(["/C", "start", "", url])
199 .spawn();
200 #[cfg(target_os = "macos")]
201 let _ = std::process::Command::new("open").arg(url).spawn();
202 #[cfg(all(unix, not(target_os = "macos")))]
203 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use axum::body::Body;
210 use axum::http::{Request, StatusCode};
211 use tower::ServiceExt; fn test_state(root: PathBuf) -> AppState {
214 let (tx, _rx) = broadcast::channel(8);
215 AppState {
216 current: Some(root),
217 tx,
218 clients: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
219 }
220 }
221
222 #[tokio::test]
223 async fn health_and_board_endpoints() {
224 let dir = tempfile::tempdir().unwrap();
225 let store = Store::init(dir.path(), "Daemon Test", chrono::Utc::now()).unwrap();
226 wipe_core::ops::create_ticket(
227 &store,
228 wipe_core::ops::NewTicket {
229 title: "Hello".into(),
230 ..Default::default()
231 },
232 "tester",
233 chrono::Utc::now(),
234 )
235 .unwrap();
236
237 let app = router(test_state(store.root().to_path_buf()));
238
239 let health = app
240 .clone()
241 .oneshot(
242 Request::builder()
243 .uri("/api/health")
244 .body(Body::empty())
245 .unwrap(),
246 )
247 .await
248 .unwrap();
249 assert_eq!(health.status(), StatusCode::OK);
250
251 let board = app
252 .oneshot(
253 Request::builder()
254 .uri("/api/board")
255 .body(Body::empty())
256 .unwrap(),
257 )
258 .await
259 .unwrap();
260 assert_eq!(board.status(), StatusCode::OK);
261 let bytes = axum::body::to_bytes(board.into_body(), 1 << 20)
262 .await
263 .unwrap();
264 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
265 assert_eq!(v["board"], "Daemon Test");
266 assert_eq!(v["lists"][0]["tickets"][0]["title"], "Hello");
267 }
268
269 use wipe_core::Store;
270
271 fn enc(s: &str) -> String {
273 s.bytes()
274 .map(|b| match b {
275 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
276 (b as char).to_string()
277 }
278 _ => format!("%{b:02X}"),
279 })
280 .collect()
281 }
282
283 async fn board_titles(app: &Router, project: &str) -> Vec<String> {
284 let res = app
285 .clone()
286 .oneshot(
287 Request::builder()
288 .uri(format!("/api/board?project={}", enc(project)))
289 .body(Body::empty())
290 .unwrap(),
291 )
292 .await
293 .unwrap();
294 let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
295 .await
296 .unwrap();
297 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
298 v["lists"]
299 .as_array()
300 .unwrap()
301 .iter()
302 .flat_map(|l| l["tickets"].as_array().unwrap().clone())
303 .map(|t| t["title"].as_str().unwrap().to_string())
304 .collect()
305 }
306
307 #[tokio::test]
310 async fn mutations_target_the_requested_project_not_the_served_one() {
311 let served = tempfile::tempdir().unwrap();
312 let other = tempfile::tempdir().unwrap();
313 let served_store = Store::init(served.path(), "Served", chrono::Utc::now()).unwrap();
314 let other_store = Store::init(other.path(), "Other", chrono::Utc::now()).unwrap();
315 let other_root = other_store.root().display().to_string();
316
317 let app = router(test_state(served_store.root().to_path_buf()));
319
320 let res = app
322 .clone()
323 .oneshot(
324 Request::builder()
325 .method("POST")
326 .uri(format!("/api/tickets?project={}", enc(&other_root)))
327 .header("content-type", "application/json")
328 .body(Body::from(r#"{"title":"lands in other"}"#))
329 .unwrap(),
330 )
331 .await
332 .unwrap();
333 assert_eq!(res.status(), StatusCode::OK);
334
335 assert_eq!(
337 board_titles(&app, &other_root).await,
338 vec!["lands in other".to_string()]
339 );
340 assert!(
341 board_titles(&app, &served_store.root().display().to_string())
342 .await
343 .is_empty()
344 );
345 }
346}