From bb01b2d42f0a38a4bbc5eb0f304f03f7fa6683a9 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:39:40 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E8=8A=82=E7=82=B9=E5=8A=A0?= =?UTF-8?q?=E5=85=AC=E5=BC=80=E5=A4=87=E6=B3=A8=EF=BC=8C=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E9=A1=B5=E5=8F=AF=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.rs | 40 +++++++++++++++++++++++++++++- src/db.rs | 27 +++++++++++++++++--- web-admin/src/components/Admin.tsx | 8 ++++++ web-admin/src/lib/api.ts | 2 ++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/api.rs b/src/api.rs index 91958852..7edec91b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -200,6 +200,8 @@ fn node_view(node: &Node, current: Option<&Agent>, traffic: &Traffic, full: bool // 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, + // Written by the operator for visitors, unlike `remark` below. + "public_remark": node.public_remark, "sort": node.sort, "public": node.public, "online": current.is_some(), @@ -684,6 +686,10 @@ fn group_error(group: &mut String) -> Option<&'static str> { None } +/// A public remark goes to every visitor in every frame, every two seconds, where +/// a node's public view is about 1 KB; a hundred CJK characters add 300 bytes. +const MAX_PUBLIC_REMARK: usize = 100; + /// Normalizes the currency and billing cycle, or names the one that cannot be /// stored. The currency is held to the ISO 4217 form of three letters, the only /// one `Intl.NumberFormat` accepts. Hubs before 1.3.1 stored it unchecked, so a @@ -720,6 +726,13 @@ fn patch_error(node: &mut NodePatch) -> Option<&'static str> { return Some(message); } } + // Refused rather than truncated, as a group name is. + if let Some(text) = &mut node.public_remark { + *text = text.trim().to_owned(); + if text.chars().count() > MAX_PUBLIC_REMARK || text.chars().any(char::is_control) { + return Some("公开备注最多 100 个字,不能含控制字符"); + } + } node_limits(node.traffic_reset_day, node.price, node.traffic_limit) .or_else(|| billing_error(node.currency.as_mut(), node.billing_cycle.as_mut())) .or_else(|| pins(node)) @@ -2805,7 +2818,12 @@ mod tests { 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})] { + for refused in [ + json!({"name": "x"}), + json!({"ipv4_pin": "1.2.3.4"}), + json!({"public": false}), + json!({"public_remark": "x"}), + ] { assert!(serde_json::from_value::(refused.clone()).is_err(), "{refused}"); } // Counted in characters, not bytes: thirteen of them take 39 bytes. @@ -2822,6 +2840,26 @@ mod tests { assert!(live_snapshot(&app, false).as_str().contains(r#""group":"香港""#), "the group is public"); } + /// The public note reaches visitors trimmed, and one too long for every + /// frame, or one carrying a line break, is refused rather than cut. + #[tokio::test] + async fn a_public_remark_is_bounded_and_reaches_visitors() { + let app = std::sync::Arc::new(app()); + let id = node(&app, "n", true); + let put = |text: String| { + let patch = serde_json::from_value(json!({ "public_remark": text })).unwrap(); + update_node(Admin, axum::extract::State(app.clone()), Path(id), Ok(Json(patch))) + }; + + assert_eq!(put("港".repeat(MAX_PUBLIC_REMARK + 1)).await.status(), StatusCode::BAD_REQUEST); + assert_eq!(put("一行\n两行".into()).await.status(), StatusCode::BAD_REQUEST); + assert_eq!(put(" CN2 GIA ".into()).await.status(), StatusCode::OK); + let public = &visible_nodes(&app, false).unwrap()[0]; + assert_eq!((&public["public_remark"], &public["remark"]), (&json!("CN2 GIA"), &Value::Null)); + // Counted in characters, not bytes. + assert_eq!(put("港".repeat(MAX_PUBLIC_REMARK)).await.status(), StatusCode::OK); + } + /// 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] diff --git a/src/db.rs b/src/db.rs index 84ce6d3a..12418da1 100644 --- a/src/db.rs +++ b/src/db.rs @@ -93,6 +93,9 @@ CREATE TABLE IF NOT EXISTS node ( -- 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 and shown on the status page, unlike `remark`. Empty is + -- none. + public_remark 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 '', @@ -209,7 +212,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 = 11; +const SCHEMA_VERSION: i64 = 12; /// Adds a column older databases lack. A duplicate column indicates the /// migration has already run; every other error must propagate. @@ -383,6 +386,10 @@ fn migrate_to_11(conn: &Connection) -> Result<()> { Ok(()) } +fn migrate_to_12(conn: &Connection) -> Result<()> { + add_column(conn, "node", "public_remark 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. @@ -429,6 +436,9 @@ fn migrate(conn: &Connection, from: i64) -> Result<()> { if from < 11 { migrate_to_11(&tx)?; } + if from < 12 { + migrate_to_12(&tx)?; + } tx.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))?; tx.commit()?; Ok(()) @@ -472,6 +482,10 @@ pub struct Node { pub expires_at: Option, #[serde(default)] pub remark: String, + /// Set in the panel for visitors to read, beside `remark`, which they never + /// see. Empty is none. + #[serde(default)] + pub public_remark: String, /// Monthly allowance in bytes; 0 means unmetered. #[serde(default)] pub traffic_limit: i64, @@ -562,6 +576,7 @@ pub struct NodePatch { #[serde(default, deserialize_with = "expiry_patch")] pub expires_at: Option>, pub remark: Option, + pub public_remark: Option, pub traffic_limit: Option, pub traffic_mode: Option, pub traffic_reset_day: Option, @@ -1095,7 +1110,8 @@ impl Db { traffic_reset_day=COALESCE(?12,traffic_reset_day), notify=COALESCE(?13,notify), country_pin=COALESCE(?14,country_pin), ipv4_pin=COALESCE(?15,ipv4_pin), ipv6_pin=COALESCE(?16,ipv6_pin), - group_name=COALESCE(?17,group_name) + group_name=COALESCE(?17,group_name), + public_remark=COALESCE(?18,public_remark) WHERE id=?1", )?; for id in ids { @@ -1116,7 +1132,8 @@ impl Db { n.country_pin, n.ipv4_pin, n.ipv6_pin, - n.group + n.group, + n.public_remark ])?; // Dropping the transaction uncommitted rolls back the nodes // already updated. @@ -2491,6 +2508,7 @@ fn row_to_node(r: &rusqlite::Row<'_>) -> Node { billing_cycle: s("billing_cycle"), expires_at: r.get::<_, Option>("expires_at").unwrap_or(None), remark: s("remark"), + public_remark: s("public_remark"), traffic_limit: n("traffic_limit"), traffic_mode: s("traffic_mode"), traffic_reset_day: n("traffic_reset_day") as u32, @@ -3301,13 +3319,14 @@ mod tests { let patch = |v| serde_json::from_value::(v).unwrap(); db.update_node( id, - &patch(serde_json::json!({"public":false,"remark":"private","expires_at":"2030-01-01"})), + &patch(serde_json::json!({"public":false,"remark":"private","public_remark":"CN2 GIA","expires_at":"2030-01-01"})), ) .unwrap(); db.update_node(id, &patch(serde_json::json!({"price":20}))).unwrap(); let n = db.node(id).unwrap().unwrap(); assert!(!n.public); assert_eq!(n.remark, "private"); + assert_eq!(n.public_remark, "CN2 GIA"); assert_eq!(n.expires_at.as_deref(), Some("2030-01-01")); db.update_node(id, &patch(serde_json::json!({"price":0,"expires_at":null}))).unwrap(); let n = db.node(id).unwrap().unwrap(); diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index db31d484..05f9124f 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -760,6 +760,7 @@ function NodeForm({ node, nodes, onClose, onSaved }: { name: form.name.trim(), public: form.public, remark: form.remark, + public_remark: (form.public_remark ?? "").trim(), group: (form.group ?? "").trim(), traffic_mode: form.traffic_mode, traffic_limit: Math.round(Number(limitGib) * GIB), @@ -816,6 +817,13 @@ function NodeForm({ node, nodes, onClose, onSaved }: { set("group", v)} /> + + set("public_remark", e.target.value)} + placeholder="公开页可见,最多 100 字" + /> + set("remark", e.target.value)} placeholder="仅管理员可见" /> diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index 627f4ddd..cf763634 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -78,6 +78,8 @@ export type Node = { /** Panel only. The agent's reporting interval in seconds, read from its reports; null until two have arrived. */ interval?: number | null remark?: string + /** Set by hand and public. Absent from a hub predating it. */ + public_remark?: string /** Panel only. */ token?: string /** Panel only. Whether going offline and returning are announced. */ From b9097404f8d77cb4e58125bb9ce17fdf81cd0ccd Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:59:09 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E5=85=AC=E5=BC=80=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E4=B8=8E=E7=A7=81=E6=9C=89=E5=A4=87=E6=B3=A8=E5=B9=B6=E6=8E=92?= =?UTF-8?q?=EF=BC=8C=E5=A4=87=E6=B3=A8=E6=94=B9=E7=A7=B0=E7=A7=81=E6=9C=89?= =?UTF-8?q?=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-admin/src/components/Admin.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index 05f9124f..a1e1f86b 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -817,14 +817,14 @@ function NodeForm({ node, nodes, onClose, onSaved }: { set("group", v)} /> - + set("public_remark", e.target.value)} placeholder="公开页可见,最多 100 字" /> - + set("remark", e.target.value)} placeholder="仅管理员可见" /> From fd264fc28ed2ad9860ce11f91542dd2404890134 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:12:31 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=E6=B3=A8=E9=87=8A=E5=86=99?= =?UTF-8?q?=E6=98=8E=E8=AE=BF=E5=AE=A2=E6=8B=BF=E4=B8=8D=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E6=98=AF=E7=A7=81=E6=9C=89=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.rs b/src/api.rs index 7edec91b..d721dda5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -247,7 +247,7 @@ fn node_view(node: &Node, current: Option<&Agent>, traffic: &Traffic, full: bool // from its own repository, so a field added there would otherwise reach // anonymous visitors the day it is released, and a node token in the wrong // hands could fill the panel's frame with whatever it sends. No address, - // hostname or note may ever reach a visitor. + // hostname or private note may ever reach a visitor. if let Some(m) = view["metrics"].as_object_mut() { m.retain(|k, _| PUBLIC_METRICS.contains(&k.as_str()) || (full && k == "iface")); // The same figures as the top-level ones, from the same row. Both official