mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
web: honor the node opt-out marker on /api/destinations (#943)
This commit is contained in:
4 files changed
+60
-6
No files matched your search
+1
-1
@@ -34,7 +34,7 @@
|
||||
* Docs: document the Reticulum config directory, its Docker volume, and how to add interfaces to it
|
||||
* Data: `ALLOWED_CHANNELS`, `HIDDEN_CHANNELS`, and `MAP_ZOOM` no longer break on the packaged Compose default's quoted-empty value (ACCEPTANCE CH-A1-CH-A3)
|
||||
* Data/Web: one Reticulum node row per announced destination, grouped by `nodes.identity_hash` (SPEC RE1)
|
||||
* Data/Web: `user.publicKey` carries the identity's real public key; destinations get their own table, served by `GET /api/destinations` (SPEC RE2)
|
||||
* Data/Web: `user.publicKey` carries the identity's real public key; destinations get their own table, served by `GET /api/destinations`, which honors the node opt-out marker (SPEC RE2)
|
||||
* Data: new `RETICULUM_INTERFACES` allowlist scopes announce ingestion by interface, applied from one hop out; the operator's own nodes are always ingested (SPEC RN4/RE4)
|
||||
* Web: the `/charts` mesh-activity figure draws the Reticulum series (SPEC RN2)
|
||||
* Web: `instances.reticulum_nodes_count` gets its own migration (SPEC RN7)
|
||||
|
||||
@@ -455,6 +455,10 @@ One row per announced destination, newest `last_heard` first.
|
||||
- No retention floor. A destination is a relationship, not an event, and the
|
||||
nodes it belongs to already clamp their own window - a floor here would hide
|
||||
the addresses of a node the table is still showing.
|
||||
- Privacy. Honors the node opt-out marker: rows whose `node_id` names an
|
||||
opted-out node are omitted, so `?node_id=` for that node returns `[]`. The
|
||||
opt-out is node-level: a marker in a non-headline destination's own name hides
|
||||
nothing, so the operator puts the marker in the node's headline name.
|
||||
- This route holds no response cache, so `since`/`before` have no cached path to
|
||||
bypass; the weak ETag varies with the cursor because it is hashed from the
|
||||
body the cursor produced.
|
||||
|
||||
@@ -316,13 +316,18 @@ module PotatoMesh
|
||||
# bind parameters.
|
||||
STATS_WINDOWS = %w[hour day week month].freeze
|
||||
|
||||
# Per-protocol scopes counted alongside the unfiltered +total+, paired with
|
||||
# the short column-alias prefix used in the generated SQL.
|
||||
# Fetch the destinations a node has announced on (SPEC RE-A6).
|
||||
#
|
||||
# A Reticulum identity announces on several destinations -- one per aspect
|
||||
# -- and each is its own +nodes+ row, so this table is what lets a reader
|
||||
# group those rows back into one peer via +identity_hash+.
|
||||
# A Reticulum identity is one +nodes+ row (SPEC RE7) that announces on
|
||||
# several destinations -- one per aspect -- so each destination is a row
|
||||
# here, linked to that node by +node_id+ and to its identity by
|
||||
# +identity_hash+.
|
||||
#
|
||||
# Honors the node opt-out marker (Invariant II): rows whose +node_id+
|
||||
# names an opted-out node are never returned, so +node_id:+ set to such a
|
||||
# node yields an empty list. The opt-out is node-level, decided on the
|
||||
# +nodes+ row: a marker only in a non-headline destination's own name
|
||||
# hides nothing.
|
||||
#
|
||||
# @param limit [Integer] maximum rows to return.
|
||||
# @param node_id [String, nil] restrict to one node's destinations.
|
||||
@@ -351,6 +356,11 @@ module PotatoMesh
|
||||
params << threshold
|
||||
end
|
||||
append_before_filter(clauses, params, before, column: "last_heard")
|
||||
# Node-level opt-out (Invariant II): the +node_id+ fragment the positions,
|
||||
# telemetry and waypoints reads already apply. Filtering in SQL keeps
|
||||
# +LIMIT+ counting visible rows only, so a short page still means the
|
||||
# window is exhausted for a backward pager (SPEC RA8).
|
||||
append_opt_out_filter(clauses, params, opt_out_node_id_filter("node_id"))
|
||||
sql = +"SELECT id, node_id, identity_hash, name, aspect, role, interface, " \
|
||||
"first_heard, last_heard, protocol FROM destinations"
|
||||
sql << " WHERE #{clauses.join(" AND ")}" if clauses.any?
|
||||
@@ -361,6 +371,8 @@ module PotatoMesh
|
||||
handle&.close unless db
|
||||
end
|
||||
|
||||
# Per-protocol scopes counted alongside the unfiltered +total+, paired with
|
||||
# the short column-alias prefix used in the generated SQL.
|
||||
STATS_PROTOCOL_SCOPES = [["meshcore", "mc"], ["meshtastic", "mt"], ["reticulum", "rt"]].freeze
|
||||
|
||||
# Return exact activity counts for /api/stats as a scope → metric → window
|
||||
|
||||
@@ -340,6 +340,44 @@ RSpec.describe "Reticulum protocol support" do
|
||||
"aspect" => "lxmf.delivery",
|
||||
)
|
||||
end
|
||||
|
||||
it "omits the destinations of an opted-out node (Invariant II)" do
|
||||
register_reticulum_ingestor
|
||||
post_reticulum_nodes
|
||||
marker = PotatoMesh::Config.node_opt_out_marker
|
||||
opted_identity = "0badcafe#{"00" * 12}"
|
||||
opted = reticulum_node_fixture(last_heard: now - 20, dest_id: RETICULUM_DEST_HASH2)
|
||||
opted["user"]["longName"] = "Quiet #{marker} Station"
|
||||
opted["identityHash"] = opted_identity
|
||||
payload = {
|
||||
RETICULUM_NODE_ID2 => opted,
|
||||
"ingestor" => RETICULUM_INGESTOR_ID,
|
||||
"protocol" => "reticulum",
|
||||
}
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
# Preconditions: ingest is not refused, so the destination row is stored,
|
||||
# and the node row itself is already hidden (A2c). Only the destinations
|
||||
# read path is left to leak it.
|
||||
with_db(readonly: true) do |db|
|
||||
stored = db.execute("SELECT id FROM destinations WHERE node_id = ?", [RETICULUM_NODE_ID2])
|
||||
expect(stored.map { |r| r["id"] }).to eq([RETICULUM_DEST_HASH2])
|
||||
end
|
||||
get "/api/nodes"
|
||||
expect(JSON.parse(last_response.body).map { |n| n["node_id"] }).not_to include(RETICULUM_NODE_ID2)
|
||||
|
||||
get "/api/destinations"
|
||||
expect(last_response.status).to eq(200)
|
||||
rows = JSON.parse(last_response.body)
|
||||
expect(rows.map { |r| r["id"] }).to eq([RETICULUM_DEST_HASH])
|
||||
expect(rows.map { |r| r["identity_hash"] }).not_to include(opted_identity)
|
||||
expect(rows.map { |r| r["name"] }).not_to include("Quiet #{marker} Station")
|
||||
|
||||
get "/api/destinations?node_id=#{RETICULUM_NODE_ID2}"
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(JSON.parse(last_response.body)).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/destinations?node_id=" do
|
||||
|
||||
Reference in new issue
Block a user