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: Option<PathBuf>,
30 pub tx: broadcast::Sender<String>,
32 pub clients: Arc<AtomicUsize>,
35}
36
37pub struct ApiError(anyhow::Error);
39
40impl<E: Into<anyhow::Error>> From<E> for ApiError {
41 fn from(e: E) -> Self {
42 ApiError(e.into())
43 }
44}
45
46impl IntoResponse for ApiError {
47 fn into_response(self) -> Response {
48 let body = Json(json!({ "ok": false, "error": self.0.to_string() }));
49 (StatusCode::BAD_REQUEST, body).into_response()
50 }
51}
52
53type ApiResult = Result<Json<Value>, ApiError>;
54
55#[derive(Debug, Deserialize)]
57pub struct ProjectQuery {
58 project: Option<String>,
59}
60
61fn store_for(state: &AppState, project: Option<String>) -> Result<Store, ApiError> {
65 let root = project
66 .map(PathBuf::from)
67 .or_else(|| state.current.clone())
68 .ok_or_else(|| ApiError(anyhow::anyhow!("no project selected")))?;
69 Ok(Store::open(root)?)
70}
71
72fn notify(state: &AppState) {
73 let _ = state.tx.send("changed".to_string());
74}
75
76fn resolve_actor(store: &Store, provided: Option<String>) -> String {
80 ops::resolve_identity(Some(store), provided.as_deref())
81}
82
83fn board_json(board: &Board, view: &[(String, Vec<Ticket>)]) -> Value {
84 let lists: Vec<Value> = view
85 .iter()
86 .map(|(list_id, tickets)| {
87 let name = board
88 .list(list_id)
89 .map(|l| l.name.clone())
90 .unwrap_or_else(|| list_id.clone());
91 json!({ "list": list_id, "name": name, "tickets": tickets })
92 })
93 .collect();
94 json!({ "board": board.name, "lists": lists })
95}
96
97pub async fn health() -> Json<Value> {
101 Json(json!({ "ok": true, "service": "wipe-daemon", "version": env!("CARGO_PKG_VERSION") }))
102}
103
104pub async fn app_config() -> Json<Value> {
107 let g = wipe_core::GlobalConfig::load();
108 Json(json!({
109 "accent": g.ui_accent,
110 "theme": g.ui_theme,
111 "default_identity": g.default_identity,
112 "prefer_default_identity": g.prefer_default_identity.unwrap_or(false),
113 }))
114}
115
116#[derive(Debug, Deserialize)]
118pub struct ConfigPatch {
119 #[serde(default)]
120 accent: Option<String>,
121 #[serde(default)]
122 theme: Option<String>,
123 #[serde(default)]
124 default_identity: Option<String>,
125 #[serde(default)]
126 prefer_default_identity: Option<bool>,
127}
128
129pub async fn patch_config(Json(b): Json<ConfigPatch>) -> ApiResult {
131 let mut g = wipe_core::GlobalConfig::load();
132 if let Some(a) = b.accent {
133 g.ui_accent = Some(a);
134 }
135 if let Some(t) = b.theme {
136 g.ui_theme = Some(t);
137 }
138 if let Some(id) = b.default_identity {
139 let id = id.trim().to_string();
140 if !id.is_empty() {
141 g.default_identity = Some(id);
142 }
143 }
144 if let Some(p) = b.prefer_default_identity {
145 g.prefer_default_identity = Some(p);
146 }
147 g.save()
148 .map_err(|e| ApiError(anyhow::anyhow!("saving config: {e}")))?;
149 Ok(Json(json!({
150 "ok": true,
151 "accent": g.ui_accent,
152 "theme": g.ui_theme,
153 "default_identity": g.default_identity,
154 "prefer_default_identity": g.prefer_default_identity.unwrap_or(false),
155 })))
156}
157
158pub async fn rescan() -> ApiResult {
161 let (found, projects) = tokio::task::spawn_blocking(|| {
163 crate::registry::prune();
164 let roots: Vec<std::path::PathBuf> = {
165 let g = wipe_core::GlobalConfig::load();
166 match g.scan_roots {
167 Some(r) if !r.is_empty() => r.into_iter().map(std::path::PathBuf::from).collect(),
168 _ => crate::registry::default_scan_roots(),
169 }
170 };
171 let found = crate::registry::scan(&roots, 7).len();
172 (found, crate::registry::list())
173 })
174 .await
175 .map_err(|e| ApiError(anyhow::anyhow!("scan task failed: {e}")))?;
176 Ok(Json(json!({ "found": found, "projects": projects })))
177}
178
179pub async fn projects(State(state): State<AppState>) -> ApiResult {
185 let current = state
186 .current
187 .as_ref()
188 .filter(|root| Store::open(root).is_ok())
189 .map(|root| root.display().to_string());
190 if let Some(root) = &state.current {
191 crate::registry::register(root);
192 }
193 Ok(Json(
194 json!({ "projects": crate::registry::list(), "current": current }),
195 ))
196}
197
198pub async fn board(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
200 let store = store_for(&state, q.project)?;
201 let (board, view) = ops::board_view(&store)?;
202 Ok(Json(board_json(&board, &view)))
203}
204
205pub async fn history(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
207 let store = store_for(&state, q.project)?;
208 let commits = git::log(store.root(), Some(".wipe"), Some(200))?;
209 Ok(Json(json!({ "commits": commits })))
210}
211
212#[derive(Debug, Deserialize)]
214pub struct AtQuery {
215 project: Option<String>,
216 commit: String,
217}
218
219pub async fn board_at(State(state): State<AppState>, Query(q): Query<AtQuery>) -> ApiResult {
221 let store = store_for(&state, q.project)?;
222 let root = store.root();
223 let board_src = git::file_at_commit(root, &q.commit, ".wipe/board.json")?
224 .ok_or_else(|| ApiError(anyhow::anyhow!("no board at commit {}", q.commit)))?;
225 let board: Board = serde_json::from_str(&board_src)?;
226
227 let mut lists = Vec::with_capacity(board.lists.len());
228 for list in &board.lists {
229 let mut tickets = Vec::with_capacity(list.cards.len());
230 for id in &list.cards {
231 let rel = format!(".wipe/tickets/{id}.json");
232 if let Some(src) = git::file_at_commit(root, &q.commit, &rel)? {
233 if let Ok(t) = serde_json::from_str::<Ticket>(&src) {
234 tickets.push(t);
235 }
236 }
237 }
238 lists.push(json!({ "list": list.id, "name": list.name, "tickets": tickets }));
239 }
240 Ok(Json(
241 json!({ "board": board.name, "commit": q.commit, "lists": lists }),
242 ))
243}
244
245#[derive(Debug, Deserialize)]
249pub struct CreateTicketBody {
250 project: Option<String>,
251 title: String,
252 #[serde(default)]
253 body: Option<String>,
254 #[serde(default)]
255 priority: Option<String>,
256 #[serde(default)]
257 list: Option<String>,
258 #[serde(default)]
259 labels: Vec<String>,
260 #[serde(default)]
261 assignees: Vec<String>,
262 #[serde(default)]
263 actor: Option<String>,
264}
265
266pub async fn create_ticket(
268 State(state): State<AppState>,
269 Query(pq): Query<ProjectQuery>,
270 Json(b): Json<CreateTicketBody>,
271) -> ApiResult {
272 let store = store_for(&state, pq.project.or(b.project))?;
273 let actor = resolve_actor(&store, b.actor);
274 let spec = NewTicket {
275 title: b.title,
276 body: b.body,
277 priority: b.priority,
278 list: b.list,
279 labels: b.labels,
280 assignees: b.assignees,
281 };
282 let ticket = ops::create_ticket(&store, spec, &actor, Utc::now())?;
283 notify(&state);
284 Ok(Json(serde_json::to_value(ticket)?))
285}
286
287#[derive(Debug, Deserialize)]
289pub struct MoveBody {
290 project: Option<String>,
291 to: String,
292 #[serde(default)]
293 pos: Option<usize>,
294 #[serde(default)]
295 actor: Option<String>,
296}
297
298pub async fn move_ticket(
300 State(state): State<AppState>,
301 Path(id): Path<String>,
302 Query(pq): Query<ProjectQuery>,
303 Json(b): Json<MoveBody>,
304) -> ApiResult {
305 let store = store_for(&state, pq.project.or(b.project))?;
306 let actor = resolve_actor(&store, b.actor);
307 ops::move_ticket(&store, &id, &b.to, b.pos, &actor, Utc::now())?;
308 notify(&state);
309 Ok(Json(json!({ "ok": true, "id": id, "list": b.to })))
310}
311
312#[derive(Debug, Deserialize)]
314pub struct CommentBody {
315 project: Option<String>,
316 #[serde(default)]
317 author: Option<String>,
318 body: String,
319}
320
321pub async fn add_comment(
323 State(state): State<AppState>,
324 Path(id): Path<String>,
325 Query(pq): Query<ProjectQuery>,
326 Json(b): Json<CommentBody>,
327) -> ApiResult {
328 let store = store_for(&state, pq.project.or(b.project))?;
329 let author = b.author.unwrap_or_else(|| "ui".to_string());
330 let cid = ops::add_comment(&store, &id, &author, &b.body, Utc::now())?;
331 notify(&state);
332 Ok(Json(json!({ "ok": true, "ticket": id, "comment": cid })))
333}
334
335pub async fn definitions(
337 State(state): State<AppState>,
338 Query(q): Query<ProjectQuery>,
339) -> ApiResult {
340 let store = store_for(&state, q.project)?;
341 Ok(Json(serde_json::to_value(store.load_definitions()?)?))
342}
343
344#[derive(Debug, Deserialize)]
346pub struct LabelBody {
347 project: Option<String>,
348 name: String,
349 #[serde(default)]
350 color: Option<String>,
351 #[serde(default)]
352 description: Option<String>,
353}
354
355pub async fn create_label(
357 State(state): State<AppState>,
358 Query(pq): Query<ProjectQuery>,
359 Json(b): Json<LabelBody>,
360) -> ApiResult {
361 let store = store_for(&state, pq.project.or(b.project))?;
362 let label = ops::create_label(&store, &b.name, b.color, b.description)?;
363 notify(&state);
364 Ok(Json(serde_json::to_value(label)?))
365}
366
367#[derive(Debug, Deserialize)]
369pub struct LabelColorBody {
370 project: Option<String>,
371 color: String,
372}
373
374pub async fn recolor_label(
376 State(state): State<AppState>,
377 Path(name): Path<String>,
378 Query(pq): Query<ProjectQuery>,
379 Json(b): Json<LabelColorBody>,
380) -> ApiResult {
381 let store = store_for(&state, pq.project.or(b.project))?;
382 let label = ops::set_label_color(&store, &name, &b.color)?;
383 notify(&state);
384 Ok(Json(serde_json::to_value(label)?))
385}
386
387pub async fn delete_label(
389 State(state): State<AppState>,
390 Path(name): Path<String>,
391 Query(q): Query<ProjectQuery>,
392) -> ApiResult {
393 let store = store_for(&state, q.project)?;
394 ops::delete_label(&store, &name, Utc::now())?;
395 notify(&state);
396 Ok(Json(json!({ "ok": true })))
397}
398
399#[derive(Debug, Deserialize)]
402pub struct PatchBody {
403 project: Option<String>,
404 #[serde(default)]
405 title: Option<String>,
406 #[serde(default)]
407 body: Option<String>,
408 #[serde(default)]
409 priority: Option<Option<String>>,
410 #[serde(default)]
411 labels: Option<Vec<String>>,
412 #[serde(default)]
413 assignees: Option<Vec<String>>,
414 #[serde(default)]
415 actor: Option<String>,
416}
417
418pub async fn patch_ticket(
420 State(state): State<AppState>,
421 Path(id): Path<String>,
422 Query(pq): Query<ProjectQuery>,
423 Json(b): Json<PatchBody>,
424) -> ApiResult {
425 let store = store_for(&state, pq.project.or(b.project))?;
426 let actor = resolve_actor(&store, b.actor);
427 let patch = TicketPatch {
428 title: b.title,
429 body: b.body,
430 priority: b.priority,
431 labels: b.labels,
432 assignees: b.assignees,
433 };
434 let ticket = ops::update_ticket(&store, &id, patch, &actor, Utc::now())?;
435 notify(&state);
436 Ok(Json(serde_json::to_value(ticket)?))
437}
438
439pub async fn identities(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
441 let store = store_for(&state, q.project)?;
442 Ok(Json(json!({ "identities": ops::list_identities(&store)? })))
443}
444
445#[derive(Debug, Deserialize)]
447pub struct IdentityBody {
448 project: Option<String>,
449 display_name: String,
450 #[serde(default)]
451 kind: Option<String>,
452}
453
454pub async fn put_identity(
456 State(state): State<AppState>,
457 Path(id): Path<String>,
458 Query(pq): Query<ProjectQuery>,
459 Json(b): Json<IdentityBody>,
460) -> ApiResult {
461 let store = store_for(&state, pq.project.or(b.project))?;
462 let kind = match b.kind.as_deref() {
463 Some("agent") => Some(IdentityKind::Agent),
464 Some("human") => Some(IdentityKind::Human),
465 _ => None,
466 };
467 let ident = ops::upsert_identity(&store, &id, &b.display_name, kind)?;
468 notify(&state);
469 Ok(Json(serde_json::to_value(ident)?))
470}
471
472pub async fn delete_identity(
475 State(state): State<AppState>,
476 Path(id): Path<String>,
477 Query(q): Query<ProjectQuery>,
478) -> ApiResult {
479 let store = store_for(&state, q.project)?;
480 ops::delete_identity(&store, &id)?;
481 notify(&state);
482 Ok(Json(json!({ "ok": true, "id": id })))
483}
484
485pub async fn upload_attachment(
487 State(state): State<AppState>,
488 Path(id): Path<String>,
489 Query(q): Query<ProjectQuery>,
490 mut multipart: Multipart,
491) -> ApiResult {
492 let store = store_for(&state, q.project)?;
493 let actor = resolve_actor(&store, None);
494 let max = store.load_settings()?.max_attachment_mb * 1024 * 1024;
495
496 while let Some(field) = multipart
497 .next_field()
498 .await
499 .map_err(|e| ApiError(anyhow::anyhow!("bad upload: {e}")))?
500 {
501 if field.name() != Some("file") {
502 continue;
503 }
504 let name = field.file_name().unwrap_or("file").to_string();
505 let mime = field
506 .content_type()
507 .map(|s| s.to_string())
508 .unwrap_or_else(|| "application/octet-stream".to_string());
509 let bytes = field
510 .bytes()
511 .await
512 .map_err(|e| ApiError(anyhow::anyhow!("read upload: {e}")))?;
513 if bytes.len() as u64 > max {
514 return Err(ApiError(anyhow::anyhow!(
515 "attachment is {:.1} MB, over the {} MB limit",
516 bytes.len() as f64 / 1_048_576.0,
517 max / 1024 / 1024
518 )));
519 }
520 let att = ops::add_attachment(&store, &id, &name, &bytes, &mime, &actor, Utc::now())?;
521 notify(&state);
522 return Ok(Json(serde_json::to_value(att)?));
523 }
524 Err(ApiError(anyhow::anyhow!("no `file` field in upload")))
525}
526
527#[derive(Debug, Deserialize)]
529pub struct DetachBody {
530 project: Option<String>,
531 path: String,
532 #[serde(default)]
533 actor: Option<String>,
534}
535
536pub async fn delete_attachment(
538 State(state): State<AppState>,
539 Path(id): Path<String>,
540 Query(pq): Query<ProjectQuery>,
541 Json(b): Json<DetachBody>,
542) -> ApiResult {
543 let store = store_for(&state, pq.project.or(b.project))?;
544 let actor = resolve_actor(&store, b.actor);
545 ops::remove_attachment(&store, &id, &b.path, &actor, Utc::now())?;
546 notify(&state);
547 Ok(Json(json!({ "ok": true })))
548}
549
550pub async fn serve_media(
552 State(state): State<AppState>,
553 Query(q): Query<ProjectQuery>,
554 Path(path): Path<String>,
555) -> Response {
556 let store = match store_for(&state, q.project) {
557 Ok(s) => s,
558 Err(e) => return e.into_response(),
559 };
560 let rel = path.replace('\\', "/");
561 if rel.contains("..") {
562 return (StatusCode::BAD_REQUEST, "invalid path").into_response();
563 }
564 let full = store.root().join(&rel);
565 let within = std::fs::canonicalize(store.root())
567 .ok()
568 .zip(std::fs::canonicalize(&full).ok())
569 .map(|(root, target)| target.starts_with(root))
570 .unwrap_or(false);
571 if !within {
572 return (StatusCode::NOT_FOUND, "not found").into_response();
573 }
574 match std::fs::read(&full) {
575 Ok(bytes) => {
576 let mime = mime_guess::from_path(&full).first_or_octet_stream();
577 ([(header::CONTENT_TYPE, mime.as_ref())], bytes).into_response()
578 }
579 Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(),
580 }
581}
582
583pub async fn graph(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
585 let store = store_for(&state, q.project)?;
586 let commits = git::graph(store.root(), Some(300))?;
587 Ok(Json(json!({ "commits": commits })))
588}
589
590#[derive(Debug, Deserialize)]
592pub struct AddListBody {
593 project: Option<String>,
594 name: String,
595}
596
597pub async fn add_list(
599 State(state): State<AppState>,
600 Query(pq): Query<ProjectQuery>,
601 Json(b): Json<AddListBody>,
602) -> ApiResult {
603 let store = store_for(&state, pq.project.or(b.project))?;
604 let id = ops::add_list(&store, &b.name, Utc::now())?;
605 notify(&state);
606 Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
607}
608
609#[derive(Debug, Deserialize)]
611pub struct RenameListBody {
612 project: Option<String>,
613 name: String,
614}
615
616pub async fn rename_list(
618 State(state): State<AppState>,
619 Path(id): Path<String>,
620 Query(pq): Query<ProjectQuery>,
621 Json(b): Json<RenameListBody>,
622) -> ApiResult {
623 let store = store_for(&state, pq.project.or(b.project))?;
624 ops::rename_list(&store, &id, &b.name, Utc::now())?;
625 notify(&state);
626 Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
627}
628
629#[derive(Debug, Deserialize)]
631pub struct MoveListBody {
632 project: Option<String>,
633 index: usize,
634}
635
636pub async fn move_list(
638 State(state): State<AppState>,
639 Path(id): Path<String>,
640 Query(pq): Query<ProjectQuery>,
641 Json(b): Json<MoveListBody>,
642) -> ApiResult {
643 let store = store_for(&state, pq.project.or(b.project))?;
644 ops::move_list(&store, &id, b.index, Utc::now())?;
645 notify(&state);
646 Ok(Json(json!({ "ok": true, "id": id, "index": b.index })))
647}
648
649#[derive(Debug, Deserialize)]
651pub struct RemoveListQuery {
652 project: Option<String>,
653 #[serde(default)]
654 force: bool,
655}
656
657pub async fn remove_list(
659 State(state): State<AppState>,
660 Path(id): Path<String>,
661 Query(q): Query<RemoveListQuery>,
662) -> ApiResult {
663 let store = store_for(&state, q.project)?;
664 ops::remove_list(&store, &id, q.force, Utc::now())?;
665 notify(&state);
666 Ok(Json(json!({ "ok": true, "id": id })))
667}
668
669pub async fn forum_list(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
673 let store = store_for(&state, q.project)?;
674 let all = forum::index(&store)?;
675 let threads: Vec<Value> = all
676 .iter()
677 .filter(|p| p.depth == 0)
678 .map(|r| {
679 let posts = all.iter().filter(|p| p.thread_id == r.thread_id).count();
680 json!({
681 "id": r.thread_id,
682 "title": r.thread_title,
683 "author": r.author,
684 "labels": r.labels,
685 "posts": posts,
686 "created": r.created,
687 })
688 })
689 .collect();
690 Ok(Json(json!({ "threads": threads })))
691}
692
693pub async fn forum_thread(
695 State(state): State<AppState>,
696 Path(id): Path<String>,
697 Query(q): Query<ProjectQuery>,
698) -> ApiResult {
699 let store = store_for(&state, q.project)?;
700 Ok(Json(serde_json::to_value(forum::get_thread(&store, &id)?)?))
701}
702
703#[derive(Debug, Deserialize)]
705pub struct ForumPostBody {
706 project: Option<String>,
707 title: String,
708 #[serde(default)]
709 body: String,
710 #[serde(default)]
711 labels: Vec<String>,
712 #[serde(default)]
713 refs: Vec<String>,
714 #[serde(default)]
715 actor: Option<String>,
716}
717
718pub async fn forum_create(
720 State(state): State<AppState>,
721 Query(pq): Query<ProjectQuery>,
722 Json(b): Json<ForumPostBody>,
723) -> ApiResult {
724 let store = store_for(&state, pq.project.or(b.project))?;
725 let actor = resolve_actor(&store, b.actor);
726 let t = forum::create_thread(
727 &store,
728 NewThread {
729 title: b.title,
730 body: b.body,
731 labels: b.labels,
732 refs: b.refs,
733 attachments: Vec::new(),
734 },
735 &actor,
736 Utc::now(),
737 )?;
738 notify(&state);
739 Ok(Json(serde_json::to_value(t)?))
740}
741
742#[derive(Debug, Deserialize)]
744pub struct ForumReplyBody {
745 project: Option<String>,
746 body: String,
747 #[serde(default)]
748 labels: Vec<String>,
749 #[serde(default)]
750 refs: Vec<String>,
751 #[serde(default)]
752 actor: Option<String>,
753}
754
755pub async fn forum_reply(
757 State(state): State<AppState>,
758 Path(id): Path<String>,
759 Query(pq): Query<ProjectQuery>,
760 Json(b): Json<ForumReplyBody>,
761) -> ApiResult {
762 let store = store_for(&state, pq.project.or(b.project))?;
763 let actor = resolve_actor(&store, b.actor);
764 let child = forum::reply(
765 &store,
766 &id,
767 NewReply {
768 body: b.body,
769 labels: b.labels,
770 refs: b.refs,
771 attachments: Vec::new(),
772 },
773 &actor,
774 Utc::now(),
775 )?;
776 notify(&state);
777 Ok(Json(json!({ "ok": true, "id": child, "parent": id })))
778}
779
780#[derive(Debug, Deserialize)]
782pub struct ForumEditBody {
783 project: Option<String>,
784 body: String,
785}
786
787pub async fn forum_edit(
789 State(state): State<AppState>,
790 Path(id): Path<String>,
791 Query(pq): Query<ProjectQuery>,
792 Json(b): Json<ForumEditBody>,
793) -> ApiResult {
794 let store = store_for(&state, pq.project.or(b.project))?;
795 forum::edit_post(&store, &id, &b.body, Utc::now())?;
796 notify(&state);
797 Ok(Json(json!({ "ok": true, "id": id })))
798}
799
800pub async fn forum_delete(
802 State(state): State<AppState>,
803 Path(id): Path<String>,
804 Query(q): Query<ProjectQuery>,
805) -> ApiResult {
806 let store = store_for(&state, q.project)?;
807 forum::delete_post(&store, &id, Utc::now())?;
808 notify(&state);
809 Ok(Json(json!({ "ok": true, "id": id })))
810}
811
812#[derive(Debug, Deserialize)]
814pub struct ForumSearchParams {
815 project: Option<String>,
816 #[serde(default)]
817 q: Option<String>,
818 #[serde(default)]
819 author: Option<String>,
820 #[serde(default)]
821 label: Option<String>,
822 #[serde(default)]
823 scope: Option<String>,
824 #[serde(default)]
825 titles: Option<bool>,
826 #[serde(default)]
827 depth: Option<usize>,
828 #[serde(default)]
829 limit: Option<usize>,
830}
831
832pub async fn forum_search(
834 State(state): State<AppState>,
835 Query(p): Query<ForumSearchParams>,
836) -> ApiResult {
837 let store = store_for(&state, p.project)?;
838 let pattern = match p.q.as_deref() {
839 Some(s) if !s.trim().is_empty() => Some(forum::compile_pattern(s, true)?),
840 _ => None,
841 };
842 let query = SearchQuery {
843 pattern,
844 author: p.author,
845 labels: p.label.into_iter().collect(),
846 scope: p.scope,
847 max_depth: p.depth,
848 titles_only: p.titles.unwrap_or(false),
849 limit: p.limit,
850 };
851 Ok(Json(json!({ "posts": forum::search(&store, &query)? })))
852}
853
854pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
858 ws.on_upgrade(move |socket| ws_loop(socket, state))
859}
860
861struct ClientGuard(Arc<AtomicUsize>);
863impl Drop for ClientGuard {
864 fn drop(&mut self) {
865 self.0.fetch_sub(1, Ordering::SeqCst);
866 }
867}
868
869async fn ws_loop(mut socket: WebSocket, state: AppState) {
870 let mut rx = state.tx.subscribe();
871 state.clients.fetch_add(1, Ordering::SeqCst);
874 let _guard = ClientGuard(state.clients.clone());
875 let _ = socket.send(Message::Text("connected".into())).await;
876 loop {
877 tokio::select! {
878 msg = rx.recv() => match msg {
879 Ok(m) => {
880 if socket.send(Message::Text(m.into())).await.is_err() {
881 break;
882 }
883 }
884 Err(broadcast::error::RecvError::Closed) => break,
885 Err(broadcast::error::RecvError::Lagged(_)) => {}
886 },
887 incoming = socket.recv() => match incoming {
888 Some(Ok(_)) => {}
889 _ => break,
890 },
891 }
892 }
893}