feat: add Pro-equivalent management API on top of OSS hbbs
Brings the rustdesk-server up to feature parity with RustDesk Server Pro for
the API surface the desktop client expects (CONSOLE_API.md). Implemented as
an in-process axum router mounted by hbbs alongside its existing
rendezvous + relay TCP/UDP/WS listeners; everything persists in the existing
SQLx + SQLite database via additional CREATE TABLE IF NOT EXISTS migrations.
================================================================================
M1 — Auth foundation + heartbeat + sysinfo
================================================================================
- New tables: users, tokens, device_sysinfo.
- Endpoints: HEAD+GET /api/login-options, POST /api/login, POST /api/logout,
POST /api/currentUser, POST /api/heartbeat, POST /api/sysinfo_ver,
POST /api/sysinfo.
- Bearer-token auth: tokens are 32 random bytes (base64url); only the
sha256 of the token is stored. `tokens.last_used_at`/`expires_at` slide
forward on every authenticated request (30-day TTL by default).
- Bcrypt-cost-10 password hashing, always wrapped in
tokio::task::spawn_blocking to keep the runtime responsive.
- New CLI flags --http-port, --bootstrap-admin-username,
--bootstrap-admin-password.
- Heartbeat returns the `sysinfo: true` flag on first contact and after
cfg.sysinfo_ver bumps; sysinfo upload returns the bare-string body
("SYSINFO_UPDATED" / "ID_NOT_FOUND") the client expects.
================================================================================
M2 — Address book, device groups, accessible peers
================================================================================
- New tables: address_books, address_book_shares, address_book_peers,
address_book_tags, address_book_peer_tags, device_groups,
device_group_members. Soft-ALTER adds device_sysinfo.user_id (the
binding from a device to its enrolled user, set by /api/login).
- Endpoints: POST /api/ab/settings, POST /api/ab/personal,
POST /api/ab/shared/profiles, POST /api/ab/peers, POST /api/ab/tags/{guid},
POST /api/ab/peer/add/{guid}, PUT /api/ab/peer/update/{guid},
DELETE /api/ab/peer/{guid}, POST /api/ab/tag/add/{guid},
PUT /api/ab/tag/rename/{guid}, PUT /api/ab/tag/update/{guid},
DELETE /api/ab/tag/{guid}, GET+POST /api/ab (legacy single-blob fallback),
GET /api/device-group/accessible, GET /api/users, GET /api/peers.
- Share-rule enforcement (1=read, 2=read/write, 3=full) at the top of every
AB mutation. Owners are full; other rules come from
address_book_shares (direct or via device_group). Rejection is HTTP 200 +
{"error":"read-only"} so the client doesn't yank the session.
- New CLI flags --ab-legacy-mode, --ab-max-peers-per-book.
- Action endpoints (peer add/update/delete, tag CRUD) return HTTP 200 with
EMPTY body on success — matches the Flutter _jsonDecodeActionResp at
ab_model.dart:2002 which treats {} as the literal error string "null".
================================================================================
M3 — Audit, recording, strategy push
================================================================================
- New tables: audit_conn (PK guid echoed back to client),
audit_file, audit_alarm, recordings, strategies, strategy_assignments,
heartbeat_commands.
- Endpoints: POST /api/audit/conn (returns {"guid":"..."}),
POST /api/audit/file, POST /api/audit/alarm, PUT /api/audit (note update),
POST /api/record?type={new|part|tail|remove}.
- Recording uploader: filesystem state machine under --recording-dir;
filenames sanitized to a single Normal path component to block traversal;
`tail` writes the first ≤1024 bytes at offset 0 after all `part` chunks.
- Heartbeat extended to:
* resolve a per-peer strategy (peer > device-group > user, highest
priority wins) and emit `strategy.config_options` + `extra` +
`modified_at`.
* read-and-delete heartbeat_commands rows so an admin can queue
`disconnect: [conn_id]` or force `sysinfo: true` via SQL and have it
delivered on the next 15-second tick.
- New CLI flags --recording-dir (default ./recordings),
--recording-max-size-mb, --audit-retention-days.
================================================================================
secure_tcp on the rendezvous TCP listener (M3 polish)
================================================================================
A logged-in client conditionally calls secure_tcp() on its TCP rendezvous
connection (src/client.rs:427-431, gated on `key && token` both non-empty).
OSS hbbs had no KeyExchange handler at all on TCP rendezvous, so the
client's secure_tcp_impl read timed out with "Failed to secure tcp:
deadline has elapsed". Added:
- A try_secure_tcp_handshake helper that, on every accepted TCP connection,
generates an ephemeral box keypair, signs the box public key with the
server's Ed25519 sk (already loaded for relay-response signing), sends
KeyExchange, then waits 5s for the client's reply.
- Reply is KeyExchange[client_box_pk, sealed_sym_key] -> decrypt the
sealed key, install Encrypt on both halves of the stream.
- Reply is any other RendezvousMessage -> buffer it and replay through
the normal handle_tcp dispatcher (plain-mode clients filter unsolicited
KeyExchange via get_next_nonkeyexchange_msg, so our preceding KX is
harmless).
- Reply never comes (timeout) -> fall through to plain mode.
- Sink::TcpStream now carries an Option<Encrypt>; outgoing writes are
sealed when keyed. Symmetric Encrypt is cloned for inbound (`dec`) and
outbound (`enc`) so the two directions track independent counters.
================================================================================
M4 — Advanced auth (TOTP, email-code, OIDC), CLI assign, plugin signing
================================================================================
- New tables: user_totp_secrets, pending_tfa_challenges,
pending_email_codes, oidc_providers, oidc_sessions. Soft-ALTER adds
users.oidc_subject.
- /api/login extended:
* type:"account" (existing) — issues an `tfa_check` challenge (5-min
nonce in `secret`) when the user has TOTP enrolled.
* type:"tfa_code" — verifies the nonce + the 6-digit TOTP code against
user_totp_secrets.secret_b32.
* type:"email_code" — passwordless. First leg mints a 6-digit code and
sends it via SMTP (or logs to stdout when --smtp-host is empty);
second leg verifies. Brute-force capped at 5 attempts per code, then
the row is purged.
- /api/oidc/auth + GET /oidc/callback + GET /api/oidc/auth-query implement
the standard OAuth2 authorization-code flow with userinfo. Discovery via
<issuer>/.well-known/openid-configuration with an in-memory cache.
--oidc-config TOML upserts providers at startup; --public-base-url builds
the redirect_uri.
- New endpoints: POST /api/2fa/enroll (admin-only, returns secret_b32 +
otpauth_url), POST /api/2fa/unenroll, POST /api/devices/cli (used by
`rustdesk --assign`; binds device to user, ensures device-group, adds
AB entry, attaches peer-scoped strategy), POST /lic/web/api/plugin-sign
(Ed25519 over the request body using the same id_ed25519 secret).
- /api/login-options is now dynamic: returns ["account"], plus "email_code"
when SMTP or ALLOW_DEV_EMAIL_CODE is set, plus an "oidc/<name>" entry
per enabled provider in oidc_providers.
- New CLI flags --smtp-host, --smtp-port, --smtp-user, --smtp-pass,
--smtp-from, --smtp-tls, --public-base-url, --oidc-config.
- New crate deps: tokio (fs/io-util features), totp-rs, lettre (rustls +
builder + smtp-transport, no defaults), toml.
================================================================================
Code organization
================================================================================
- src/api/ axum router + shared state + error envelope
├── ab/ address book endpoints (settings/profiles/peers/
│ tags/legacy/rules)
├── audit/ conn/file/alarm/note
├── oidc/ providers/discovery/auth/callback/poll
├── record/ storage state machine + handler
├── strategy/ resolver wrapper around DB
├── auth.rs login/logout/currentUser
├── devices_cli.rs /api/devices/cli
├── email.rs SMTP transport (lettre) + dev-mode stdout fallback
├── error.rs ApiError enum -> HTTP 200/401/403/404 + JSON envelope
├── groups.rs /api/device-group/accessible
├── heartbeat.rs /api/heartbeat
├── middleware.rs AuthedUser extractor (Bearer -> sha256 -> token row)
├── pagination.rs Page<T> + PageQuery
├── peers.rs /api/peers
├── plugin_sign.rs /lic/web/api/plugin-sign
├── state.rs AppState + ApiConfig (incl. EmailConfig)
├── sysinfo.rs /api/sysinfo, /api/sysinfo_ver
├── twofa.rs /api/2fa/enroll, /unenroll
└── users.rs UserPayload + /api/users + bcrypt helpers
================================================================================
Conventions enforced throughout
================================================================================
- All new SQL uses the runtime sqlx::query("...") form (NOT the query!
macro) so first-time builds don't require DATABASE_URL to point at a DB
containing the new tables.
- Soft-ALTER helper (try_alter) swallows "duplicate column name" errors so
schema migrations are idempotent across re-runs and existing-DB upgrades.
- Bcrypt compares always via spawn_blocking.
- Tokens (Bearer access_token, TFA challenge nonce, OIDC poll handle) are
always 24-32 random bytes from sodiumoxide::randombytes; the Bearer is
stored only as its sha256.
- Constant-time hash comparison for email codes.
- Action endpoints return HTTP 200 with empty body on success; HTTP 200 +
{"error": "..."} for business errors so the client doesn't get logged
out; 401 only from the auth middleware.
Tested end-to-end via curl + a stock RustDesk client (M1-M2 verified
against two laptops; M3 verified against the strategy-push and
force-disconnect paths; M4 verified via direct flow tests + a mock IdP for
OIDC). Stock client connect now works whether the user is signed in or
not (the secure_tcp regression that blocked logged-in connect is fixed).
The remaining piece on the M4 plan — HttpProxyRequest, the TCP-over-
rendezvous fallback for clients with OPTION_USE_RAW_TCP_FOR_API=Y — is
gated on bumping the OSS server's vendored hbb_common to a commit that
includes proto tags 27 and 28. That work lives on a separate branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
//! Legacy single-blob address book — `GET /api/ab` and `POST /api/ab`.
|
||||
//!
|
||||
//! Activated when the operator sets `--ab-legacy-mode=on` (which makes
|
||||
//! `/api/ab/personal` 404 — the documented signal in CONSOLE_API.md §4.2).
|
||||
//! The wire shape is a JSON-string field `data` whose contents are a second
|
||||
//! JSON object: `{tags, peers, tag_colors}`. We translate to/from the
|
||||
//! normalized M2 schema on the personal AB.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::AbPeerRow;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn get(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let guid = state
|
||||
.db
|
||||
.ab_get_or_create_personal(user.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
// Pull all peers and all tags. Page size 1000 is fine — legacy clients
|
||||
// expected a single blob anyway.
|
||||
let (_total, peers) = state
|
||||
.db
|
||||
.ab_list_peers(&guid, 0, 10_000)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let tags = state
|
||||
.db
|
||||
.ab_list_tags(&guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let mut tag_colors = Map::new();
|
||||
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||
for t in &tags {
|
||||
tag_colors.insert(t.name.clone(), Value::from(t.color));
|
||||
}
|
||||
let peer_arr: Vec<Value> = peers
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"id": p.id,
|
||||
"alias": p.alias,
|
||||
"tags": p.tags,
|
||||
"username": p.username,
|
||||
"hostname": p.hostname,
|
||||
"platform": p.platform,
|
||||
"hash": p.hash,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let inner = json!({
|
||||
"tags": tag_names,
|
||||
"peers": peer_arr,
|
||||
"tag_colors": Value::String(serde_json::to_string(&tag_colors).unwrap_or_default()),
|
||||
});
|
||||
Ok(Json(json!({ "data": inner.to_string() })))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LegacyPostBody {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
pub async fn put(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Json(body): Json<LegacyPostBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let guid = state
|
||||
.db
|
||||
.ab_get_or_create_personal(user.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let inner: Value = serde_json::from_str(&body.data)
|
||||
.map_err(|e| ApiError::BadRequest(format!("data is not valid json: {}", e)))?;
|
||||
// Tag colors are stored as a JSON-encoded string field (Flutter wraps
|
||||
// the map in another JSON layer). Tolerate either an inline map or the
|
||||
// doubly-encoded form.
|
||||
let tag_colors_map: Map<String, Value> = match inner.get("tag_colors") {
|
||||
Some(Value::String(s)) => serde_json::from_str(s).unwrap_or_default(),
|
||||
Some(Value::Object(m)) => m.clone(),
|
||||
_ => Map::new(),
|
||||
};
|
||||
let tag_names: Vec<String> = inner
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tags: Vec<(String, i64)> = tag_names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let color = tag_colors_map
|
||||
.get(n)
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
(n.clone(), color)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let peer_arr = inner
|
||||
.get("peers")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut peers: Vec<AbPeerRow> = Vec::with_capacity(peer_arr.len());
|
||||
for p in peer_arr {
|
||||
let id = p
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tags = p
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
peers.push(AbPeerRow {
|
||||
id,
|
||||
alias: get_str(&p, "alias"),
|
||||
note: String::new(),
|
||||
password: String::new(),
|
||||
hash: get_str(&p, "hash"),
|
||||
username: get_str(&p, "username"),
|
||||
hostname: get_str(&p, "hostname"),
|
||||
platform: get_str(&p, "platform"),
|
||||
tags,
|
||||
});
|
||||
}
|
||||
state
|
||||
.db
|
||||
.ab_legacy_replace(&guid, &tags, &peers)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
fn get_str(v: &Value, k: &str) -> String {
|
||||
v.get(k)
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod legacy;
|
||||
pub mod peers;
|
||||
pub mod profiles;
|
||||
pub mod rules;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
@@ -0,0 +1,198 @@
|
||||
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=<guid>`.
|
||||
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=<guid>` — 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<String>,
|
||||
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<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Query(q): Query<AbQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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<PeerOut> = 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<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hostname: Option<String>,
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
}
|
||||
|
||||
/// `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<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(body): Json<PeerAddBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(ids): Json<Vec<String>>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::pagination::{Page, PageQuery};
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `POST /api/ab/personal` — returns the caller's personal AB GUID, creating
|
||||
/// it if missing. When `--ab-legacy-mode=on` is configured, returns 404 to
|
||||
/// signal "this server speaks the legacy single-blob protocol" (the client
|
||||
/// then falls back to GET/POST /api/ab).
|
||||
pub async fn personal(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if state.cfg.ab_legacy_mode {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
let guid = state
|
||||
.db
|
||||
.ab_get_or_create_personal(user.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Json(json!({ "guid": guid })))
|
||||
}
|
||||
|
||||
/// `POST /api/ab/shared/profiles` — paginated list of shared address books
|
||||
/// the caller can see. Wire shape matches the Flutter `AbProfile` decoder at
|
||||
/// flutter/lib/common/hbbs/hbbs.dart:258.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AbProfileOut {
|
||||
guid: String,
|
||||
name: String,
|
||||
owner: String,
|
||||
note: String,
|
||||
rule: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
info: Option<Value>,
|
||||
}
|
||||
|
||||
pub async fn shared_profiles(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Query(page): Query<PageQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let (total, rows) = state
|
||||
.db
|
||||
.ab_list_shared_for_user(user.user_id, page.offset(), page.limit())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let data = rows
|
||||
.into_iter()
|
||||
.map(|r| AbProfileOut {
|
||||
guid: r.guid,
|
||||
name: r.name,
|
||||
owner: r.owner,
|
||||
note: r.note,
|
||||
rule: r.rule,
|
||||
info: r
|
||||
.info_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok()),
|
||||
})
|
||||
.collect();
|
||||
Ok((StatusCode::OK, Json(Page { total, data })))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
|
||||
/// Share-rule levels for a shared address book. Wire integers match the
|
||||
/// Flutter client's `ShareRule` enum at flutter/lib/common/hbbs/hbbs.dart:210.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Rule {
|
||||
Read = 1,
|
||||
ReadWrite = 2,
|
||||
Full = 3,
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
pub fn from_i64(v: i64) -> Option<Self> {
|
||||
match v {
|
||||
1 => Some(Rule::Read),
|
||||
2 => Some(Rule::ReadWrite),
|
||||
3 => Some(Rule::Full),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforce that `caller` has at least `needed` access on `ab_guid`. Used at
|
||||
/// the top of every AB handler. Resolution lives in
|
||||
/// `Database::ab_resolve_rule` and considers (a) AB ownership and (b) the
|
||||
/// largest matching rule across direct and device-group shares.
|
||||
pub async fn enforce(
|
||||
state: &AppState,
|
||||
caller_user_id: i64,
|
||||
ab_guid: &str,
|
||||
needed: Rule,
|
||||
) -> Result<(), ApiError> {
|
||||
let resolved = state
|
||||
.db
|
||||
.ab_resolve_rule(caller_user_id, ab_guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let Some(have) = resolved.and_then(Rule::from_i64) else {
|
||||
// Either the AB doesn't exist or the caller has no relationship with
|
||||
// it. Surface as "not allowed" so we don't leak existence.
|
||||
return Err(ApiError::Forbidden("not allowed".into()));
|
||||
};
|
||||
if have >= needed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Forbidden("read-only".into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::Extension;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `POST /api/ab/settings` — capability/limit probe. The Flutter client
|
||||
/// (ab_model.dart:230-258) calls this once per pull cycle to learn
|
||||
/// `max_peer_one_ab`. Auth is required even though there is no body.
|
||||
pub async fn settings(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
_user: AuthedUser,
|
||||
) -> Json<Value> {
|
||||
Json(json!({ "max_peer_one_ab": state.cfg.ab_max_peers_per_book }))
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use crate::api::ab::rules::{enforce, Rule};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `POST /api/ab/tags/{guid}` — list tags. Wire shape is a bare JSON array
|
||||
/// `[{name, color}]`, NOT the `Page<T>` envelope.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TagOut {
|
||||
name: String,
|
||||
color: i64,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::Read).await?;
|
||||
let rows = state
|
||||
.db
|
||||
.ab_list_tags(&guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let out: Vec<TagOut> = rows
|
||||
.into_iter()
|
||||
.map(|t| TagOut {
|
||||
name: t.name,
|
||||
color: t.color,
|
||||
})
|
||||
.collect();
|
||||
Ok((StatusCode::OK, Json(out)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TagAddBody {
|
||||
pub name: String,
|
||||
pub color: i64,
|
||||
}
|
||||
|
||||
pub async fn add(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(body): Json<TagAddBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
if body.name.is_empty() {
|
||||
return Err(ApiError::BadRequest("name required".into()));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.ab_tag_insert(&guid, &body.name, body.color)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TagRenameBody {
|
||||
#[serde(rename = "old")]
|
||||
pub old_name: String,
|
||||
#[serde(rename = "new")]
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
pub async fn rename(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(body): Json<TagRenameBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
state
|
||||
.db
|
||||
.ab_tag_rename(&guid, &body.old_name, &body.new_name)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TagUpdateBody {
|
||||
pub name: String,
|
||||
pub color: i64,
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(body): Json<TagUpdateBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
state
|
||||
.db
|
||||
.ab_tag_update_color(&guid, &body.name, body.color)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(names): Json<Vec<String>>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
state
|
||||
.db
|
||||
.ab_tags_delete(&guid, &names)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
Reference in New Issue
Block a user