mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-07 17:33:26 +02:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user