Add private mode to hide chat and message APIs (#204)

* Add private mode to hide chat and message APIs

* run rufo
This commit is contained in:
l5y
2025-10-04 09:36:43 +02:00
committed by GitHub
parent 1f2328613c
commit 74c4596dc5
4 changed files with 106 additions and 19 deletions
+3 -2
View File
@@ -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 <API_TOKEN>`)
* POST `/api/positions` - appends positions provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
* POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
* POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`; 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.
+33 -11
View File
@@ -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
+56
View File
@@ -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('<div id="chat"')
expect(body).to include("const CHAT_ENABLED = false;")
expect(body).not_to include("Track nodes, messages, and coverage in real time.")
expect(body).to include("Track nodes and coverage in real time.")
end
end
describe "GET /api/positions" do
it "returns stored positions ordered by receive time" do
node_id = "!specfetch"
+14 -6
View File
@@ -564,7 +564,9 @@ var(--fg); }
</div>
<div class="map-row">
<div id="chat" aria-label="Chat log"></div>
<% unless private_mode %>
<div id="chat" aria-label="Chat log"></div>
<% end %>
<div id="map" role="region" aria-label="Nodes map"></div>
</div>
@@ -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();