Refactor config and metadata helpers into PotatoMesh modules (#290)

This commit is contained in:
l5y
2025-10-11 23:19:25 +02:00
committed by GitHub
parent 4ad718e164
commit 58998ba274
5 changed files with 454 additions and 231 deletions
+108 -209
View File
@@ -40,59 +40,32 @@ require "ipaddr"
require "set"
require "digest"
require_relative "lib/potato_mesh/config"
require_relative "lib/potato_mesh/sanitizer"
require_relative "lib/potato_mesh/meta"
# Path to the SQLite database used by the web application.
DB_PATH = ENV.fetch("MESH_DB", File.join(__dir__, "../data/mesh.db"))
# Default timeout applied to SQLite ``busy`` responses in milliseconds.
DB_BUSY_TIMEOUT_MS = ENV.fetch("DB_BUSY_TIMEOUT_MS", "5000").to_i
# Maximum number of SQLite ``busy`` retries before failing the request.
DB_BUSY_MAX_RETRIES = ENV.fetch("DB_BUSY_MAX_RETRIES", "5").to_i
# Base delay in seconds between SQLite ``busy`` retries.
DB_BUSY_RETRY_DELAY = ENV.fetch("DB_BUSY_RETRY_DELAY", "0.05").to_f
# Open the SQLite database with a configured busy timeout.
#
# @param readonly [Boolean] whether to open the database in read-only mode.
# @return [SQLite3::Database]
def open_database(readonly: false)
SQLite3::Database.new(DB_PATH, readonly: readonly).tap do |db|
db.busy_timeout = DB_BUSY_TIMEOUT_MS
SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: readonly).tap do |db|
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
db.execute("PRAGMA foreign_keys = ON")
end
end
# Convenience constant used when filtering stale records.
WEEK_SECONDS = 7 * 24 * 60 * 60
# Default request body size allowed for JSON uploads.
DEFAULT_MAX_JSON_BODY_BYTES = 1_048_576
# Maximum request body size for JSON uploads, configurable via ``MAX_JSON_BODY_BYTES``.
MAX_JSON_BODY_BYTES = begin
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
# Fallback version string used when Git metadata is unavailable.
VERSION_FALLBACK = "v0.5.0"
DEFAULT_REFRESH_INTERVAL_SECONDS = 60
REFRESH_INTERVAL_SECONDS = begin
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
MAP_TILE_FILTER_LIGHT = ENV.fetch(
"MAP_TILE_FILTER_LIGHT",
"grayscale(1) saturate(0) brightness(0.92) contrast(1.05)"
)
MAP_TILE_FILTER_DARK = ENV.fetch(
"MAP_TILE_FILTER_DARK",
"grayscale(1) invert(1) brightness(0.9) contrast(1.08)"
)
PROM_REPORT_IDS = ENV.fetch("PROM_REPORT_IDS", "")
# Map the comma-separated list of node IDs into a sanitized array.
$prom_report_ids = PROM_REPORT_IDS.split(",").map(&:strip).reject(&:empty?)
def prom_report_ids
PotatoMesh::Config.prom_report_id_list
end
# Fetch a configuration string from environment variables.
#
@@ -100,11 +73,7 @@ $prom_report_ids = PROM_REPORT_IDS.split(",").map(&:strip).reject(&:empty?)
# @param default [String] fallback value returned when the variable is unset or blank.
# @return [String] sanitized configuration value.
def fetch_config_string(key, default)
value = ENV[key]
return default if value.nil?
trimmed = value.strip
trimmed.empty? ? default : trimmed
PotatoMesh::Config.fetch_string(key, default)
end
# Convert a value into a trimmed string or return ``nil`` when blank.
@@ -112,11 +81,7 @@ end
# @param value [Object]
# @return [String, nil]
def string_or_nil(value)
return nil if value.nil?
str = value.is_a?(String) ? value : value.to_s
trimmed = str.strip
trimmed.empty? ? nil : trimmed
PotatoMesh::Sanitizer.string_or_nil(value)
end
# Normalise domain strings supplied by remote instances or configuration inputs.
@@ -124,15 +89,7 @@ end
# @param value [Object] untrusted domain string.
# @return [String, nil] canonical domain without schemes or paths.
def sanitize_instance_domain(value)
host = string_or_nil(value)
return nil unless host
trimmed = host.strip
trimmed = trimmed.delete_suffix(".") while trimmed.end_with?(".")
return nil if trimmed.empty?
return nil if trimmed.match?(%r{[\s/\\@]})
trimmed
PotatoMesh::Sanitizer.sanitize_instance_domain(value)
end
# Extract the hostname component from an instance domain string, handling IPv6
@@ -141,23 +98,7 @@ end
# @param domain [String]
# @return [String, nil]
def instance_domain_host(domain)
return nil if domain.nil?
candidate = domain.strip
return nil if candidate.empty?
if candidate.start_with?("[")
match = candidate.match(/\A\[(?<host>[^\]]+)\](?::(?<port>\d+))?\z/)
return match[:host] if match
return nil
end
host, port = candidate.split(":", 2)
if port && !host.include?(":") && port.match?(/\A\d+\z/)
return host
end
candidate
PotatoMesh::Sanitizer.instance_domain_host(domain)
end
# Parse an IP address when the provided domain represents an address literal.
@@ -165,12 +106,7 @@ end
# @param domain [String]
# @return [IPAddr, nil]
def ip_from_domain(domain)
host = instance_domain_host(domain)
return nil unless host
IPAddr.new(host)
rescue IPAddr::InvalidAddressError
nil
PotatoMesh::Sanitizer.ip_from_domain(domain)
end
# Attempt to resolve the instance's vanity domain from configuration or reverse
@@ -394,13 +330,13 @@ end
def determine_app_version
repo_root = File.expand_path("..", __dir__)
git_dir = File.join(repo_root, ".git")
return VERSION_FALLBACK unless File.directory?(git_dir)
return PotatoMesh::Config.version_fallback unless File.directory?(git_dir)
stdout, status = Open3.capture2("git", "-C", repo_root, "describe", "--tags", "--long", "--abbrev=7")
return VERSION_FALLBACK unless status.success?
return PotatoMesh::Config.version_fallback unless status.success?
raw = stdout.strip
return VERSION_FALLBACK if raw.empty?
return PotatoMesh::Config.version_fallback if raw.empty?
match = /\A(?<tag>.+)-(?<count>\d+)-g(?<hash>[0-9a-f]+)\z/.match(raw)
return raw unless match
@@ -412,41 +348,30 @@ def determine_app_version
"#{tag}+#{count}-#{hash}"
rescue StandardError
VERSION_FALLBACK
PotatoMesh::Config.version_fallback
end
APP_VERSION = determine_app_version
KEYFILE_PATH = File.join(__dir__, ".config", "keyfile")
WELL_KNOWN_RELATIVE_PATH = File.join(".well-known", "potato-mesh")
WELL_KNOWN_STORAGE_ROOT = File.join(__dir__, ".config", "well-known")
LEGACY_PUBLIC_WELL_KNOWN_PATH = File.join(__dir__, "public", WELL_KNOWN_RELATIVE_PATH)
WELL_KNOWN_REFRESH_INTERVAL = 24 * 60 * 60
INSTANCE_SIGNATURE_ALGORITHM = "rsa-sha256"
REMOTE_INSTANCE_HTTP_TIMEOUT = 5
REMOTE_INSTANCE_MAX_NODE_AGE = 86_400
REMOTE_INSTANCE_MIN_NODE_COUNT = 10
FEDERATION_SEED_DOMAINS = ["potatomesh.net"].freeze
FEDERATION_ANNOUNCEMENT_INTERVAL = 24 * 60 * 60
class InstanceFetchError < StandardError; end
def load_or_generate_instance_private_key
FileUtils.mkdir_p(File.dirname(KEYFILE_PATH))
if File.exist?(KEYFILE_PATH)
contents = File.binread(KEYFILE_PATH)
keyfile_path = PotatoMesh::Config.keyfile_path
FileUtils.mkdir_p(File.dirname(keyfile_path))
if File.exist?(keyfile_path)
contents = File.binread(keyfile_path)
return [OpenSSL::PKey.read(contents), false]
end
key = OpenSSL::PKey::RSA.new(2048)
File.open(KEYFILE_PATH, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
File.open(keyfile_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
file.write(key.export)
end
[key, true]
rescue OpenSSL::PKey::PKeyError, ArgumentError => e
warn "[warn] failed to load instance private key, generating a new key: #{e.message}"
key = OpenSSL::PKey::RSA.new(2048)
File.open(KEYFILE_PATH, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
File.open(keyfile_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
file.write(key.export)
end
[key, true]
@@ -457,16 +382,20 @@ INSTANCE_PUBLIC_KEY_PEM = INSTANCE_PRIVATE_KEY.public_key.export
SELF_INSTANCE_ID = Digest::SHA256.hexdigest(INSTANCE_PUBLIC_KEY_PEM)
def well_known_directory
WELL_KNOWN_STORAGE_ROOT
PotatoMesh::Config.well_known_storage_root
end
def well_known_file_path
File.join(well_known_directory, File.basename(WELL_KNOWN_RELATIVE_PATH))
File.join(
well_known_directory,
File.basename(PotatoMesh::Config.well_known_relative_path),
)
end
begin
FileUtils.rm_f(LEGACY_PUBLIC_WELL_KNOWN_PATH)
legacy_dir = File.dirname(LEGACY_PUBLIC_WELL_KNOWN_PATH)
legacy_path = PotatoMesh::Config.legacy_public_well_known_path
FileUtils.rm_f(legacy_path)
legacy_dir = File.dirname(legacy_path)
FileUtils.rmdir(legacy_dir) if Dir.exist?(legacy_dir) && Dir.empty?(legacy_dir)
rescue SystemCallError
# Ignore errors removing legacy static files; failure only means the directory
@@ -490,7 +419,7 @@ def build_well_known_document
document = payload.merge(
signature: signature,
signatureAlgorithm: INSTANCE_SIGNATURE_ALGORITHM,
signatureAlgorithm: PotatoMesh::Config.instance_signature_algorithm,
signedPayload: Base64.strict_encode64(signed_payload),
)
@@ -504,7 +433,9 @@ def refresh_well_known_document_if_stale
now = Time.now
if File.exist?(path)
mtime = File.mtime(path)
return if (now - mtime) < WELL_KNOWN_REFRESH_INTERVAL
if (now - mtime) < PotatoMesh::Config.well_known_refresh_interval
return
end
end
json_output, signature = build_well_known_document
@@ -513,9 +444,9 @@ def refresh_well_known_document_if_stale
file.write("\n") unless json_output.end_with?("\n")
end
debug_log("Updated #{WELL_KNOWN_RELATIVE_PATH} content: #{json_output}")
debug_log("Updated #{PotatoMesh::Config.well_known_relative_path} content: #{json_output}")
debug_log(
"Updated #{WELL_KNOWN_RELATIVE_PATH} signature (#{INSTANCE_SIGNATURE_ALGORITHM}): #{signature}",
"Updated #{PotatoMesh::Config.well_known_relative_path} signature (#{PotatoMesh::Config.instance_signature_algorithm}): #{signature}",
)
end
@@ -524,7 +455,7 @@ set :views, File.join(__dir__, "views")
set :federation_thread, nil
def latest_node_update_timestamp
return nil unless File.exist?(DB_PATH)
return nil unless File.exist?(PotatoMesh::Config.db_path)
db = open_database(readonly: true)
value = db.get_first_value(
@@ -538,7 +469,7 @@ ensure
end
get "/favicon.ico" do
cache_control :public, max_age: WEEK_SECONDS
cache_control :public, max_age: PotatoMesh::Config.week_seconds
ico_path = File.join(settings.public_folder, "favicon.ico")
if File.file?(ico_path)
send_file ico_path, type: "image/x-icon"
@@ -558,12 +489,12 @@ get "/version" do
siteName: sanitized_site_name,
defaultChannel: sanitized_default_channel,
defaultFrequency: sanitized_default_frequency,
refreshIntervalSeconds: REFRESH_INTERVAL_SECONDS,
refreshIntervalSeconds: PotatoMesh::Config.refresh_interval_seconds,
mapCenter: {
lat: MAP_CENTER_LAT,
lon: MAP_CENTER_LON,
lat: PotatoMesh::Config.map_center_lat,
lon: PotatoMesh::Config.map_center_lon,
},
maxNodeDistanceKm: MAX_NODE_DISTANCE_KM,
maxNodeDistanceKm: PotatoMesh::Config.max_node_distance_km,
matrixRoom: sanitized_matrix_room,
instanceDomain: INSTANCE_DOMAIN,
privateMode: private_mode?,
@@ -574,20 +505,12 @@ end
get "/.well-known/potato-mesh" do
refresh_well_known_document_if_stale
cache_control :public, max_age: WELL_KNOWN_REFRESH_INTERVAL
cache_control :public, max_age: PotatoMesh::Config.well_known_refresh_interval
content_type :json
send_file well_known_file_path
end
SITE_NAME = fetch_config_string("SITE_NAME", "PotatoMesh Demo")
DEFAULT_CHANNEL = fetch_config_string("DEFAULT_CHANNEL", "#LongFast")
DEFAULT_FREQUENCY = fetch_config_string("DEFAULT_FREQUENCY", "915MHz")
MAP_CENTER_LAT = ENV.fetch("MAP_CENTER_LAT", "38.761944").to_f
MAP_CENTER_LON = ENV.fetch("MAP_CENTER_LON", "-27.090833").to_f
MAX_NODE_DISTANCE_KM = ENV.fetch("MAX_NODE_DISTANCE_KM", "42").to_f
MATRIX_ROOM = ENV.fetch("MATRIX_ROOM", "#potatomesh:dod.ngo")
INSTANCE_DOMAIN, INSTANCE_DOMAIN_SOURCE = determine_instance_domain
DEBUG = ENV["DEBUG"] == "1"
#
# Prometheus metrics
@@ -672,7 +595,7 @@ Prometheus::Client.registry.register($prom_node_altitude)
# @param message [String] text written to the configured logger.
# @return [void]
def debug_log(message)
return unless DEBUG
return unless PotatoMesh::Config.debug?
logger = settings.logger if respond_to?(:settings)
logger&.debug(message)
@@ -685,7 +608,9 @@ end
def log_instance_public_key
debug_log("Instance public key (PEM):\n#{INSTANCE_PUBLIC_KEY_PEM}")
if INSTANCE_KEY_GENERATED
debug_log("Generated new instance private key at #{KEYFILE_PATH}")
debug_log(
"Generated new instance private key at #{PotatoMesh::Config.keyfile_path}",
)
end
end
@@ -794,8 +719,8 @@ def self_instance_attributes
version: APP_VERSION,
channel: sanitized_default_channel,
frequency: sanitized_default_frequency,
latitude: MAP_CENTER_LAT,
longitude: MAP_CENTER_LON,
latitude: PotatoMesh::Config.map_center_lat,
longitude: PotatoMesh::Config.map_center_lon,
last_update_time: last_update,
is_private: private_mode?,
}
@@ -858,7 +783,7 @@ end
# @return [Array<String>]
def federation_target_domains(self_domain)
domains = Set.new
FEDERATION_SEED_DOMAINS.each do |seed|
PotatoMesh::Config.federation_seed_domains.each do |seed|
sanitized = sanitize_instance_domain(seed)
domains << sanitized.downcase if sanitized
end
@@ -875,7 +800,10 @@ def federation_target_domains(self_domain)
end
domains.to_a
rescue SQLite3::Exception
domains = FEDERATION_SEED_DOMAINS.map { |seed| sanitize_instance_domain(seed)&.downcase }.compact
domains =
PotatoMesh::Config.federation_seed_domains.map do |seed|
sanitize_instance_domain(seed)&.downcase
end.compact
self_domain ? domains.reject { |domain| domain == self_domain.downcase } : domains
ensure
db&.close
@@ -893,8 +821,8 @@ def announce_instance_to_domain(domain, payload_json)
instance_uri_candidates(domain, "/api/instances").each do |uri|
begin
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = REMOTE_INSTANCE_HTTP_TIMEOUT
http.read_timeout = REMOTE_INSTANCE_HTTP_TIMEOUT
http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout
http.read_timeout = PotatoMesh::Config.remote_instance_http_timeout
http.use_ssl = uri.scheme == "https"
response = http.start do |connection|
request = Net::HTTP::Post.new(uri)
@@ -944,7 +872,7 @@ def start_federation_announcer!
thread = Thread.new do
loop do
sleep FEDERATION_ANNOUNCEMENT_INTERVAL
sleep PotatoMesh::Config.federation_announcement_interval
begin
announce_instance_to_all_domains
rescue StandardError => e
@@ -986,28 +914,28 @@ end
# @param value [Object] input value converted using ``to_s``.
# @return [String] trimmed representation of ``value``.
def sanitized_string(value)
value.to_s.strip
PotatoMesh::Sanitizer.sanitized_string(value)
end
# Return the configured site name stripped of leading and trailing whitespace.
#
# @return [String] sanitized site name used throughout the UI.
def sanitized_site_name
sanitized_string(SITE_NAME)
PotatoMesh::Sanitizer.sanitized_site_name
end
# Return the configured default channel label.
#
# @return [String] sanitized channel label for the UI.
def sanitized_default_channel
sanitized_string(DEFAULT_CHANNEL)
PotatoMesh::Sanitizer.sanitized_default_channel
end
# Return the configured default frequency label.
#
# @return [String] sanitized frequency string.
def sanitized_default_frequency
sanitized_string(DEFAULT_FREQUENCY)
PotatoMesh::Sanitizer.sanitized_default_frequency
end
# Assemble configuration exposed to the frontend JavaScript bundle.
@@ -1015,17 +943,17 @@ end
# @return [Hash] settings describing refresh cadence and map defaults.
def frontend_app_config
{
refreshIntervalSeconds: REFRESH_INTERVAL_SECONDS,
refreshMs: REFRESH_INTERVAL_SECONDS * 1000,
refreshIntervalSeconds: PotatoMesh::Config.refresh_interval_seconds,
refreshMs: PotatoMesh::Config.refresh_interval_seconds * 1000,
chatEnabled: !private_mode?,
defaultChannel: sanitized_default_channel,
defaultFrequency: sanitized_default_frequency,
mapCenter: { lat: MAP_CENTER_LAT, lon: MAP_CENTER_LON },
maxNodeDistanceKm: MAX_NODE_DISTANCE_KM,
tileFilters: {
light: MAP_TILE_FILTER_LIGHT,
dark: MAP_TILE_FILTER_DARK,
mapCenter: {
lat: PotatoMesh::Config.map_center_lat,
lon: PotatoMesh::Config.map_center_lon,
},
maxNodeDistanceKm: PotatoMesh::Config.max_node_distance_km,
tileFilters: PotatoMesh::Config.tile_filters,
instanceDomain: INSTANCE_DOMAIN,
}
end
@@ -1034,8 +962,7 @@ end
#
# @return [String, nil] Matrix room identifier or nil when blank.
def sanitized_matrix_room
value = sanitized_string(MATRIX_ROOM)
value.empty? ? nil : value
PotatoMesh::Sanitizer.sanitized_matrix_room
end
# Coerce arbitrary values into strings or nil when blank.
@@ -1206,8 +1133,8 @@ end
# @return [String]
def perform_instance_http_request(uri)
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = REMOTE_INSTANCE_HTTP_TIMEOUT
http.read_timeout = REMOTE_INSTANCE_HTTP_TIMEOUT
http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout
http.read_timeout = PotatoMesh::Config.remote_instance_http_timeout
http.use_ssl = uri.scheme == "https"
http.start do |connection|
response = connection.request(Net::HTTP::Get.new(uri))
@@ -1262,7 +1189,9 @@ def validate_well_known_document(document, domain, pubkey)
return [false, "domain mismatch"] unless remote_domain.casecmp?(domain)
algorithm = string_or_nil(document["signatureAlgorithm"])
return [false, "unsupported signature algorithm"] unless algorithm&.casecmp?(INSTANCE_SIGNATURE_ALGORITHM)
unless algorithm&.casecmp?(PotatoMesh::Config.instance_signature_algorithm)
return [false, "unsupported signature algorithm"]
end
signed_payload_b64 = string_or_nil(document["signedPayload"])
signature_b64 = string_or_nil(document["signature"])
@@ -1302,7 +1231,9 @@ def validate_remote_nodes(nodes)
return [false, "node response is not an array"]
end
return [false, "insufficient nodes"] if nodes.length < REMOTE_INSTANCE_MIN_NODE_COUNT
if nodes.length < PotatoMesh::Config.remote_instance_min_node_count
return [false, "insufficient nodes"]
end
latest = nodes.filter_map do |node|
next unless node.is_a?(Hash)
@@ -1316,7 +1247,7 @@ def validate_remote_nodes(nodes)
return [false, "missing recent node updates"] unless latest
cutoff = Time.now.to_i - REMOTE_INSTANCE_MAX_NODE_AGE
cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
return [false, "node data is stale"] if latest < cutoff
[true, nil]
@@ -1411,13 +1342,7 @@ end
#
# @return [Float, nil] positive distance in kilometres or nil when invalid.
def sanitized_max_distance_km
return nil unless defined?(MAX_NODE_DISTANCE_KM)
distance = MAX_NODE_DISTANCE_KM
return nil unless distance.is_a?(Numeric)
return nil unless distance.positive?
distance
PotatoMesh::Sanitizer.sanitized_max_distance_km
end
# Format a distance in kilometres for display.
@@ -1425,54 +1350,21 @@ end
# @param distance [Numeric] value expressed in kilometres.
# @return [String] distance formatted with a single decimal place.
def formatted_distance_km(distance)
format("%.1f", distance).sub(/\.0\z/, "")
PotatoMesh::Meta.formatted_distance_km(distance)
end
# Compose the meta description used by the landing page.
#
# @return [String] readable sentence summarising the instance.
def meta_description
site = sanitized_site_name
channel = sanitized_default_channel
frequency = sanitized_default_frequency
matrix = sanitized_matrix_room
summary = "Live Meshtastic mesh map for #{site}"
if channel.empty? && frequency.empty?
summary += "."
elsif channel.empty?
summary += " tuned to #{frequency}."
elsif frequency.empty?
summary += " on #{channel}."
else
summary += " on #{channel} (#{frequency})."
end
activity_sentence = if private_mode?
"Track nodes and coverage in real time."
else
"Track nodes, messages, and coverage in real time."
end
sentences = [summary, activity_sentence]
if (distance = sanitized_max_distance_km)
sentences << "Shows nodes within roughly #{formatted_distance_km(distance)} km of the map center."
end
sentences << "Join the community in #{matrix} on Matrix." if matrix
sentences.join(" ")
PotatoMesh::Meta.description(private_mode: private_mode?)
end
# Return the metadata used to populate the HTML head section.
#
# @return [Hash] hash containing ``:title``, ``:name``, and ``:description`` keys.
def meta_configuration
site = sanitized_site_name
{
title: site,
name: site,
description: meta_description,
}
PotatoMesh::Meta.configuration(private_mode: private_mode?)
end
class << Sinatra::Application
@@ -1483,7 +1375,7 @@ class << Sinatra::Application
logger = settings.logger
return unless logger
logger.level = DEBUG ? Logger::DEBUG : Logger::WARN
logger.level = PotatoMesh::Config.debug? ? Logger::DEBUG : Logger::WARN
end
end
@@ -1495,7 +1387,10 @@ end
# @param base_delay [Float] base delay in seconds for linear backoff between
# retries.
# @yieldreturn [Object] result of the block once it succeeds.
def with_busy_retry(max_retries: DB_BUSY_MAX_RETRIES, base_delay: DB_BUSY_RETRY_DELAY)
def with_busy_retry(
max_retries: PotatoMesh::Config.db_busy_max_retries,
base_delay: PotatoMesh::Config.db_busy_retry_delay
)
attempts = 0
begin
yield
@@ -1511,7 +1406,7 @@ end
#
# @return [Boolean] true when both +nodes+ and +messages+ tables exist.
def db_schema_present?
return false unless File.exist?(DB_PATH)
return false unless File.exist?(PotatoMesh::Config.db_path)
db = open_database(readonly: true)
required = %w[nodes messages positions telemetry neighbors instances]
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('nodes','messages','positions','telemetry','neighbors','instances')").flatten
@@ -1526,7 +1421,7 @@ end
#
# @return [void]
def init_db
FileUtils.mkdir_p(File.dirname(DB_PATH))
FileUtils.mkdir_p(File.dirname(PotatoMesh::Config.db_path))
db = open_database
%w[nodes messages positions telemetry neighbors instances].each do |schema|
sql_file = File.expand_path("../data/#{schema}.sql", __dir__)
@@ -1686,7 +1581,7 @@ def query_nodes(limit, node_ref: nil)
db = open_database(readonly: true)
db.results_as_hash = true
now = Time.now.to_i
min_last_heard = now - WEEK_SECONDS
min_last_heard = now - PotatoMesh::Config.week_seconds
params = []
where_clauses = []
@@ -1795,7 +1690,7 @@ def query_messages(limit, node_ref: nil)
rows = db.execute(sql, params)
msg_fields = %w[id rx_time rx_iso from_id to_id channel portnum text encrypted msg_snr rssi hop_limit]
rows.each do |r|
if DEBUG && (r["from_id"].nil? || r["from_id"].to_s.empty?)
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.empty?)
raw = db.execute("SELECT * FROM messages WHERE id = ?", [r["id"]]).first
Kernel.warn "[debug] messages row before join: #{raw.inspect}"
Kernel.warn "[debug] row after join: #{r.inspect}"
@@ -1840,7 +1735,7 @@ def query_messages(limit, node_ref: nil)
r["from_id"] = canonical_from_id
end
end
if DEBUG && (r["from_id"].nil? || r["from_id"].to_s.empty?)
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.empty?)
Kernel.warn "[debug] row after processing: #{r.inspect}"
end
end
@@ -2408,9 +2303,11 @@ end
# @param limit [Integer, nil] optional override for the number of bytes.
# @return [String]
def read_json_body(limit: nil)
max_bytes = limit || MAX_JSON_BODY_BYTES
max_bytes = limit || PotatoMesh::Config.max_json_body_bytes
max_bytes = max_bytes.to_i
max_bytes = MAX_JSON_BODY_BYTES if max_bytes <= 0
if max_bytes <= 0
max_bytes = PotatoMesh::Config.max_json_body_bytes
end
body = request.body.read(max_bytes + 1)
body = "" if body.nil?
@@ -2889,9 +2786,10 @@ end
# @param pos [Hash] position information from the node payload.
# @return [void]
def update_prometheus_metrics(node_id, user = nil, role = "", met = nil, pos = nil)
return if $prom_report_ids.empty? || !node_id
ids = prom_report_ids
return if ids.empty? || !node_id
return unless $prom_report_ids[0] == "*" || $prom_report_ids.include?(node_id)
return unless ids[0] == "*" || ids.include?(node_id)
if user && user.is_a?(Hash) && role && role != ""
$prom_node.set(
@@ -2957,12 +2855,13 @@ def update_all_prometheus_metrics_from_nodes
$prom_nodes.set(nodes.size)
# update prometheus metrics on startup if enabled
if !$prom_report_ids.empty?
ids = prom_report_ids
unless ids.empty?
# fill in details for each node
nodes.each do |n|
node_id = n["node_id"]
if $prom_report_ids[0] != "*" && !$prom_report_ids.include?(node_id)
if ids[0] != "*" && !ids.include?(node_id)
next
end
@@ -3599,13 +3498,13 @@ get "/" do
meta_description: meta[:description],
default_channel: sanitized_default_channel,
default_frequency: sanitized_default_frequency,
map_center_lat: MAP_CENTER_LAT,
map_center_lon: MAP_CENTER_LON,
max_node_distance_km: MAX_NODE_DISTANCE_KM,
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,
version: APP_VERSION,
private_mode: private_mode?,
refresh_interval_seconds: REFRESH_INTERVAL_SECONDS,
refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
app_config_json: JSON.generate(config),
initial_theme: theme,
}
+180
View File
@@ -0,0 +1,180 @@
# frozen_string_literal: true
module PotatoMesh
module Config
module_function
def web_root
@web_root ||= File.expand_path("../..", __dir__)
end
def repo_root
@repo_root ||= File.expand_path("..", web_root)
end
def default_db_path
File.expand_path("../data/mesh.db", web_root)
end
def db_path
ENV.fetch("MESH_DB", default_db_path)
end
def db_busy_timeout_ms
ENV.fetch("DB_BUSY_TIMEOUT_MS", "5000").to_i
end
def db_busy_max_retries
ENV.fetch("DB_BUSY_MAX_RETRIES", "5").to_i
end
def db_busy_retry_delay
ENV.fetch("DB_BUSY_RETRY_DELAY", "0.05").to_f
end
def week_seconds
7 * 24 * 60 * 60
end
def default_max_json_body_bytes
1_048_576
end
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
def version_fallback
"v0.5.0"
end
def default_refresh_interval_seconds
60
end
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
def map_tile_filter_light
ENV.fetch(
"MAP_TILE_FILTER_LIGHT",
"grayscale(1) saturate(0) brightness(0.92) contrast(1.05)",
)
end
def map_tile_filter_dark
ENV.fetch(
"MAP_TILE_FILTER_DARK",
"grayscale(1) invert(1) brightness(0.9) contrast(1.08)",
)
end
def tile_filters
{
light: map_tile_filter_light,
dark: map_tile_filter_dark,
}.freeze
end
def prom_report_ids
ENV.fetch("PROM_REPORT_IDS", "")
end
def prom_report_id_list
prom_report_ids.split(",").map(&:strip).reject(&:empty?)
end
def keyfile_path
File.join(web_root, ".config", "keyfile")
end
def well_known_relative_path
File.join(".well-known", "potato-mesh")
end
def well_known_storage_root
File.join(web_root, ".config", "well-known")
end
def legacy_public_well_known_path
File.join(web_root, "public", well_known_relative_path)
end
def well_known_refresh_interval
24 * 60 * 60
end
def instance_signature_algorithm
"rsa-sha256"
end
def remote_instance_http_timeout
5
end
def remote_instance_max_node_age
86_400
end
def remote_instance_min_node_count
10
end
def federation_seed_domains
["potatomesh.net"].freeze
end
def federation_announcement_interval
24 * 60 * 60
end
def site_name
fetch_string("SITE_NAME", "PotatoMesh Demo")
end
def default_channel
fetch_string("DEFAULT_CHANNEL", "#LongFast")
end
def default_frequency
fetch_string("DEFAULT_FREQUENCY", "915MHz")
end
def map_center_lat
ENV.fetch("MAP_CENTER_LAT", "38.761944").to_f
end
def map_center_lon
ENV.fetch("MAP_CENTER_LON", "-27.090833").to_f
end
def max_node_distance_km
ENV.fetch("MAX_NODE_DISTANCE_KM", "42").to_f
end
def matrix_room
ENV.fetch("MATRIX_ROOM", "#potatomesh:dod.ngo")
end
def debug?
ENV["DEBUG"] == "1"
end
def fetch_string(key, default)
value = ENV[key]
return default if value.nil?
trimmed = value.strip
trimmed.empty? ? default : trimmed
end
end
end
+55
View File
@@ -0,0 +1,55 @@
# frozen_string_literal: true
require_relative "config"
require_relative "sanitizer"
module PotatoMesh
module Meta
module_function
def formatted_distance_km(distance)
format("%.1f", distance).sub(/\.0\z/, "")
end
def description(private_mode:)
site = Sanitizer.sanitized_site_name
channel = Sanitizer.sanitized_default_channel
frequency = Sanitizer.sanitized_default_frequency
matrix = Sanitizer.sanitized_matrix_room
summary = "Live Meshtastic mesh map for #{site}"
if channel.empty? && frequency.empty?
summary += "."
elsif channel.empty?
summary += " tuned to #{frequency}."
elsif frequency.empty?
summary += " on #{channel}."
else
summary += " on #{channel} (#{frequency})."
end
activity_sentence = if private_mode
"Track nodes and coverage in real time."
else
"Track nodes, messages, and coverage in real time."
end
sentences = [summary, activity_sentence]
if (distance = 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(" ")
end
def configuration(private_mode:)
site = Sanitizer.sanitized_site_name
{
title: site,
name: site,
description: description(private_mode: private_mode),
}.freeze
end
end
end
+89
View File
@@ -0,0 +1,89 @@
# frozen_string_literal: true
require "ipaddr"
require_relative "config"
module PotatoMesh
module Sanitizer
module_function
def string_or_nil(value)
return nil if value.nil?
str = value.is_a?(String) ? value : value.to_s
trimmed = str.strip
trimmed.empty? ? nil : trimmed
end
def sanitize_instance_domain(value)
host = string_or_nil(value)
return nil unless host
trimmed = host.strip
trimmed = trimmed.delete_suffix(".") while trimmed.end_with?(".")
return nil if trimmed.empty?
return nil if trimmed.match?(%r{[\s/\\@]})
trimmed
end
def instance_domain_host(domain)
return nil if domain.nil?
candidate = domain.strip
return nil if candidate.empty?
if candidate.start_with?("[")
match = candidate.match(/\A\[(?<host>[^\]]+)\](?::(?<port>\d+))?\z/)
return match[:host] if match
return nil
end
host, port = candidate.split(":", 2)
if port && !host.include?(":") && port.match?(/\A\d+\z/)
return host
end
candidate
end
def ip_from_domain(domain)
host = instance_domain_host(domain)
return nil unless host
IPAddr.new(host)
rescue IPAddr::InvalidAddressError
nil
end
def sanitized_string(value)
value.to_s.strip
end
def sanitized_site_name
sanitized_string(Config.site_name)
end
def sanitized_default_channel
sanitized_string(Config.default_channel)
end
def sanitized_default_frequency
sanitized_string(Config.default_frequency)
end
def sanitized_matrix_room
value = sanitized_string(Config.matrix_room)
value.empty? ? nil : value
end
def sanitized_max_distance_km
distance = Config.max_node_distance_km
return nil unless distance.is_a?(Numeric)
return nil unless distance.positive?
distance
end
end
end
+22 -22
View File
@@ -38,8 +38,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
# @yieldparam db [SQLite3::Database] open database handle.
# @return [void]
def with_db(readonly: false)
db = SQLite3::Database.new(DB_PATH, readonly: readonly)
db.busy_timeout = DB_BUSY_TIMEOUT_MS
db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: readonly)
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
db.execute("PRAGMA foreign_keys = ON")
yield db
ensure
@@ -355,7 +355,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
it "returns the fallback when the git directory is missing" do
allow(File).to receive(:directory?).with(git_dir).and_return(false)
expect(determine_app_version).to eq(VERSION_FALLBACK)
expect(determine_app_version).to eq(PotatoMesh::Config.version_fallback)
end
it "returns the fallback when git describe fails" do
@@ -363,7 +363,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
status = instance_double(Process::Status, success?: false)
allow(Open3).to receive(:capture2).and_return(["ignored", status])
expect(determine_app_version).to eq(VERSION_FALLBACK)
expect(determine_app_version).to eq(PotatoMesh::Config.version_fallback)
end
it "returns the fallback when git describe output is empty" do
@@ -371,7 +371,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
status = instance_double(Process::Status, success?: true)
allow(Open3).to receive(:capture2).and_return(["\n", status])
expect(determine_app_version).to eq(VERSION_FALLBACK)
expect(determine_app_version).to eq(PotatoMesh::Config.version_fallback)
end
it "returns the original describe output when the format is unexpected" do
@@ -402,7 +402,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
allow(File).to receive(:directory?).with(git_dir).and_return(true)
allow(Open3).to receive(:capture2).and_raise(StandardError, "boom")
expect(determine_app_version).to eq(VERSION_FALLBACK)
expect(determine_app_version).to eq(PotatoMesh::Config.version_fallback)
end
end
@@ -413,7 +413,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
it "returns nil for blank matrix rooms" do
stub_const("MATRIX_ROOM", " \t ")
allow(PotatoMesh::Config).to receive(:matrix_room).and_return(" \t ")
expect(sanitized_matrix_room).to be_nil
end
@@ -483,13 +483,13 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
it "returns nil when the maximum distance is invalid" do
stub_const("MAX_NODE_DISTANCE_KM", -5)
allow(PotatoMesh::Config).to receive(:max_node_distance_km).and_return(-5)
expect(sanitized_max_distance_km).to be_nil
stub_const("MAX_NODE_DISTANCE_KM", "string")
allow(PotatoMesh::Config).to receive(:max_node_distance_km).and_return("string")
expect(sanitized_max_distance_km).to be_nil
stub_const("MAX_NODE_DISTANCE_KM", 15.5)
allow(PotatoMesh::Config).to receive(:max_node_distance_km).and_return(15.5)
expect(sanitized_max_distance_km).to eq(15.5)
end
end
@@ -645,7 +645,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
it "switches to DEBUG when debug logging is enabled" do
stub_const("DEBUG", true)
allow(PotatoMesh::Config).to receive(:debug?).and_return(true)
Sinatra::Application.apply_logger_level!
expect(Sinatra::Application.settings.logger.level).to eq(Logger::DEBUG)
@@ -701,11 +701,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
it "includes SEO metadata from configuration" do
stub_const("SITE_NAME", "Spec Mesh Title")
stub_const("DEFAULT_CHANNEL", "#SpecChannel")
stub_const("DEFAULT_FREQUENCY", "915MHz")
stub_const("MAX_NODE_DISTANCE_KM", 120.5)
stub_const("MATRIX_ROOM", " #spec-room:example.org ")
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 ")
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."
@@ -720,9 +720,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
describe "database initialization" do
it "creates the schema when booting" do
expect(File).to exist(DB_PATH)
expect(File).to exist(PotatoMesh::Config.db_path)
db = SQLite3::Database.new(DB_PATH, readonly: true)
db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: true)
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('nodes','messages')").flatten
expect(tables).to include("nodes")
@@ -852,7 +852,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
let(:remote_nodes) do
now = Time.now.to_i
Array.new(REMOTE_INSTANCE_MIN_NODE_COUNT) do |index|
Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
{
"node_id" => "remote-node-#{index}",
"last_heard" => now - index,
@@ -1141,7 +1141,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
it "returns 413 when the request body exceeds the configured byte limit" do
limit = 64
stub_const("MAX_JSON_BODY_BYTES", limit)
allow(PotatoMesh::Config).to receive(:max_json_body_bytes).and_return(limit)
payload = { "huge-node" => { "user" => { "shortName" => "A" * (limit + 50) } } }.to_json
expect(payload.bytesize).to be > limit
@@ -1352,7 +1352,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
it "rejects message payloads that are larger than the configured byte limit" do
limit = 64
stub_const("MAX_JSON_BODY_BYTES", limit)
allow(PotatoMesh::Config).to receive(:max_json_body_bytes).and_return(limit)
payload = [{ "id" => "m1", "text" => "A" * (limit + 50) }].to_json
expect(payload.bytesize).to be > limit
@@ -2302,7 +2302,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
context "when DEBUG logging is enabled" do
it "logs diagnostics for messages missing a sender" do
stub_const("DEBUG", true)
allow(PotatoMesh::Config).to receive(:debug?).and_return(true)
allow(Kernel).to receive(:warn)
message_id = 987_654