diff --git a/README.md b/README.md index 8dd8c44..9368d07 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ The web app can be configured with environment variables (defaults shown): * `MAP_CENTER_LAT` / `MAP_CENTER_LON` - default map center coordinates (default: `52.502889` / `13.404194`) * `MAX_NODE_DISTANCE_KM` - hide nodes farther than this distance from the center (default: `137`) * `MATRIX_ROOM` - matrix room id for a footer link (default: `#meshtastic-berlin:matrix.org`) +* `PRIVATE` - set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients (default: unset) The application derives SEO-friendly document titles, descriptions, and social preview tags from these existing configuration values and reuses the bundled @@ -72,10 +73,10 @@ The web app contains an API: * GET `/api/nodes?limit=100` - returns the latest 100 nodes reported to the app * GET `/api/positions?limit=100` - returns the latest 100 position data -* GET `/api/messages?limit=100` - returns the latest 100 messages +* GET `/api/messages?limit=100` - returns the latest 100 messages (disabled when `PRIVATE=1`) * POST `/api/nodes` - upserts nodes provided as JSON object mapping node ids to node data (requires `Authorization: Bearer `) * POST `/api/positions` - appends positions provided as a JSON object or array (requires `Authorization: Bearer `) -* POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer `) +* POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer `; disabled when `PRIVATE=1`) The `API_TOKEN` environment variable must be set to a non-empty value and match the token supplied in the `Authorization` header for `POST` requests. diff --git a/web/app.rb b/web/app.rb index 7ee75bb..fbb818d 100644 --- a/web/app.rb +++ b/web/app.rb @@ -98,6 +98,10 @@ MAX_NODE_DISTANCE_KM = ENV.fetch("MAX_NODE_DISTANCE_KM", "137").to_f MATRIX_ROOM = ENV.fetch("MATRIX_ROOM", "#meshtastic-berlin:matrix.org") DEBUG = ENV["DEBUG"] == "1" +def private_mode? + ENV["PRIVATE"] == "1" +end + def sanitized_string(value) value.to_s.strip end @@ -204,7 +208,13 @@ def meta_description summary += " on #{channel} (#{frequency})." end - sentences = [summary, "Track nodes, messages, and coverage in real time."] + activity_sentence = if private_mode? + "Track nodes and coverage in real time." + else + "Track nodes, messages, and coverage in real time." + end + + sentences = [summary, activity_sentence] if (distance = sanitized_max_distance_km) sentences << "Shows nodes within roughly #{formatted_distance_km(distance)} km of the map center." end @@ -308,16 +318,25 @@ def query_nodes(limit) db.results_as_hash = true now = Time.now.to_i min_last_heard = now - WEEK_SECONDS - rows = db.execute <<~SQL, [min_last_heard, limit] - SELECT node_id, short_name, long_name, hw_model, role, snr, - battery_level, voltage, last_heard, first_heard, - uptime_seconds, channel_utilization, air_util_tx, - position_time, latitude, longitude, altitude - FROM nodes - WHERE last_heard >= ? - ORDER BY last_heard DESC - LIMIT ? - SQL + params = [min_last_heard] + sql = <<~SQL + SELECT node_id, short_name, long_name, hw_model, role, snr, + battery_level, voltage, last_heard, first_heard, + uptime_seconds, channel_utilization, air_util_tx, + position_time, latitude, longitude, altitude + FROM nodes + WHERE last_heard >= ? + SQL + if private_mode? + sql += " AND (role IS NULL OR role <> 'CLIENT_HIDDEN')\n" + end + sql += <<~SQL + ORDER BY last_heard DESC + LIMIT ? + SQL + params << limit + + rows = db.execute(sql, params) rows.each do |r| r["role"] ||= "CLIENT" lh = r["last_heard"]&.to_i @@ -458,6 +477,7 @@ end # # Returns a JSON array of stored text messages including node metadata. get "/api/messages" do + halt 404 if private_mode? content_type :json limit = [params["limit"]&.to_i || 200, 1000].min query_messages(limit).to_json @@ -1197,6 +1217,7 @@ end # # Accepts an array or object describing text messages and stores each entry. post "/api/messages" do + halt 404 if private_mode? require_token! content_type :json begin @@ -1273,5 +1294,6 @@ get "/" do max_node_distance_km: MAX_NODE_DISTANCE_KM, matrix_room: sanitized_matrix_room, version: APP_VERSION, + private_mode: private_mode?, } end diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index aa1955f..965267f 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -150,13 +150,20 @@ RSpec.describe "Potato Mesh Sinatra app" do before do @original_token = ENV["API_TOKEN"] + @original_private = ENV["PRIVATE"] ENV["API_TOKEN"] = api_token + ENV.delete("PRIVATE") allow(Time).to receive(:now).and_return(reference_time) clear_database end after do ENV["API_TOKEN"] = @original_token + if @original_private.nil? + ENV.delete("PRIVATE") + else + ENV["PRIVATE"] = @original_private + end end describe "logging configuration" do @@ -1318,6 +1325,55 @@ RSpec.describe "Potato Mesh Sinatra app" do end end + context "when private mode is enabled" do + before do + ENV["PRIVATE"] = "1" + end + + it "returns 404 for GET /api/messages" do + get "/api/messages" + expect(last_response.status).to eq(404) + end + + it "returns 404 for POST /api/messages" do + post "/api/messages", {}.to_json, auth_headers + expect(last_response.status).to eq(404) + end + + it "excludes hidden clients from the nodes API" do + now = reference_time.to_i + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)", + ["!hidden", "hidn", "Hidden", "TBEAM", "CLIENT_HIDDEN", 0.0, now, now], + ) + db.execute( + "INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)", + ["!visible", "vis", "Visible", "TBEAM", "CLIENT", 1.0, now, now], + ) + end + + get "/api/nodes?limit=10" + + expect(last_response).to be_ok + nodes = JSON.parse(last_response.body) + ids = nodes.map { |node| node["node_id"] } + expect(ids).to include("!visible") + expect(ids).not_to include("!hidden") + end + + it "removes the chat interface from the homepage" do + get "/" + + expect(last_response).to be_ok + body = last_response.body + expect(body).not_to include('
-
+ <% unless private_mode %> +
+ <% end %>
@@ -658,6 +660,7 @@ var(--fg); } const CHAT_LIMIT = 1000; const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60; const REFRESH_MS = <%= refresh_interval_seconds * 1000 %>; + const CHAT_ENABLED = <%= private_mode ? "false" : "true" %>; refreshInfo.textContent = `<%= default_channel %> (<%= default_frequency %>) — active nodes: …`; let refreshTimer = null; @@ -1206,6 +1209,7 @@ var(--fg); } const itemsContainer = L.DomUtil.create('div', 'legend-items', div); legendRoleButtons.clear(); for (const [role, color] of Object.entries(roleColors)) { + if (!CHAT_ENABLED && role === 'CLIENT_HIDDEN') continue; const item = L.DomUtil.create('button', 'legend-item', itemsContainer); item.type = 'button'; item.setAttribute('aria-pressed', 'false'); @@ -1526,7 +1530,7 @@ var(--fg); } } function renderChatLog(nodes, messages) { - if (!chatEl) return; + if (!CHAT_ENABLED || !chatEl) return; const entries = []; for (const n of nodes || []) { entries.push({ type: 'node', ts: n.first_heard ?? 0, item: n }); @@ -1649,6 +1653,7 @@ var(--fg); } } async function fetchMessages(limit = NODE_LIMIT) { + if (!CHAT_ENABLED) return []; const r = await fetch(`/api/messages?limit=${limit}`, { cache: 'no-store' }); if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); @@ -1815,10 +1820,13 @@ var(--fg); } const nodes = await fetchNodes(); nodes.forEach(applyNodeNameFallback); computeDistances(nodes); - const messages = await fetchMessages(); - messages.forEach(message => { - if (message && message.node) applyNodeNameFallback(message.node); - }); + let messages = []; + if (CHAT_ENABLED) { + messages = await fetchMessages(); + messages.forEach(message => { + if (message && message.node) applyNodeNameFallback(message.node); + }); + } renderChatLog(nodes, messages); allNodes = nodes; applyFilter();