Skip to main content

wipe_daemon/
api.rs

1//! HTTP + WebSocket API handlers over `wipe-core`.
2
3use 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/// Shared server state.
20#[derive(Clone)]
21pub struct AppState {
22    /// The project the daemon was started in (default target).
23    pub current: PathBuf,
24    /// Broadcast channel for live-update notifications.
25    pub tx: broadcast::Sender<String>,
26}
27
28/// An error that renders as a JSON `{ok:false,error}` body.
29pub 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/// Query string carrying an optional project root.
47#[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
77// --- read endpoints --------------------------------------------------------
78
79/// `GET /api/health`
80pub async fn health() -> Json<Value> {
81    Json(json!({ "ok": true, "service": "wipe-daemon", "version": env!("CARGO_PKG_VERSION") }))
82}
83
84/// `GET /api/projects`
85pub async fn projects(State(state): State<AppState>) -> ApiResult {
86    crate::registry::register(&state.current);
87    Ok(Json(json!({ "projects": crate::registry::list() })))
88}
89
90/// `GET /api/board`
91pub 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
97/// `GET /api/history` — commits touching `.wipe/`, most recent first.
98pub 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/// Query for a historical board snapshot.
105#[derive(Debug, Deserialize)]
106pub struct AtQuery {
107    project: Option<String>,
108    commit: String,
109}
110
111/// `GET /api/board/at` — reconstruct the board as of a commit (the rewind feature).
112pub 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// --- write endpoints -------------------------------------------------------
138
139/// Body for creating a ticket.
140#[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
156/// `POST /api/tickets`
157pub 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/// Body for moving a ticket.
176#[derive(Debug, Deserialize)]
177pub struct MoveBody {
178    project: Option<String>,
179    to: String,
180    #[serde(default)]
181    pos: Option<usize>,
182}
183
184/// `POST /api/tickets/{id}/move`
185pub 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/// Body for adding a comment.
197#[derive(Debug, Deserialize)]
198pub struct CommentBody {
199    project: Option<String>,
200    #[serde(default)]
201    author: Option<String>,
202    body: String,
203}
204
205/// `POST /api/tickets/{id}/comments`
206pub 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
218/// `GET /api/definitions` — labels + priorities.
219pub 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/// Body for creating a label definition. `color` is optional (auto-assigned).
228#[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
238/// `POST /api/labels` — define a new label (auto-colored if no color given).
239pub 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/// Body for updating a label's color.
247#[derive(Debug, Deserialize)]
248pub struct LabelColorBody {
249    project: Option<String>,
250    color: String,
251}
252
253/// `PATCH /api/labels/{name}` — change a label's color.
254pub 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
265/// `DELETE /api/labels/{name}` — delete a label and strip it from all tickets.
266pub 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/// Body for patching a ticket. Absent fields are left unchanged; an explicit
278/// `null` for `priority` clears it.
279#[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
294/// `PATCH /api/tickets/{id}` — update ticket fields.
295pub 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
313/// `GET /api/identities` — humans (from git) + agents (registry).
314pub 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/// Body for creating/updating an identity.
320#[derive(Debug, Deserialize)]
321pub struct IdentityBody {
322    project: Option<String>,
323    display_name: String,
324    #[serde(default)]
325    kind: Option<String>,
326}
327
328/// `PUT /api/identities/{id}` — set an identity's display name / kind.
329pub 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
345/// `POST /api/tickets/{id}/attachments` — multipart file upload (field `file`).
346pub 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/// Body for detaching an attachment.
387#[derive(Debug, Deserialize)]
388pub struct DetachBody {
389    project: Option<String>,
390    path: String,
391}
392
393/// `DELETE /api/tickets/{id}/attachments` — detach by repo-relative path.
394pub 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
405/// `GET /api/media/{*path}` — serve an attachment for preview/download.
406pub 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    // Confine reads to within the project root.
421    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
438/// `GET /api/graph` — the commit graph (all branches) with board checkpoints.
439pub 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/// Body for adding a list.
446#[derive(Debug, Deserialize)]
447pub struct AddListBody {
448    project: Option<String>,
449    name: String,
450}
451
452/// `POST /api/lists` — add a list to the board.
453pub 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/// Body for renaming a list.
461#[derive(Debug, Deserialize)]
462pub struct RenameListBody {
463    project: Option<String>,
464    name: String,
465}
466
467/// `PATCH /api/lists/{id}` — rename a list.
468pub 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/// Body for reordering a list.
480#[derive(Debug, Deserialize)]
481pub struct MoveListBody {
482    project: Option<String>,
483    index: usize,
484}
485
486/// `POST /api/lists/{id}/move` — reorder a list to a new index.
487pub 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/// Query for removing a list.
499#[derive(Debug, Deserialize)]
500pub struct RemoveListQuery {
501    project: Option<String>,
502    #[serde(default)]
503    force: bool,
504}
505
506/// `DELETE /api/lists/{id}` — remove a list (use `?force=true` to delete its cards).
507pub 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
518// --- websocket -------------------------------------------------------------
519
520/// `GET /ws` — upgrade to a WebSocket that streams change notifications.
521pub 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}