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