diff --git a/.env.example b/.env.example index f80a0aa..ad770af 100644 --- a/.env.example +++ b/.env.example @@ -26,29 +26,28 @@ CONNECTION=/dev/ttyACM0 SITE_NAME=My Meshtastic Network # Default Meshtastic channel -DEFAULT_CHANNEL=#MediumFast +CHANNEL=#LongFast # Default frequency for your region # Common frequencies: 868MHz (Europe), 915MHz (US), 433MHz (Worldwide) -DEFAULT_FREQUENCY=868MHz +FREQUENCY=915MHz # Map center coordinates (latitude, longitude) # Berlin, Germany: 52.502889, 13.404194 # Denver, Colorado: 39.7392, -104.9903 # London, UK: 51.5074, -0.1278 -MAP_CENTER_LAT=52.502889 -MAP_CENTER_LON=13.404194 +MAP_CENTER="38.761944,-27.090833" # Maximum distance to show nodes (kilometers) -MAX_NODE_DISTANCE_KM=50 +MAX_DISTANCE=42 # ============================================================================= # OPTIONAL INTEGRATIONS # ============================================================================= -# Matrix chat room for your community (optional) -# Format: !roomid:matrix.org -MATRIX_ROOM='#meshtastic-berlin:matrix.org' +# Community chat link or Matrix room for your community (optional) +# Matrix aliases (e.g. #meshtastic-berlin:matrix.org) will be linked via matrix.to automatically. +CONTACT_LINK='#potatomesh:dod.ngo' # ============================================================================= @@ -70,10 +69,3 @@ POTATOMESH_IMAGE_ARCH=linux-amd64 # Meshtastic channel index (0=primary, 1=secondary, etc.) CHANNEL_INDEX=0 -# Database settings -DB_BUSY_TIMEOUT_MS=5000 -DB_BUSY_MAX_RETRIES=5 -DB_BUSY_RETRY_DELAY=0.05 - -# Application settings -MAX_JSON_BODY_BYTES=1048576 diff --git a/DOCKER.md b/DOCKER.md index 00692e1..9f3a590 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -35,8 +35,8 @@ CONNECTION=/dev/ttyACM0 Additional environment variables are optional: -- `DEFAULT_CHANNEL`, `DEFAULT_FREQUENCY`, `MAP_CENTER_LAT`, `MAP_CENTER_LON`, - `MAX_NODE_DISTANCE_KM`, and `MATRIX_ROOM` customise the UI. +- `CHANNEL`, `FREQUENCY`, `MAP_CENTER`, `MAX_DISTANCE`, and `CONTACT_LINK` + customise the UI. - `POTATOMESH_INSTANCE` (defaults to `http://web:41447`) lets the ingestor post to a remote PotatoMesh instance if you do not run both services together. - `CONNECTION` overrides the default serial device or network endpoint used by diff --git a/Dockerfile b/Dockerfile index b709968..a7c92b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,8 +54,8 @@ COPY --chown=potatomesh:potatomesh web/views/ ./views/ COPY --chown=potatomesh:potatomesh data/*.sql /data/ # Create data directory for SQLite database -RUN mkdir -p /app/data && \ - chown -R potatomesh:potatomesh /app/data +RUN mkdir -p /app/data /app/.local/share/potato-mesh && \ + chown -R potatomesh:potatomesh /app/data /app/.local # Switch to non-root user USER potatomesh @@ -65,18 +65,13 @@ EXPOSE 41447 # Default environment variables (can be overridden by host) ENV APP_ENV=production \ - MESH_DB=/app/data/mesh.db \ - DB_BUSY_TIMEOUT_MS=5000 \ - DB_BUSY_MAX_RETRIES=5 \ - DB_BUSY_RETRY_DELAY=0.05 \ - MAX_JSON_BODY_BYTES=1048576 \ + RACK_ENV=production \ SITE_NAME="PotatoMesh Demo" \ - DEFAULT_CHANNEL="#LongFast" \ - DEFAULT_FREQUENCY="915MHz" \ - MAP_CENTER_LAT=38.761944 \ - MAP_CENTER_LON=-27.090833 \ - MAX_NODE_DISTANCE_KM=42 \ - MATRIX_ROOM="#potatomesh:dod.ngo" \ + CHANNEL="#LongFast" \ + FREQUENCY="915MHz" \ + MAP_CENTER="38.761944,-27.090833" \ + MAX_DISTANCE=42 \ + CONTACT_LINK="#potatomesh:dod.ngo" \ DEBUG=0 # Start the application diff --git a/README.md b/README.md index 879189c..e2b571a 100644 --- a/README.md +++ b/README.md @@ -84,13 +84,12 @@ well-known document is staged in The web app can be configured with environment variables (defaults shown): * `SITE_NAME` - title and header shown in the UI (default: "PotatoMesh Demo") -* `DEFAULT_CHANNEL` - default channel shown in the UI (default: "#LongFast") -* `DEFAULT_FREQUENCY` - default frequency shown in the UI (default: "915MHz") -* `MAP_CENTER_LAT` / `MAP_CENTER_LON` - default map center coordinates (default: `38.761944` / `-27.090833`) -* `MAX_NODE_DISTANCE_KM` - hide nodes farther than this distance from the center (default: `42`) -* `MATRIX_ROOM` - matrix room id for a footer link (default: `#potatomesh:dod.ngo`) +* `CHANNEL` - default channel shown in the UI (default: "#LongFast") +* `FREQUENCY` - default frequency shown in the UI (default: "915MHz") +* `MAP_CENTER` - default map center coordinates (default: `38.761944,-27.090833`) +* `MAX_DISTANCE` - hide nodes farther than this distance from the center (default: `42`) +* `CONTACT_LINK` - chat link or Matrix alias for footer and overlay (default: `#potatomesh:dod.ngo`) * `PRIVATE` - set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients (default: unset) -* `PROM_REPORT_IDS` - comma-separated list of Node IDs to report in prometheus metrics, `*` for all (default: unset) The application derives SEO-friendly document titles, descriptions, and social preview tags from these existing configuration values and reuses the bundled @@ -99,7 +98,7 @@ logo for Open Graph and Twitter cards. Example: ```bash -SITE_NAME="PotatoMesh Demo" MAP_CENTER_LAT=38.761944 MAP_CENTER_LON=-27.090833 MAX_NODE_DISTANCE_KM=42 MATRIX_ROOM="#potatomesh:dod.ngo" ./app.sh +SITE_NAME="PotatoMesh Demo" MAP_CENTER=38.761944,-27.090833 MAX_DISTANCE=42 CONTACT_LINK="#potatomesh:dod.ngo" ./app.sh ``` ### API @@ -183,5 +182,5 @@ See the [Docker guide](DOCKER.md) for more details and custome deployment instru Apache v2.0, Contact -Join our Matrix to discuss the dashboard or ask for technical support: +Join our community chat to discuss the dashboard or ask for technical support: [#potatomesh:dod.ngo](https://matrix.to/#/#potatomesh:dod.ngo) diff --git a/configure.sh b/configure.sh index 3ebc76a..ba95234 100755 --- a/configure.sh +++ b/configure.sh @@ -68,32 +68,30 @@ update_env() { # Get current values from .env if they exist SITE_NAME=$(grep "^SITE_NAME=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "PotatoMesh Demo") -DEFAULT_CHANNEL=$(grep "^DEFAULT_CHANNEL=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#LongFast") -DEFAULT_FREQUENCY=$(grep "^DEFAULT_FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "915MHz") -MAP_CENTER_LAT=$(grep "^MAP_CENTER_LAT=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "38.761944") -MAP_CENTER_LON=$(grep "^MAP_CENTER_LON=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "-27.090833") -MAX_NODE_DISTANCE_KM=$(grep "^MAX_NODE_DISTANCE_KM=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "42") -MATRIX_ROOM=$(grep "^MATRIX_ROOM=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#potatomesh:dod.ngo") +CHANNEL=$(grep "^CHANNEL=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#LongFast") +FREQUENCY=$(grep "^FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "915MHz") +MAP_CENTER=$(grep "^MAP_CENTER=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "38.761944,-27.090833") +MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "42") +CONTACT_LINK=$(grep "^CONTACT_LINK=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#potatomesh:dod.ngo") API_TOKEN=$(grep "^API_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "") POTATOMESH_IMAGE_ARCH=$(grep "^POTATOMESH_IMAGE_ARCH=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "linux-amd64") echo "📍 Location Settings" echo "-------------------" read_with_default "Site Name (your mesh network name)" "$SITE_NAME" SITE_NAME -read_with_default "Map Center Latitude" "$MAP_CENTER_LAT" MAP_CENTER_LAT -read_with_default "Map Center Longitude" "$MAP_CENTER_LON" MAP_CENTER_LON -read_with_default "Max Node Distance (km)" "$MAX_NODE_DISTANCE_KM" MAX_NODE_DISTANCE_KM +read_with_default "Map Center (lat,lon)" "$MAP_CENTER" MAP_CENTER +read_with_default "Max Distance (km)" "$MAX_DISTANCE" MAX_DISTANCE echo "" echo "📡 Meshtastic Settings" echo "---------------------" -read_with_default "Default Channel" "$DEFAULT_CHANNEL" DEFAULT_CHANNEL -read_with_default "Default Frequency (868MHz, 915MHz, etc.)" "$DEFAULT_FREQUENCY" DEFAULT_FREQUENCY +read_with_default "Channel" "$CHANNEL" CHANNEL +read_with_default "Frequency (868MHz, 915MHz, etc.)" "$FREQUENCY" FREQUENCY echo "" echo "💬 Optional Settings" echo "-------------------" -read_with_default "Matrix Room (optional, e.g., #meshtastic-berlin:matrix.org)" "$MATRIX_ROOM" MATRIX_ROOM +read_with_default "Chat link or Matrix room (optional)" "$CONTACT_LINK" CONTACT_LINK echo "" echo "🛠 Docker Settings" @@ -137,12 +135,11 @@ echo "📝 Updating .env file..." # Update .env file update_env "SITE_NAME" "\"$SITE_NAME\"" -update_env "DEFAULT_CHANNEL" "\"$DEFAULT_CHANNEL\"" -update_env "DEFAULT_FREQUENCY" "\"$DEFAULT_FREQUENCY\"" -update_env "MAP_CENTER_LAT" "$MAP_CENTER_LAT" -update_env "MAP_CENTER_LON" "$MAP_CENTER_LON" -update_env "MAX_NODE_DISTANCE_KM" "$MAX_NODE_DISTANCE_KM" -update_env "MATRIX_ROOM" "\"$MATRIX_ROOM\"" +update_env "CHANNEL" "\"$CHANNEL\"" +update_env "FREQUENCY" "\"$FREQUENCY\"" +update_env "MAP_CENTER" "\"$MAP_CENTER\"" +update_env "MAX_DISTANCE" "$MAX_DISTANCE" +update_env "CONTACT_LINK" "\"$CONTACT_LINK\"" update_env "API_TOKEN" "$API_TOKEN" update_env "POTATOMESH_IMAGE_ARCH" "$POTATOMESH_IMAGE_ARCH" @@ -172,11 +169,11 @@ echo "✅ Configuration complete!" echo "" echo "📋 Your settings:" echo " Site Name: $SITE_NAME" -echo " Map Center: $MAP_CENTER_LAT, $MAP_CENTER_LON" -echo " Max Distance: ${MAX_NODE_DISTANCE_KM}km" -echo " Channel: $DEFAULT_CHANNEL" -echo " Frequency: $DEFAULT_FREQUENCY" -echo " Matrix Room: ${MATRIX_ROOM:-'Not set'}" +echo " Map Center: $MAP_CENTER" +echo " Max Distance: ${MAX_DISTANCE}km" +echo " Channel: $CHANNEL" +echo " Frequency: $FREQUENCY" +echo " Chat: ${CONTACT_LINK:-'Not set'}" echo " API Token: ${API_TOKEN:0:8}..." echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH" echo "" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c7ade38..9520e39 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,7 +5,7 @@ services: DEBUG: 1 volumes: - ./web:/app - - ./data:/data + - ./data:/app/.local/share/potato-mesh - /app/vendor/bundle web-bridge: @@ -13,7 +13,7 @@ services: DEBUG: 1 volumes: - ./web:/app - - ./data:/data + - ./data:/app/.local/share/potato-mesh - /app/vendor/bundle ports: - "41447:41447" @@ -24,6 +24,7 @@ services: DEBUG: 1 volumes: - ./data:/app + - ./data:/app/.local/share/potato-mesh - /app/.local ingestor-bridge: @@ -31,4 +32,5 @@ services: DEBUG: 1 volumes: - ./data:/app + - ./data:/app/.local/share/potato-mesh - /app/.local diff --git a/docker-compose.yml b/docker-compose.yml index 11ce429..8fd9500 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,17 +4,16 @@ x-web-base: &web-base APP_ENV: ${APP_ENV:-production} RACK_ENV: ${RACK_ENV:-production} SITE_NAME: ${SITE_NAME:-PotatoMesh Demo} - DEFAULT_CHANNEL: ${DEFAULT_CHANNEL:-#LongFast} - DEFAULT_FREQUENCY: ${DEFAULT_FREQUENCY:-915MHz} - MAP_CENTER_LAT: ${MAP_CENTER_LAT:-38.761944} - MAP_CENTER_LON: ${MAP_CENTER_LON:--27.090833} - MAX_NODE_DISTANCE_KM: ${MAX_NODE_DISTANCE_KM:-42} - MATRIX_ROOM: ${MATRIX_ROOM:-#potatomesh:dod.ngo} + CHANNEL: ${CHANNEL:-#LongFast} + FREQUENCY: ${FREQUENCY:-915MHz} + MAP_CENTER: ${MAP_CENTER:-38.761944,-27.090833} + MAX_DISTANCE: ${MAX_DISTANCE:-42} + CONTACT_LINK: ${CONTACT_LINK:-#potatomesh:dod.ngo} API_TOKEN: ${API_TOKEN} DEBUG: ${DEBUG:-0} command: ["ruby", "app.rb", "-p", "41447", "-o", "0.0.0.0"] volumes: - - potatomesh_data:/app/data + - potatomesh_data:/app/.local/share/potato-mesh - potatomesh_logs:/app/logs restart: unless-stopped deploy: @@ -35,7 +34,7 @@ x-ingestor-base: &ingestor-base API_TOKEN: ${API_TOKEN} DEBUG: ${DEBUG:-0} volumes: - - potatomesh_data:/app/data + - potatomesh_data:/app/.local/share/potato-mesh - potatomesh_logs:/app/logs devices: - ${CONNECTION:-/dev/ttyACM0}:${CONNECTION:-/dev/ttyACM0} diff --git a/web/Dockerfile b/web/Dockerfile index 86d7f97..01d95ca 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -68,18 +68,12 @@ ENV RACK_ENV=production \ APP_ENV=production \ XDG_DATA_HOME=/app/.local/share \ XDG_CONFIG_HOME=/app/.config \ - MESH_DB=/app/data/mesh.db \ - DB_BUSY_TIMEOUT_MS=5000 \ - DB_BUSY_MAX_RETRIES=5 \ - DB_BUSY_RETRY_DELAY=0.05 \ - MAX_JSON_BODY_BYTES=1048576 \ SITE_NAME="PotatoMesh Demo" \ - DEFAULT_CHANNEL="#LongFast" \ - DEFAULT_FREQUENCY="915MHz" \ - MAP_CENTER_LAT=38.761944 \ - MAP_CENTER_LON=-27.090833 \ - MAX_NODE_DISTANCE_KM=42 \ - MATRIX_ROOM="#potatomesh:dod.ngo" \ + CHANNEL="#LongFast" \ + FREQUENCY="915MHz" \ + MAP_CENTER="38.761944,-27.090833" \ + MAX_DISTANCE=42 \ + CONTACT_LINK="#potatomesh:dod.ngo" \ DEBUG=0 # Start the application diff --git a/web/app.sh b/web/app.sh index ee16f09..b1a1cda 100755 --- a/web/app.sh +++ b/web/app.sh @@ -18,7 +18,4 @@ set -euo pipefail bundle install -PORT=${PORT:-41447} -BIND_ADDRESS=${BIND_ADDRESS:-0.0.0.0} - -exec ruby app.rb -p "${PORT}" -o "${BIND_ADDRESS}" +exec ruby app.rb -p 41447 -o 0.0.0.0 diff --git a/web/lib/potato_mesh/application.rb b/web/lib/potato_mesh/application.rb index 83efb9c..d50b502 100644 --- a/web/lib/potato_mesh/application.rb +++ b/web/lib/potato_mesh/application.rb @@ -78,6 +78,9 @@ module PotatoMesh register App::Routes::Ingest register App::Routes::Root + DEFAULT_PORT = 41_447 + DEFAULT_BIND_ADDRESS = "0.0.0.0" + APP_VERSION = determine_app_version INSTANCE_PRIVATE_KEY, INSTANCE_KEY_GENERATED = load_or_generate_instance_private_key INSTANCE_PUBLIC_KEY_PEM = INSTANCE_PRIVATE_KEY.public_key.export @@ -98,12 +101,7 @@ module PotatoMesh # # @param default_port [Integer] fallback port when ENV['PORT'] is absent or invalid. # @return [Integer] port number for the HTTP server. - def self.resolve_port(default_port: 41_447) - raw = ENV["PORT"] - return default_port if raw.nil? - - Integer(raw, 10) - rescue ArgumentError + def self.resolve_port(default_port: DEFAULT_PORT) default_port end @@ -112,6 +110,7 @@ module PotatoMesh set :views, File.expand_path("../../views", __dir__) set :federation_thread, nil set :port, resolve_port + set :bind, DEFAULT_BIND_ADDRESS app_logger = PotatoMesh::Logging.build_logger($stdout) set :logger, app_logger diff --git a/web/lib/potato_mesh/application/federation.rb b/web/lib/potato_mesh/application/federation.rb index 23d5c82..886e0d5 100644 --- a/web/lib/potato_mesh/application/federation.rb +++ b/web/lib/potato_mesh/application/federation.rb @@ -31,8 +31,8 @@ module PotatoMesh pubkey: app_constant(:INSTANCE_PUBLIC_KEY_PEM), name: sanitized_site_name, version: app_constant(:APP_VERSION), - channel: sanitized_default_channel, - frequency: sanitized_default_frequency, + channel: sanitized_channel, + frequency: sanitized_frequency, latitude: PotatoMesh::Config.map_center_lat, longitude: PotatoMesh::Config.map_center_lon, last_update_time: last_update, diff --git a/web/lib/potato_mesh/application/helpers.rb b/web/lib/potato_mesh/application/helpers.rb index 0566760..ef4da36 100644 --- a/web/lib/potato_mesh/application/helpers.rb +++ b/web/lib/potato_mesh/application/helpers.rb @@ -89,18 +89,18 @@ module PotatoMesh PotatoMesh::Sanitizer.sanitized_site_name end - # Retrieve the configured default channel. + # Retrieve the configured channel. # # @return [String] sanitised channel identifier. - def sanitized_default_channel - PotatoMesh::Sanitizer.sanitized_default_channel + def sanitized_channel + PotatoMesh::Sanitizer.sanitized_channel end - # Retrieve the configured default frequency descriptor. + # Retrieve the configured frequency descriptor. # # @return [String] sanitised frequency text. - def sanitized_default_frequency - PotatoMesh::Sanitizer.sanitized_default_frequency + def sanitized_frequency + PotatoMesh::Sanitizer.sanitized_frequency end # Build the configuration hash exposed to the frontend application. @@ -111,23 +111,32 @@ module PotatoMesh refreshIntervalSeconds: PotatoMesh::Config.refresh_interval_seconds, refreshMs: PotatoMesh::Config.refresh_interval_seconds * 1000, chatEnabled: !private_mode?, - defaultChannel: sanitized_default_channel, - defaultFrequency: sanitized_default_frequency, + channel: sanitized_channel, + frequency: sanitized_frequency, + contactLink: sanitized_contact_link, + contactLinkUrl: sanitized_contact_link_url, mapCenter: { lat: PotatoMesh::Config.map_center_lat, lon: PotatoMesh::Config.map_center_lon, }, - maxNodeDistanceKm: PotatoMesh::Config.max_node_distance_km, + maxDistanceKm: PotatoMesh::Config.max_distance_km, tileFilters: PotatoMesh::Config.tile_filters, instanceDomain: app_constant(:INSTANCE_DOMAIN), } end - # Retrieve the configured Matrix room or nil when unset. + # Retrieve the configured contact link or nil when unset. # - # @return [String, nil] Matrix room identifier. - def sanitized_matrix_room - PotatoMesh::Sanitizer.sanitized_matrix_room + # @return [String, nil] contact link identifier. + def sanitized_contact_link + PotatoMesh::Sanitizer.sanitized_contact_link + end + + # Retrieve the hyperlink derived from the configured contact link. + # + # @return [String, nil] hyperlink pointing to the community chat. + def sanitized_contact_link_url + PotatoMesh::Sanitizer.sanitized_contact_link_url end # Retrieve the configured maximum node distance in kilometres. diff --git a/web/lib/potato_mesh/application/routes/api.rb b/web/lib/potato_mesh/application/routes/api.rb index a7874da..15889fb 100644 --- a/web/lib/potato_mesh/application/routes/api.rb +++ b/web/lib/potato_mesh/application/routes/api.rb @@ -26,15 +26,16 @@ module PotatoMesh lastNodeUpdate: last_update, config: { siteName: sanitized_site_name, - defaultChannel: sanitized_default_channel, - defaultFrequency: sanitized_default_frequency, + channel: sanitized_channel, + frequency: sanitized_frequency, + contactLink: sanitized_contact_link, + contactLinkUrl: sanitized_contact_link_url, refreshIntervalSeconds: PotatoMesh::Config.refresh_interval_seconds, mapCenter: { lat: PotatoMesh::Config.map_center_lat, lon: PotatoMesh::Config.map_center_lon, }, - maxNodeDistanceKm: PotatoMesh::Config.max_node_distance_km, - matrixRoom: sanitized_matrix_room, + maxDistanceKm: PotatoMesh::Config.max_distance_km, instanceDomain: app_constant(:INSTANCE_DOMAIN), privateMode: private_mode?, }, diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb index 045b347..1be7909 100644 --- a/web/lib/potato_mesh/application/routes/root.rb +++ b/web/lib/potato_mesh/application/routes/root.rb @@ -53,12 +53,13 @@ module PotatoMesh meta_title: meta[:title], meta_name: meta[:name], meta_description: meta[:description], - default_channel: sanitized_default_channel, - default_frequency: sanitized_default_frequency, + channel: sanitized_channel, + frequency: sanitized_frequency, map_center_lat: PotatoMesh::Config.map_center_lat, map_center_lon: PotatoMesh::Config.map_center_lon, - max_node_distance_km: PotatoMesh::Config.max_node_distance_km, - matrix_room: sanitized_matrix_room, + max_distance_km: PotatoMesh::Config.max_distance_km, + contact_link: sanitized_contact_link, + contact_link_url: sanitized_contact_link_url, version: app_constant(:APP_VERSION), private_mode: private_mode?, refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds, diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index 51d0ac4..91279fd 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -18,6 +18,21 @@ module PotatoMesh module Config module_function + DEFAULT_DB_BUSY_TIMEOUT_MS = 5_000 + DEFAULT_DB_BUSY_MAX_RETRIES = 5 + DEFAULT_DB_BUSY_RETRY_DELAY = 0.05 + DEFAULT_MAX_JSON_BODY_BYTES = 1_048_576 + DEFAULT_REFRESH_INTERVAL_SECONDS = 60 + DEFAULT_TILE_FILTER_LIGHT = "grayscale(1) saturate(0) brightness(0.92) contrast(1.05)" + DEFAULT_TILE_FILTER_DARK = "grayscale(1) invert(1) brightness(0.9) contrast(1.08)" + DEFAULT_MAP_CENTER_LAT = 38.761944 + DEFAULT_MAP_CENTER_LON = -27.090833 + DEFAULT_MAP_CENTER = "#{DEFAULT_MAP_CENTER_LAT},#{DEFAULT_MAP_CENTER_LON}" + DEFAULT_CHANNEL = "#LongFast" + DEFAULT_FREQUENCY = "915MHz" + DEFAULT_CONTACT_LINK = "#potatomesh:dod.ngo" + DEFAULT_MAX_DISTANCE_KM = 42.0 + # Resolve the absolute path to the web application root directory. # # @return [String] absolute filesystem path of the web folder. @@ -65,28 +80,28 @@ module PotatoMesh # # @return [String] absolute path to the database file. def db_path - ENV.fetch("MESH_DB", default_db_path) + default_db_path end # Retrieve the SQLite busy timeout duration in milliseconds. # # @return [Integer] timeout value in milliseconds. def db_busy_timeout_ms - ENV.fetch("DB_BUSY_TIMEOUT_MS", "5000").to_i + DEFAULT_DB_BUSY_TIMEOUT_MS end # Retrieve the maximum number of retries when encountering SQLITE_BUSY. # # @return [Integer] maximum retry attempts. def db_busy_max_retries - ENV.fetch("DB_BUSY_MAX_RETRIES", "5").to_i + DEFAULT_DB_BUSY_MAX_RETRIES end # Retrieve the backoff delay between busy retries in seconds. # # @return [Float] seconds to wait between retries. def db_busy_retry_delay - ENV.fetch("DB_BUSY_RETRY_DELAY", "0.05").to_f + DEFAULT_DB_BUSY_RETRY_DELAY end # Convenience constant describing the number of seconds in a week. @@ -100,17 +115,13 @@ module PotatoMesh # # @return [Integer] byte ceiling for HTTP request bodies. def default_max_json_body_bytes - 1_048_576 + DEFAULT_MAX_JSON_BODY_BYTES end # Determine the maximum allowed JSON body size with validation. # # @return [Integer] configured byte limit. def max_json_body_bytes - raw = ENV.fetch("MAX_JSON_BODY_BYTES", default_max_json_body_bytes.to_s) - value = Integer(raw, 10) - value.positive? ? value : default_max_json_body_bytes - rescue ArgumentError default_max_json_body_bytes end @@ -125,17 +136,13 @@ module PotatoMesh # # @return [Integer] refresh period in seconds. def default_refresh_interval_seconds - 60 + DEFAULT_REFRESH_INTERVAL_SECONDS end # Fetch the refresh interval, ensuring a positive integer value. # # @return [Integer] polling cadence in seconds. def refresh_interval_seconds - raw = ENV.fetch("REFRESH_INTERVAL_SECONDS", default_refresh_interval_seconds.to_s) - value = Integer(raw, 10) - value.positive? ? value : default_refresh_interval_seconds - rescue ArgumentError default_refresh_interval_seconds end @@ -143,20 +150,14 @@ module PotatoMesh # # @return [String] CSS filter string. def map_tile_filter_light - ENV.fetch( - "MAP_TILE_FILTER_LIGHT", - "grayscale(1) saturate(0) brightness(0.92) contrast(1.05)", - ) + DEFAULT_TILE_FILTER_LIGHT end # Retrieve the CSS filter used for dark themed maps. # # @return [String] CSS filter string for dark tiles. def map_tile_filter_dark - ENV.fetch( - "MAP_TILE_FILTER_DARK", - "grayscale(1) invert(1) brightness(0.9) contrast(1.08)", - ) + DEFAULT_TILE_FILTER_DARK end # Provide a simple hash of tile filters for template use. @@ -173,7 +174,7 @@ module PotatoMesh # # @return [String] comma separated list of report IDs. def prom_report_ids - ENV.fetch("PROM_REPORT_IDS", "") + "" end # Transform Prometheus report identifiers into a cleaned array. @@ -313,44 +314,85 @@ module PotatoMesh # Retrieve the default radio channel label. # # @return [String] channel name from configuration. - def default_channel - fetch_string("DEFAULT_CHANNEL", "#LongFast") + def channel + fetch_string("CHANNEL", DEFAULT_CHANNEL) end # Retrieve the default radio frequency description. # # @return [String] frequency identifier. - def default_frequency - fetch_string("DEFAULT_FREQUENCY", "915MHz") + def frequency + fetch_string("FREQUENCY", DEFAULT_FREQUENCY) + end + + # Parse the configured map centre coordinates. + # + # @return [Hash{Symbol=>Float}] latitude and longitude in decimal degrees. + def map_center + raw = fetch_string("MAP_CENTER", DEFAULT_MAP_CENTER) + lat_str, lon_str = raw.split(",", 2).map { |part| part&.strip }.compact + lat = Float(lat_str, exception: false) + lon = Float(lon_str, exception: false) + lat = DEFAULT_MAP_CENTER_LAT unless lat + lon = DEFAULT_MAP_CENTER_LON unless lon + { lat: lat, lon: lon } end # Map display latitude centre for the frontend map widget. # # @return [Float] latitude in decimal degrees. def map_center_lat - ENV.fetch("MAP_CENTER_LAT", "38.761944").to_f + map_center[:lat] end # Map display longitude centre for the frontend map widget. # # @return [Float] longitude in decimal degrees. def map_center_lon - ENV.fetch("MAP_CENTER_LON", "-27.090833").to_f + map_center[:lon] end # Maximum straight-line distance between nodes before relationships are # hidden. # # @return [Float] distance in kilometres. - def max_node_distance_km - ENV.fetch("MAX_NODE_DISTANCE_KM", "42").to_f + def max_distance_km + raw = fetch_string("MAX_DISTANCE", nil) + parsed = raw && Float(raw, exception: false) + return parsed if parsed && parsed.positive? + + DEFAULT_MAX_DISTANCE_KM end - # Matrix room identifier for community discussion. + # Contact link for community discussion. # - # @return [String] Matrix room alias. - def matrix_room - ENV.fetch("MATRIX_ROOM", "#potatomesh:dod.ngo") + # @return [String] contact URI or identifier. + def contact_link + fetch_string("CONTACT_LINK", DEFAULT_CONTACT_LINK) + end + + # Determine the best URL to represent the configured contact link. + # + # @return [String, nil] absolute URL when derivable, otherwise nil. + def contact_link_url + link = contact_link.to_s.strip + return nil if link.empty? + + if matrix_alias?(link) + "https://matrix.to/#/#{link}" + elsif link.match?(%r{\Ahttps?://}i) + link + else + nil + end + end + + # Check whether a contact link is a Matrix room alias. + # + # @param link [String] candidate link string. + # @return [Boolean] true when the link resembles a Matrix alias. + def matrix_alias?(link) + link.match?(/\A[#!][^\s:]+:[^\s]+\z/) end # Check whether verbose debugging is enabled for the runtime. diff --git a/web/lib/potato_mesh/meta.rb b/web/lib/potato_mesh/meta.rb index efb373a..bf532cc 100644 --- a/web/lib/potato_mesh/meta.rb +++ b/web/lib/potato_mesh/meta.rb @@ -34,9 +34,9 @@ module PotatoMesh # @return [String] generated description text. def description(private_mode:) site = Sanitizer.sanitized_site_name - channel = Sanitizer.sanitized_default_channel - frequency = Sanitizer.sanitized_default_frequency - matrix = Sanitizer.sanitized_matrix_room + channel = Sanitizer.sanitized_channel + frequency = Sanitizer.sanitized_frequency + contact = Sanitizer.sanitized_contact_link summary = "Live Meshtastic mesh map for #{site}" if channel.empty? && frequency.empty? @@ -59,7 +59,7 @@ module PotatoMesh if (distance = Sanitizer.sanitized_max_distance_km) sentences << "Shows nodes within roughly #{formatted_distance_km(distance)} km of the map center." end - sentences << "Join the community in #{matrix} on Matrix." if matrix + sentences << "Join the community in #{contact} via chat." if contact sentences.join(" ") end diff --git a/web/lib/potato_mesh/sanitizer.rb b/web/lib/potato_mesh/sanitizer.rb index 3fb9309..b3ac4ad 100644 --- a/web/lib/potato_mesh/sanitizer.rb +++ b/web/lib/potato_mesh/sanitizer.rb @@ -106,33 +106,40 @@ module PotatoMesh sanitized_string(Config.site_name) end - # Retrieve the configured default channel as a cleaned string. + # Retrieve the configured channel as a cleaned string. # # @return [String] trimmed configuration value. - def sanitized_default_channel - sanitized_string(Config.default_channel) + def sanitized_channel + sanitized_string(Config.channel) end - # Retrieve the configured default frequency as a cleaned string. + # Retrieve the configured frequency as a cleaned string. # # @return [String] trimmed configuration value. - def sanitized_default_frequency - sanitized_string(Config.default_frequency) + def sanitized_frequency + sanitized_string(Config.frequency) end - # Retrieve the configured Matrix room and normalise blank values to nil. + # Retrieve the configured contact link and normalise blank values to nil. # - # @return [String, nil] Matrix room identifier or +nil+ when blank. - def sanitized_matrix_room - value = sanitized_string(Config.matrix_room) + # @return [String, nil] contact link identifier or +nil+ when blank. + def sanitized_contact_link + value = sanitized_string(Config.contact_link) value.empty? ? nil : value end + # Retrieve the best effort URL for the configured contact link. + # + # @return [String, nil] contact hyperlink when derivable. + def sanitized_contact_link_url + Config.contact_link_url + end + # Return a positive numeric maximum distance when configured. # # @return [Numeric, nil] distance value in kilometres. def sanitized_max_distance_km - distance = Config.max_node_distance_km + distance = Config.max_distance_km return nil unless distance.is_a?(Numeric) return nil unless distance.positive? diff --git a/web/public/assets/js/app/__tests__/config.test.js b/web/public/assets/js/app/__tests__/config.test.js index 527f297..c6539d6 100644 --- a/web/public/assets/js/app/__tests__/config.test.js +++ b/web/public/assets/js/app/__tests__/config.test.js @@ -80,9 +80,11 @@ test('mergeConfig coerces numeric values and nested objects', () => { mapCenter: { lat: '10.5', lon: '20.1' }, tileFilters: { dark: 'contrast(2)' }, chatEnabled: 0, - defaultChannel: '#Custom', - defaultFrequency: '915MHz', - maxNodeDistanceKm: '55.5' + channel: '#Custom', + frequency: '915MHz', + contactLink: 'https://example.org/chat', + contactLinkUrl: 'https://example.org/chat', + maxDistanceKm: '55.5' }); assert.equal(result.refreshIntervalSeconds, 30); @@ -90,21 +92,23 @@ test('mergeConfig coerces numeric values and nested objects', () => { assert.deepEqual(result.mapCenter, { lat: 10.5, lon: 20.1 }); assert.deepEqual(result.tileFilters, { light: DEFAULT_CONFIG.tileFilters.light, dark: 'contrast(2)' }); assert.equal(result.chatEnabled, false); - assert.equal(result.defaultChannel, '#Custom'); - assert.equal(result.defaultFrequency, '915MHz'); - assert.equal(result.maxNodeDistanceKm, 55.5); + assert.equal(result.channel, '#Custom'); + assert.equal(result.frequency, '915MHz'); + assert.equal(result.contactLink, 'https://example.org/chat'); + assert.equal(result.contactLinkUrl, 'https://example.org/chat'); + assert.equal(result.maxDistanceKm, 55.5); }); test('mergeConfig falls back to defaults for invalid numeric values', () => { const result = mergeConfig({ refreshIntervalSeconds: 'NaN', refreshMs: 'NaN', - maxNodeDistanceKm: 'oops' + maxDistanceKm: 'oops' }); assert.equal(result.refreshIntervalSeconds, DEFAULT_CONFIG.refreshIntervalSeconds); assert.equal(result.refreshMs, DEFAULT_CONFIG.refreshMs); - assert.equal(result.maxNodeDistanceKm, DEFAULT_CONFIG.maxNodeDistanceKm); + assert.equal(result.maxDistanceKm, DEFAULT_CONFIG.maxDistanceKm); }); test('document stub returns null for unrelated selectors', () => { diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index b06b5b2..d89111c 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -27,10 +27,10 @@ import { refreshNodeInformation } from './node-details.js'; * refreshMs: number, * refreshIntervalSeconds: number, * chatEnabled: boolean, - * defaultChannel: string, - * defaultFrequency: string, + * channel: string, + * frequency: string, * mapCenter: { lat: number, lon: number }, - * maxNodeDistanceKm: number, + * maxDistanceKm: number, * tileFilters: { light: string, dark: string } * }} config Normalized application configuration. * @returns {void} @@ -111,7 +111,7 @@ export function initializeApp(config) { const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60; const REFRESH_MS = config.refreshMs; const CHAT_ENABLED = Boolean(config.chatEnabled); - refreshInfo.textContent = `${config.defaultChannel} (${config.defaultFrequency}) — active nodes: …`; + refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: …`; /** @type {ReturnType|null} */ let refreshTimer = null; @@ -285,9 +285,10 @@ export function initializeApp(config) { let tiles = null; let offlineTiles = null; let usingOfflineTiles = false; - const MAX_NODE_DISTANCE_KM = Number.isFinite(config.maxNodeDistanceKm) && config.maxNodeDistanceKm > 0 - ? config.maxNodeDistanceKm - : 1; + const MAX_DISTANCE_KM = Number.isFinite(config.maxDistanceKm) && config.maxDistanceKm > 0 + ? config.maxDistanceKm + : null; + const LIMIT_DISTANCE = Number.isFinite(MAX_DISTANCE_KM); const INITIAL_VIEW_PADDING_PX = 12; const AUTO_FIT_PADDING_PX = 12; const MAX_INITIAL_ZOOM = 13; @@ -1052,7 +1053,11 @@ export function initializeApp(config) { tiles.addTo(map); observeTileContainer(tiles); - const initialBounds = computeBoundingBox(MAP_CENTER_COORDS, MAX_NODE_DISTANCE_KM, { minimumRangeKm: 1 }); + const initialBounds = computeBoundingBox( + MAP_CENTER_COORDS, + LIMIT_DISTANCE ? MAX_DISTANCE_KM : null, + { minimumRangeKm: 1 } + ); if (initialBounds) { fitMapToBounds(initialBounds, { animate: false, paddingPx: INITIAL_VIEW_PADDING_PX, maxZoom: MAX_INITIAL_ZOOM }); } else if (mapCenterLatLng) { @@ -2726,8 +2731,8 @@ export function initializeApp(config) { if (!Number.isFinite(srcLat) || !Number.isFinite(srcLon) || !Number.isFinite(tgtLat) || !Number.isFinite(tgtLon)) { continue; } - if (sourceNode.distance_km != null && sourceNode.distance_km > MAX_NODE_DISTANCE_KM) continue; - if (targetNode.distance_km != null && targetNode.distance_km > MAX_NODE_DISTANCE_KM) continue; + if (LIMIT_DISTANCE && sourceNode.distance_km != null && sourceNode.distance_km > MAX_DISTANCE_KM) continue; + if (LIMIT_DISTANCE && targetNode.distance_km != null && targetNode.distance_km > MAX_DISTANCE_KM) continue; const priority = getRoleRenderPriority(sourceNode.role); const rxTimeRaw = entry.rx_time; @@ -2820,7 +2825,7 @@ export function initializeApp(config) { if (latRaw == null || latRaw === '' || lonRaw == null || lonRaw === '') continue; const lat = Number(latRaw), lon = Number(lonRaw); if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - if (n.distance_km != null && n.distance_km > MAX_NODE_DISTANCE_KM) continue; + if (LIMIT_DISTANCE && n.distance_km != null && n.distance_km > MAX_DISTANCE_KM) continue; const color = getRoleColor(n.role); const marker = L.circleMarker([lat, lon], { @@ -2894,7 +2899,9 @@ export function initializeApp(config) { if (pts.length && fitBoundsEl && fitBoundsEl.checked) { const bounds = computeBoundsForPoints(pts, { paddingFraction: 0.2, - minimumRangeKm: Math.min(Math.max(MAX_NODE_DISTANCE_KM * 0.1, 1), MAX_NODE_DISTANCE_KM) + minimumRangeKm: LIMIT_DISTANCE + ? Math.min(Math.max(MAX_DISTANCE_KM * 0.1, 1), MAX_DISTANCE_KM) + : 1 }); fitMapToBounds(bounds, { animate: false, paddingPx: AUTO_FIT_PADDING_PX }); } @@ -3075,6 +3082,6 @@ export function initializeApp(config) { const c = nodes.filter(n => n.last_heard && nowSec - Number(n.last_heard) <= w.secs).length; return `${c}/${w.label}`; }).join(', '); - refreshInfo.textContent = `${config.defaultChannel} (${config.defaultFrequency}) — active nodes: ${counts}.`; + refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: ${counts}.`; } } diff --git a/web/public/assets/js/app/settings.js b/web/public/assets/js/app/settings.js index 5f12492..3f4038a 100644 --- a/web/public/assets/js/app/settings.js +++ b/web/public/assets/js/app/settings.js @@ -19,10 +19,12 @@ * refreshMs: number, * refreshIntervalSeconds: number, * chatEnabled: boolean, - * defaultChannel: string, - * defaultFrequency: string, + * channel: string, + * frequency: string, + * contactLink: string, + * contactLinkUrl: string | null, * mapCenter: { lat: number, lon: number }, - * maxNodeDistanceKm: number, + * maxDistanceKm: number, * tileFilters: { light: string, dark: string } * }} */ @@ -30,10 +32,12 @@ export const DEFAULT_CONFIG = { refreshMs: 60_000, refreshIntervalSeconds: 60, chatEnabled: true, - defaultChannel: '#LongFast', - defaultFrequency: '915MHz', + channel: '#LongFast', + frequency: '915MHz', + contactLink: '#potatomesh:dod.ngo', + contactLinkUrl: 'https://matrix.to/#/#potatomesh:dod.ngo', mapCenter: { lat: 38.761944, lon: -27.090833 }, - maxNodeDistanceKm: 42, + maxDistanceKm: 42, tileFilters: { light: 'grayscale(1) saturate(0) brightness(0.92) contrast(1.05)', dark: 'grayscale(1) invert(1) brightness(0.9) contrast(1.08)' @@ -65,11 +69,13 @@ export function mergeConfig(raw) { const refreshMs = Number(raw?.refreshMs ?? config.refreshIntervalSeconds * 1000); config.refreshMs = Number.isFinite(refreshMs) ? refreshMs : DEFAULT_CONFIG.refreshMs; config.chatEnabled = Boolean(raw?.chatEnabled ?? DEFAULT_CONFIG.chatEnabled); - config.defaultChannel = raw?.defaultChannel || DEFAULT_CONFIG.defaultChannel; - config.defaultFrequency = raw?.defaultFrequency || DEFAULT_CONFIG.defaultFrequency; - const maxDistance = Number(raw?.maxNodeDistanceKm ?? DEFAULT_CONFIG.maxNodeDistanceKm); - config.maxNodeDistanceKm = Number.isFinite(maxDistance) + config.channel = raw?.channel || DEFAULT_CONFIG.channel; + config.frequency = raw?.frequency || DEFAULT_CONFIG.frequency; + config.contactLink = raw?.contactLink || DEFAULT_CONFIG.contactLink; + config.contactLinkUrl = raw?.contactLinkUrl ?? DEFAULT_CONFIG.contactLinkUrl; + const maxDistance = Number(raw?.maxDistanceKm ?? DEFAULT_CONFIG.maxDistanceKm); + config.maxDistanceKm = Number.isFinite(maxDistance) ? maxDistance - : DEFAULT_CONFIG.maxNodeDistanceKm; + : DEFAULT_CONFIG.maxDistanceKm; return config; } diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 53fbba1..3f96c6b 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -26,37 +26,18 @@ RSpec.describe "Potato Mesh Sinatra app" do let(:application_class) { PotatoMesh::Application } describe "configuration" do - it "sets the default HTTP port to 41_447" do - expect(app.settings.port).to eq(41_447) + it "sets the default HTTP port to the baked-in value" do + expect(app.settings.port).to eq(PotatoMesh::Application::DEFAULT_PORT) end end describe ".resolve_port" do - around do |example| - original_present = ENV.key?("PORT") - original_value = ENV["PORT"] if original_present - ENV.delete("PORT") - example.run - ensure - if original_present - ENV["PORT"] = original_value - else - ENV.delete("PORT") - end - end - - it "returns the default port when the environment is unset" do - expect(application_class.resolve_port).to eq(41_447) - end - - it "parses the environment override when provided" do + it "always returns the baked-in default port" do + expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT) ENV["PORT"] = "51515" - expect(application_class.resolve_port).to eq(51_515) - end - - it "falls back to the default port when parsing fails" do - ENV["PORT"] = "potato" - expect(application_class.resolve_port).to eq(41_447) + expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT) + ensure + ENV.delete("PORT") end end @@ -495,9 +476,9 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(sanitized_string(nil)).to eq("") end - it "returns nil for blank matrix rooms" do - allow(PotatoMesh::Config).to receive(:matrix_room).and_return(" \t ") - expect(sanitized_matrix_room).to be_nil + it "returns nil for blank contact links" do + allow(PotatoMesh::Config).to receive(:contact_link).and_return(" \t ") + expect(sanitized_contact_link).to be_nil end it "coerces string_or_nil inputs" do @@ -566,13 +547,13 @@ RSpec.describe "Potato Mesh Sinatra app" do end it "returns nil when the maximum distance is invalid" do - allow(PotatoMesh::Config).to receive(:max_node_distance_km).and_return(-5) + allow(PotatoMesh::Config).to receive(:max_distance_km).and_return(-5) expect(sanitized_max_distance_km).to be_nil - allow(PotatoMesh::Config).to receive(:max_node_distance_km).and_return("string") + allow(PotatoMesh::Config).to receive(:max_distance_km).and_return("string") expect(sanitized_max_distance_km).to be_nil - allow(PotatoMesh::Config).to receive(:max_node_distance_km).and_return(15.5) + allow(PotatoMesh::Config).to receive(:max_distance_km).and_return(15.5) expect(sanitized_max_distance_km).to eq(15.5) end end @@ -785,12 +766,12 @@ RSpec.describe "Potato Mesh Sinatra app" do it "includes SEO metadata from configuration" do allow(PotatoMesh::Config).to receive(:site_name).and_return("Spec Mesh Title") - allow(PotatoMesh::Config).to receive(:default_channel).and_return("#SpecChannel") - allow(PotatoMesh::Config).to receive(:default_frequency).and_return("915MHz") - allow(PotatoMesh::Config).to receive(:max_node_distance_km).and_return(120.5) - allow(PotatoMesh::Config).to receive(:matrix_room).and_return(" #spec-room:example.org ") + allow(PotatoMesh::Config).to receive(:channel).and_return("#SpecChannel") + allow(PotatoMesh::Config).to receive(:frequency).and_return("915MHz") + allow(PotatoMesh::Config).to receive(:max_distance_km).and_return(120.5) + allow(PotatoMesh::Config).to receive(:contact_link).and_return(" #spec-room:example.org ") - expected_description = "Live Meshtastic mesh map for Spec Mesh Title on #SpecChannel (915MHz). Track nodes, messages, and coverage in real time. Shows nodes within roughly 120.5 km of the map center. Join the community in #spec-room:example.org on Matrix." + expected_description = "Live Meshtastic mesh map for Spec Mesh Title on #SpecChannel (915MHz). Track nodes, messages, and coverage in real time. Shows nodes within roughly 120.5 km of the map center. Join the community in #spec-room:example.org via chat." get "/" diff --git a/web/spec/config_spec.rb b/web/spec/config_spec.rb index 92ae906..048695f 100644 --- a/web/spec/config_spec.rb +++ b/web/spec/config_spec.rb @@ -138,77 +138,122 @@ RSpec.describe PotatoMesh::Config do end describe ".db_path" do - it "uses the environment override when available" do - within_env("MESH_DB" => "/tmp/spec.db") do - expect(described_class.db_path).to eq("/tmp/spec.db") - end - end - - it "falls back to the bundled database path" do - within_env("MESH_DB" => nil) do - expect(described_class.db_path).to eq(described_class.default_db_path) - end + it "returns the default path inside the data directory" do + expect(described_class.db_path).to eq(described_class.default_db_path) + expect(described_class.db_path).to eq(File.join(described_class.data_directory, "mesh.db")) end end describe ".max_json_body_bytes" do - it "returns the default when the value is missing" do - within_env("MAX_JSON_BODY_BYTES" => nil) do - expect(described_class.max_json_body_bytes).to eq( - described_class.default_max_json_body_bytes, - ) - end - end - - it "returns the parsed integer when valid" do - within_env("MAX_JSON_BODY_BYTES" => "2048") do - expect(described_class.max_json_body_bytes).to eq(2048) - end - end - - it "rejects invalid and non-positive values" do - within_env("MAX_JSON_BODY_BYTES" => "potato") do - expect(described_class.max_json_body_bytes).to eq( - described_class.default_max_json_body_bytes, - ) - end - - within_env("MAX_JSON_BODY_BYTES" => "0") do - expect(described_class.max_json_body_bytes).to eq( - described_class.default_max_json_body_bytes, - ) - end + it "returns the baked-in default size" do + expect(described_class.max_json_body_bytes).to eq(described_class.default_max_json_body_bytes) end end describe ".refresh_interval_seconds" do - it "returns the default when the configuration is invalid" do - within_env("REFRESH_INTERVAL_SECONDS" => "invalid") do - expect(described_class.refresh_interval_seconds).to eq( - described_class.default_refresh_interval_seconds, - ) - end - end - - it "honours positive integer overrides" do - within_env("REFRESH_INTERVAL_SECONDS" => "120") do - expect(described_class.refresh_interval_seconds).to eq(120) - end - end - - it "rejects zero or negative overrides" do - within_env("REFRESH_INTERVAL_SECONDS" => "0") do - expect(described_class.refresh_interval_seconds).to eq( - described_class.default_refresh_interval_seconds, - ) - end + it "returns the baked-in refresh cadence" do + expect(described_class.refresh_interval_seconds).to eq(described_class.default_refresh_interval_seconds) end end describe ".prom_report_id_list" do - it "splits and normalises identifiers" do - within_env("PROM_REPORT_IDS" => " alpha , beta,, ") do - expect(described_class.prom_report_id_list).to eq(%w[alpha beta]) + it "returns an empty collection when no identifiers are configured" do + expect(described_class.prom_report_id_list).to eq([]) + end + end + + describe ".channel" do + it "returns the default channel when unset" do + within_env("CHANNEL" => nil) do + expect(described_class.channel).to eq(PotatoMesh::Config::DEFAULT_CHANNEL) + end + end + + it "trims whitespace from overrides" do + within_env("CHANNEL" => " #Spec ") do + expect(described_class.channel).to eq("#Spec") + end + end + end + + describe ".frequency" do + it "returns the default frequency when unset" do + within_env("FREQUENCY" => nil) do + expect(described_class.frequency).to eq(PotatoMesh::Config::DEFAULT_FREQUENCY) + end + end + + it "trims whitespace from overrides" do + within_env("FREQUENCY" => " 915MHz ") do + expect(described_class.frequency).to eq("915MHz") + end + end + end + + describe ".map_center" do + it "parses latitude and longitude from the environment" do + within_env("MAP_CENTER" => "10.5, -20.25") do + expect(described_class.map_center).to eq({ lat: 10.5, lon: -20.25 }) + end + end + + it "falls back to defaults when parsing fails" do + within_env("MAP_CENTER" => "potato") do + expect(described_class.map_center).to eq({ lat: PotatoMesh::Config::DEFAULT_MAP_CENTER_LAT, lon: PotatoMesh::Config::DEFAULT_MAP_CENTER_LON }) + end + end + end + + describe ".max_distance_km" do + it "returns the default distance when unset" do + within_env("MAX_DISTANCE" => nil) do + expect(described_class.max_distance_km).to eq(PotatoMesh::Config::DEFAULT_MAX_DISTANCE_KM) + end + end + + it "parses positive numeric overrides" do + within_env("MAX_DISTANCE" => "105.5") do + expect(described_class.max_distance_km).to eq(105.5) + end + end + + it "rejects invalid overrides" do + within_env("MAX_DISTANCE" => "-1") do + expect(described_class.max_distance_km).to eq(PotatoMesh::Config::DEFAULT_MAX_DISTANCE_KM) + end + end + end + + describe ".contact_link" do + it "returns the default contact when unset" do + within_env("CONTACT_LINK" => nil) do + expect(described_class.contact_link).to eq(PotatoMesh::Config::DEFAULT_CONTACT_LINK) + end + end + + it "trims whitespace from overrides" do + within_env("CONTACT_LINK" => " https://example.org/chat ") do + expect(described_class.contact_link).to eq("https://example.org/chat") + end + end + end + + describe ".contact_link_url" do + it "builds a matrix.to URL for aliases" do + within_env("CONTACT_LINK" => "#spec:example.org") do + expect(described_class.contact_link_url).to eq("https://matrix.to/#/#spec:example.org") + end + end + + it "passes through existing URLs" do + within_env("CONTACT_LINK" => "https://example.org/chat") do + expect(described_class.contact_link_url).to eq("https://example.org/chat") + end + end + + it "returns nil for unrecognised values" do + within_env("CONTACT_LINK" => "Community Portal") do + expect(described_class.contact_link_url).to be_nil end end end diff --git a/web/spec/sanitizer_spec.rb b/web/spec/sanitizer_spec.rb index ff08b16..f93096f 100644 --- a/web/spec/sanitizer_spec.rb +++ b/web/spec/sanitizer_spec.rb @@ -62,35 +62,37 @@ RSpec.describe PotatoMesh::Sanitizer do before do allow(PotatoMesh::Config).to receive_messages( site_name: " Spec Mesh ", - default_channel: " #Spec ", - default_frequency: " 915MHz ", - matrix_room: " #room:example.org ", - max_node_distance_km: 42, + channel: " #Spec ", + frequency: " 915MHz ", + contact_link: " #room:example.org ", + max_distance_km: 42, ) end it "provides trimmed strings" do expect(described_class.sanitized_site_name).to eq("Spec Mesh") - expect(described_class.sanitized_default_channel).to eq("#Spec") - expect(described_class.sanitized_default_frequency).to eq("915MHz") - expect(described_class.sanitized_matrix_room).to eq("#room:example.org") + expect(described_class.sanitized_channel).to eq("#Spec") + expect(described_class.sanitized_frequency).to eq("915MHz") + expect(described_class.sanitized_contact_link).to eq("#room:example.org") + expect(described_class.sanitized_contact_link_url).to eq("https://matrix.to/#/#room:example.org") expect(described_class.sanitized_max_distance_km).to eq(42) end - it "returns nil when the matrix room is blank" do - allow(PotatoMesh::Config).to receive(:matrix_room).and_return(" \t ") + it "returns nil when the contact link is blank" do + allow(PotatoMesh::Config).to receive(:contact_link).and_return(" \t ") - expect(described_class.sanitized_matrix_room).to be_nil + expect(described_class.sanitized_contact_link).to be_nil + expect(described_class.sanitized_contact_link_url).to be_nil end it "returns nil when the distance is not positive" do - allow(PotatoMesh::Config).to receive(:max_node_distance_km).and_return(0) + allow(PotatoMesh::Config).to receive(:max_distance_km).and_return(0) expect(described_class.sanitized_max_distance_km).to be_nil end it "returns nil when the distance is not numeric" do - allow(PotatoMesh::Config).to receive(:max_node_distance_km).and_return("far") + allow(PotatoMesh::Config).to receive(:max_distance_km).and_return("far") expect(described_class.sanitized_max_distance_km).to be_nil end diff --git a/web/spec/spec_helper.rb b/web/spec/spec_helper.rb index d3ed236..50422e7 100644 --- a/web/spec/spec_helper.rb +++ b/web/spec/spec_helper.rb @@ -36,7 +36,6 @@ require "fileutils" ENV["RACK_ENV"] = "test" SPEC_TMPDIR = Dir.mktmpdir("potato-mesh-spec-") -ENV["MESH_DB"] = File.join(SPEC_TMPDIR, "mesh.db") ENV["XDG_DATA_HOME"] = File.join(SPEC_TMPDIR, "xdg-data") ENV["XDG_CONFIG_HOME"] = File.join(SPEC_TMPDIR, "xdg-config") diff --git a/web/views/index.erb b/web/views/index.erb index cad9d59..710b11f 100644 --- a/web/views/index.erb +++ b/web/views/index.erb @@ -83,7 +83,7 @@
-

<%= default_channel %> (<%= default_frequency %>) — active nodes: …

+

<%= channel %> (<%= frequency %>) — active nodes: …

@@ -108,19 +108,23 @@

About <%= site_name %>

Quick facts about this PotatoMesh instance.

-
Default channel
-
<%= default_channel %>
+
Channel
+
<%= channel %>
Frequency
-
<%= default_frequency %>
+
<%= frequency %>
Map center
<%= format("%.5f, %.5f", map_center_lat, map_center_lon) %>
Visible range
-
Nodes within roughly <%= max_node_distance_km %> km of the center are shown.
+
Nodes within roughly <%= max_distance_km %> km of the center are shown.
Auto-refresh
Updates every <%= refresh_interval_seconds %> seconds.
- <% if matrix_room && !matrix_room.empty? %> -
Matrix room
-
<%= matrix_room %>
+ <% if contact_link && !contact_link.empty? %> +
Chat
+ <% if contact_link_url %> +
<%= contact_link %>
+ <% else %> +
<%= contact_link %>
+ <% end %> <% end %>
@@ -196,9 +200,13 @@ <%= version %> — <% end %> GitHub: l5yth/potato-mesh - <% if matrix_room && !matrix_room.empty? %> - — <%= site_name %> Matrix: - <%= matrix_room %> + <% if contact_link && !contact_link.empty? %> + — <%= site_name %> chat: + <% if contact_link_url %> + <%= contact_link %> + <% else %> + <%= contact_link %> + <% end %> <% end %>