use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Multipart, Path, Query, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use chrono::Utc;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::sync::broadcast;
use wipe_core::forum::{self, NewReply, NewThread, SearchQuery};
use wipe_core::model::{Board, IdentityKind, Ticket};
use wipe_core::ops::{self, NewTicket, TicketPatch};
use wipe_core::{git, Store};
#[derive(Clone)]
pub struct AppState {
pub current: Option<PathBuf>,
pub tx: broadcast::Sender<String>,
pub clients: Arc<AtomicUsize>,
}
pub struct ApiError(anyhow::Error);
impl<E: Into<anyhow::Error>> From<E> for ApiError {
fn from(e: E) -> Self {
ApiError(e.into())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = Json(json!({ "ok": false, "error": self.0.to_string() }));
(StatusCode::BAD_REQUEST, body).into_response()
}
}
type ApiResult = Result<Json<Value>, ApiError>;
#[derive(Debug, Deserialize)]
pub struct ProjectQuery {
project: Option<String>,
}
fn store_for(state: &AppState, project: Option<String>) -> Result<Store, ApiError> {
let root = project
.map(PathBuf::from)
.or_else(|| state.current.clone())
.ok_or_else(|| ApiError(anyhow::anyhow!("no project selected")))?;
Ok(Store::open(root)?)
}
fn notify(state: &AppState) {
let _ = state.tx.send("changed".to_string());
}
fn resolve_actor(store: &Store, provided: Option<String>) -> String {
ops::resolve_identity(Some(store), provided.as_deref())
}
fn board_json(board: &Board, view: &[(String, Vec<Ticket>)]) -> Value {
let lists: Vec<Value> = view
.iter()
.map(|(list_id, tickets)| {
let name = board
.list(list_id)
.map(|l| l.name.clone())
.unwrap_or_else(|| list_id.clone());
json!({ "list": list_id, "name": name, "tickets": tickets })
})
.collect();
json!({ "board": board.name, "lists": lists })
}
pub async fn health() -> Json<Value> {
Json(json!({ "ok": true, "service": "wipe-daemon", "version": env!("CARGO_PKG_VERSION") }))
}
pub async fn app_config() -> Json<Value> {
let g = wipe_core::GlobalConfig::load();
Json(json!({
"accent": g.ui_accent,
"theme": g.ui_theme,
"default_identity": g.default_identity,
"prefer_default_identity": g.prefer_default_identity.unwrap_or(false),
}))
}
#[derive(Debug, Deserialize)]
pub struct ConfigPatch {
#[serde(default)]
accent: Option<String>,
#[serde(default)]
theme: Option<String>,
#[serde(default)]
default_identity: Option<String>,
#[serde(default)]
prefer_default_identity: Option<bool>,
}
pub async fn patch_config(Json(b): Json<ConfigPatch>) -> ApiResult {
let mut g = wipe_core::GlobalConfig::load();
if let Some(a) = b.accent {
g.ui_accent = Some(a);
}
if let Some(t) = b.theme {
g.ui_theme = Some(t);
}
if let Some(id) = b.default_identity {
let id = id.trim().to_string();
if !id.is_empty() {
g.default_identity = Some(id);
}
}
if let Some(p) = b.prefer_default_identity {
g.prefer_default_identity = Some(p);
}
g.save()
.map_err(|e| ApiError(anyhow::anyhow!("saving config: {e}")))?;
Ok(Json(json!({
"ok": true,
"accent": g.ui_accent,
"theme": g.ui_theme,
"default_identity": g.default_identity,
"prefer_default_identity": g.prefer_default_identity.unwrap_or(false),
})))
}
pub async fn rescan() -> ApiResult {
let (found, projects) = tokio::task::spawn_blocking(|| {
crate::registry::prune();
let roots: Vec<std::path::PathBuf> = {
let g = wipe_core::GlobalConfig::load();
match g.scan_roots {
Some(r) if !r.is_empty() => r.into_iter().map(std::path::PathBuf::from).collect(),
_ => crate::registry::default_scan_roots(),
}
};
let found = crate::registry::scan(&roots, 7).len();
(found, crate::registry::list())
})
.await
.map_err(|e| ApiError(anyhow::anyhow!("scan task failed: {e}")))?;
Ok(Json(json!({ "found": found, "projects": projects })))
}
pub async fn projects(State(state): State<AppState>) -> ApiResult {
let current = state
.current
.as_ref()
.filter(|root| Store::open(root).is_ok())
.map(|root| root.display().to_string());
if let Some(root) = &state.current {
crate::registry::register(root);
}
Ok(Json(
json!({ "projects": crate::registry::list(), "current": current }),
))
}
pub async fn board(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
let store = store_for(&state, q.project)?;
let (board, view) = ops::board_view(&store)?;
Ok(Json(board_json(&board, &view)))
}
pub async fn history(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
let store = store_for(&state, q.project)?;
let commits = git::log(store.root(), Some(".wipe"), Some(200))?;
Ok(Json(json!({ "commits": commits })))
}
#[derive(Debug, Deserialize)]
pub struct AtQuery {
project: Option<String>,
commit: String,
}
pub async fn board_at(State(state): State<AppState>, Query(q): Query<AtQuery>) -> ApiResult {
let store = store_for(&state, q.project)?;
let root = store.root();
let board_src = git::file_at_commit(root, &q.commit, ".wipe/board.json")?
.ok_or_else(|| ApiError(anyhow::anyhow!("no board at commit {}", q.commit)))?;
let board: Board = serde_json::from_str(&board_src)?;
let mut lists = Vec::with_capacity(board.lists.len());
for list in &board.lists {
let mut tickets = Vec::with_capacity(list.cards.len());
for id in &list.cards {
let rel = format!(".wipe/tickets/{id}.json");
if let Some(src) = git::file_at_commit(root, &q.commit, &rel)? {
if let Ok(t) = serde_json::from_str::<Ticket>(&src) {
tickets.push(t);
}
}
}
lists.push(json!({ "list": list.id, "name": list.name, "tickets": tickets }));
}
Ok(Json(
json!({ "board": board.name, "commit": q.commit, "lists": lists }),
))
}
#[derive(Debug, Deserialize)]
pub struct CreateTicketBody {
project: Option<String>,
title: String,
#[serde(default)]
body: Option<String>,
#[serde(default)]
priority: Option<String>,
#[serde(default)]
list: Option<String>,
#[serde(default)]
labels: Vec<String>,
#[serde(default)]
assignees: Vec<String>,
#[serde(default)]
actor: Option<String>,
}
pub async fn create_ticket(
State(state): State<AppState>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<CreateTicketBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let actor = resolve_actor(&store, b.actor);
let spec = NewTicket {
title: b.title,
body: b.body,
priority: b.priority,
list: b.list,
labels: b.labels,
assignees: b.assignees,
};
let ticket = ops::create_ticket(&store, spec, &actor, Utc::now())?;
notify(&state);
Ok(Json(serde_json::to_value(ticket)?))
}
#[derive(Debug, Deserialize)]
pub struct MoveBody {
project: Option<String>,
to: String,
#[serde(default)]
pos: Option<usize>,
#[serde(default)]
actor: Option<String>,
}
pub async fn move_ticket(
State(state): State<AppState>,
Path(id): Path<String>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<MoveBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let actor = resolve_actor(&store, b.actor);
ops::move_ticket(&store, &id, &b.to, b.pos, &actor, Utc::now())?;
notify(&state);
Ok(Json(json!({ "ok": true, "id": id, "list": b.to })))
}
#[derive(Debug, Deserialize)]
pub struct CommentBody {
project: Option<String>,
#[serde(default)]
author: Option<String>,
body: String,
}
pub async fn add_comment(
State(state): State<AppState>,
Path(id): Path<String>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<CommentBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let author = b.author.unwrap_or_else(|| "ui".to_string());
let cid = ops::add_comment(&store, &id, &author, &b.body, Utc::now())?;
notify(&state);
Ok(Json(json!({ "ok": true, "ticket": id, "comment": cid })))
}
pub async fn definitions(
State(state): State<AppState>,
Query(q): Query<ProjectQuery>,
) -> ApiResult {
let store = store_for(&state, q.project)?;
Ok(Json(serde_json::to_value(store.load_definitions()?)?))
}
#[derive(Debug, Deserialize)]
pub struct LabelBody {
project: Option<String>,
name: String,
#[serde(default)]
color: Option<String>,
#[serde(default)]
description: Option<String>,
}
pub async fn create_label(
State(state): State<AppState>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<LabelBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let label = ops::create_label(&store, &b.name, b.color, b.description)?;
notify(&state);
Ok(Json(serde_json::to_value(label)?))
}
#[derive(Debug, Deserialize)]
pub struct LabelColorBody {
project: Option<String>,
color: String,
}
pub async fn recolor_label(
State(state): State<AppState>,
Path(name): Path<String>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<LabelColorBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let label = ops::set_label_color(&store, &name, &b.color)?;
notify(&state);
Ok(Json(serde_json::to_value(label)?))
}
pub async fn delete_label(
State(state): State<AppState>,
Path(name): Path<String>,
Query(q): Query<ProjectQuery>,
) -> ApiResult {
let store = store_for(&state, q.project)?;
ops::delete_label(&store, &name, Utc::now())?;
notify(&state);
Ok(Json(json!({ "ok": true })))
}
#[derive(Debug, Deserialize)]
pub struct PatchBody {
project: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
priority: Option<Option<String>>,
#[serde(default)]
labels: Option<Vec<String>>,
#[serde(default)]
assignees: Option<Vec<String>>,
#[serde(default)]
actor: Option<String>,
}
pub async fn patch_ticket(
State(state): State<AppState>,
Path(id): Path<String>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<PatchBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let actor = resolve_actor(&store, b.actor);
let patch = TicketPatch {
title: b.title,
body: b.body,
priority: b.priority,
labels: b.labels,
assignees: b.assignees,
};
let ticket = ops::update_ticket(&store, &id, patch, &actor, Utc::now())?;
notify(&state);
Ok(Json(serde_json::to_value(ticket)?))
}
pub async fn identities(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
let store = store_for(&state, q.project)?;
Ok(Json(json!({ "identities": ops::list_identities(&store)? })))
}
#[derive(Debug, Deserialize)]
pub struct IdentityBody {
project: Option<String>,
display_name: String,
#[serde(default)]
kind: Option<String>,
}
pub async fn put_identity(
State(state): State<AppState>,
Path(id): Path<String>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<IdentityBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let kind = match b.kind.as_deref() {
Some("agent") => Some(IdentityKind::Agent),
Some("human") => Some(IdentityKind::Human),
_ => None,
};
let ident = ops::upsert_identity(&store, &id, &b.display_name, kind)?;
notify(&state);
Ok(Json(serde_json::to_value(ident)?))
}
pub async fn delete_identity(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ProjectQuery>,
) -> ApiResult {
let store = store_for(&state, q.project)?;
ops::delete_identity(&store, &id)?;
notify(&state);
Ok(Json(json!({ "ok": true, "id": id })))
}
pub async fn upload_attachment(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ProjectQuery>,
mut multipart: Multipart,
) -> ApiResult {
let store = store_for(&state, q.project)?;
let actor = resolve_actor(&store, None);
let max = store.load_settings()?.max_attachment_mb * 1024 * 1024;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| ApiError(anyhow::anyhow!("bad upload: {e}")))?
{
if field.name() != Some("file") {
continue;
}
let name = field.file_name().unwrap_or("file").to_string();
let mime = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let bytes = field
.bytes()
.await
.map_err(|e| ApiError(anyhow::anyhow!("read upload: {e}")))?;
if bytes.len() as u64 > max {
return Err(ApiError(anyhow::anyhow!(
"attachment is {:.1} MB, over the {} MB limit",
bytes.len() as f64 / 1_048_576.0,
max / 1024 / 1024
)));
}
let att = ops::add_attachment(&store, &id, &name, &bytes, &mime, &actor, Utc::now())?;
notify(&state);
return Ok(Json(serde_json::to_value(att)?));
}
Err(ApiError(anyhow::anyhow!("no `file` field in upload")))
}
#[derive(Debug, Deserialize)]
pub struct DetachBody {
project: Option<String>,
path: String,
#[serde(default)]
actor: Option<String>,
}
pub async fn delete_attachment(
State(state): State<AppState>,
Path(id): Path<String>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<DetachBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let actor = resolve_actor(&store, b.actor);
ops::remove_attachment(&store, &id, &b.path, &actor, Utc::now())?;
notify(&state);
Ok(Json(json!({ "ok": true })))
}
pub async fn serve_media(
State(state): State<AppState>,
Query(q): Query<ProjectQuery>,
Path(path): Path<String>,
) -> Response {
let store = match store_for(&state, q.project) {
Ok(s) => s,
Err(e) => return e.into_response(),
};
let rel = path.replace('\\', "/");
if rel.contains("..") {
return (StatusCode::BAD_REQUEST, "invalid path").into_response();
}
let full = store.root().join(&rel);
let within = std::fs::canonicalize(store.root())
.ok()
.zip(std::fs::canonicalize(&full).ok())
.map(|(root, target)| target.starts_with(root))
.unwrap_or(false);
if !within {
return (StatusCode::NOT_FOUND, "not found").into_response();
}
match std::fs::read(&full) {
Ok(bytes) => {
let mime = mime_guess::from_path(&full).first_or_octet_stream();
([(header::CONTENT_TYPE, mime.as_ref())], bytes).into_response()
}
Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(),
}
}
pub async fn graph(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
let store = store_for(&state, q.project)?;
let commits = git::graph(store.root(), Some(300))?;
Ok(Json(json!({ "commits": commits })))
}
#[derive(Debug, Deserialize)]
pub struct AddListBody {
project: Option<String>,
name: String,
}
pub async fn add_list(
State(state): State<AppState>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<AddListBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let id = ops::add_list(&store, &b.name, Utc::now())?;
notify(&state);
Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
}
#[derive(Debug, Deserialize)]
pub struct RenameListBody {
project: Option<String>,
name: String,
}
pub async fn rename_list(
State(state): State<AppState>,
Path(id): Path<String>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<RenameListBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
ops::rename_list(&store, &id, &b.name, Utc::now())?;
notify(&state);
Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
}
#[derive(Debug, Deserialize)]
pub struct MoveListBody {
project: Option<String>,
index: usize,
}
pub async fn move_list(
State(state): State<AppState>,
Path(id): Path<String>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<MoveListBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
ops::move_list(&store, &id, b.index, Utc::now())?;
notify(&state);
Ok(Json(json!({ "ok": true, "id": id, "index": b.index })))
}
#[derive(Debug, Deserialize)]
pub struct RemoveListQuery {
project: Option<String>,
#[serde(default)]
force: bool,
}
pub async fn remove_list(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<RemoveListQuery>,
) -> ApiResult {
let store = store_for(&state, q.project)?;
ops::remove_list(&store, &id, q.force, Utc::now())?;
notify(&state);
Ok(Json(json!({ "ok": true, "id": id })))
}
pub async fn forum_list(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
let store = store_for(&state, q.project)?;
let all = forum::index(&store)?;
let threads: Vec<Value> = all
.iter()
.filter(|p| p.depth == 0)
.map(|r| {
let posts = all.iter().filter(|p| p.thread_id == r.thread_id).count();
json!({
"id": r.thread_id,
"title": r.thread_title,
"author": r.author,
"labels": r.labels,
"posts": posts,
"created": r.created,
})
})
.collect();
Ok(Json(json!({ "threads": threads })))
}
pub async fn forum_thread(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ProjectQuery>,
) -> ApiResult {
let store = store_for(&state, q.project)?;
Ok(Json(serde_json::to_value(forum::get_thread(&store, &id)?)?))
}
#[derive(Debug, Deserialize)]
pub struct ForumPostBody {
project: Option<String>,
title: String,
#[serde(default)]
body: String,
#[serde(default)]
labels: Vec<String>,
#[serde(default)]
refs: Vec<String>,
#[serde(default)]
actor: Option<String>,
}
pub async fn forum_create(
State(state): State<AppState>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<ForumPostBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let actor = resolve_actor(&store, b.actor);
let t = forum::create_thread(
&store,
NewThread {
title: b.title,
body: b.body,
labels: b.labels,
refs: b.refs,
attachments: Vec::new(),
},
&actor,
Utc::now(),
)?;
notify(&state);
Ok(Json(serde_json::to_value(t)?))
}
#[derive(Debug, Deserialize)]
pub struct ForumReplyBody {
project: Option<String>,
body: String,
#[serde(default)]
labels: Vec<String>,
#[serde(default)]
refs: Vec<String>,
#[serde(default)]
actor: Option<String>,
}
pub async fn forum_reply(
State(state): State<AppState>,
Path(id): Path<String>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<ForumReplyBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
let actor = resolve_actor(&store, b.actor);
let child = forum::reply(
&store,
&id,
NewReply {
body: b.body,
labels: b.labels,
refs: b.refs,
attachments: Vec::new(),
},
&actor,
Utc::now(),
)?;
notify(&state);
Ok(Json(json!({ "ok": true, "id": child, "parent": id })))
}
#[derive(Debug, Deserialize)]
pub struct ForumEditBody {
project: Option<String>,
body: String,
}
pub async fn forum_edit(
State(state): State<AppState>,
Path(id): Path<String>,
Query(pq): Query<ProjectQuery>,
Json(b): Json<ForumEditBody>,
) -> ApiResult {
let store = store_for(&state, pq.project.or(b.project))?;
forum::edit_post(&store, &id, &b.body, Utc::now())?;
notify(&state);
Ok(Json(json!({ "ok": true, "id": id })))
}
pub async fn forum_delete(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ProjectQuery>,
) -> ApiResult {
let store = store_for(&state, q.project)?;
forum::delete_post(&store, &id, Utc::now())?;
notify(&state);
Ok(Json(json!({ "ok": true, "id": id })))
}
#[derive(Debug, Deserialize)]
pub struct ForumSearchParams {
project: Option<String>,
#[serde(default)]
q: Option<String>,
#[serde(default)]
author: Option<String>,
#[serde(default)]
label: Option<String>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
titles: Option<bool>,
#[serde(default)]
depth: Option<usize>,
#[serde(default)]
limit: Option<usize>,
}
pub async fn forum_search(
State(state): State<AppState>,
Query(p): Query<ForumSearchParams>,
) -> ApiResult {
let store = store_for(&state, p.project)?;
let pattern = match p.q.as_deref() {
Some(s) if !s.trim().is_empty() => Some(forum::compile_pattern(s, true)?),
_ => None,
};
let query = SearchQuery {
pattern,
author: p.author,
labels: p.label.into_iter().collect(),
scope: p.scope,
max_depth: p.depth,
titles_only: p.titles.unwrap_or(false),
limit: p.limit,
};
Ok(Json(json!({ "posts": forum::search(&store, &query)? })))
}
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
ws.on_upgrade(move |socket| ws_loop(socket, state))
}
struct ClientGuard(Arc<AtomicUsize>);
impl Drop for ClientGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
async fn ws_loop(mut socket: WebSocket, state: AppState) {
let mut rx = state.tx.subscribe();
state.clients.fetch_add(1, Ordering::SeqCst);
let _guard = ClientGuard(state.clients.clone());
let _ = socket.send(Message::Text("connected".into())).await;
loop {
tokio::select! {
msg = rx.recv() => match msg {
Ok(m) => {
if socket.send(Message::Text(m.into())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => {}
},
incoming = socket.recv() => match incoming {
Some(Ok(_)) => {}
_ => break,
},
}
}
}