1use std::path::PathBuf;
4
5use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
6use axum::extract::{Multipart, Path, Query, State};
7use axum::http::{header, StatusCode};
8use axum::response::{IntoResponse, Response};
9use axum::Json;
10use chrono::Utc;
11use serde::Deserialize;
12use serde_json::{json, Value};
13use tokio::sync::broadcast;
14
15use wipe_core::model::{Board, IdentityKind, Ticket};
16use wipe_core::ops::{self, NewTicket, TicketPatch};
17use wipe_core::{git, Store};
18
19#[derive(Clone)]
21pub struct AppState {
22 pub current: PathBuf,
24 pub tx: broadcast::Sender<String>,
26}
27
28pub struct ApiError(anyhow::Error);
30
31impl<E: Into<anyhow::Error>> From<E> for ApiError {
32 fn from(e: E) -> Self {
33 ApiError(e.into())
34 }
35}
36
37impl IntoResponse for ApiError {
38 fn into_response(self) -> Response {
39 let body = Json(json!({ "ok": false, "error": self.0.to_string() }));
40 (StatusCode::BAD_REQUEST, body).into_response()
41 }
42}
43
44type ApiResult = Result<Json<Value>, ApiError>;
45
46#[derive(Debug, Deserialize)]
48pub struct ProjectQuery {
49 project: Option<String>,
50}
51
52fn store_for(state: &AppState, project: Option<String>) -> Result<Store, ApiError> {
53 let root = project
54 .map(PathBuf::from)
55 .unwrap_or_else(|| state.current.clone());
56 Ok(Store::open(root)?)
57}
58
59fn notify(state: &AppState) {
60 let _ = state.tx.send("changed".to_string());
61}
62
63fn resolve_actor(store: &Store, provided: Option<String>) -> String {
67 provided
68 .filter(|s| !s.trim().is_empty())
69 .or_else(|| git::config_identity(store.root()))
70 .unwrap_or_else(|| "someone".to_string())
71}
72
73fn board_json(board: &Board, view: &[(String, Vec<Ticket>)]) -> Value {
74 let lists: Vec<Value> = view
75 .iter()
76 .map(|(list_id, tickets)| {
77 let name = board
78 .list(list_id)
79 .map(|l| l.name.clone())
80 .unwrap_or_else(|| list_id.clone());
81 json!({ "list": list_id, "name": name, "tickets": tickets })
82 })
83 .collect();
84 json!({ "board": board.name, "lists": lists })
85}
86
87pub async fn health() -> Json<Value> {
91 Json(json!({ "ok": true, "service": "wipe-daemon", "version": env!("CARGO_PKG_VERSION") }))
92}
93
94pub async fn projects(State(state): State<AppState>) -> ApiResult {
96 crate::registry::register(&state.current);
97 Ok(Json(json!({ "projects": crate::registry::list() })))
98}
99
100pub async fn board(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
102 let store = store_for(&state, q.project)?;
103 let (board, view) = ops::board_view(&store)?;
104 Ok(Json(board_json(&board, &view)))
105}
106
107pub async fn history(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
109 let store = store_for(&state, q.project)?;
110 let commits = git::log(store.root(), Some(".wipe"), Some(200))?;
111 Ok(Json(json!({ "commits": commits })))
112}
113
114#[derive(Debug, Deserialize)]
116pub struct AtQuery {
117 project: Option<String>,
118 commit: String,
119}
120
121pub async fn board_at(State(state): State<AppState>, Query(q): Query<AtQuery>) -> ApiResult {
123 let store = store_for(&state, q.project)?;
124 let root = store.root();
125 let board_src = git::file_at_commit(root, &q.commit, ".wipe/board.json")?
126 .ok_or_else(|| ApiError(anyhow::anyhow!("no board at commit {}", q.commit)))?;
127 let board: Board = serde_json::from_str(&board_src)?;
128
129 let mut lists = Vec::with_capacity(board.lists.len());
130 for list in &board.lists {
131 let mut tickets = Vec::with_capacity(list.cards.len());
132 for id in &list.cards {
133 let rel = format!(".wipe/tickets/{id}.json");
134 if let Some(src) = git::file_at_commit(root, &q.commit, &rel)? {
135 if let Ok(t) = serde_json::from_str::<Ticket>(&src) {
136 tickets.push(t);
137 }
138 }
139 }
140 lists.push(json!({ "list": list.id, "name": list.name, "tickets": tickets }));
141 }
142 Ok(Json(
143 json!({ "board": board.name, "commit": q.commit, "lists": lists }),
144 ))
145}
146
147#[derive(Debug, Deserialize)]
151pub struct CreateTicketBody {
152 project: Option<String>,
153 title: String,
154 #[serde(default)]
155 body: Option<String>,
156 #[serde(default)]
157 priority: Option<String>,
158 #[serde(default)]
159 list: Option<String>,
160 #[serde(default)]
161 labels: Vec<String>,
162 #[serde(default)]
163 assignees: Vec<String>,
164 #[serde(default)]
165 actor: Option<String>,
166}
167
168pub async fn create_ticket(
170 State(state): State<AppState>,
171 Json(b): Json<CreateTicketBody>,
172) -> ApiResult {
173 let store = store_for(&state, b.project)?;
174 let actor = resolve_actor(&store, b.actor);
175 let spec = NewTicket {
176 title: b.title,
177 body: b.body,
178 priority: b.priority,
179 list: b.list,
180 labels: b.labels,
181 assignees: b.assignees,
182 };
183 let ticket = ops::create_ticket(&store, spec, &actor, Utc::now())?;
184 notify(&state);
185 Ok(Json(serde_json::to_value(ticket)?))
186}
187
188#[derive(Debug, Deserialize)]
190pub struct MoveBody {
191 project: Option<String>,
192 to: String,
193 #[serde(default)]
194 pos: Option<usize>,
195 #[serde(default)]
196 actor: Option<String>,
197}
198
199pub async fn move_ticket(
201 State(state): State<AppState>,
202 Path(id): Path<String>,
203 Json(b): Json<MoveBody>,
204) -> ApiResult {
205 let store = store_for(&state, b.project)?;
206 let actor = resolve_actor(&store, b.actor);
207 ops::move_ticket(&store, &id, &b.to, b.pos, &actor, Utc::now())?;
208 notify(&state);
209 Ok(Json(json!({ "ok": true, "id": id, "list": b.to })))
210}
211
212#[derive(Debug, Deserialize)]
214pub struct CommentBody {
215 project: Option<String>,
216 #[serde(default)]
217 author: Option<String>,
218 body: String,
219}
220
221pub async fn add_comment(
223 State(state): State<AppState>,
224 Path(id): Path<String>,
225 Json(b): Json<CommentBody>,
226) -> ApiResult {
227 let store = store_for(&state, b.project)?;
228 let author = b.author.unwrap_or_else(|| "ui".to_string());
229 let cid = ops::add_comment(&store, &id, &author, &b.body, Utc::now())?;
230 notify(&state);
231 Ok(Json(json!({ "ok": true, "ticket": id, "comment": cid })))
232}
233
234pub async fn definitions(
236 State(state): State<AppState>,
237 Query(q): Query<ProjectQuery>,
238) -> ApiResult {
239 let store = store_for(&state, q.project)?;
240 Ok(Json(serde_json::to_value(store.load_definitions()?)?))
241}
242
243#[derive(Debug, Deserialize)]
245pub struct LabelBody {
246 project: Option<String>,
247 name: String,
248 #[serde(default)]
249 color: Option<String>,
250 #[serde(default)]
251 description: Option<String>,
252}
253
254pub async fn create_label(State(state): State<AppState>, Json(b): Json<LabelBody>) -> ApiResult {
256 let store = store_for(&state, b.project)?;
257 let label = ops::create_label(&store, &b.name, b.color, b.description)?;
258 notify(&state);
259 Ok(Json(serde_json::to_value(label)?))
260}
261
262#[derive(Debug, Deserialize)]
264pub struct LabelColorBody {
265 project: Option<String>,
266 color: String,
267}
268
269pub async fn recolor_label(
271 State(state): State<AppState>,
272 Path(name): Path<String>,
273 Json(b): Json<LabelColorBody>,
274) -> ApiResult {
275 let store = store_for(&state, b.project)?;
276 let label = ops::set_label_color(&store, &name, &b.color)?;
277 notify(&state);
278 Ok(Json(serde_json::to_value(label)?))
279}
280
281pub async fn delete_label(
283 State(state): State<AppState>,
284 Path(name): Path<String>,
285 Query(q): Query<ProjectQuery>,
286) -> ApiResult {
287 let store = store_for(&state, q.project)?;
288 ops::delete_label(&store, &name, Utc::now())?;
289 notify(&state);
290 Ok(Json(json!({ "ok": true })))
291}
292
293#[derive(Debug, Deserialize)]
296pub struct PatchBody {
297 project: Option<String>,
298 #[serde(default)]
299 title: Option<String>,
300 #[serde(default)]
301 body: Option<String>,
302 #[serde(default)]
303 priority: Option<Option<String>>,
304 #[serde(default)]
305 labels: Option<Vec<String>>,
306 #[serde(default)]
307 assignees: Option<Vec<String>>,
308 #[serde(default)]
309 actor: Option<String>,
310}
311
312pub async fn patch_ticket(
314 State(state): State<AppState>,
315 Path(id): Path<String>,
316 Json(b): Json<PatchBody>,
317) -> ApiResult {
318 let store = store_for(&state, b.project)?;
319 let actor = resolve_actor(&store, b.actor);
320 let patch = TicketPatch {
321 title: b.title,
322 body: b.body,
323 priority: b.priority,
324 labels: b.labels,
325 assignees: b.assignees,
326 };
327 let ticket = ops::update_ticket(&store, &id, patch, &actor, Utc::now())?;
328 notify(&state);
329 Ok(Json(serde_json::to_value(ticket)?))
330}
331
332pub async fn identities(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
334 let store = store_for(&state, q.project)?;
335 Ok(Json(json!({ "identities": ops::list_identities(&store)? })))
336}
337
338#[derive(Debug, Deserialize)]
340pub struct IdentityBody {
341 project: Option<String>,
342 display_name: String,
343 #[serde(default)]
344 kind: Option<String>,
345}
346
347pub async fn put_identity(
349 State(state): State<AppState>,
350 Path(id): Path<String>,
351 Json(b): Json<IdentityBody>,
352) -> ApiResult {
353 let store = store_for(&state, b.project)?;
354 let kind = match b.kind.as_deref() {
355 Some("agent") => Some(IdentityKind::Agent),
356 Some("human") => Some(IdentityKind::Human),
357 _ => None,
358 };
359 let ident = ops::upsert_identity(&store, &id, &b.display_name, kind)?;
360 notify(&state);
361 Ok(Json(serde_json::to_value(ident)?))
362}
363
364pub async fn delete_identity(
367 State(state): State<AppState>,
368 Path(id): Path<String>,
369 Query(q): Query<ProjectQuery>,
370) -> ApiResult {
371 let store = store_for(&state, q.project)?;
372 ops::delete_identity(&store, &id)?;
373 notify(&state);
374 Ok(Json(json!({ "ok": true, "id": id })))
375}
376
377pub async fn upload_attachment(
379 State(state): State<AppState>,
380 Path(id): Path<String>,
381 Query(q): Query<ProjectQuery>,
382 mut multipart: Multipart,
383) -> ApiResult {
384 let store = store_for(&state, q.project)?;
385 let actor = resolve_actor(&store, None);
386 let max = store.load_settings()?.max_attachment_mb * 1024 * 1024;
387
388 while let Some(field) = multipart
389 .next_field()
390 .await
391 .map_err(|e| ApiError(anyhow::anyhow!("bad upload: {e}")))?
392 {
393 if field.name() != Some("file") {
394 continue;
395 }
396 let name = field.file_name().unwrap_or("file").to_string();
397 let mime = field
398 .content_type()
399 .map(|s| s.to_string())
400 .unwrap_or_else(|| "application/octet-stream".to_string());
401 let bytes = field
402 .bytes()
403 .await
404 .map_err(|e| ApiError(anyhow::anyhow!("read upload: {e}")))?;
405 if bytes.len() as u64 > max {
406 return Err(ApiError(anyhow::anyhow!(
407 "attachment is {:.1} MB, over the {} MB limit",
408 bytes.len() as f64 / 1_048_576.0,
409 max / 1024 / 1024
410 )));
411 }
412 let att = ops::add_attachment(&store, &id, &name, &bytes, &mime, &actor, Utc::now())?;
413 notify(&state);
414 return Ok(Json(serde_json::to_value(att)?));
415 }
416 Err(ApiError(anyhow::anyhow!("no `file` field in upload")))
417}
418
419#[derive(Debug, Deserialize)]
421pub struct DetachBody {
422 project: Option<String>,
423 path: String,
424 #[serde(default)]
425 actor: Option<String>,
426}
427
428pub async fn delete_attachment(
430 State(state): State<AppState>,
431 Path(id): Path<String>,
432 Json(b): Json<DetachBody>,
433) -> ApiResult {
434 let store = store_for(&state, b.project)?;
435 let actor = resolve_actor(&store, b.actor);
436 ops::remove_attachment(&store, &id, &b.path, &actor, Utc::now())?;
437 notify(&state);
438 Ok(Json(json!({ "ok": true })))
439}
440
441pub async fn serve_media(
443 State(state): State<AppState>,
444 Query(q): Query<ProjectQuery>,
445 Path(path): Path<String>,
446) -> Response {
447 let store = match store_for(&state, q.project) {
448 Ok(s) => s,
449 Err(e) => return e.into_response(),
450 };
451 let rel = path.replace('\\', "/");
452 if rel.contains("..") {
453 return (StatusCode::BAD_REQUEST, "invalid path").into_response();
454 }
455 let full = store.root().join(&rel);
456 let within = std::fs::canonicalize(store.root())
458 .ok()
459 .zip(std::fs::canonicalize(&full).ok())
460 .map(|(root, target)| target.starts_with(root))
461 .unwrap_or(false);
462 if !within {
463 return (StatusCode::NOT_FOUND, "not found").into_response();
464 }
465 match std::fs::read(&full) {
466 Ok(bytes) => {
467 let mime = mime_guess::from_path(&full).first_or_octet_stream();
468 ([(header::CONTENT_TYPE, mime.as_ref())], bytes).into_response()
469 }
470 Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(),
471 }
472}
473
474pub async fn graph(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
476 let store = store_for(&state, q.project)?;
477 let commits = git::graph(store.root(), Some(300))?;
478 Ok(Json(json!({ "commits": commits })))
479}
480
481#[derive(Debug, Deserialize)]
483pub struct AddListBody {
484 project: Option<String>,
485 name: String,
486}
487
488pub async fn add_list(State(state): State<AppState>, Json(b): Json<AddListBody>) -> ApiResult {
490 let store = store_for(&state, b.project)?;
491 let id = ops::add_list(&store, &b.name, Utc::now())?;
492 notify(&state);
493 Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
494}
495
496#[derive(Debug, Deserialize)]
498pub struct RenameListBody {
499 project: Option<String>,
500 name: String,
501}
502
503pub async fn rename_list(
505 State(state): State<AppState>,
506 Path(id): Path<String>,
507 Json(b): Json<RenameListBody>,
508) -> ApiResult {
509 let store = store_for(&state, b.project)?;
510 ops::rename_list(&store, &id, &b.name, Utc::now())?;
511 notify(&state);
512 Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
513}
514
515#[derive(Debug, Deserialize)]
517pub struct MoveListBody {
518 project: Option<String>,
519 index: usize,
520}
521
522pub async fn move_list(
524 State(state): State<AppState>,
525 Path(id): Path<String>,
526 Json(b): Json<MoveListBody>,
527) -> ApiResult {
528 let store = store_for(&state, b.project)?;
529 ops::move_list(&store, &id, b.index, Utc::now())?;
530 notify(&state);
531 Ok(Json(json!({ "ok": true, "id": id, "index": b.index })))
532}
533
534#[derive(Debug, Deserialize)]
536pub struct RemoveListQuery {
537 project: Option<String>,
538 #[serde(default)]
539 force: bool,
540}
541
542pub async fn remove_list(
544 State(state): State<AppState>,
545 Path(id): Path<String>,
546 Query(q): Query<RemoveListQuery>,
547) -> ApiResult {
548 let store = store_for(&state, q.project)?;
549 ops::remove_list(&store, &id, q.force, Utc::now())?;
550 notify(&state);
551 Ok(Json(json!({ "ok": true, "id": id })))
552}
553
554pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
558 ws.on_upgrade(move |socket| ws_loop(socket, state))
559}
560
561async fn ws_loop(mut socket: WebSocket, state: AppState) {
562 let mut rx = state.tx.subscribe();
563 let _ = socket.send(Message::Text("connected".into())).await;
564 loop {
565 tokio::select! {
566 msg = rx.recv() => match msg {
567 Ok(m) => {
568 if socket.send(Message::Text(m.into())).await.is_err() {
569 break;
570 }
571 }
572 Err(broadcast::error::RecvError::Closed) => break,
573 Err(broadcast::error::RecvError::Lagged(_)) => {}
574 },
575 incoming = socket.recv() => match incoming {
576 Some(Ok(_)) => {}
577 _ => break,
578 },
579 }
580 }
581}