Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 121 additions & 10 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ fn node_view(node: &Node, current: Option<&Agent>, traffic: &Traffic, full: bool
// in, which is what a status page conveys, without locating it. The
// address it was derived from remains behind the panel.
"country": if node.country_pin.is_empty() { &node.country } else { &node.country_pin },
// Named by the operator for the status page to divide the list by, so
// public like the node's name. Empty is ungrouped.
"group": node.group,
"sort": node.sort,
"public": node.public,
"online": current.is_some(),
Expand Down Expand Up @@ -535,6 +538,37 @@ fn node_limits(reset_day: Option<u32>, price: Option<f64>, limit: Option<i64>) -
None
}

/// A theme shows the group as a tab label, so it is held to a short one.
const MAX_GROUP: usize = 32;

/// Trims a group name, or refuses it. Refused rather than truncated: the panel
/// would otherwise report saved a name that is not the one stored.
fn group_error(group: &mut String) -> Option<&'static str> {
*group = group.trim().to_owned();
if group.chars().count() > MAX_GROUP || group.chars().any(char::is_control) {
return Some("group must be at most 32 characters, without control characters");
}
None
}

/// Normalizes a patch, or names the first value that cannot be stored. The one
/// check both the single and the batch write pass through, so the two accept
/// exactly the same values.
fn patch_error(node: &mut NodePatch) -> Option<&'static str> {
if let Some(name) = &mut node.name {
*name = name.trim().to_owned();
if name.is_empty() {
return Some("name is required");
}
}
if let Some(group) = &mut node.group {
if let Some(message) = group_error(group) {
return Some(message);
}
}
node_limits(node.traffic_reset_day, node.price, node.traffic_limit).or_else(|| pins(node))
}

/// Normalizes the values set by hand, or names the one that cannot stand. Each
/// takes the place of an automatic value, so it is held to what that value would
/// have to be: the country to the rule a looked-up one passes, as both reach the
Expand Down Expand Up @@ -598,6 +632,7 @@ pub async fn create_node(
}
if let Some(message) =
node_limits(Some(node.traffic_reset_day), Some(node.price), Some(node.traffic_limit))
.or_else(|| group_error(&mut node.group))
{
return bad(message);
}
Expand Down Expand Up @@ -769,16 +804,7 @@ pub async fn update_node(
body: Result<Json<NodePatch>, JsonRejection>,
) -> Response {
let Ok(Json(mut node)) = body else { return bad("invalid node") };
if let Some(name) = &mut node.name {
*name = name.trim().to_owned();
if name.is_empty() {
return bad("name is required");
}
}
if let Some(message) = node_limits(node.traffic_reset_day, node.price, node.traffic_limit) {
return bad(message);
}
if let Some(message) = pins(&mut node) {
if let Some(message) = patch_error(&mut node) {
return bad(message);
}
match app.db.update_node(id, &node) {
Expand All @@ -791,6 +817,53 @@ pub async fn update_node(
}
}

/// What a batch may set: the settings the panel applies across a selection.
/// An allowlist, so a field added to [`NodePatch`] later, possibly one that
/// describes a single machine, is refused here until it is listed.
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct BatchPatch {
group: Option<String>,
notify: Option<bool>,
}

#[derive(Deserialize)]
pub struct NodeBatch {
ids: Vec<i64>,
#[serde(default)]
patch: BatchPatch,
}

/// Applies one patch to every selected node, all or none.
pub async fn update_nodes(
_: Admin,
State(app): State<Shared>,
body: Result<Json<NodeBatch>, JsonRejection>,
) -> Response {
let Ok(Json(NodeBatch { mut ids, patch })) = body else {
return bad("invalid batch: only group and notify apply to several nodes at once");
};
ids.sort_unstable();
ids.dedup();
if ids.is_empty() {
return bad("no nodes selected");
}
let mut patch = NodePatch { group: patch.group, notify: patch.notify, ..Default::default() };
if let Some(message) = patch_error(&mut patch) {
return bad(message);
}
match app.db.update_nodes(&ids, &patch) {
Ok(true) => {
invalidate_snapshot(&app);
Json(json!({"updated": ids.len()})).into_response()
}
Ok(false) => {
(StatusCode::NOT_FOUND, "有节点已被删除,没有做任何修改;刷新后重新选择").into_response()
}
Err(e) => fail(e),
}
}

#[derive(Deserialize)]
pub struct NodeOrder {
ids: Vec<i64>,
Expand Down Expand Up @@ -2301,6 +2374,44 @@ mod tests {
assert!(app.agents.read().unwrap().is_empty(), "the node must read as offline at once");
}

/// A batch writes to every selected node or to none, accepts only what a
/// selection can share, and a group reaches the status page.
#[tokio::test]
async fn a_batch_edit_applies_to_all_selected_nodes_or_none() {
let app = std::sync::Arc::new(app());
let state = || axum::extract::State(app.clone());
let (a, b, c) = (node(&app, "a", true), node(&app, "b", true), node(&app, "c", true));
let batch = |ids: Vec<i64>, patch: Value| {
Ok(Json(NodeBatch { ids, patch: serde_json::from_value(patch).unwrap() }))
};
let group = |id| app.db.node(id).unwrap().unwrap().group;

let r =
update_nodes(Admin, state(), batch(vec![a, b, a], json!({"group": " 香港 ", "notify": true})))
.await;
assert_eq!(r.status(), StatusCode::OK);
assert_eq!((group(a), group(b), group(c)), ("香港".into(), "香港".into(), String::new()));
assert!(app.db.node(b).unwrap().unwrap().notify);

// One id gone: nothing is written, not even to the nodes still there.
let r = update_nodes(Admin, state(), batch(vec![a, 999], json!({"group": "东京"}))).await;
assert_eq!(r.status(), StatusCode::NOT_FOUND);
assert_eq!(group(a), "香港", "a refused batch leaves every node as it was");

// Only the listed fields deserialize, so the extractor refuses the rest.
for refused in [json!({"name": "x"}), json!({"ipv4_pin": "1.2.3.4"}), json!({"public": false})] {
assert!(serde_json::from_value::<BatchPatch>(refused.clone()).is_err(), "{refused}");
}
let r = update_nodes(Admin, state(), batch(vec![a], json!({"group": "g".repeat(33)}))).await;
assert_eq!(r.status(), StatusCode::BAD_REQUEST);
assert_eq!(
update_nodes(Admin, state(), batch(vec![], json!({"notify": false}))).await.status(),
StatusCode::BAD_REQUEST
);

assert!(live_snapshot(&app, false).as_str().contains(r#""group":"香港""#), "the group is public");
}

/// What the panel saves is what an anonymous visitor reads, under the same
/// condition as the node list, and only an installed theme takes a write.
#[tokio::test]
Expand Down
110 changes: 74 additions & 36 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ CREATE TABLE IF NOT EXISTS node (
-- Set in the panel. When not empty it is the country shown, in place of the
-- looked-up one, which goes on updating underneath.
country_pin TEXT NOT NULL DEFAULT '',
-- Set in the panel and shown on the status page, where a theme may divide the
-- node list by it. Empty is ungrouped. Not `group`, a reserved word.
group_name TEXT NOT NULL DEFAULT '',
-- Set in the panel, each replacing the address shown for its family. Empty
-- means automatic. Panel only, like the reported addresses.
ipv4_pin TEXT NOT NULL DEFAULT '', ipv6_pin TEXT NOT NULL DEFAULT '',
Expand Down Expand Up @@ -149,7 +152,7 @@ CREATE TABLE IF NOT EXISTS session (
/// cannot: `open` runs `SCHEMA` before migrating, and on an older file the
/// column is not there yet. `an_upgraded_release_matches_a_fresh_database`
/// holds every migration to these rules, starting from v1.0.0's schema.
const SCHEMA_VERSION: i64 = 6;
const SCHEMA_VERSION: i64 = 7;

/// Adds a column older databases lack. A duplicate column indicates the
/// migration has already run; every other error must propagate.
Expand Down Expand Up @@ -266,6 +269,10 @@ fn migrate_to_6(conn: &Connection) -> Result<()> {
add_column(conn, "ping_task", "auto_join INTEGER NOT NULL DEFAULT 0")
}

fn migrate_to_7(conn: &Connection) -> Result<()> {
add_column(conn, "node", "group_name TEXT NOT NULL DEFAULT ''")
}

/// Brings a database already in service up to `SCHEMA_VERSION` and stamps it.
/// `from` is its current version, so a fresh file passes `SCHEMA_VERSION` and
/// receives only the stamp.
Expand Down Expand Up @@ -297,6 +304,9 @@ fn migrate(conn: &Connection, from: i64) -> Result<()> {
if from < 6 {
migrate_to_6(&tx)?;
}
if from < 7 {
migrate_to_7(&tx)?;
}
tx.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))?;
tx.commit()?;
Ok(())
Expand Down Expand Up @@ -373,6 +383,9 @@ pub struct Node {
/// `country`. What the status page shows is this when present.
#[serde(default)]
pub country_pin: String,
/// Set in the panel; empty is ungrouped. Public, like the name.
#[serde(default)]
pub group: String,
/// Set in the panel, in canonical form, for what neither agent nor hub can
/// know: the home line behind a transparent proxy, or which of several public
/// addresses to show. Each replaces the address shown for its family; empty
Expand Down Expand Up @@ -419,6 +432,7 @@ pub struct NodePatch {
pub country_pin: Option<String>,
pub ipv4_pin: Option<String>,
pub ipv6_pin: Option<String>,
pub group: Option<String>,
}

fn expiry_patch<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Option<String>>, D::Error> {
Expand Down Expand Up @@ -626,8 +640,9 @@ impl Db {
// A new node belongs at the end. The caller sends sort 0, which would
// tie with whatever the last reorder placed first.
"INSERT INTO node (name, token, sort, public, price, currency, billing_cycle,
expires_at, remark, traffic_limit, traffic_mode, traffic_reset_day, created_at)
VALUES (?1,?2,(SELECT COALESCE(MAX(sort),-1)+1 FROM node),?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",
expires_at, remark, traffic_limit, traffic_mode, traffic_reset_day, created_at,
group_name)
VALUES (?1,?2,(SELECT COALESCE(MAX(sort),-1)+1 FROM node),?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)",
params![
n.name,
token,
Expand All @@ -640,7 +655,8 @@ impl Db {
n.traffic_limit,
n.traffic_mode,
n.traffic_reset_day,
Utc::now().timestamp()
Utc::now().timestamp(),
n.group
],
)?;
let id = tx.last_insert_rowid();
Expand Down Expand Up @@ -668,38 +684,59 @@ impl Db {

/// False when no node has this id.
pub fn update_node(&self, id: i64, n: &NodePatch) -> Result<bool> {
let found = self.conn().execute(
"UPDATE node SET name=COALESCE(?2,name), sort=COALESCE(?3,sort), public=COALESCE(?4,public),
price=COALESCE(?5,price), currency=COALESCE(?6,currency),
billing_cycle=COALESCE(?7,billing_cycle),
expires_at=CASE WHEN ?8 THEN ?9 ELSE expires_at END,
remark=COALESCE(?10,remark), traffic_limit=COALESCE(?11,traffic_limit),
traffic_mode=COALESCE(?12,traffic_mode),
traffic_reset_day=COALESCE(?13,traffic_reset_day),
notify=COALESCE(?14,notify), country_pin=COALESCE(?15,country_pin),
ipv4_pin=COALESCE(?16,ipv4_pin), ipv6_pin=COALESCE(?17,ipv6_pin)
WHERE id=?1",
params![
id,
n.name,
n.sort,
n.public,
n.price,
n.currency,
n.billing_cycle,
n.expires_at.is_some(),
n.expires_at.as_ref().and_then(|v| v.as_deref()),
n.remark,
n.traffic_limit,
n.traffic_mode,
n.traffic_reset_day,
n.notify,
n.country_pin,
n.ipv4_pin,
n.ipv6_pin
],
)?;
Ok(found > 0)
self.update_nodes(&[id], n)
}

/// Applies one patch to every node in `ids` in a single transaction. False,
/// with nothing written, when any of them no longer exists: a batch applied
/// to part of what was selected would leave the panel to work out which part.
pub fn update_nodes(&self, ids: &[i64], n: &NodePatch) -> Result<bool> {
let mut conn = self.conn();
let tx = conn.transaction()?;
{
let mut update = tx.prepare(
"UPDATE node SET name=COALESCE(?2,name), sort=COALESCE(?3,sort), public=COALESCE(?4,public),
price=COALESCE(?5,price), currency=COALESCE(?6,currency),
billing_cycle=COALESCE(?7,billing_cycle),
expires_at=CASE WHEN ?8 THEN ?9 ELSE expires_at END,
remark=COALESCE(?10,remark), traffic_limit=COALESCE(?11,traffic_limit),
traffic_mode=COALESCE(?12,traffic_mode),
traffic_reset_day=COALESCE(?13,traffic_reset_day),
notify=COALESCE(?14,notify), country_pin=COALESCE(?15,country_pin),
ipv4_pin=COALESCE(?16,ipv4_pin), ipv6_pin=COALESCE(?17,ipv6_pin),
group_name=COALESCE(?18,group_name)
WHERE id=?1",
)?;
for id in ids {
let found = update.execute(params![
id,
n.name,
n.sort,
n.public,
n.price,
n.currency,
n.billing_cycle,
n.expires_at.is_some(),
n.expires_at.as_ref().and_then(|v| v.as_deref()),
n.remark,
n.traffic_limit,
n.traffic_mode,
n.traffic_reset_day,
n.notify,
n.country_pin,
n.ipv4_pin,
n.ipv6_pin,
n.group
])?;
// Dropping the transaction uncommitted rolls back the nodes
// already updated.
if found == 0 {
return Ok(false);
}
}
}
tx.commit()?;
Ok(true)
}

pub fn set_expiry(&self, id: i64, date: &str) -> Result<()> {
Expand Down Expand Up @@ -1743,6 +1780,7 @@ fn row_to_node(r: &rusqlite::Row<'_>) -> Node {
ipv6: s("ipv6"),
country: s("country"),
country_pin: s("country_pin"),
group: s("group_name"),
ipv4_pin: s("ipv4_pin"),
ipv6_pin: s("ipv6_pin"),
last_seen: n("last_seen"),
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ async fn main() -> Result<()> {
.route("/api/nodes", post(api::create_node))
.route("/api/register-window", post(api::open_register).delete(api::close_register))
.route("/api/nodes/order", put(api::reorder_nodes))
.route("/api/nodes/batch", put(api::update_nodes))
.route("/api/nodes/{id}", put(api::update_node).delete(api::delete_node))
.route("/api/nodes/{id}/token", post(api::reset_token))
.route("/api/nodes/{id}/traffic", put(api::patch_traffic))
Expand Down
Loading
Loading