Server: - POST /groups/:name/leave — remove self from group - POST /groups/:name/kick — creator can kick members - GET /groups/:name/members — list with aliases + creator badge CLI TUI: - /gleave — leave current group - /gkick <fp_or_alias> — kick (creator only) - /gmembers — show member list with aliases and ★ for creator Web client: - Same commands: /gleave, /gkick, /gmembers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
298 lines
9.0 KiB
Rust
298 lines
9.0 KiB
Rust
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<AppState> {
|
|
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<String>, // 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<MemberMessage>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct MemberMessage {
|
|
to: String, // member fingerprint
|
|
message: Vec<u8>, // encrypted payload (same format as 1:1 messages)
|
|
}
|
|
|
|
fn normalize_fp(fp: &str) -> String {
|
|
fp.chars()
|
|
.filter(|c| c.is_ascii_hexdigit())
|
|
.collect::<String>()
|
|
.to_lowercase()
|
|
}
|
|
|
|
fn load_group(db: &sled::Tree, name: &str) -> Option<GroupInfo> {
|
|
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<AppState>,
|
|
Json(req): Json<CreateRequest>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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<AppState>,
|
|
Path(name): Path<String>,
|
|
Json(req): Json<JoinRequest>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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<AppState>,
|
|
Path(name): Path<String>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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<AppState>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let groups: Vec<serde_json::Value> = state
|
|
.db
|
|
.groups
|
|
.iter()
|
|
.filter_map(|item| {
|
|
item.ok().and_then(|(_, data)| {
|
|
serde_json::from_slice::<GroupInfo>(&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<AppState>,
|
|
Path(name): Path<String>,
|
|
Json(req): Json<GroupSendRequest>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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<AppState>,
|
|
Path(name): Path<String>,
|
|
Json(req): Json<JoinRequest>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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<AppState>,
|
|
Path(name): Path<String>,
|
|
Json(req): Json<KickRequest>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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<AppState>,
|
|
Path(name): Path<String>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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<serde_json::Value> = 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(),
|
|
})))
|
|
}
|