Add placeholder nodes for unknown senders (#181)

* Add placeholder nodes for unknown senders

* run rufo
This commit is contained in:
l5y
2025-09-26 14:24:30 +02:00
committed by GitHub
parent 5116313ab0
commit c8c7c8cc05
2 changed files with 174 additions and 0 deletions

View File

@@ -504,6 +504,112 @@ rescue ArgumentError
nil
end
# Determine canonical node identifiers and derived metadata for a reference.
#
# @param node_ref [Object] raw node identifier or numeric reference.
# @param fallback_num [Object] optional numeric reference used when the
# identifier does not encode the value directly.
# @return [Array(String, Integer, String), nil] tuple containing the canonical
# node ID, numeric node reference, and uppercase short identifier suffix when
# the reference can be parsed. Returns nil when the reference cannot be
# converted into a canonical ID.
def canonical_node_parts(node_ref, fallback_num = nil)
fallback = coerce_integer(fallback_num)
hex = nil
num = nil
case node_ref
when Integer
num = node_ref
when Numeric
num = node_ref.to_i
when String
trimmed = node_ref.strip
return nil if trimmed.empty?
if trimmed.start_with?("!")
hex = trimmed.delete_prefix("!")
elsif trimmed.match?(/\A0[xX][0-9A-Fa-f]+\z/)
hex = trimmed[2..].to_s
elsif trimmed.match?(/\A-?\d+\z/)
num = trimmed.to_i
elsif trimmed.match?(/\A[0-9A-Fa-f]+\z/)
hex = trimmed
else
return nil
end
when nil
num = fallback if fallback
else
return nil
end
num ||= fallback if fallback
if hex
begin
num ||= Integer(hex, 16)
rescue ArgumentError
return nil
end
elsif num
return nil if num.negative?
hex = format("%08x", num & 0xFFFFFFFF)
else
return nil
end
return nil if hex.nil? || hex.empty?
begin
parsed = Integer(hex, 16)
rescue ArgumentError
return nil
end
parsed &= 0xFFFFFFFF
canonical_hex = format("%08x", parsed)
short_id = canonical_hex[-4, 4].upcase
["!#{canonical_hex}", parsed, short_id]
end
# Ensure a placeholder node entry exists for the provided identifier.
#
# Messages and telemetry can reference nodes before the daemon has received a
# full node snapshot. When this happens we create a minimal hidden entry so the
# sender can be resolved in the UI until richer metadata becomes available.
#
# @param db [SQLite3::Database] open database handle.
# @param node_ref [Object] raw identifier extracted from the payload.
# @param fallback_num [Object] optional numeric reference used when the
# identifier is missing.
def ensure_unknown_node(db, node_ref, fallback_num = nil)
parts = canonical_node_parts(node_ref, fallback_num)
return unless parts
node_id, node_num, short_id = parts
existing = db.get_first_value(
"SELECT 1 FROM nodes WHERE node_id = ? LIMIT 1",
[node_id],
)
return if existing
long_name = "Meshtastic #{short_id}"
with_busy_retry do
db.execute(
<<~SQL,
INSERT OR IGNORE INTO nodes(node_id,num,short_name,long_name,role)
VALUES (?,?,?,?,?)
SQL
[node_id, node_num, short_id, long_name, "CLIENT_HIDDEN"],
)
end
end
# Insert or update a node row with the most recent metrics.
#
# @param db [SQLite3::Database] open database handle.
@@ -743,6 +849,8 @@ def insert_position(db, payload)
canonical = normalize_node_id(db, node_id || node_num)
node_id = canonical if canonical
ensure_unknown_node(db, node_id || node_num, node_num)
to_id = string_or_nil(payload["to_id"] || payload["to"])
position_section = payload["position"].is_a?(Hash) ? payload["position"] : {}
@@ -936,6 +1044,8 @@ def insert_message(db, m)
encrypted = string_or_nil(m["encrypted"])
ensure_unknown_node(db, from_id || raw_from_id, m["from_num"])
row = [
msg_id,
rx_time,

View File

@@ -450,6 +450,39 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "creates hidden nodes for unknown message senders" do
payload = {
"id" => 9_999,
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.iso8601,
"from_id" => "!feedf00d",
"to_id" => "^all",
"channel" => 0,
"portnum" => "TEXT_MESSAGE_APP",
"text" => "Spec placeholder message",
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT node_id, num, short_name, long_name, role FROM nodes WHERE node_id = ?",
["!feedf00d"],
)
expect(row).not_to be_nil
expect(row["node_id"]).to eq("!feedf00d")
expect(row["num"]).to eq(0xfeedf00d)
expect(row["short_name"]).to eq("F00D")
expect(row["long_name"]).to eq("Meshtastic F00D")
expect(row["role"]).to eq("CLIENT_HIDDEN")
end
end
it "returns 400 when the payload is not valid JSON" do
post "/api/messages", "{", auth_headers
@@ -605,6 +638,37 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "creates hidden nodes for unknown position senders" do
payload = {
"id" => 42,
"node_id" => "!0badc0de",
"rx_time" => reference_time.to_i,
"rx_iso" => reference_time.iso8601,
"latitude" => 52.1,
"longitude" => 13.1,
}
post "/api/positions", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT node_id, num, short_name, long_name, role FROM nodes WHERE node_id = ?",
["!0badc0de"],
)
expect(row).not_to be_nil
expect(row["node_id"]).to eq("!0badc0de")
expect(row["num"]).to eq(0x0badc0de)
expect(row["short_name"]).to eq("C0DE")
expect(row["long_name"]).to eq("Meshtastic C0DE")
expect(row["role"]).to eq("CLIENT_HIDDEN")
end
end
it "returns 400 when the payload is not valid JSON" do
post "/api/positions", "{", auth_headers