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
42 changes: 40 additions & 2 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -245,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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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::<BatchPatch>(refused.clone()).is_err(), "{refused}");
}
// Counted in characters, not bytes: thirteen of them take 39 bytes.
Expand All @@ -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]
Expand Down
27 changes: 23 additions & 4 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 '',
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -472,6 +482,10 @@ pub struct Node {
pub expires_at: Option<String>,
#[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,
Expand Down Expand Up @@ -562,6 +576,7 @@ pub struct NodePatch {
#[serde(default, deserialize_with = "expiry_patch")]
pub expires_at: Option<Option<String>>,
pub remark: Option<String>,
pub public_remark: Option<String>,
pub traffic_limit: Option<i64>,
pub traffic_mode: Option<String>,
pub traffic_reset_day: Option<u32>,
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -2491,6 +2508,7 @@ fn row_to_node(r: &rusqlite::Row<'_>) -> Node {
billing_cycle: s("billing_cycle"),
expires_at: r.get::<_, Option<String>>("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,
Expand Down Expand Up @@ -3301,13 +3319,14 @@ mod tests {
let patch = |v| serde_json::from_value::<NodePatch>(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();
Expand Down
10 changes: 9 additions & 1 deletion web-admin/src/components/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -816,7 +817,14 @@ function NodeForm({ node, nodes, onClose, onSaved }: {
<Field label="分组" hint="公开页可见,留空为未分组">
<GroupInput nodes={nodes} value={form.group ?? ""} onChange={(v) => set("group", v)} />
</Field>
<Field label="备注" className="sm:col-span-2">
<Field label="公开备注">
<Input
value={form.public_remark ?? ""}
onChange={(e) => set("public_remark", e.target.value)}
placeholder="公开页可见,最多 100 字"
/>
</Field>
<Field label="私有备注">
<Input value={form.remark ?? ""} onChange={(e) => set("remark", e.target.value)} placeholder="仅管理员可见" />
</Field>
</div>
Expand Down
2 changes: 2 additions & 0 deletions web-admin/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading