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:
2026-05-01 19:07:01 +02:00
parent 815c728837
commit 3e89d61566
42 changed files with 5848 additions and 15 deletions
+161
View File
@@ -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()
}
+6
View File
@@ -0,0 +1,6 @@
pub mod legacy;
pub mod peers;
pub mod profiles;
pub mod rules;
pub mod settings;
pub mod tags;
+198
View File
@@ -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)
}
+71
View File
@@ -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 })))
}
+49
View File
@@ -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()))
}
}
+16
View File
@@ -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 }))
}
+122
View File
@@ -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)
}