Files
rustdesk-server/src/api/admin/pages/strategies.rs
T
2026-05-21 12:24:06 +02:00

469 lines
15 KiB
Rust

//! Strategies page — list / create / edit-config / delete, plus the
//! assignment matrix that decides which clients receive each strategy.
//! Assignments are scoped to device groups or individual peers; user-level
//! assignments are still SQL-driven (rare in practice).
use super::shared::{html_escape, notice_html, require_admin};
use crate::api::admin::i18n::{t, tf1, tf2, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_full(&state, lang).await?))
}
#[derive(Debug, Deserialize)]
pub struct CreateForm {
pub name: String,
#[serde(default)]
pub config_options_json: String,
}
pub async fn create(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Form(form): Form<CreateForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if form.name.trim().is_empty() {
return notice_then(&state, lang, "error", t(lang, "groups.name_required")).await;
}
let cfg = if form.config_options_json.trim().is_empty() {
"{}".to_string()
} else {
// Validate it's a JSON object — empty object is fine, anything else
// gets rejected with a friendly message.
match serde_json::from_str::<serde_json::Value>(&form.config_options_json) {
Ok(v) if v.is_object() => form.config_options_json.clone(),
Ok(_) => {
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
}
Err(e) => {
return notice_then(
&state,
lang,
"error",
&tf1(lang, "strategies.invalid_json", &e.to_string()),
)
.await
}
}
};
state
.db
.strategy_create(form.name.trim(), &cfg, "{}")
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
lang,
"ok",
&tf1(lang, "strategies.created", &form.name),
)
.await
}
#[derive(Debug, Deserialize)]
pub struct UpdateForm {
pub config_options_json: String,
}
pub async fn update(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
Form(form): Form<UpdateForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cfg = match serde_json::from_str::<serde_json::Value>(&form.config_options_json) {
Ok(v) if v.is_object() => form.config_options_json.clone(),
_ => {
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
}
};
state
.db
.strategy_update_config(id, &cfg)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(&state, lang, "ok", t(lang, "strategies.updated")).await
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.strategy_delete(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
lang,
if ok { "ok" } else { "error" },
if ok { t(lang, "strategies.deleted") } else { t(lang, "common.already_gone") },
)
.await
}
#[derive(Debug, Deserialize)]
pub struct AssignGroupForm {
pub device_group_id: i64,
}
pub async fn assign_group(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
Form(form): Form<AssignGroupForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.strategy_assign_group(id, form.device_group_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(&state, lang, "ok", t(lang, "strategies.group_assigned")).await
}
#[derive(Debug, Deserialize)]
pub struct AssignPeerForm {
pub peer_id: String,
}
pub async fn assign_peer(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
Form(form): Form<AssignPeerForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let peer_id = form.peer_id.trim();
if peer_id.is_empty() {
return notice_then(&state, lang, "error", t(lang, "groups.peer_id_required")).await;
}
let exists = state
.db
.peer_exists(peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !exists {
return notice_then(
&state,
lang,
"error",
&tf1(lang, "groups.no_device_yet", peer_id),
)
.await;
}
state
.db
.strategy_assign_peer_replace(id, peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
lang,
"ok",
&tf1(lang, "strategies.peer_assigned", peer_id),
)
.await
}
pub async fn unassign(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path((id, assignment_id)): Path<(i64, i64)>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.strategy_assignment_delete(id, assignment_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
lang,
if ok { "ok" } else { "error" },
if ok { t(lang, "strategies.unassigned") } else { t(lang, "common.already_gone") },
)
.await
}
// ---------- rendering ----------
async fn notice_then(
state: &Arc<AppState>,
lang: Lang,
kind: &str,
msg: &str,
) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg);
html.push_str(&render_full(state, lang).await?);
Ok(Html(html))
}
async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
let strategies = state
.db
.strategies_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let all_groups = state
.db
.device_groups_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
let _ = write!(
s,
r##"<div id="strategies-region" class="space-y-6">
<header>
<h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500 mt-1">{tagline}</p>
</header>
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">{create_heading}</h3>
<form class="space-y-2 text-sm" hx-post="/admin/pages/strategies/create" hx-target="#strategies-region" hx-swap="outerHTML">
<input name="name" placeholder="{ph}" required class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<textarea name="config_options_json" rows="3" placeholder='{{"enable-udp": "N", "whitelist": ""}}'
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs"></textarea>
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">{create}</button>
</form>
</section>
"##,
heading = t(lang, "strategies.heading"),
tagline = t(lang, "strategies.tagline"),
create_heading = t(lang, "strategies.create_heading"),
ph = t(lang, "strategies.name_unique"),
create = t(lang, "common.create"),
);
if strategies.is_empty() {
let _ = write!(
s,
r##"<p class="text-slate-500 text-sm">{}</p>"##,
t(lang, "strategies.no_strategies"),
);
}
for str_ in &strategies {
let assignments = state
.db
.strategy_assignments_for(str_.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let _ = write!(
s,
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
<header class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{name}</h3>
<p class="text-xs text-slate-500">{meta}</p>
</div>
<button class="text-xs text-rose-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{id}/delete"
hx-confirm="{confirm}"
hx-target="#strategies-region" hx-swap="outerHTML">{delete}</button>
</header>
<form class="space-y-2 text-sm"
hx-post="/admin/pages/strategies/{id}/update"
hx-target="#strategies-region" hx-swap="outerHTML">
<label class="block text-xs text-slate-400">{cfg_label}</label>
<textarea name="config_options_json" rows="4"
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs">{cfg}</textarea>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{save}</button>
</form>"##,
id = str_.id,
name = html_escape(&str_.name),
meta = tf2(lang, "strategies.id_modified", &str_.id.to_string(), &str_.modified_at.to_string()),
cfg = html_escape(&str_.config_options_json),
confirm = html_escape(&tf1(lang, "strategies.confirm_delete", &str_.name)),
delete = t(lang, "common.delete"),
cfg_label = t(lang, "strategies.config_label"),
save = t(lang, "common.save"),
);
// ---- Assignments section ----
render_assignments(&mut s, lang, str_.id, &assignments, &all_groups);
s.push_str("</section>");
}
s.push_str("</div>");
Ok(s)
}
fn render_assignments(
s: &mut String,
lang: Lang,
strategy_id: i64,
assignments: &[crate::database::StrategyAssignmentRow],
all_groups: &[crate::database::DeviceGroupRow],
) {
let group_assigned: std::collections::HashSet<i64> = assignments
.iter()
.filter_map(|a| a.device_group_id)
.collect();
let group_rows: Vec<&crate::database::StrategyAssignmentRow> = assignments
.iter()
.filter(|a| a.device_group_id.is_some())
.collect();
let peer_rows: Vec<&crate::database::StrategyAssignmentRow> = assignments
.iter()
.filter(|a| a.peer_id.is_some())
.collect();
let _ = write!(
s,
r##"<div class="pt-3 border-t border-slate-800 space-y-3">
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide">{filter_heading}</h4>
<p class="text-[11px] text-slate-500">{filter_hint}</p>"##,
filter_heading = t(lang, "strategies.filter_heading"),
filter_hint = t(lang, "strategies.filter_hint"),
);
// ---- Device groups ----
let _ = write!(
s,
r##"<div>
<div class="text-[11px] font-semibold text-slate-400 mb-1">{groups_label}</div>
<ul class="text-sm divide-y divide-slate-800">"##,
groups_label = t(lang, "strategies.groups_label"),
);
if group_rows.is_empty() {
let _ = write!(
s,
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
t(lang, "strategies.no_group_assignments"),
);
}
for a in &group_rows {
let name = a
.device_group_name
.as_deref()
.unwrap_or("(deleted group)");
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="text-slate-200">{name}</span>
<button class="text-xs text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{sid}/assignments/{aid}/delete"
hx-target="#strategies-region" hx-swap="outerHTML">{remove}</button>
</li>"##,
name = html_escape(name),
sid = strategy_id,
aid = a.id,
remove = t(lang, "common.remove"),
);
}
s.push_str("</ul>");
let candidates: Vec<&crate::database::DeviceGroupRow> = all_groups
.iter()
.filter(|g| !group_assigned.contains(&g.id))
.collect();
if !candidates.is_empty() {
let _ = write!(
s,
r##"<form class="flex gap-2 text-sm pt-2"
hx-post="/admin/pages/strategies/{sid}/assignments/group"
hx-target="#strategies-region" hx-swap="outerHTML">
<select name="device_group_id" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5">
"##,
sid = strategy_id,
);
for g in &candidates {
let _ = write!(
s,
r##"<option value="{gid}">{name}</option>"##,
gid = g.id,
name = html_escape(&g.name),
);
}
let _ = write!(
s,
r##"</select>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{assign}</button>
</form>"##,
assign = t(lang, "strategies.assign_group"),
);
} else if !all_groups.is_empty() {
let _ = write!(
s,
r##"<p class="text-[11px] text-slate-500 pt-1">{}</p>"##,
t(lang, "strategies.all_groups_assigned"),
);
} else {
let _ = write!(
s,
r##"<p class="text-[11px] text-slate-500 pt-1">{}</p>"##,
t(lang, "strategies.no_groups_exist"),
);
}
s.push_str("</div>");
// ---- Peers ----
let _ = write!(
s,
r##"<div>
<div class="text-[11px] font-semibold text-slate-400 mb-1">{peers_label}</div>
<ul class="text-sm divide-y divide-slate-800">"##,
peers_label = t(lang, "strategies.peers_label"),
);
if peer_rows.is_empty() {
let _ = write!(
s,
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
t(lang, "strategies.no_peer_assignments"),
);
}
for a in &peer_rows {
let pid = a.peer_id.as_deref().unwrap_or("");
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="font-mono text-slate-200">{pid}</span>
<button class="text-xs text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{sid}/assignments/{aid}/delete"
hx-target="#strategies-region" hx-swap="outerHTML">{remove}</button>
</li>"##,
pid = html_escape(pid),
sid = strategy_id,
aid = a.id,
remove = t(lang, "common.remove"),
);
}
s.push_str("</ul>");
let _ = write!(
s,
r##"<form class="flex gap-2 text-sm pt-2"
hx-post="/admin/pages/strategies/{sid}/assignments/peer"
hx-target="#strategies-region" hx-swap="outerHTML">
<input name="peer_id" placeholder="{ph}" required
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono"/>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{assign}</button>
</form>"##,
sid = strategy_id,
ph = t(lang, "groups.peer_id_placeholder"),
assign = t(lang, "strategies.assign_peer"),
);
s.push_str("</div></div>");
}