//! `POST /api/devices/cli` — used by `rustdesk --assign --token ...` //! to enroll a freshly installed device into a tenant slot. //! //! Per CONSOLE_API.md §11: bearer-authenticated; the response body is plain //! text (empty = success, non-empty = informational message). The client //! prints "Done!" when the body is empty. use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use crate::api::state::AppState; use crate::database::AbPeerInsert; use axum::extract::Extension; use axum::http::header; use axum::response::IntoResponse; use axum::Json; use serde::Deserialize; use serde_json::Value; use std::sync::Arc; #[derive(Debug, Deserialize)] pub struct AssignBody { pub id: String, pub uuid: String, #[serde(default)] pub user_name: Option, #[serde(default)] pub strategy_name: Option, #[serde(default)] pub address_book_name: Option, #[serde(default)] pub address_book_tag: Option, #[serde(default)] pub address_book_alias: Option, #[serde(default)] pub address_book_password: Option, #[serde(default)] pub address_book_note: Option, #[serde(default)] pub device_group_name: Option, #[serde(default)] pub note: Option, #[serde(default)] pub device_username: Option, #[serde(default)] pub device_name: Option, } pub async fn assign( Extension(state): Extension>, caller: AuthedUser, Json(body): Json, ) -> Result { if body.id.is_empty() || body.uuid.is_empty() { return Err(ApiError::BadRequest("id and uuid required".into())); } let mut warnings: Vec = vec![]; // Resolve owner. If --user_name was supplied, that's the owner; otherwise // the caller becomes the owner (matches `rustdesk --assign` flows where // the operator's account is the destination). let owner = if let Some(name) = body.user_name.as_deref().filter(|s| !s.is_empty()) { if !caller.is_admin { return Err(ApiError::Forbidden( "admin required to assign to another user".into(), )); } match state .db .user_find_by_username(name) .await .map_err(|e| ApiError::Internal(e.to_string()))? { Some(u) => u, None => { return Err(ApiError::BadRequest(format!( "no such user: {}", name ))); } } } else { state .db .user_find_by_id(caller.user_id) .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::Unauthorized)? }; // Bind the device to the owner (mirrors what /api/login's device_claim // does, but here it's an admin operation rather than user-initiated). state.db.device_claim(owner.id, &body.id, &body.uuid).await; // Address-book entry. We always target the *owner's* personal AB. if let Some(ab_name) = body.address_book_name.as_deref().filter(|s| !s.is_empty()) { let _ = ab_name; // M2's get_or_create_personal ignores the name; OSS has one personal AB per user. let ab_guid = state .db .ab_get_or_create_personal(owner.id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let tags: Option> = body .address_book_tag .as_deref() .filter(|s| !s.is_empty()) .map(|t| t.split(',').map(|s| s.trim().to_string()).collect()); if let Err(e) = state .db .ab_peer_insert( &ab_guid, AbPeerInsert { id: &body.id, alias: body.address_book_alias.as_deref(), note: body.address_book_note.as_deref(), password: body.address_book_password.as_deref(), hash: None, username: body.device_username.as_deref(), hostname: body.device_name.as_deref(), platform: None, }, tags.as_deref(), ) .await { // Likely a UNIQUE conflict if the peer is already in the AB; // surface as a warning rather than failing the whole call. warnings.push(format!("address-book entry not added: {}", e)); } } // Strategy assignment by name. We attach to the device directly (peer-scoped), // which is the most-specific tier in our resolver. if let Some(name) = body.strategy_name.as_deref().filter(|s| !s.is_empty()) { match resolve_strategy_id(&state, name).await? { Some(strategy_id) => { if let Err(e) = state .db .strategy_assign_peer(strategy_id, &body.id) .await { warnings.push(format!("strategy assignment failed: {}", e)); } } None => { warnings.push(format!("strategy {:?} does not exist", name)); } } } // Device-group membership: ensure the group exists, ensure the owner is a // member. We treat the group name as the natural key per the M2 schema. if let Some(group_name) = body.device_group_name.as_deref().filter(|s| !s.is_empty()) { if let Err(e) = state .db .device_group_ensure_member(group_name, owner.id) .await { warnings.push(format!("device-group assignment failed: {}", e)); } } // Fields we accept but don't currently persist as discrete columns. These // travel with the next sysinfo upload anyway (note, device_username, // device_name end up in `device_sysinfo.payload` JSON). if body.note.as_deref().map(|s| !s.is_empty()).unwrap_or(false) { warnings.push( "--note is currently surfaced via sysinfo only, not persisted as a discrete field" .into(), ); } let body_text = if warnings.is_empty() { String::new() } else { warnings.join("\n") }; Ok(( [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body_text, )) } async fn resolve_strategy_id( state: &AppState, name: &str, ) -> Result, ApiError> { state .db .strategy_find_by_name(name) .await .map_err(|e| ApiError::Internal(e.to_string())) } /// Wrap the `Value` JSON the request _could_ have under `Json` if a /// future variation needs it. Currently unused; kept for symmetry with other /// modules that work with raw JSON in/out. #[allow(dead_code)] fn ignore_value(_v: Value) {}