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 board_json(board: &Board, view: &[(String, Vec<Ticket>)]) -> Value {
64 let lists: Vec<Value> = view
65 .iter()
66 .map(|(list_id, tickets)| {
67 let name = board
68 .list(list_id)
69 .map(|l| l.name.clone())
70 .unwrap_or_else(|| list_id.clone());
71 json!({ "list": list_id, "name": name, "tickets": tickets })
72 })
73 .collect();
74 json!({ "board": board.name, "lists": lists })
75}
76
77pub async fn health() -> Json<Value> {
81 Json(json!({ "ok": true, "service": "wipe-daemon", "version": env!("CARGO_PKG_VERSION") }))
82}
83
84pub async fn projects(State(state): State<AppState>) -> ApiResult {
86 crate::registry::register(&state.current);
87 Ok(Json(json!({ "projects": crate::registry::list() })))
88}
89
90pub async fn board(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
92 let store = store_for(&state, q.project)?;
93 let (board, view) = ops::board_view(&store)?;
94 Ok(Json(board_json(&board, &view)))
95}
96
97pub async fn history(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
99 let store = store_for(&state, q.project)?;
100 let commits = git::log(store.root(), Some(".wipe"), Some(200))?;
101 Ok(Json(json!({ "commits": commits })))
102}
103
104#[derive(Debug, Deserialize)]
106pub struct AtQuery {
107 project: Option<String>,
108 commit: String,
109}
110
111pub async fn board_at(State(state): State<AppState>, Query(q): Query<AtQuery>) -> ApiResult {
113 let store = store_for(&state, q.project)?;
114 let root = store.root();
115 let board_src = git::file_at_commit(root, &q.commit, ".wipe/board.json")?
116 .ok_or_else(|| ApiError(anyhow::anyhow!("no board at commit {}", q.commit)))?;
117 let board: Board = serde_json::from_str(&board_src)?;
118
119 let mut lists = Vec::with_capacity(board.lists.len());
120 for list in &board.lists {
121 let mut tickets = Vec::with_capacity(list.cards.len());
122 for id in &list.cards {
123 let rel = format!(".wipe/tickets/{id}.json");
124 if let Some(src) = git::file_at_commit(root, &q.commit, &rel)? {
125 if let Ok(t) = serde_json::from_str::<Ticket>(&src) {
126 tickets.push(t);
127 }
128 }
129 }
130 lists.push(json!({ "list": list.id, "name": list.name, "tickets": tickets }));
131 }
132 Ok(Json(
133 json!({ "board": board.name, "commit": q.commit, "lists": lists }),
134 ))
135}
136
137#[derive(Debug, Deserialize)]
141pub struct CreateTicketBody {
142 project: Option<String>,
143 title: String,
144 #[serde(default)]
145 body: Option<String>,
146 #[serde(default)]
147 priority: Option<String>,
148 #[serde(default)]
149 list: Option<String>,
150 #[serde(default)]
151 labels: Vec<String>,
152 #[serde(default)]
153 assignees: Vec<String>,
154}
155
156pub async fn create_ticket(
158 State(state): State<AppState>,
159 Json(b): Json<CreateTicketBody>,
160) -> ApiResult {
161 let store = store_for(&state, b.project)?;
162 let spec = NewTicket {
163 title: b.title,
164 body: b.body,
165 priority: b.priority,
166 list: b.list,
167 labels: b.labels,
168 assignees: b.assignees,
169 };
170 let ticket = ops::create_ticket(&store, spec, Utc::now())?;
171 notify(&state);
172 Ok(Json(serde_json::to_value(ticket)?))
173}
174
175#[derive(Debug, Deserialize)]
177pub struct MoveBody {
178 project: Option<String>,
179 to: String,
180 #[serde(default)]
181 pos: Option<usize>,
182}
183
184pub async fn move_ticket(
186 State(state): State<AppState>,
187 Path(id): Path<String>,
188 Json(b): Json<MoveBody>,
189) -> ApiResult {
190 let store = store_for(&state, b.project)?;
191 ops::move_ticket(&store, &id, &b.to, b.pos, Utc::now())?;
192 notify(&state);
193 Ok(Json(json!({ "ok": true, "id": id, "list": b.to })))
194}
195
196#[derive(Debug, Deserialize)]
198pub struct CommentBody {
199 project: Option<String>,
200 #[serde(default)]
201 author: Option<String>,
202 body: String,
203}
204
205pub async fn add_comment(
207 State(state): State<AppState>,
208 Path(id): Path<String>,
209 Json(b): Json<CommentBody>,
210) -> ApiResult {
211 let store = store_for(&state, b.project)?;
212 let author = b.author.unwrap_or_else(|| "ui".to_string());
213 let cid = ops::add_comment(&store, &id, &author, &b.body, Utc::now())?;
214 notify(&state);
215 Ok(Json(json!({ "ok": true, "ticket": id, "comment": cid })))
216}
217
218pub async fn definitions(
220 State(state): State<AppState>,
221 Query(q): Query<ProjectQuery>,
222) -> ApiResult {
223 let store = store_for(&state, q.project)?;
224 Ok(Json(serde_json::to_value(store.load_definitions()?)?))
225}
226
227#[derive(Debug, Deserialize)]
229pub struct LabelBody {
230 project: Option<String>,
231 name: String,
232 #[serde(default)]
233 color: Option<String>,
234 #[serde(default)]
235 description: Option<String>,
236}
237
238pub async fn create_label(State(state): State<AppState>, Json(b): Json<LabelBody>) -> ApiResult {
240 let store = store_for(&state, b.project)?;
241 let label = ops::create_label(&store, &b.name, b.color, b.description)?;
242 notify(&state);
243 Ok(Json(serde_json::to_value(label)?))
244}
245
246#[derive(Debug, Deserialize)]
248pub struct LabelColorBody {
249 project: Option<String>,
250 color: String,
251}
252
253pub async fn recolor_label(
255 State(state): State<AppState>,
256 Path(name): Path<String>,
257 Json(b): Json<LabelColorBody>,
258) -> ApiResult {
259 let store = store_for(&state, b.project)?;
260 let label = ops::set_label_color(&store, &name, &b.color)?;
261 notify(&state);
262 Ok(Json(serde_json::to_value(label)?))
263}
264
265pub async fn delete_label(
267 State(state): State<AppState>,
268 Path(name): Path<String>,
269 Query(q): Query<ProjectQuery>,
270) -> ApiResult {
271 let store = store_for(&state, q.project)?;
272 ops::delete_label(&store, &name, Utc::now())?;
273 notify(&state);
274 Ok(Json(json!({ "ok": true })))
275}
276
277#[derive(Debug, Deserialize)]
280pub struct PatchBody {
281 project: Option<String>,
282 #[serde(default)]
283 title: Option<String>,
284 #[serde(default)]
285 body: Option<String>,
286 #[serde(default)]
287 priority: Option<Option<String>>,
288 #[serde(default)]
289 labels: Option<Vec<String>>,
290 #[serde(default)]
291 assignees: Option<Vec<String>>,
292}
293
294pub async fn patch_ticket(
296 State(state): State<AppState>,
297 Path(id): Path<String>,
298 Json(b): Json<PatchBody>,
299) -> ApiResult {
300 let store = store_for(&state, b.project)?;
301 let patch = TicketPatch {
302 title: b.title,
303 body: b.body,
304 priority: b.priority,
305 labels: b.labels,
306 assignees: b.assignees,
307 };
308 let ticket = ops::update_ticket(&store, &id, patch, Utc::now())?;
309 notify(&state);
310 Ok(Json(serde_json::to_value(ticket)?))
311}
312
313pub async fn identities(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
315 let store = store_for(&state, q.project)?;
316 Ok(Json(json!({ "identities": ops::list_identities(&store)? })))
317}
318
319#[derive(Debug, Deserialize)]
321pub struct IdentityBody {
322 project: Option<String>,
323 display_name: String,
324 #[serde(default)]
325 kind: Option<String>,
326}
327
328pub async fn put_identity(
330 State(state): State<AppState>,
331 Path(id): Path<String>,
332 Json(b): Json<IdentityBody>,
333) -> ApiResult {
334 let store = store_for(&state, b.project)?;
335 let kind = match b.kind.as_deref() {
336 Some("agent") => Some(IdentityKind::Agent),
337 Some("human") => Some(IdentityKind::Human),
338 _ => None,
339 };
340 let ident = ops::upsert_identity(&store, &id, &b.display_name, kind)?;
341 notify(&state);
342 Ok(Json(serde_json::to_value(ident)?))
343}
344
345pub async fn upload_attachment(
347 State(state): State<AppState>,
348 Path(id): Path<String>,
349 Query(q): Query<ProjectQuery>,
350 mut multipart: Multipart,
351) -> ApiResult {
352 let store = store_for(&state, q.project)?;
353 let max = store.load_settings()?.max_attachment_mb * 1024 * 1024;
354
355 while let Some(field) = multipart
356 .next_field()
357 .await
358 .map_err(|e| ApiError(anyhow::anyhow!("bad upload: {e}")))?
359 {
360 if field.name() != Some("file") {
361 continue;
362 }
363 let name = field.file_name().unwrap_or("file").to_string();
364 let mime = field
365 .content_type()
366 .map(|s| s.to_string())
367 .unwrap_or_else(|| "application/octet-stream".to_string());
368 let bytes = field
369 .bytes()
370 .await
371 .map_err(|e| ApiError(anyhow::anyhow!("read upload: {e}")))?;
372 if bytes.len() as u64 > max {
373 return Err(ApiError(anyhow::anyhow!(
374 "attachment is {:.1} MB, over the {} MB limit",
375 bytes.len() as f64 / 1_048_576.0,
376 max / 1024 / 1024
377 )));
378 }
379 let att = ops::add_attachment(&store, &id, &name, &bytes, &mime, Utc::now())?;
380 notify(&state);
381 return Ok(Json(serde_json::to_value(att)?));
382 }
383 Err(ApiError(anyhow::anyhow!("no `file` field in upload")))
384}
385
386#[derive(Debug, Deserialize)]
388pub struct DetachBody {
389 project: Option<String>,
390 path: String,
391}
392
393pub async fn delete_attachment(
395 State(state): State<AppState>,
396 Path(id): Path<String>,
397 Json(b): Json<DetachBody>,
398) -> ApiResult {
399 let store = store_for(&state, b.project)?;
400 ops::remove_attachment(&store, &id, &b.path, Utc::now())?;
401 notify(&state);
402 Ok(Json(json!({ "ok": true })))
403}
404
405pub async fn serve_media(
407 State(state): State<AppState>,
408 Query(q): Query<ProjectQuery>,
409 Path(path): Path<String>,
410) -> Response {
411 let store = match store_for(&state, q.project) {
412 Ok(s) => s,
413 Err(e) => return e.into_response(),
414 };
415 let rel = path.replace('\\', "/");
416 if rel.contains("..") {
417 return (StatusCode::BAD_REQUEST, "invalid path").into_response();
418 }
419 let full = store.root().join(&rel);
420 let within = std::fs::canonicalize(store.root())
422 .ok()
423 .zip(std::fs::canonicalize(&full).ok())
424 .map(|(root, target)| target.starts_with(root))
425 .unwrap_or(false);
426 if !within {
427 return (StatusCode::NOT_FOUND, "not found").into_response();
428 }
429 match std::fs::read(&full) {
430 Ok(bytes) => {
431 let mime = mime_guess::from_path(&full).first_or_octet_stream();
432 ([(header::CONTENT_TYPE, mime.as_ref())], bytes).into_response()
433 }
434 Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(),
435 }
436}
437
438pub async fn graph(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
440 let store = store_for(&state, q.project)?;
441 let commits = git::graph(store.root(), Some(300))?;
442 Ok(Json(json!({ "commits": commits })))
443}
444
445#[derive(Debug, Deserialize)]
447pub struct AddListBody {
448 project: Option<String>,
449 name: String,
450}
451
452pub async fn add_list(State(state): State<AppState>, Json(b): Json<AddListBody>) -> ApiResult {
454 let store = store_for(&state, b.project)?;
455 let id = ops::add_list(&store, &b.name, Utc::now())?;
456 notify(&state);
457 Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
458}
459
460#[derive(Debug, Deserialize)]
462pub struct RenameListBody {
463 project: Option<String>,
464 name: String,
465}
466
467pub async fn rename_list(
469 State(state): State<AppState>,
470 Path(id): Path<String>,
471 Json(b): Json<RenameListBody>,
472) -> ApiResult {
473 let store = store_for(&state, b.project)?;
474 ops::rename_list(&store, &id, &b.name, Utc::now())?;
475 notify(&state);
476 Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
477}
478
479#[derive(Debug, Deserialize)]
481pub struct MoveListBody {
482 project: Option<String>,
483 index: usize,
484}
485
486pub async fn move_list(
488 State(state): State<AppState>,
489 Path(id): Path<String>,
490 Json(b): Json<MoveListBody>,
491) -> ApiResult {
492 let store = store_for(&state, b.project)?;
493 ops::move_list(&store, &id, b.index, Utc::now())?;
494 notify(&state);
495 Ok(Json(json!({ "ok": true, "id": id, "index": b.index })))
496}
497
498#[derive(Debug, Deserialize)]
500pub struct RemoveListQuery {
501 project: Option<String>,
502 #[serde(default)]
503 force: bool,
504}
505
506pub async fn remove_list(
508 State(state): State<AppState>,
509 Path(id): Path<String>,
510 Query(q): Query<RemoveListQuery>,
511) -> ApiResult {
512 let store = store_for(&state, q.project)?;
513 ops::remove_list(&store, &id, q.force, Utc::now())?;
514 notify(&state);
515 Ok(Json(json!({ "ok": true, "id": id })))
516}
517
518pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
522 ws.on_upgrade(move |socket| ws_loop(socket, state))
523}
524
525async fn ws_loop(mut socket: WebSocket, state: AppState) {
526 let mut rx = state.tx.subscribe();
527 let _ = socket.send(Message::Text("connected".into())).await;
528 loop {
529 tokio::select! {
530 msg = rx.recv() => match msg {
531 Ok(m) => {
532 if socket.send(Message::Text(m.into())).await.is_err() {
533 break;
534 }
535 }
536 Err(broadcast::error::RecvError::Closed) => break,
537 Err(broadcast::error::RecvError::Lagged(_)) => {}
538 },
539 incoming = socket.recv() => match incoming {
540 Some(Ok(_)) => {}
541 _ => break,
542 },
543 }
544 }
545}