mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-07 09:22:53 +02:00
Refine environment configuration defaults (#318)
This commit is contained in:
+7
-15
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-13
@@ -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
|
||||
|
||||
@@ -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 <COM0@l5y.tech>
|
||||
|
||||
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)
|
||||
|
||||
+20
-23
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-8
@@ -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}
|
||||
|
||||
+5
-11
@@ -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
|
||||
|
||||
+1
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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?,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<typeof setTimeout>|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}.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+18
-37
@@ -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 "/"
|
||||
|
||||
|
||||
+104
-59
@@ -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
|
||||
|
||||
+14
-12
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
+19
-11
@@ -83,7 +83,7 @@
|
||||
<div class="row meta">
|
||||
<div class="meta-info">
|
||||
<div class="refresh-row">
|
||||
<p id="refreshInfo" class="refresh-info" aria-live="polite"><%= default_channel %> (<%= default_frequency %>) — active nodes: …</p>
|
||||
<p id="refreshInfo" class="refresh-info" aria-live="polite"><%= channel %> (<%= frequency %>) — active nodes: …</p>
|
||||
<div class="refresh-actions">
|
||||
<label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
|
||||
<button id="refreshBtn" type="button">Refresh now</button>
|
||||
@@ -108,19 +108,23 @@
|
||||
<h2 id="infoTitle" class="info-title">About <%= site_name %></h2>
|
||||
<p class="info-intro">Quick facts about this PotatoMesh instance.</p>
|
||||
<dl class="info-details">
|
||||
<dt>Default channel</dt>
|
||||
<dd><%= default_channel %></dd>
|
||||
<dt>Channel</dt>
|
||||
<dd><%= channel %></dd>
|
||||
<dt>Frequency</dt>
|
||||
<dd><%= default_frequency %></dd>
|
||||
<dd><%= frequency %></dd>
|
||||
<dt>Map center</dt>
|
||||
<dd><%= format("%.5f, %.5f", map_center_lat, map_center_lon) %></dd>
|
||||
<dt>Visible range</dt>
|
||||
<dd>Nodes within roughly <%= max_node_distance_km %> km of the center are shown.</dd>
|
||||
<dd>Nodes within roughly <%= max_distance_km %> km of the center are shown.</dd>
|
||||
<dt>Auto-refresh</dt>
|
||||
<dd>Updates every <%= refresh_interval_seconds %> seconds.</dd>
|
||||
<% if matrix_room && !matrix_room.empty? %>
|
||||
<dt>Matrix room</dt>
|
||||
<dd><a href="https://matrix.to/#/<%= matrix_room %>" target="_blank" rel="noreferrer noopener"><%= matrix_room %></a></dd>
|
||||
<% if contact_link && !contact_link.empty? %>
|
||||
<dt>Chat</dt>
|
||||
<% if contact_link_url %>
|
||||
<dd><a href="<%= contact_link_url %>" target="_blank" rel="noreferrer noopener"><%= contact_link %></a></dd>
|
||||
<% else %>
|
||||
<dd><%= contact_link %></dd>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -196,9 +200,13 @@
|
||||
<span class="mono"><%= version %></span> —
|
||||
<% end %>
|
||||
GitHub: <a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
|
||||
<% if matrix_room && !matrix_room.empty? %>
|
||||
— <%= site_name %> Matrix:
|
||||
<a href="https://matrix.to/#/<%= matrix_room %>" target="_blank"><%= matrix_room %></a>
|
||||
<% if contact_link && !contact_link.empty? %>
|
||||
— <%= site_name %> chat:
|
||||
<% if contact_link_url %>
|
||||
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
|
||||
<% else %>
|
||||
<%= contact_link %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user