1use std::path::PathBuf;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::Arc;
6
7use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
8use axum::extract::{Multipart, Path, Query, State};
9use axum::http::{header, StatusCode};
10use axum::response::{IntoResponse, Response};
11use axum::Json;
12use chrono::Utc;
13use serde::Deserialize;
14use serde_json::{json, Value};
15use tokio::sync::broadcast;
16
17use wipe_core::forum::{self, NewReply, NewThread, SearchQuery};
18use wipe_core::model::{Board, IdentityKind, Ticket};
19use wipe_core::ops::{self, NewTicket, TicketPatch};
20use wipe_core::{git, Store};
21
22#[derive(Clone)]
24pub struct AppState {
25 pub current: PathBuf,
27 pub tx: broadcast::Sender<String>,
29 pub clients: Arc<AtomicUsize>,
32}
33
34pub struct ApiError(anyhow::Error);
36
37impl<E: Into<anyhow::Error>> From<E> for ApiError {
38 fn from(e: E) -> Self {
39 ApiError(e.into())
40 }
41}
42
43impl IntoResponse for ApiError {
44 fn into_response(self) -> Response {
45 let body = Json(json!({ "ok": false, "error": self.0.to_string() }));
46 (StatusCode::BAD_REQUEST, body).into_response()
47 }
48}
49
50type ApiResult = Result<Json<Value>, ApiError>;
51
52#[derive(Debug, Deserialize)]
54pub struct ProjectQuery {
55 project: Option<String>,
56}
57
58fn store_for(state: &AppState, project: Option<String>) -> Result<Store, ApiError> {
59 let root = project
60 .map(PathBuf::from)
61 .unwrap_or_else(|| state.current.clone());
62 Ok(Store::open(root)?)
63}
64
65fn notify(state: &AppState) {
66 let _ = state.tx.send("changed".to_string());
67}
68
69fn resolve_actor(store: &Store, provided: Option<String>) -> String {
73 provided
74 .filter(|s| !s.trim().is_empty())
75 .or_else(|| git::config_identity(store.root()))
76 .unwrap_or_else(|| "someone".to_string())
77}
78
79fn board_json(board: &Board, view: &[(String, Vec<Ticket>)]) -> Value {
80 let lists: Vec<Value> = view
81 .iter()
82 .map(|(list_id, tickets)| {
83 let name = board
84 .list(list_id)
85 .map(|l| l.name.clone())
86 .unwrap_or_else(|| list_id.clone());
87 json!({ "list": list_id, "name": name, "tickets": tickets })
88 })
89 .collect();
90 json!({ "board": board.name, "lists": lists })
91}
92
93pub async fn health() -> Json<Value> {
97 Json(json!({ "ok": true, "service": "wipe-daemon", "version": env!("CARGO_PKG_VERSION") }))
98}
99
100pub async fn app_config() -> Json<Value> {
103 let g = wipe_core::GlobalConfig::load();
104 Json(json!({ "accent": g.ui_accent, "theme": g.ui_theme }))
105}
106
107pub async fn projects(State(state): State<AppState>) -> ApiResult {
109 crate::registry::register(&state.current);
110 Ok(Json(json!({ "projects": crate::registry::list() })))
111}
112
113pub async fn board(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
115 let store = store_for(&state, q.project)?;
116 let (board, view) = ops::board_view(&store)?;
117 Ok(Json(board_json(&board, &view)))
118}
119
120pub async fn history(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
122 let store = store_for(&state, q.project)?;
123 let commits = git::log(store.root(), Some(".wipe"), Some(200))?;
124 Ok(Json(json!({ "commits": commits })))
125}
126
127#[derive(Debug, Deserialize)]
129pub struct AtQuery {
130 project: Option<String>,
131 commit: String,
132}
133
134pub async fn board_at(State(state): State<AppState>, Query(q): Query<AtQuery>) -> ApiResult {
136 let store = store_for(&state, q.project)?;
137 let root = store.root();
138 let board_src = git::file_at_commit(root, &q.commit, ".wipe/board.json")?
139 .ok_or_else(|| ApiError(anyhow::anyhow!("no board at commit {}", q.commit)))?;
140 let board: Board = serde_json::from_str(&board_src)?;
141
142 let mut lists = Vec::with_capacity(board.lists.len());
143 for list in &board.lists {
144 let mut tickets = Vec::with_capacity(list.cards.len());
145 for id in &list.cards {
146 let rel = format!(".wipe/tickets/{id}.json");
147 if let Some(src) = git::file_at_commit(root, &q.commit, &rel)? {
148 if let Ok(t) = serde_json::from_str::<Ticket>(&src) {
149 tickets.push(t);
150 }
151 }
152 }
153 lists.push(json!({ "list": list.id, "name": list.name, "tickets": tickets }));
154 }
155 Ok(Json(
156 json!({ "board": board.name, "commit": q.commit, "lists": lists }),
157 ))
158}
159
160#[derive(Debug, Deserialize)]
164pub struct CreateTicketBody {
165 project: Option<String>,
166 title: String,
167 #[serde(default)]
168 body: Option<String>,
169 #[serde(default)]
170 priority: Option<String>,
171 #[serde(default)]
172 list: Option<String>,
173 #[serde(default)]
174 labels: Vec<String>,
175 #[serde(default)]
176 assignees: Vec<String>,
177 #[serde(default)]
178 actor: Option<String>,
179}
180
181pub async fn create_ticket(
183 State(state): State<AppState>,
184 Json(b): Json<CreateTicketBody>,
185) -> ApiResult {
186 let store = store_for(&state, b.project)?;
187 let actor = resolve_actor(&store, b.actor);
188 let spec = NewTicket {
189 title: b.title,
190 body: b.body,
191 priority: b.priority,
192 list: b.list,
193 labels: b.labels,
194 assignees: b.assignees,
195 };
196 let ticket = ops::create_ticket(&store, spec, &actor, Utc::now())?;
197 notify(&state);
198 Ok(Json(serde_json::to_value(ticket)?))
199}
200
201#[derive(Debug, Deserialize)]
203pub struct MoveBody {
204 project: Option<String>,
205 to: String,
206 #[serde(default)]
207 pos: Option<usize>,
208 #[serde(default)]
209 actor: Option<String>,
210}
211
212pub async fn move_ticket(
214 State(state): State<AppState>,
215 Path(id): Path<String>,
216 Json(b): Json<MoveBody>,
217) -> ApiResult {
218 let store = store_for(&state, b.project)?;
219 let actor = resolve_actor(&store, b.actor);
220 ops::move_ticket(&store, &id, &b.to, b.pos, &actor, Utc::now())?;
221 notify(&state);
222 Ok(Json(json!({ "ok": true, "id": id, "list": b.to })))
223}
224
225#[derive(Debug, Deserialize)]
227pub struct CommentBody {
228 project: Option<String>,
229 #[serde(default)]
230 author: Option<String>,
231 body: String,
232}
233
234pub async fn add_comment(
236 State(state): State<AppState>,
237 Path(id): Path<String>,
238 Json(b): Json<CommentBody>,
239) -> ApiResult {
240 let store = store_for(&state, b.project)?;
241 let author = b.author.unwrap_or_else(|| "ui".to_string());
242 let cid = ops::add_comment(&store, &id, &author, &b.body, Utc::now())?;
243 notify(&state);
244 Ok(Json(json!({ "ok": true, "ticket": id, "comment": cid })))
245}
246
247pub async fn definitions(
249 State(state): State<AppState>,
250 Query(q): Query<ProjectQuery>,
251) -> ApiResult {
252 let store = store_for(&state, q.project)?;
253 Ok(Json(serde_json::to_value(store.load_definitions()?)?))
254}
255
256#[derive(Debug, Deserialize)]
258pub struct LabelBody {
259 project: Option<String>,
260 name: String,
261 #[serde(default)]
262 color: Option<String>,
263 #[serde(default)]
264 description: Option<String>,
265}
266
267pub async fn create_label(State(state): State<AppState>, Json(b): Json<LabelBody>) -> ApiResult {
269 let store = store_for(&state, b.project)?;
270 let label = ops::create_label(&store, &b.name, b.color, b.description)?;
271 notify(&state);
272 Ok(Json(serde_json::to_value(label)?))
273}
274
275#[derive(Debug, Deserialize)]
277pub struct LabelColorBody {
278 project: Option<String>,
279 color: String,
280}
281
282pub async fn recolor_label(
284 State(state): State<AppState>,
285 Path(name): Path<String>,
286 Json(b): Json<LabelColorBody>,
287) -> ApiResult {
288 let store = store_for(&state, b.project)?;
289 let label = ops::set_label_color(&store, &name, &b.color)?;
290 notify(&state);
291 Ok(Json(serde_json::to_value(label)?))
292}
293
294pub async fn delete_label(
296 State(state): State<AppState>,
297 Path(name): Path<String>,
298 Query(q): Query<ProjectQuery>,
299) -> ApiResult {
300 let store = store_for(&state, q.project)?;
301 ops::delete_label(&store, &name, Utc::now())?;
302 notify(&state);
303 Ok(Json(json!({ "ok": true })))
304}
305
306#[derive(Debug, Deserialize)]
309pub struct PatchBody {
310 project: Option<String>,
311 #[serde(default)]
312 title: Option<String>,
313 #[serde(default)]
314 body: Option<String>,
315 #[serde(default)]
316 priority: Option<Option<String>>,
317 #[serde(default)]
318 labels: Option<Vec<String>>,
319 #[serde(default)]
320 assignees: Option<Vec<String>>,
321 #[serde(default)]
322 actor: Option<String>,
323}
324
325pub async fn patch_ticket(
327 State(state): State<AppState>,
328 Path(id): Path<String>,
329 Json(b): Json<PatchBody>,
330) -> ApiResult {
331 let store = store_for(&state, b.project)?;
332 let actor = resolve_actor(&store, b.actor);
333 let patch = TicketPatch {
334 title: b.title,
335 body: b.body,
336 priority: b.priority,
337 labels: b.labels,
338 assignees: b.assignees,
339 };
340 let ticket = ops::update_ticket(&store, &id, patch, &actor, Utc::now())?;
341 notify(&state);
342 Ok(Json(serde_json::to_value(ticket)?))
343}
344
345pub async fn identities(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
347 let store = store_for(&state, q.project)?;
348 Ok(Json(json!({ "identities": ops::list_identities(&store)? })))
349}
350
351#[derive(Debug, Deserialize)]
353pub struct IdentityBody {
354 project: Option<String>,
355 display_name: String,
356 #[serde(default)]
357 kind: Option<String>,
358}
359
360pub async fn put_identity(
362 State(state): State<AppState>,
363 Path(id): Path<String>,
364 Json(b): Json<IdentityBody>,
365) -> ApiResult {
366 let store = store_for(&state, b.project)?;
367 let kind = match b.kind.as_deref() {
368 Some("agent") => Some(IdentityKind::Agent),
369 Some("human") => Some(IdentityKind::Human),
370 _ => None,
371 };
372 let ident = ops::upsert_identity(&store, &id, &b.display_name, kind)?;
373 notify(&state);
374 Ok(Json(serde_json::to_value(ident)?))
375}
376
377pub async fn delete_identity(
380 State(state): State<AppState>,
381 Path(id): Path<String>,
382 Query(q): Query<ProjectQuery>,
383) -> ApiResult {
384 let store = store_for(&state, q.project)?;
385 ops::delete_identity(&store, &id)?;
386 notify(&state);
387 Ok(Json(json!({ "ok": true, "id": id })))
388}
389
390pub async fn upload_attachment(
392 State(state): State<AppState>,
393 Path(id): Path<String>,
394 Query(q): Query<ProjectQuery>,
395 mut multipart: Multipart,
396) -> ApiResult {
397 let store = store_for(&state, q.project)?;
398 let actor = resolve_actor(&store, None);
399 let max = store.load_settings()?.max_attachment_mb * 1024 * 1024;
400
401 while let Some(field) = multipart
402 .next_field()
403 .await
404 .map_err(|e| ApiError(anyhow::anyhow!("bad upload: {e}")))?
405 {
406 if field.name() != Some("file") {
407 continue;
408 }
409 let name = field.file_name().unwrap_or("file").to_string();
410 let mime = field
411 .content_type()
412 .map(|s| s.to_string())
413 .unwrap_or_else(|| "application/octet-stream".to_string());
414 let bytes = field
415 .bytes()
416 .await
417 .map_err(|e| ApiError(anyhow::anyhow!("read upload: {e}")))?;
418 if bytes.len() as u64 > max {
419 return Err(ApiError(anyhow::anyhow!(
420 "attachment is {:.1} MB, over the {} MB limit",
421 bytes.len() as f64 / 1_048_576.0,
422 max / 1024 / 1024
423 )));
424 }
425 let att = ops::add_attachment(&store, &id, &name, &bytes, &mime, &actor, Utc::now())?;
426 notify(&state);
427 return Ok(Json(serde_json::to_value(att)?));
428 }
429 Err(ApiError(anyhow::anyhow!("no `file` field in upload")))
430}
431
432#[derive(Debug, Deserialize)]
434pub struct DetachBody {
435 project: Option<String>,
436 path: String,
437 #[serde(default)]
438 actor: Option<String>,
439}
440
441pub async fn delete_attachment(
443 State(state): State<AppState>,
444 Path(id): Path<String>,
445 Json(b): Json<DetachBody>,
446) -> ApiResult {
447 let store = store_for(&state, b.project)?;
448 let actor = resolve_actor(&store, b.actor);
449 ops::remove_attachment(&store, &id, &b.path, &actor, Utc::now())?;
450 notify(&state);
451 Ok(Json(json!({ "ok": true })))
452}
453
454pub async fn serve_media(
456 State(state): State<AppState>,
457 Query(q): Query<ProjectQuery>,
458 Path(path): Path<String>,
459) -> Response {
460 let store = match store_for(&state, q.project) {
461 Ok(s) => s,
462 Err(e) => return e.into_response(),
463 };
464 let rel = path.replace('\\', "/");
465 if rel.contains("..") {
466 return (StatusCode::BAD_REQUEST, "invalid path").into_response();
467 }
468 let full = store.root().join(&rel);
469 let within = std::fs::canonicalize(store.root())
471 .ok()
472 .zip(std::fs::canonicalize(&full).ok())
473 .map(|(root, target)| target.starts_with(root))
474 .unwrap_or(false);
475 if !within {
476 return (StatusCode::NOT_FOUND, "not found").into_response();
477 }
478 match std::fs::read(&full) {
479 Ok(bytes) => {
480 let mime = mime_guess::from_path(&full).first_or_octet_stream();
481 ([(header::CONTENT_TYPE, mime.as_ref())], bytes).into_response()
482 }
483 Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(),
484 }
485}
486
487pub async fn graph(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
489 let store = store_for(&state, q.project)?;
490 let commits = git::graph(store.root(), Some(300))?;
491 Ok(Json(json!({ "commits": commits })))
492}
493
494#[derive(Debug, Deserialize)]
496pub struct AddListBody {
497 project: Option<String>,
498 name: String,
499}
500
501pub async fn add_list(State(state): State<AppState>, Json(b): Json<AddListBody>) -> ApiResult {
503 let store = store_for(&state, b.project)?;
504 let id = ops::add_list(&store, &b.name, Utc::now())?;
505 notify(&state);
506 Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
507}
508
509#[derive(Debug, Deserialize)]
511pub struct RenameListBody {
512 project: Option<String>,
513 name: String,
514}
515
516pub async fn rename_list(
518 State(state): State<AppState>,
519 Path(id): Path<String>,
520 Json(b): Json<RenameListBody>,
521) -> ApiResult {
522 let store = store_for(&state, b.project)?;
523 ops::rename_list(&store, &id, &b.name, Utc::now())?;
524 notify(&state);
525 Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
526}
527
528#[derive(Debug, Deserialize)]
530pub struct MoveListBody {
531 project: Option<String>,
532 index: usize,
533}
534
535pub async fn move_list(
537 State(state): State<AppState>,
538 Path(id): Path<String>,
539 Json(b): Json<MoveListBody>,
540) -> ApiResult {
541 let store = store_for(&state, b.project)?;
542 ops::move_list(&store, &id, b.index, Utc::now())?;
543 notify(&state);
544 Ok(Json(json!({ "ok": true, "id": id, "index": b.index })))
545}
546
547#[derive(Debug, Deserialize)]
549pub struct RemoveListQuery {
550 project: Option<String>,
551 #[serde(default)]
552 force: bool,
553}
554
555pub async fn remove_list(
557 State(state): State<AppState>,
558 Path(id): Path<String>,
559 Query(q): Query<RemoveListQuery>,
560) -> ApiResult {
561 let store = store_for(&state, q.project)?;
562 ops::remove_list(&store, &id, q.force, Utc::now())?;
563 notify(&state);
564 Ok(Json(json!({ "ok": true, "id": id })))
565}
566
567pub async fn forum_list(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
571 let store = store_for(&state, q.project)?;
572 let all = forum::index(&store)?;
573 let threads: Vec<Value> = all
574 .iter()
575 .filter(|p| p.depth == 0)
576 .map(|r| {
577 let posts = all.iter().filter(|p| p.thread_id == r.thread_id).count();
578 json!({
579 "id": r.thread_id,
580 "title": r.thread_title,
581 "author": r.author,
582 "labels": r.labels,
583 "posts": posts,
584 "created": r.created,
585 })
586 })
587 .collect();
588 Ok(Json(json!({ "threads": threads })))
589}
590
591pub async fn forum_thread(
593 State(state): State<AppState>,
594 Path(id): Path<String>,
595 Query(q): Query<ProjectQuery>,
596) -> ApiResult {
597 let store = store_for(&state, q.project)?;
598 Ok(Json(serde_json::to_value(forum::get_thread(&store, &id)?)?))
599}
600
601#[derive(Debug, Deserialize)]
603pub struct ForumPostBody {
604 project: Option<String>,
605 title: String,
606 #[serde(default)]
607 body: String,
608 #[serde(default)]
609 labels: Vec<String>,
610 #[serde(default)]
611 refs: Vec<String>,
612 #[serde(default)]
613 actor: Option<String>,
614}
615
616pub async fn forum_create(
618 State(state): State<AppState>,
619 Json(b): Json<ForumPostBody>,
620) -> ApiResult {
621 let store = store_for(&state, b.project)?;
622 let actor = resolve_actor(&store, b.actor);
623 let t = forum::create_thread(
624 &store,
625 NewThread {
626 title: b.title,
627 body: b.body,
628 labels: b.labels,
629 refs: b.refs,
630 attachments: Vec::new(),
631 },
632 &actor,
633 Utc::now(),
634 )?;
635 notify(&state);
636 Ok(Json(serde_json::to_value(t)?))
637}
638
639#[derive(Debug, Deserialize)]
641pub struct ForumReplyBody {
642 project: Option<String>,
643 body: String,
644 #[serde(default)]
645 labels: Vec<String>,
646 #[serde(default)]
647 refs: Vec<String>,
648 #[serde(default)]
649 actor: Option<String>,
650}
651
652pub async fn forum_reply(
654 State(state): State<AppState>,
655 Path(id): Path<String>,
656 Json(b): Json<ForumReplyBody>,
657) -> ApiResult {
658 let store = store_for(&state, b.project)?;
659 let actor = resolve_actor(&store, b.actor);
660 let child = forum::reply(
661 &store,
662 &id,
663 NewReply {
664 body: b.body,
665 labels: b.labels,
666 refs: b.refs,
667 attachments: Vec::new(),
668 },
669 &actor,
670 Utc::now(),
671 )?;
672 notify(&state);
673 Ok(Json(json!({ "ok": true, "id": child, "parent": id })))
674}
675
676#[derive(Debug, Deserialize)]
678pub struct ForumEditBody {
679 project: Option<String>,
680 body: String,
681}
682
683pub async fn forum_edit(
685 State(state): State<AppState>,
686 Path(id): Path<String>,
687 Json(b): Json<ForumEditBody>,
688) -> ApiResult {
689 let store = store_for(&state, b.project)?;
690 forum::edit_post(&store, &id, &b.body, Utc::now())?;
691 notify(&state);
692 Ok(Json(json!({ "ok": true, "id": id })))
693}
694
695pub async fn forum_delete(
697 State(state): State<AppState>,
698 Path(id): Path<String>,
699 Query(q): Query<ProjectQuery>,
700) -> ApiResult {
701 let store = store_for(&state, q.project)?;
702 forum::delete_post(&store, &id, Utc::now())?;
703 notify(&state);
704 Ok(Json(json!({ "ok": true, "id": id })))
705}
706
707#[derive(Debug, Deserialize)]
709pub struct ForumSearchParams {
710 project: Option<String>,
711 #[serde(default)]
712 q: Option<String>,
713 #[serde(default)]
714 author: Option<String>,
715 #[serde(default)]
716 label: Option<String>,
717 #[serde(default)]
718 scope: Option<String>,
719 #[serde(default)]
720 titles: Option<bool>,
721 #[serde(default)]
722 depth: Option<usize>,
723 #[serde(default)]
724 limit: Option<usize>,
725}
726
727pub async fn forum_search(
729 State(state): State<AppState>,
730 Query(p): Query<ForumSearchParams>,
731) -> ApiResult {
732 let store = store_for(&state, p.project)?;
733 let pattern = match p.q.as_deref() {
734 Some(s) if !s.trim().is_empty() => Some(forum::compile_pattern(s, true)?),
735 _ => None,
736 };
737 let query = SearchQuery {
738 pattern,
739 author: p.author,
740 labels: p.label.into_iter().collect(),
741 scope: p.scope,
742 max_depth: p.depth,
743 titles_only: p.titles.unwrap_or(false),
744 limit: p.limit,
745 };
746 Ok(Json(json!({ "posts": forum::search(&store, &query)? })))
747}
748
749pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
753 ws.on_upgrade(move |socket| ws_loop(socket, state))
754}
755
756struct ClientGuard(Arc<AtomicUsize>);
758impl Drop for ClientGuard {
759 fn drop(&mut self) {
760 self.0.fetch_sub(1, Ordering::SeqCst);
761 }
762}
763
764async fn ws_loop(mut socket: WebSocket, state: AppState) {
765 let mut rx = state.tx.subscribe();
766 state.clients.fetch_add(1, Ordering::SeqCst);
769 let _guard = ClientGuard(state.clients.clone());
770 let _ = socket.send(Message::Text("connected".into())).await;
771 loop {
772 tokio::select! {
773 msg = rx.recv() => match msg {
774 Ok(m) => {
775 if socket.send(Message::Text(m.into())).await.is_err() {
776 break;
777 }
778 }
779 Err(broadcast::error::RecvError::Closed) => break,
780 Err(broadcast::error::RecvError::Lagged(_)) => {}
781 },
782 incoming = socket.recv() => match incoming {
783 Some(Ok(_)) => {}
784 _ => break,
785 },
786 }
787 }
788}