use crate::api::ab::rules::{enforce, Rule}; use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use crate::api::pagination::Page; use crate::api::state::AppState; use crate::database::AbPeerInsert; use axum::extract::{Extension, Path, Query}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::Arc; /// `serde_urlencoded` (axum's query decoder) does not honour /// `#[serde(flatten)]`, so the pagination fields are spelled out inline. #[derive(Debug, Deserialize)] pub struct AbQuery { /// guid sent in the query string for `/api/ab/peers?ab=`. pub ab: String, #[serde(default = "default_current")] pub current: i64, #[serde(default = "default_page_size", rename = "pageSize")] pub page_size: i64, } fn default_current() -> i64 { 1 } fn default_page_size() -> i64 { 100 } impl AbQuery { fn offset(&self) -> i64 { (self.current.max(1) - 1) * self.limit() } fn limit(&self) -> i64 { self.page_size.clamp(1, 1000) } } /// `POST /api/ab/peers?ab=` — paginated peer list inside an AB. /// Wire shape matches the Flutter `Peer` decoder; only fields documented in /// CONSOLE_API.md §4.4 are surfaced. #[derive(Debug, Serialize)] struct PeerOut { id: String, alias: String, tags: Vec, note: String, #[serde(skip_serializing_if = "String::is_empty")] password: String, #[serde(skip_serializing_if = "String::is_empty")] hash: String, #[serde(skip_serializing_if = "String::is_empty")] username: String, #[serde(skip_serializing_if = "String::is_empty")] hostname: String, #[serde(skip_serializing_if = "String::is_empty")] platform: String, } pub async fn list( Extension(state): Extension>, user: AuthedUser, Query(q): Query, ) -> Result { enforce(&state, user.user_id, &q.ab, Rule::Read).await?; let (total, rows) = state .db .ab_list_peers(&q.ab, q.offset(), q.limit()) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let data: Vec = rows .into_iter() .map(|r| PeerOut { id: r.id, alias: r.alias, tags: r.tags, note: r.note, password: r.password, hash: r.hash, username: r.username, hostname: r.hostname, platform: r.platform, }) .collect(); Ok((StatusCode::OK, Json(Page { total, data }))) } #[derive(Debug, Deserialize)] pub struct PeerAddBody { pub id: String, #[serde(default)] pub alias: Option, #[serde(default)] pub tags: Option>, #[serde(default)] pub note: Option, #[serde(default)] pub password: Option, #[serde(default)] pub hash: Option, #[serde(default)] pub username: Option, #[serde(default)] pub hostname: Option, #[serde(default)] pub platform: Option, } /// `POST /api/ab/peer/add/{guid}` — insert one peer. **Returns HTTP 200 /// with an empty body on success**, or `{"error":"..."}` JSON body on failure /// (also HTTP 200). The Flutter `_jsonDecodeActionResp` at /// flutter/lib/models/ab_model.dart:2002 treats *any* non-empty success body /// as an error to surface — including `{}` (which produces the literal string /// "null"), so action endpoints must reply with truly empty bodies. pub async fn add( Extension(state): Extension>, user: AuthedUser, Path(guid): Path, Json(body): Json, ) -> Result { enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?; if body.id.is_empty() { return Err(ApiError::BadRequest("id required".into())); } let max = state.cfg.ab_max_peers_per_book; let count = state .db .ab_count_peers(&guid) .await .map_err(|e| ApiError::Internal(e.to_string()))?; if count >= max { return Err(ApiError::Forbidden("exceed_max_devices".into())); } state .db .ab_peer_insert( &guid, AbPeerInsert { id: &body.id, alias: body.alias.as_deref(), note: body.note.as_deref(), password: body.password.as_deref(), hash: body.hash.as_deref(), username: body.username.as_deref(), hostname: body.hostname.as_deref(), platform: body.platform.as_deref(), }, body.tags.as_deref(), ) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(StatusCode::OK) } /// `PUT /api/ab/peer/update/{guid}` — partial update. Body always carries /// `id`, plus any subset of mutable fields. Empty success body, see `add`. pub async fn update( Extension(state): Extension>, user: AuthedUser, Path(guid): Path, Json(body): Json, ) -> Result { enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?; let id = body .get("id") .and_then(|v| v.as_str()) .ok_or_else(|| ApiError::BadRequest("id required".into()))?; let updated = state .db .ab_peer_partial_update(&guid, id, &body) .await .map_err(|e| ApiError::Internal(e.to_string()))?; if !updated { return Err(ApiError::Forbidden("peer not found".into())); } Ok(StatusCode::OK) } /// `DELETE /api/ab/peer/{guid}` — body is a JSON array of peer IDs. Empty /// success body, see `add`. pub async fn delete( Extension(state): Extension>, user: AuthedUser, Path(guid): Path, Json(ids): Json>, ) -> Result { enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?; state .db .ab_peers_delete(&guid, &ids) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(StatusCode::OK) }