use axum::{ extract::{Path, State}, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use crate::errors::AppResult; use crate::state::AppState; pub fn routes() -> Router { Router::new() .route("/groups", get(list_groups)) .route("/groups/create", post(create_group)) .route("/groups/:name", get(get_group)) .route("/groups/:name/join", post(join_group)) .route("/groups/:name/send", post(send_to_group)) .route("/groups/:name/leave", post(leave_group)) .route("/groups/:name/kick", post(kick_member)) .route("/groups/:name/members", get(get_members)) } #[derive(Serialize, Deserialize, Clone)] struct GroupInfo { name: String, creator: String, members: Vec, // fingerprints } #[derive(Deserialize)] struct CreateRequest { name: String, creator: String, // fingerprint } #[derive(Deserialize)] struct JoinRequest { fingerprint: String, } /// A group message: the client sends one ciphertext per member. /// Server fans out each entry to the respective member's message queue. #[derive(Deserialize)] struct GroupSendRequest { from: String, /// Each entry is an encrypted message destined for one member. /// The client encrypts separately for each recipient. messages: Vec, } #[derive(Deserialize)] struct MemberMessage { to: String, // member fingerprint message: Vec, // encrypted payload (same format as 1:1 messages) } fn normalize_fp(fp: &str) -> String { fp.chars() .filter(|c| c.is_ascii_hexdigit()) .collect::() .to_lowercase() } fn load_group(db: &sled::Tree, name: &str) -> Option { db.get(name.as_bytes()) .ok() .flatten() .and_then(|data| serde_json::from_slice(&data).ok()) } fn save_group(db: &sled::Tree, group: &GroupInfo) -> anyhow::Result<()> { let data = serde_json::to_vec(group)?; db.insert(group.name.as_bytes(), data)?; Ok(()) } async fn create_group( State(state): State, Json(req): Json, ) -> AppResult> { let name = req.name.trim().to_lowercase(); if name.is_empty() { return Ok(Json(serde_json::json!({ "error": "name required" }))); } if load_group(&state.db.groups, &name).is_some() { return Ok(Json(serde_json::json!({ "error": "group already exists" }))); } let creator = normalize_fp(&req.creator); let group = GroupInfo { name: name.clone(), creator: creator.clone(), members: vec![creator], }; save_group(&state.db.groups, &group)?; tracing::info!("Group '{}' created", name); Ok(Json(serde_json::json!({ "ok": true, "name": name }))) } async fn join_group( State(state): State, Path(name): Path, Json(req): Json, ) -> AppResult> { let fp = normalize_fp(&req.fingerprint); // Auto-create if group doesn't exist let mut group = match load_group(&state.db.groups, &name) { Some(g) => g, None => { let g = GroupInfo { name: name.clone(), creator: fp.clone(), members: vec![], }; tracing::info!("Group '{}' auto-created by {}", name, fp); g } }; if !group.members.contains(&fp) { group.members.push(fp.clone()); tracing::info!("{} joined group '{}' ({} members)", fp, name, group.members.len()); } save_group(&state.db.groups, &group)?; Ok(Json(serde_json::json!({ "ok": true, "members": group.members.len() }))) } async fn get_group( State(state): State, Path(name): Path, ) -> AppResult> { match load_group(&state.db.groups, &name) { Some(group) => Ok(Json(serde_json::json!({ "name": group.name, "creator": group.creator, "members": group.members, "count": group.members.len(), }))), None => Ok(Json(serde_json::json!({ "error": "group not found" }))), } } async fn list_groups( State(state): State, ) -> AppResult> { let groups: Vec = state .db .groups .iter() .filter_map(|item| { item.ok().and_then(|(_, data)| { serde_json::from_slice::(&data).ok().map(|g| { serde_json::json!({ "name": g.name, "members": g.members.len(), }) }) }) }) .collect(); Ok(Json(serde_json::json!({ "groups": groups }))) } /// Fan-out: client sends per-member encrypted messages, server puts each /// in the respective member's queue. This reuses the existing message /// queue infrastructure — group messages look like 1:1 messages to the /// recipient, but with a group tag. async fn send_to_group( State(state): State, Path(name): Path, Json(req): Json, ) -> AppResult> { let group = match load_group(&state.db.groups, &name) { Some(g) => g, None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), }; let from = normalize_fp(&req.from); if !group.members.contains(&from) { return Ok(Json(serde_json::json!({ "error": "not a member of this group" }))); } let mut delivered = 0; for msg in &req.messages { let to = normalize_fp(&msg.to); if group.members.contains(&to) { // Try WebSocket push first (instant), fall back to DB queue if state.push_to_client(&to, &msg.message).await { tracing::debug!("Group '{}': pushed to {} via WS", name, to); } else { let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4()); state.db.messages.insert(key.as_bytes(), msg.message.as_slice())?; } delivered += 1; } } tracing::info!( "Group '{}': {} sent {} messages to {} members", name, from, delivered, group.members.len() ); Ok(Json(serde_json::json!({ "ok": true, "delivered": delivered }))) } async fn leave_group( State(state): State, Path(name): Path, Json(req): Json, ) -> AppResult> { let fp = normalize_fp(&req.fingerprint); let mut group = match load_group(&state.db.groups, &name) { Some(g) => g, None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), }; group.members.retain(|m| m != &fp); save_group(&state.db.groups, &group)?; tracing::info!("{} left group '{}' ({} remaining)", fp, name, group.members.len()); Ok(Json(serde_json::json!({ "ok": true, "remaining": group.members.len() }))) } #[derive(Deserialize)] struct KickRequest { fingerprint: String, // who is doing the kicking (must be creator) target: String, // who to kick } async fn kick_member( State(state): State, Path(name): Path, Json(req): Json, ) -> AppResult> { let fp = normalize_fp(&req.fingerprint); let target = normalize_fp(&req.target); let mut group = match load_group(&state.db.groups, &name) { Some(g) => g, None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), }; if group.creator != fp { return Ok(Json(serde_json::json!({ "error": "only the creator can kick members" }))); } if target == fp { return Ok(Json(serde_json::json!({ "error": "cannot kick yourself" }))); } let before = group.members.len(); group.members.retain(|m| m != &target); if group.members.len() == before { return Ok(Json(serde_json::json!({ "error": "target is not a member" }))); } save_group(&state.db.groups, &group)?; tracing::info!("{} kicked {} from group '{}'", fp, target, name); Ok(Json(serde_json::json!({ "ok": true, "kicked": target, "remaining": group.members.len() }))) } async fn get_members( State(state): State, Path(name): Path, ) -> AppResult> { let group = match load_group(&state.db.groups, &name) { Some(g) => g, None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), }; // Resolve aliases for each member let mut members_info: Vec = Vec::new(); for fp in &group.members { let alias = state.db.aliases.get(format!("fp:{}", fp).as_bytes()) .ok().flatten() .map(|v| String::from_utf8_lossy(&v).to_string()); members_info.push(serde_json::json!({ "fingerprint": fp, "alias": alias, "is_creator": *fp == group.creator, })); } Ok(Json(serde_json::json!({ "name": group.name, "members": members_info, "count": members_info.len(), }))) }