Compare commits

..

3 Commits

Author SHA1 Message Date
l5y 81e588e44c web: add markdown static pages (#723)
* web: add markdown static pages

* web: add tests and docker

* web: improve wording and configs

* web: add tests

* web: address review comments

* web: address review comments

* Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* web: address review comments

* web: address review comments

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-08 16:42:13 +02:00
l5y 083de6418f web: fix federation for multi protocol (#722)
* web: fix federation for multi protocol

* web: fix short name emojis

* web: address review comments

* ci: fix the codeql gap

* ci: fix the codeql gap

* ci: fix the codeql gap

* ci: remove swift
2026-04-08 14:36:43 +02:00
l5y 5b9e6e3d48 data: trace analysus multi ingestor support (#721)
* data: trace analysus multi ingestor support

* address review comments
2026-04-08 11:58:32 +02:00
29 changed files with 1521 additions and 81 deletions
-9
View File
@@ -13,12 +13,3 @@
- **`mobile.yml`** - Flutter mobile tests with coverage reporting
- **`release.yml`** - Tag-triggered Flutter release builds for Android and iOS
## Usage
```bash
# Build locally
docker-compose build
# Deploy
docker-compose up -d
```
+1 -1
View File
@@ -23,7 +23,7 @@ on:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
+3
View File
@@ -74,6 +74,9 @@ web/.config
node_modules/
web/node_modules/
# Operator-customised static pages (keep only the shipped default)
web/pages/*.md
# Debug symbols
ignored.txt
ignored-*.txt
+12
View File
@@ -81,6 +81,18 @@ the container. This path stores the instance private key and staged
of container lifecycle events, generated credentials are not replaced on reboot
or re-deploy.
The `potatomesh_pages` volume mounts to `/app/pages` and holds operator-managed
Markdown files that are rendered as static content pages in the web UI. On first
start the default `1-about.md` page is copied from the image into the volume.
You can add, edit, or remove `.md` files in this volume to customise your
instance's navigation. To use a host directory instead of a named volume, replace
the volume entry with a bind mount:
```yaml
volumes:
- ./my-pages:/app/pages
```
## Start the stack
From the directory containing the Compose file:
+22
View File
@@ -128,6 +128,28 @@ well-known document is staged in
The database can be found in `$XDG_DATA_HOME/potato-mesh`.
### Custom Pages
Instance operators can publish static content pages (contact details, mesh
protocol information, legal notices, etc.) by placing Markdown files in the
`pages/` directory inside `web/`. Each `.md` file automatically becomes a nav
entry and a route under `/pages/<slug>`.
Files are named `<sort-prefix>-<slug>.md` — the numeric prefix controls
navigation order and the slug becomes the URL path and nav label:
| Filename | Nav Label | URL |
| ---------------------- | -------------- | ----------------------- |
| `1-about.md` | About | `/pages/about` |
| `5-rules.md` | Rules | `/pages/rules` |
| `9-contact.md` | Contact | `/pages/contact` |
| `20-impressum.md` | Impressum | `/pages/impressum` |
A default `1-about.md` ships with the app. In Docker deployments the directory
is exposed as the `potatomesh_pages` volume (mounted at `/app/pages`) so you can
add or edit pages without rebuilding the image. The pages directory can also be
overridden with the `PAGES_DIR` environment variable.
### Federation
PotatoMesh instances can optionally federate by publishing signed metadata and
+1
View File
@@ -70,6 +70,7 @@ _CONFIG_ATTRS = {
"CHANNEL_INDEX",
"DEBUG",
"INSTANCE",
"INSTANCES",
"API_TOKEN",
"ALLOWED_CHANNELS",
"HIDDEN_CHANNELS",
+80 -2
View File
@@ -129,6 +129,11 @@ def _resolve_instance_domain() -> str:
Reads the :envvar:`INSTANCE_DOMAIN` variable. When the value does not
contain a scheme, ``https://`` is prepended automatically.
.. note::
Kept for backward compatibility with existing tests and callers.
New code should use :func:`_resolve_instance_domains` instead.
"""
configured_instance = os.environ.get("INSTANCE_DOMAIN", "").rstrip("/")
@@ -139,8 +144,80 @@ def _resolve_instance_domain() -> str:
return configured_instance
INSTANCE = _resolve_instance_domain()
API_TOKEN = os.environ.get("API_TOKEN", "")
def _normalise_domain(raw: str) -> str:
"""Strip whitespace and trailing slashes, prepend ``https://`` when needed.
Parameters:
raw: Single domain string to normalise.
Returns:
A URL string with a scheme prefix.
"""
domain = raw.strip().rstrip("/")
if domain and "://" not in domain:
return f"https://{domain}"
return domain
def _resolve_instance_domains() -> tuple[tuple[str, str], ...]:
"""Parse :envvar:`INSTANCE_DOMAIN` and :envvar:`API_TOKEN` into paired tuples.
When ``INSTANCE_DOMAIN`` contains comma-separated values, each entry is
treated as an independent target. ``API_TOKEN`` is either broadcast to
every target (single value) or positionally paired (comma-separated with
a matching count).
Returns:
A tuple of ``(instance_url, api_token)`` pairs, deduplicated by URL.
Raises:
ValueError: When the number of comma-separated tokens exceeds the
number of domains.
"""
raw_domain = os.environ.get("INSTANCE_DOMAIN", "")
raw_token = os.environ.get("API_TOKEN", "")
domains: list[str] = []
seen: set[str] = set()
for part in raw_domain.split(","):
normalised = _normalise_domain(part)
if not normalised:
continue
key = normalised.casefold()
if key in seen:
continue
seen.add(key)
domains.append(normalised)
if not domains:
return ()
tokens = [t.strip() for t in raw_token.split(",")]
# A single token (including empty string) is broadcast to all domains.
if len(tokens) == 1:
token = tokens[0]
return tuple((d, token) for d in domains)
if len(tokens) != len(domains):
raise ValueError(
f"API_TOKEN has {len(tokens)} comma-separated values but "
f"INSTANCE_DOMAIN has {len(domains)}; counts must match or "
f"API_TOKEN must be a single value"
)
return tuple(zip(domains, tokens))
INSTANCES: tuple[tuple[str, str], ...] = _resolve_instance_domains()
"""Paired ``(instance_url, api_token)`` tuples derived from the environment."""
INSTANCE = INSTANCES[0][0] if INSTANCES else _resolve_instance_domain()
"""First configured instance URL, kept for backward compatibility."""
API_TOKEN = INSTANCES[0][1] if INSTANCES else os.environ.get("API_TOKEN", "")
"""API token for the first configured instance, kept for backward compatibility."""
ENERGY_SAVING = os.environ.get("ENERGY_SAVING") == "1"
"""When ``True``, enables the ingestor's energy saving mode."""
@@ -202,6 +279,7 @@ __all__ = [
"HIDDEN_CHANNELS",
"ALLOWED_CHANNELS",
"INSTANCE",
"INSTANCES",
"API_TOKEN",
"ENERGY_SAVING",
"LORA_FREQ",
+6 -1
View File
@@ -666,11 +666,16 @@ def main(*, provider: MeshProtocol | None = None) -> None:
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigterm)
instance_label = (
", ".join(inst for inst, _ in config.INSTANCES)
if config.INSTANCES
else "(no INSTANCE_DOMAIN configured)"
)
config._debug_log(
"Mesh daemon starting",
context="daemon.main",
severity="info",
target=config.INSTANCE or "(no INSTANCE_DOMAIN configured)",
target=instance_label,
port=config.CONNECTION or "auto",
channel=config.CHANNEL_INDEX,
)
+51 -13
View File
@@ -97,29 +97,24 @@ class QueueState:
STATE = QueueState()
def _post_json(
def _send_single(
instance: str,
api_token: str,
path: str,
payload: dict,
*,
instance: str | None = None,
api_token: str | None = None,
) -> None:
"""Send a JSON payload to the configured web API.
"""Transmit a single JSON payload to one instance.
Parameters:
path: API path relative to the configured instance root.
instance: Base URL of the target instance.
api_token: Bearer token for this instance (may be empty).
path: API path relative to the instance root.
payload: JSON-serialisable body to transmit.
instance: Optional override for :data:`config.INSTANCE`.
api_token: Optional override for :data:`config.API_TOKEN`.
"""
if instance is None:
instance = config.INSTANCE
if api_token is None:
api_token = config.API_TOKEN
if not instance:
return
url = f"{instance}{path}"
data = json.dumps(payload).encode("utf-8")
@@ -155,6 +150,49 @@ def _post_json(
)
def _post_json(
path: str,
payload: dict,
*,
instance: str | None = None,
api_token: str | None = None,
) -> None:
"""Send a JSON payload to one or more configured web API instances.
When ``instance`` is provided explicitly the payload is sent to that
single target. Otherwise every ``(url, token)`` pair in
:data:`config.INSTANCES` receives the payload independently so that
one failure does not block delivery to the remaining targets.
Parameters:
path: API path relative to the instance root.
payload: JSON-serialisable body to transmit.
instance: Optional single-instance override.
api_token: Optional token override (only used with ``instance``).
"""
if instance is not None:
if not instance:
return
_send_single(instance, api_token or "", path, payload)
return
targets: tuple[tuple[str, str], ...] = config.INSTANCES
if not targets:
# Backward-compatible fallback for callers that only set
# config.INSTANCE / config.API_TOKEN directly.
inst = config.INSTANCE
if not inst:
return
_send_single(inst, api_token or config.API_TOKEN, path, payload)
return
for inst, token in targets:
if not inst:
continue
_send_single(inst, token, path, payload)
def _enqueue_post_json(
path: str,
payload: dict,
+3
View File
@@ -34,6 +34,7 @@ x-web-base: &web-base
- potatomesh_data:/app/.local/share/potato-mesh
- potatomesh_config:/app/.config/potato-mesh
- potatomesh_logs:/app/logs
- potatomesh_pages:/app/pages
restart: unless-stopped
deploy:
resources:
@@ -160,6 +161,8 @@ volumes:
driver: local
potatomesh_logs:
driver: local
potatomesh_pages:
driver: local
potatomesh_matrix_bridge_state:
driver: local
+78
View File
@@ -96,6 +96,84 @@ class TestParseHiddenChannels:
# ---------------------------------------------------------------------------
class TestResolveInstanceDomains:
"""Tests for :func:`config._resolve_instance_domains`."""
def test_single_domain(self, monkeypatch):
"""Single domain produces one-element tuple."""
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld")
monkeypatch.setenv("API_TOKEN", "secret")
result = config._resolve_instance_domains()
assert result == (("https://foo.tld", "secret"),)
def test_multi_domain_broadcast_token(self, monkeypatch):
"""Multiple domains with a single token broadcast the token."""
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld, bar.tld")
monkeypatch.setenv("API_TOKEN", "shared")
result = config._resolve_instance_domains()
assert result == (
("https://foo.tld", "shared"),
("https://bar.tld", "shared"),
)
def test_multi_domain_per_instance_tokens(self, monkeypatch):
"""Comma-separated tokens are positionally paired with domains."""
monkeypatch.setenv("INSTANCE_DOMAIN", "a.tld,b.tld")
monkeypatch.setenv("API_TOKEN", "tok1,tok2")
result = config._resolve_instance_domains()
assert result == (("https://a.tld", "tok1"), ("https://b.tld", "tok2"))
def test_token_count_mismatch_raises(self, monkeypatch):
"""Mismatched counts raise ValueError at parse time."""
monkeypatch.setenv("INSTANCE_DOMAIN", "a.tld,b.tld")
monkeypatch.setenv("API_TOKEN", "t1,t2,t3")
with pytest.raises(ValueError, match="counts must match"):
config._resolve_instance_domains()
def test_deduplicates_domains(self, monkeypatch):
"""Duplicate domains are collapsed to a single entry."""
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld, foo.tld")
monkeypatch.setenv("API_TOKEN", "tok")
result = config._resolve_instance_domains()
assert result == (("https://foo.tld", "tok"),)
def test_preserves_explicit_scheme(self, monkeypatch):
"""Domains with explicit schemes keep them; others get https://."""
monkeypatch.setenv("INSTANCE_DOMAIN", "http://local:41447,bar.tld")
monkeypatch.setenv("API_TOKEN", "tok")
result = config._resolve_instance_domains()
assert result == (
("http://local:41447", "tok"),
("https://bar.tld", "tok"),
)
def test_empty_domain(self, monkeypatch):
"""Empty INSTANCE_DOMAIN returns an empty tuple."""
monkeypatch.setenv("INSTANCE_DOMAIN", "")
monkeypatch.setenv("API_TOKEN", "tok")
result = config._resolve_instance_domains()
assert result == ()
def test_strips_trailing_slashes(self, monkeypatch):
"""Trailing slashes are stripped from domains."""
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld/")
monkeypatch.setenv("API_TOKEN", "tok")
result = config._resolve_instance_domains()
assert result == (("https://foo.tld", "tok"),)
def test_empty_token_broadcast(self, monkeypatch):
"""Empty API_TOKEN broadcasts empty string to all instances."""
monkeypatch.setenv("INSTANCE_DOMAIN", "a.tld,b.tld")
monkeypatch.setenv("API_TOKEN", "")
result = config._resolve_instance_domains()
assert result == (("https://a.tld", ""), ("https://b.tld", ""))
# ---------------------------------------------------------------------------
# _resolve_instance_domain (legacy, kept for backward compatibility)
# ---------------------------------------------------------------------------
class TestResolveInstanceDomain:
"""Tests for :func:`config._resolve_instance_domain`."""
+6
View File
@@ -233,7 +233,9 @@ def test_instance_domain_prefers_primary_env(mesh_module, monkeypatch):
monkeypatch.setenv("INSTANCE_DOMAIN", "https://new.example")
try:
refreshed_instances = mesh_module.config._resolve_instance_domains()
refreshed_instance = mesh_module.config._resolve_instance_domain()
mesh_module.config.INSTANCES = refreshed_instances
mesh_module.config.INSTANCE = refreshed_instance
mesh_module.INSTANCE = refreshed_instance
@@ -241,6 +243,7 @@ def test_instance_domain_prefers_primary_env(mesh_module, monkeypatch):
assert mesh_module.INSTANCE == "https://new.example"
finally:
monkeypatch.delenv("INSTANCE_DOMAIN", raising=False)
mesh_module.config.INSTANCES = mesh_module.config._resolve_instance_domains()
mesh_module.config.INSTANCE = mesh_module.config._resolve_instance_domain()
mesh_module.INSTANCE = mesh_module.config.INSTANCE
@@ -251,7 +254,9 @@ def test_instance_domain_infers_scheme_for_hostnames(mesh_module, monkeypatch):
monkeypatch.setenv("INSTANCE_DOMAIN", "mesh.example.org")
try:
refreshed_instances = mesh_module.config._resolve_instance_domains()
refreshed_instance = mesh_module.config._resolve_instance_domain()
mesh_module.config.INSTANCES = refreshed_instances
mesh_module.config.INSTANCE = refreshed_instance
mesh_module.INSTANCE = refreshed_instance
@@ -259,6 +264,7 @@ def test_instance_domain_infers_scheme_for_hostnames(mesh_module, monkeypatch):
assert mesh_module.INSTANCE == "https://mesh.example.org"
finally:
monkeypatch.delenv("INSTANCE_DOMAIN", raising=False)
mesh_module.config.INSTANCES = mesh_module.config._resolve_instance_domains()
mesh_module.config.INSTANCE = mesh_module.config._resolve_instance_domain()
mesh_module.INSTANCE = mesh_module.config.INSTANCE
+124 -35
View File
@@ -53,6 +53,19 @@ def _fresh_state() -> QueueState:
return QueueState()
class _FakeResp:
"""Minimal context-manager response stub for ``urlopen`` patches."""
def read(self):
return b""
def __enter__(self):
return self
def __exit__(self, *a):
pass
# ---------------------------------------------------------------------------
# Priority constant ordering
# ---------------------------------------------------------------------------
@@ -85,33 +98,24 @@ class TestPostJson:
"""Tests for :func:`queue._post_json`."""
def test_skips_when_no_instance(self, monkeypatch):
"""Does nothing when INSTANCE is empty."""
"""Does nothing when INSTANCES is empty."""
monkeypatch.setattr(config, "INSTANCES", ())
monkeypatch.setattr(config, "INSTANCE", "")
sent = []
with patch("urllib.request.urlopen") as mock_open:
_post_json("/api/test", {"key": "val"})
mock_open.assert_not_called()
def test_sends_json_post(self, monkeypatch):
"""Sends a POST request with JSON body and correct headers."""
monkeypatch.setattr(config, "INSTANCES", (("http://localhost", "tok"),))
monkeypatch.setattr(config, "INSTANCE", "http://localhost")
monkeypatch.setattr(config, "API_TOKEN", "tok")
captured_req = []
class FakeResp:
def read(self):
return b""
def __enter__(self):
return self
def __exit__(self, *a):
pass
def fake_urlopen(req, timeout=None):
captured_req.append(req)
return FakeResp()
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/nodes", {"a": 1})
@@ -124,6 +128,7 @@ class TestPostJson:
def test_handles_network_error_gracefully(self, monkeypatch, capsys):
"""Network errors are caught and logged, not raised."""
monkeypatch.setattr(config, "INSTANCES", (("http://localhost", ""),))
monkeypatch.setattr(config, "INSTANCE", "http://localhost")
monkeypatch.setattr(config, "API_TOKEN", "")
monkeypatch.setattr(config, "DEBUG", True)
@@ -140,19 +145,9 @@ class TestPostJson:
captured_req = []
class FakeResp:
def read(self):
return b""
def __enter__(self):
return self
def __exit__(self, *a):
pass
def fake_urlopen(req, timeout=None):
captured_req.append(req)
return FakeResp()
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/test", {}, instance="http://override")
@@ -161,24 +156,15 @@ class TestPostJson:
def test_no_auth_header_when_token_empty(self, monkeypatch):
"""No Authorization header is added when API_TOKEN is empty."""
monkeypatch.setattr(config, "INSTANCES", (("http://localhost", ""),))
monkeypatch.setattr(config, "INSTANCE", "http://localhost")
monkeypatch.setattr(config, "API_TOKEN", "")
captured_req = []
class FakeResp:
def read(self):
return b""
def __enter__(self):
return self
def __exit__(self, *a):
pass
def fake_urlopen(req, timeout=None):
captured_req.append(req)
return FakeResp()
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/test", {})
@@ -394,3 +380,106 @@ class TestClearPostQueue:
state = _fresh_state()
_clear_post_queue(state=state)
assert state.queue == []
# ---------------------------------------------------------------------------
# Multi-instance fan-out
# ---------------------------------------------------------------------------
class TestMultiInstanceFanOut:
"""Tests for multi-instance POST fan-out in :func:`queue._post_json`."""
def test_fans_out_to_all_instances(self, monkeypatch):
"""Each configured instance receives the payload."""
monkeypatch.setattr(
config,
"INSTANCES",
(("http://alpha", "t1"), ("http://beta", "t2")),
)
captured = []
def fake_urlopen(req, timeout=None):
captured.append(req)
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/nodes", {"a": 1})
assert len(captured) == 2
urls = {r.get_full_url() for r in captured}
assert urls == {"http://alpha/api/nodes", "http://beta/api/nodes"}
tokens = {r.get_header("Authorization") for r in captured}
assert tokens == {"Bearer t1", "Bearer t2"}
def test_failure_isolation(self, monkeypatch):
"""A failure on one instance does not prevent delivery to the next."""
monkeypatch.setattr(
config,
"INSTANCES",
(("http://broken", "t1"), ("http://ok", "t2")),
)
monkeypatch.setattr(config, "DEBUG", False)
captured = []
def fake_urlopen(req, timeout=None):
if "broken" in req.get_full_url():
raise OSError("connection refused")
captured.append(req)
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/test", {"x": 1})
assert len(captured) == 1
assert "http://ok" in captured[0].get_full_url()
def test_explicit_instance_skips_fanout(self, monkeypatch):
"""Passing instance= explicitly bypasses the INSTANCES fan-out."""
monkeypatch.setattr(
config,
"INSTANCES",
(("http://a", "t1"), ("http://b", "t2")),
)
captured = []
def fake_urlopen(req, timeout=None):
captured.append(req)
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/test", {}, instance="http://override")
assert len(captured) == 1
assert "http://override" in captured[0].get_full_url()
def test_empty_instances_noop(self, monkeypatch):
"""No requests are made when INSTANCES is empty."""
monkeypatch.setattr(config, "INSTANCES", ())
monkeypatch.setattr(config, "INSTANCE", "")
with patch("urllib.request.urlopen") as mock_open:
_post_json("/api/test", {})
mock_open.assert_not_called()
def test_backward_compat_fallback(self, monkeypatch):
"""Falls back to config.INSTANCE when INSTANCES is empty."""
monkeypatch.setattr(config, "INSTANCES", ())
monkeypatch.setattr(config, "INSTANCE", "http://legacy")
monkeypatch.setattr(config, "API_TOKEN", "tok")
captured = []
def fake_urlopen(req, timeout=None):
captured.append(req)
return _FakeResp()
with patch("urllib.request.urlopen", fake_urlopen):
_post_json("/api/test", {"v": 1})
assert len(captured) == 1
assert "http://legacy" in captured[0].get_full_url()
assert captured[0].get_header("Authorization") == "Bearer tok"
+3 -1
View File
@@ -76,6 +76,7 @@ COPY --chown=potatomesh:potatomesh web/spec ./spec
COPY --chown=potatomesh:potatomesh web/public ./public
COPY --chown=potatomesh:potatomesh web/views ./views
COPY --chown=potatomesh:potatomesh web/scripts ./scripts
COPY --chown=potatomesh:potatomesh web/pages ./pages
# Copy SQL schema files from data directory
COPY --chown=potatomesh:potatomesh data/*.sql /data/
@@ -84,7 +85,8 @@ COPY --chown=potatomesh:potatomesh data/mesh_ingestor/decode_payload.py /app/dat
# Create data and configuration directories with correct ownership
RUN mkdir -p /app/.local/share/potato-mesh \
&& mkdir -p /app/.config/potato-mesh/well-known \
&& chown -R potatomesh:potatomesh /app/.local/share /app/.config
&& mkdir -p /app/pages \
&& chown -R potatomesh:potatomesh /app/.local/share /app/.config /app/pages
# Switch to non-root user
USER potatomesh
+4
View File
@@ -20,6 +20,8 @@ gem "sqlite3", "~> 1.7"
gem "rackup", "~> 2.2"
gem "puma", "~> 7.0"
gem "prometheus-client"
gem "kramdown", "~> 2.4"
gem "kramdown-parser-gfm", "~> 1.1"
group :test do
gem "rspec", "~> 3.12"
@@ -29,3 +31,5 @@ group :test do
gem "simplecov_json_formatter", "~> 0.1", require: false
gem "rspec_junit_formatter", "~> 0.6", require: false
end
gem "sanitize", "7.0.0"
+4
View File
@@ -57,6 +57,7 @@ require_relative "application/meshtastic/cipher"
require_relative "application/meshtastic/payload_decoder"
require_relative "application/data_processing"
require_relative "application/filesystem"
require_relative "application/pages"
require_relative "application/instances"
require_relative "application/routes/api"
require_relative "application/routes/ingest"
@@ -74,6 +75,7 @@ module PotatoMesh
extend App::Queries
extend App::DataProcessing
extend App::Filesystem
extend App::Pages
helpers App::Helpers
include App::Database
@@ -85,6 +87,7 @@ module PotatoMesh
include App::Queries
include App::DataProcessing
include App::Filesystem
include App::Pages
register App::Routes::Api
register App::Routes::Ingest
@@ -210,6 +213,7 @@ SELF_INSTANCE_ID = PotatoMesh::Application::SELF_INSTANCE_ID unless defined?(SEL
PotatoMesh::App::Prometheus,
PotatoMesh::App::Queries,
PotatoMesh::App::DataProcessing,
PotatoMesh::App::Pages,
].each do |mod|
Object.include(mod) unless Object < mod
end
@@ -297,9 +297,12 @@ module PotatoMesh
def shutdown_federation_background_work!(timeout: nil)
request_federation_shutdown!
timeout_value = timeout || PotatoMesh::Config.federation_shutdown_timeout_seconds
# Drain the worker pool first so federation threads blocked in
# wait_for_federation_tasks unblock promptly instead of waiting
# for each task's individual timeout to expire.
shutdown_federation_worker_pool!
stop_federation_thread!(:initial_federation_thread, timeout: timeout_value)
stop_federation_thread!(:federation_thread, timeout: timeout_value)
shutdown_federation_worker_pool!
clear_federation_crawl_state!
end
+226
View File
@@ -0,0 +1,226 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "kramdown"
require "kramdown-parser-gfm"
require "sanitize"
module PotatoMesh
module App
# Discovers, parses, and renders operator-managed Markdown pages from the
# configured pages directory. Files are named with an optional numeric
# prefix for ordering (e.g. +1-about.md+, +9-contact.md+) and exposed as
# navigable routes under +/pages/:slug+.
module Pages
module_function
# Lightweight value object describing a single static page discovered on
# disk. Fields are populated by {parse_page_filename} and consumed by
# route handlers and layout templates.
#
# @!attribute [r] sort_key
# @return [String] filename stem used for alphabetical ordering.
# @!attribute [r] slug
# @return [String] URL-safe identifier derived from the filename.
# @!attribute [r] title
# @return [String] human-readable nav label.
# @!attribute [r] path
# @return [String] absolute filesystem path to the Markdown source.
PageEntry = Struct.new(:sort_key, :slug, :title, :path, keyword_init: true)
# Pattern matching a safe slug segment: lowercase alphanumeric words
# separated by single hyphens. Used to validate both parsed slugs and
# incoming route parameters.
SLUG_PATTERN = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
# Pattern used to split a page filename into an optional numeric sort
# prefix and the slug portion.
FILENAME_PATTERN = /\A(\d+)-(.+)\z/
# Maximum number of pages loaded from disk. Prevents accidental
# directory-bomb scenarios from consuming unbounded memory.
MAX_PAGES = 50
# Kramdown options shared across all page renders.
KRAMDOWN_OPTIONS = {
input: "GFM",
hard_wrap: false,
}.freeze
# HTML tags allowed in rendered markdown output. Tags not in this list
# are stripped after rendering to prevent XSS from operator content.
ALLOWED_TAGS = Set.new(%w[
h1 h2 h3 h4 h5 h6 p a em strong b i u s del code pre br hr
ul ol li dl dt dd blockquote table thead tbody tfoot tr th td
img span div sup sub abbr mark small details summary
]).freeze
@pages_cache = nil
@pages_cache_mutex = Mutex.new
# Parse a Markdown filename into a {PageEntry} without the filesystem
# path populated.
#
# Filenames are expected to follow the pattern +<digits>-<slug>.md+ where
# the numeric prefix controls navigation order. Files without a prefix
# are accepted, using the full stem as both sort key and slug.
#
# @param basename [String] bare filename (e.g. +"9-contact.md"+).
# @return [PageEntry, nil] parsed entry or +nil+ when the filename is
# invalid or contains an unsafe slug.
def parse_page_filename(basename)
stem = basename.sub(/\.md\z/i, "")
return nil if stem.empty?
match = stem.match(FILENAME_PATTERN)
if match
slug = match[2].downcase
sort_key = stem
else
slug = stem.downcase
sort_key = stem
end
return nil unless slug.match?(SLUG_PATTERN)
title = slug.split("-").map(&:capitalize).join(" ")
PageEntry.new(sort_key: sort_key, slug: slug, title: title, path: nil)
end
# Scan the pages directory and return a sorted list of page entries.
#
# The directory is read once per call; results are not cached here (see
# {static_pages} for the cached interface). Non-+.md+ files and entries
# with invalid filenames are silently skipped.
#
# @param directory [String] absolute path to the pages directory.
# @return [Array<PageEntry>] frozen, sort-key-ordered list of pages.
def load_static_pages(directory = PotatoMesh::Config.pages_directory)
return [].freeze unless directory && File.directory?(directory)
entries = Dir.glob(File.join(directory, "*.md")).filter_map do |path|
basename = File.basename(path)
entry = parse_page_filename(basename)
next unless entry
PageEntry.new(
sort_key: entry.sort_key,
slug: entry.slug,
title: entry.title,
path: path,
)
end
entries.sort_by!(&:sort_key)
entries.uniq!(&:slug)
entries.take(MAX_PAGES).freeze
end
# Return the current set of static pages, reloading from disk when the
# cache has expired.
#
# The TTL is short in non-production environments (1 second) so that
# newly added files appear almost immediately during development.
#
# @return [Array<PageEntry>] cached page entries.
def static_pages
@pages_cache_mutex.synchronize do
if @pages_cache.nil? || Time.now > @pages_cache[:expires_at]
ttl = production_environment? ? 60 : 1
@pages_cache = {
entries: load_static_pages,
expires_at: Time.now + ttl,
}
end
@pages_cache[:entries]
end
end
# Look up a page entry by its URL slug.
#
# @param slug [String] URL slug to search for.
# @return [PageEntry, nil] matching entry or +nil+.
def find_page_by_slug(slug)
static_pages.find { |entry| entry.slug == slug }
end
# Read and render a page's Markdown source to HTML.
#
# Files exceeding {Config.max_page_file_bytes} are rejected to guard
# against accidental out-of-memory conditions. Raw HTML blocks are
# disabled at the parser level to prevent XSS.
#
# @param page_entry [PageEntry] entry whose +path+ points to the source.
# @return [String, nil] sanitised HTML string, or +nil+ when the file
# cannot be read.
def render_page_content(page_entry)
return nil unless page_entry&.path
return nil unless File.file?(page_entry.path) && File.readable?(page_entry.path)
size = File.size(page_entry.path)
return nil if size > PotatoMesh::Config.max_page_file_bytes
content = File.read(page_entry.path, encoding: "utf-8")
raw_html = Kramdown::Document.new(content, **KRAMDOWN_OPTIONS).to_html
strip_unsafe_html(raw_html)
rescue SystemCallError
nil
end
# Remove HTML tags not present in {ALLOWED_TAGS} and strip dangerous
# attributes (event handlers, javascript: URIs) from the rendered output.
# This provides a safety net against XSS when operators include raw HTML
# in their Markdown source.
#
# @param html [String] raw HTML produced by kramdown.
# @return [String] HTML with disallowed tags and attributes stripped.
def strip_unsafe_html(html)
# Delegate to the sanitize gem for robust HTML and attribute
# sanitization instead of relying on ad-hoc regular expressions.
Sanitize.fragment(
html,
elements: ALLOWED_TAGS,
attributes: {
:all => %w[id class title alt],
"a" => %w[href],
"img" => %w[src width height loading decoding],
},
protocols: {
"a" => { "href" => ["http", "https", "mailto"] },
"img" => { "src" => ["http", "https"] },
},
)
end
# Invalidate the in-memory page cache so the next call to
# {static_pages} re-scans the directory. Intended for test teardown.
#
# @return [void]
def clear_pages_cache!
@pages_cache_mutex.synchronize { @pages_cache = nil }
end
# Determine whether the application is running in a production-like
# environment.
#
# @return [Boolean] true when +RACK_ENV+ or +APP_ENV+ is +"production"+.
def production_environment?
%w[production].include?(ENV.fetch("RACK_ENV", nil)) ||
%w[production].include?(ENV.fetch("APP_ENV", nil))
end
end
end
end
@@ -59,6 +59,7 @@ module PotatoMesh
initial_theme: theme,
current_view_mode: view_mode_sym,
map_zoom: PotatoMesh::Config.map_zoom,
static_pages: PotatoMesh::App::Pages.static_pages,
}
sanitized_locals = extra_locals.is_a?(Hash) ? extra_locals : {}
merged_locals = base_locals.merge(sanitized_locals)
@@ -180,6 +181,26 @@ module PotatoMesh
render_root_view(:federation, view_mode: :federation)
end
app.get "/pages/:slug" do
slug = params.fetch("slug", "")
halt 400, "Bad Request" unless slug.match?(PotatoMesh::App::Pages::SLUG_PATTERN)
page = PotatoMesh::App::Pages.find_page_by_slug(slug)
halt 404, "Not Found" unless page
page_html = PotatoMesh::App::Pages.render_page_content(page)
halt 500, "Internal Server Error" unless page_html
render_root_view(
:page,
view_mode: :"page_#{slug}",
extra_locals: {
page_title: page.title,
page_content_html: page_html,
},
)
end
app.get "/nodes/:id" do
node_ref = params.fetch("id", nil)
reference_payload = build_node_detail_reference(node_ref)
+20
View File
@@ -84,6 +84,26 @@ module PotatoMesh
value.to_s.strip != "0"
end
# Resolve the absolute path to the operator-managed static pages directory.
#
# The directory defaults to +pages/+ at the application root and can be
# overridden with the +PAGES_DIR+ environment variable.
#
# @return [String] absolute filesystem path to the pages directory.
def pages_directory
custom = fetch_string("PAGES_DIR", nil)
return File.expand_path(custom) if custom
File.join(web_root, "pages")
end
# Maximum file size in bytes accepted when reading a static page.
#
# @return [Integer] byte ceiling for markdown files.
def max_page_file_bytes
512 * 1024
end
# Resolve the absolute path to the web application root directory.
#
# @return [String] absolute filesystem path of the web folder.
+73
View File
@@ -0,0 +1,73 @@
# About This Mesh
Welcome to this [PotatoMesh](https://github.com/l5yth/potato-mesh) instance - a community dashboard for off-grid mesh networks. This is an example page, please modify it before deploying.
## What Is Meshtastic?
[Meshtastic](https://meshtastic.org) is an open-source project that turns
affordable LoRa radios into a decentralised, long-range communication network.
No cellular service or internet connection is required - nodes relay messages
across the mesh automatically.
## What Is Meshcore?
[Meshcore](https://meshcore.co.uk) is a firmware for LoRa radios focused on
reliable, low-power mesh networking. It provides a public channel system and
supports narrow-band presets optimised for long range in dense environments.
## Network Details
| Setting | Meshtastic | Meshcore |
| --------- | --------------- | ----------------- |
| Channel | #MediumFast | Public |
| Frequency | 869.525 MHz | 869.618 MHz |
| Bandwidth | 250 kHz | 62.5 kHz |
| SF | 8 | 8 |
| CR | 4/5 | 4/8 |
| Preset | Medium / Fast | EU/UK Narrow |
> Adjust this table to match the configuration of your local mesh.
## Contact
- **Public chat:** [#potatomesh:dod.ngo](https://matrix.to/#/#potatomesh:dod.ngo)
- **Source code:** [github.com/l5yth/potato-mesh](https://github.com/l5yth/potato-mesh)
## Custom Pages
Instance operators can add, edit, or remove pages by placing Markdown files in
the `pages/` directory (mounted as a Docker volume at `/app/pages`). Each file
becomes a new entry in the navigation bar.
### Filename Convention
```
<sort-prefix>-<slug>.md
```
- **Sort prefix** - a number that controls the order in the nav bar (e.g. `1`,
`5`, `10`). Files are sorted alphabetically by their full filename.
- **Slug** - lowercase, hyphen-separated words that become the URL path and nav
label. `contact` becomes `/pages/contact` with the label "Contact";
`privacy-policy` becomes `/pages/privacy-policy` labelled "Privacy Policy".
### Examples
| Filename | Nav Label | URL |
| --------------------- | ---------------- | ---------------------- |
| `1-about.md` | About | `/pages/about` |
| `5-rules.md` | Rules | `/pages/rules` |
| `9-contact.md` | Contact | `/pages/contact` |
| `10-privacy-policy.md`| Privacy Policy | `/pages/privacy-policy`|
### Impressum / Legal Notice
Operators subject to legal disclosure requirements (e.g. the German
Telemediengesetz) can create an `impressum.md` page:
```
20-impressum.md
```
Fill it with your legally required contact details - name, address, email, phone
- and it will appear in the navigation as "Impressum".
@@ -346,3 +346,46 @@ test('createMessageChatEntry: meshtastic message with @[Name] is NOT resolved as
assert.ok(shortNameCount <= 1, 'only the sender badge should be present, no mention badge');
});
});
// --- renderShortHtml badge padding ---
test('renderShortHtml leaves 4-char ASCII names unpadded', () => {
withApp(() => {
const html = globalThis.PotatoMesh.renderShortHtml('0ac7', 'CLIENT');
assert.ok(!html.includes('&nbsp;0ac7'), 'should not add leading space');
assert.ok(!html.includes('0ac7&nbsp;'), 'should not add trailing space');
});
});
test('renderShortHtml adds single space padding for short emoji names', () => {
withApp(() => {
const html = globalThis.PotatoMesh.renderShortHtml('\u26A1', 'CLIENT');
// Should produce " ⚡ " — one leading, one trailing space (as &nbsp;)
assert.ok(html.includes('&nbsp;\u26A1&nbsp;'), 'emoji should have one space on each side');
// Should NOT have double leading spaces
assert.ok(!html.includes('&nbsp;&nbsp;\u26A1'), 'should not double-pad emoji');
});
});
test('renderShortHtml adds single space padding for surrogate pair emoji', () => {
withApp(() => {
const html = globalThis.PotatoMesh.renderShortHtml('\uD83D\uDE43', 'CLIENT');
// 🙃 is a surrogate pair (length 2 in JS) but 1 grapheme
assert.ok(html.includes('&nbsp;\uD83D\uDE43&nbsp;'), 'surrogate emoji should have one space on each side');
});
});
test('renderShortHtml adds single space padding for ZWJ emoji sequence', () => {
withApp(() => {
const zwj = '\u{1F3C3}\u{200D}\u{2642}\u{FE0F}'; // 🏃‍♂️ — length 5, 1 grapheme
const html = globalThis.PotatoMesh.renderShortHtml(zwj, 'CLIENT');
assert.ok(html.includes(`&nbsp;${zwj}&nbsp;`), 'ZWJ emoji should have one space on each side');
});
});
test('renderShortHtml adds single space padding for plain 2-char name', () => {
withApp(() => {
const html = globalThis.PotatoMesh.renderShortHtml('ab', 'CLIENT');
assert.ok(html.includes('&nbsp;ab&nbsp;'), '2-char name should have one space on each side');
});
});
+11 -11
View File
@@ -1853,22 +1853,22 @@ export function initializeApp(config) {
infoAttr = attrParts.join('');
}
if (!short) {
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>?&nbsp;&nbsp;&nbsp;</span>`;
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>&nbsp;?&nbsp;</span>`;
}
// Centre the label within a 4-column badge. padStart alone only adds
// leading spaces, producing " C" for a 1-char name with no trailing
// space. Instead distribute padding evenly: 1-char → " C ", 2-char →
// " AB ", 3-char → " ABC", 4-char → unchanged. Names already at 4+
// chars are left as-is (meshtastic always stores exactly 4; the Ruby
// COMPANION override also produces exactly 4).
// Pad the label for the badge. For plain-ASCII names that are already
// 4 characters (meshtastic always stores exactly 4) no padding is added.
// Shorter names or names containing emoji/non-ASCII get a single space
// on each side — grapheme width varies too much for character-count
// centering to work reliably.
const raw = String(short);
const graphemeCount = typeof Intl !== 'undefined' && Intl.Segmenter
? [...new Intl.Segmenter().segment(raw)].length
: raw.length;
let centred;
if (raw.length >= 4) {
if (graphemeCount >= 4) {
centred = raw;
} else {
const leading = Math.ceil((4 - raw.length) / 2);
const trailing = 4 - raw.length - leading;
centred = ' '.repeat(leading) + raw + ' '.repeat(trailing);
centred = ` ${raw} `;
}
const padded = escapeHtml(centred).replace(/ /g, '&nbsp;');
const protocol = nodeData?.protocol ?? null;
+146
View File
@@ -248,6 +248,7 @@ h1 {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
height: 1.6em;
padding: 0 var(--pad);
border-radius: 999px;
@@ -274,6 +275,15 @@ h1 {
min-width: 0;
}
.site-title__link {
display: inline-flex;
align-items: center;
gap: inherit;
color: inherit;
text-decoration: none;
min-width: 0;
}
.site-title-text {
min-width: 0;
max-width: 100%;
@@ -2331,3 +2341,139 @@ body.dark #map .leaflet-tile.map-tiles {
align-self: flex-end;
}
}
/* ── Static pages (markdown content) ────────────────────────── */
.static-page {
max-width: 800px;
margin: 0 auto;
padding: 32px 16px;
}
.static-page__content.markdown-body {
color: var(--fg);
line-height: 1.7;
font-size: 15px;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4 {
color: var(--fg);
margin: 1.6em 0 0.6em;
font-weight: 600;
line-height: 1.3;
}
.markdown-body h1 { font-size: 1.6em; }
.markdown-body h2 { font-size: 1.3em; border-bottom: 1px solid var(--line); padding-bottom: 0.3em; }
.markdown-body h3 { font-size: 1.1em; }
.markdown-body h4 { font-size: 1em; }
.markdown-body h1:first-child,
.markdown-body h2:first-child,
.markdown-body h3:first-child {
margin-top: 0;
}
.markdown-body p {
margin: 0.8em 0;
}
.markdown-body a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.markdown-body a:hover {
opacity: 0.85;
}
.markdown-body ul,
.markdown-body ol {
margin: 0.8em 0;
padding-left: 1.8em;
}
.markdown-body li {
margin: 0.3em 0;
}
.markdown-body blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 3px solid var(--accent);
background: var(--bg2);
border-radius: 4px;
color: var(--muted);
}
.markdown-body blockquote p {
margin: 0.4em 0;
}
.markdown-body code {
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 0.9em;
background: var(--bg2);
padding: 0.15em 0.4em;
border-radius: 4px;
}
.markdown-body pre {
margin: 1em 0;
padding: 12px 16px;
background: var(--bg2);
border-radius: 6px;
overflow-x: auto;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: 0.85em;
line-height: 1.5;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
font-size: 0.9em;
}
.markdown-body th,
.markdown-body td {
padding: 8px 12px;
border: 1px solid var(--line);
text-align: left;
}
.markdown-body th {
background: var(--bg2);
font-weight: 600;
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--line);
margin: 2em 0;
}
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
@media (max-width: 600px) {
.static-page {
padding: 20px 12px;
}
.markdown-body h1 { font-size: 1.3em; }
.markdown-body h2 { font-size: 1.15em; }
}
+15
View File
@@ -1311,6 +1311,13 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('class="footer-content"')
end
it "renders the site title as a link to the dashboard" do
get "/"
expect(last_response.body).to include('class="site-title__link"')
expect(last_response.body).to match(%r{<a href="/" class="site-title__link">})
end
it "renders the federation instance selector when federation is enabled" do
get "/"
@@ -6980,6 +6987,14 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include(node["node_id"])
end
it "does not render the meta row on the node detail page" do
node = nodes_fixture.first
get "/nodes/#{node["node_id"]}"
expect(last_response).to be_ok
expect(last_response.body).not_to include('id="metaRow"')
expect(last_response.body).not_to include('id="refreshBtn"')
end
it "returns 404 when the node cannot be located" do
get "/nodes/!deadbeef"
expect(last_response.status).to eq(404)
+25
View File
@@ -627,6 +627,31 @@ RSpec.describe PotatoMesh::Config do
end
end
describe ".pages_directory" do
it "defaults to pages/ under the web root" do
within_env("PAGES_DIR" => nil) do
expect(described_class.pages_directory).to eq(
File.join(described_class.web_root, "pages"),
)
end
end
it "uses PAGES_DIR when set" do
Dir.mktmpdir do |dir|
within_env("PAGES_DIR" => dir) do
expect(described_class.pages_directory).to eq(File.expand_path(dir))
end
end
end
end
describe ".max_page_file_bytes" do
it "returns a positive integer" do
expect(described_class.max_page_file_bytes).to be_a(Integer)
expect(described_class.max_page_file_bytes).to be > 0
end
end
# Execute the provided block with temporary environment overrides.
#
# @param values [Hash{String=>String, nil}] key/value pairs to set in ENV.
+501
View File
@@ -0,0 +1,501 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
RSpec.describe PotatoMesh::App::Pages do
let(:pages_dir) { File.join(SPEC_TMPDIR, "pages-#{SecureRandom.hex(4)}") }
before do
FileUtils.mkdir_p(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
after do
FileUtils.rm_rf(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
# ── parse_page_filename ──────────────────────────────────────
describe ".parse_page_filename" do
it "parses a numeric-prefixed filename" do
entry = described_class.parse_page_filename("9-contact.md")
expect(entry).not_to be_nil
expect(entry.sort_key).to eq("9-contact")
expect(entry.slug).to eq("contact")
expect(entry.title).to eq("Contact")
end
it "parses a multi-word slug" do
entry = described_class.parse_page_filename("10-privacy-policy.md")
expect(entry.slug).to eq("privacy-policy")
expect(entry.title).to eq("Privacy Policy")
end
it "parses a filename without numeric prefix" do
entry = described_class.parse_page_filename("readme.md")
expect(entry).not_to be_nil
expect(entry.sort_key).to eq("readme")
expect(entry.slug).to eq("readme")
expect(entry.title).to eq("Readme")
end
it "parses a multi-digit prefix" do
entry = described_class.parse_page_filename("100-faq.md")
expect(entry.sort_key).to eq("100-faq")
expect(entry.slug).to eq("faq")
expect(entry.title).to eq("Faq")
end
it "rejects empty basename" do
expect(described_class.parse_page_filename(".md")).to be_nil
end
it "downcases uppercase slugs" do
entry = described_class.parse_page_filename("1-About.md")
expect(entry).not_to be_nil
expect(entry.slug).to eq("about")
end
it "rejects slugs with underscores" do
expect(described_class.parse_page_filename("1-my_page.md")).to be_nil
end
it "rejects slugs with path traversal" do
expect(described_class.parse_page_filename("../../etc.md")).to be_nil
end
it "rejects slugs starting with a hyphen" do
expect(described_class.parse_page_filename("1--bad.md")).to be_nil
end
it "rejects slugs ending with a hyphen" do
expect(described_class.parse_page_filename("bad-.md")).to be_nil
end
it "sets path to nil" do
entry = described_class.parse_page_filename("1-about.md")
expect(entry.path).to be_nil
end
end
# ── load_static_pages ────────────────────────────────────────
describe ".load_static_pages" do
it "returns an empty array when the directory does not exist" do
result = described_class.load_static_pages("/nonexistent/dir")
expect(result).to eq([])
expect(result).to be_frozen
end
it "returns an empty array when the directory argument is nil" do
result = described_class.load_static_pages(nil)
expect(result).to eq([])
expect(result).to be_frozen
end
it "returns an empty array when the directory is empty" do
result = described_class.load_static_pages(pages_dir)
expect(result).to eq([])
end
it "discovers and sorts markdown files" do
File.write(File.join(pages_dir, "5-beta.md"), "# Beta")
File.write(File.join(pages_dir, "1-alpha.md"), "# Alpha")
File.write(File.join(pages_dir, "9-gamma.md"), "# Gamma")
result = described_class.load_static_pages(pages_dir)
expect(result.map(&:slug)).to eq(%w[alpha beta gamma])
expect(result.map(&:sort_key)).to eq(%w[1-alpha 5-beta 9-gamma])
end
it "populates the path field" do
File.write(File.join(pages_dir, "1-test.md"), "# Test")
result = described_class.load_static_pages(pages_dir)
expect(result.first.path).to eq(File.join(pages_dir, "1-test.md"))
end
it "ignores non-md files" do
File.write(File.join(pages_dir, "1-about.md"), "# About")
File.write(File.join(pages_dir, "notes.txt"), "text")
File.write(File.join(pages_dir, "image.png"), "binary")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.slug).to eq("about")
end
it "skips files with invalid filenames" do
File.write(File.join(pages_dir, "1-good.md"), "# Good")
File.write(File.join(pages_dir, "1-bad_name.md"), "# Bad")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.slug).to eq("good")
end
it "deduplicates entries with the same slug keeping the first" do
File.write(File.join(pages_dir, "1-about.md"), "# First")
File.write(File.join(pages_dir, "2-about.md"), "# Second")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.sort_key).to eq("1-about")
end
it "limits entries to MAX_PAGES" do
(1..55).each do |i|
File.write(File.join(pages_dir, "#{i}-page#{i}.md"), "# Page #{i}")
end
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(PotatoMesh::App::Pages::MAX_PAGES)
end
it "returns a frozen array" do
File.write(File.join(pages_dir, "1-test.md"), "# Test")
result = described_class.load_static_pages(pages_dir)
expect(result).to be_frozen
end
end
# ── render_page_content ──────────────────────────────────────
describe ".render_page_content" do
it "renders markdown headings to HTML" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "# Hello World\n\nSome text.")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<h1")
expect(html).to include("Hello World")
expect(html).to include("<p>Some text.</p>")
end
it "renders links" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "[example](https://example.com)")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include('href="https://example.com"')
expect(html).to include("example")
end
it "renders fenced code blocks" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "```\ncode here\n```")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<code")
expect(html).to include("code here")
end
it "renders tables" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "| A | B |\n| - | - |\n| 1 | 2 |")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<table")
expect(html).to include("<td>")
end
it "does not pass through raw HTML script tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "<script>alert('xss')</script>\n\nSafe text.")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("<script>")
end
it "does not pass through raw HTML iframe tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<iframe src="https://evil.com"></iframe>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("<iframe")
end
it "returns nil for a nil entry" do
expect(described_class.render_page_content(nil)).to be_nil
end
it "returns nil for a missing file" do
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-gone", slug: "gone", title: "Gone",
path: File.join(pages_dir, "missing.md"),
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil when the file exceeds the size limit" do
path = File.join(pages_dir, "1-big.md")
File.write(path, "x" * (PotatoMesh::Config.max_page_file_bytes + 1))
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-big", slug: "big", title: "Big", path: path,
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil when path is nil" do
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: nil,
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil on a filesystem error" do
path = File.join(pages_dir, "1-err.md")
File.write(path, "# Error")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-err", slug: "err", title: "Err", path: path,
)
allow(File).to receive(:read).with(path, encoding: "utf-8").and_raise(Errno::EIO)
expect(described_class.render_page_content(entry)).to be_nil
end
it "strips event-handler attributes from allowed tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="https://example.com" onclick="alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include('href="https://example.com"')
expect(html).not_to include("onclick")
end
it "strips nested event-handler bypass attempts" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="#" oonnclick="alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("onclick")
end
it "strips javascript: URIs from href attributes" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="javascript:alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("javascript:")
end
it "preserves allowed HTML tags while stripping disallowed ones" do
path = File.join(pages_dir, "1-mixed.md")
File.write(path, "<strong>bold</strong> <script>bad</script>")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-mixed", slug: "mixed", title: "Mixed", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<strong>")
expect(html).not_to include("<script")
end
end
# ── find_page_by_slug ───────────────────────────────────────
describe ".find_page_by_slug" do
it "finds a page by slug" do
File.write(File.join(pages_dir, "1-alpha.md"), "# Alpha")
File.write(File.join(pages_dir, "2-beta.md"), "# Beta")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
page = described_class.find_page_by_slug("beta")
expect(page).not_to be_nil
expect(page.slug).to eq("beta")
end
it "returns nil for an unknown slug" do
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
expect(described_class.find_page_by_slug("nonexistent")).to be_nil
end
end
# ── static_pages (caching) ──────────────────────────────────
describe ".static_pages" do
it "returns cached entries from the configured directory" do
File.write(File.join(pages_dir, "1-cached.md"), "# Cached")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
result = described_class.static_pages
expect(result.length).to eq(1)
expect(result.first.slug).to eq("cached")
end
it "clears the cache when clear_pages_cache! is called" do
File.write(File.join(pages_dir, "1-first.md"), "# First")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
first = described_class.static_pages
expect(first.length).to eq(1)
File.write(File.join(pages_dir, "2-second.md"), "# Second")
described_class.clear_pages_cache!
second = described_class.static_pages
expect(second.length).to eq(2)
end
end
# ── production_environment? ─────────────────────────────────
describe ".production_environment?" do
it "returns false in the test environment" do
expect(described_class.production_environment?).to be false
end
it "returns true when RACK_ENV is production" do
original = ENV["RACK_ENV"]
begin
ENV["RACK_ENV"] = "production"
expect(described_class.production_environment?).to be true
ensure
ENV["RACK_ENV"] = original
end
end
it "returns true when APP_ENV is production" do
original_rack = ENV["RACK_ENV"]
original_app = ENV["APP_ENV"]
begin
ENV["RACK_ENV"] = "test"
ENV["APP_ENV"] = "production"
expect(described_class.production_environment?).to be true
ensure
ENV["RACK_ENV"] = original_rack
ENV["APP_ENV"] = original_app
end
end
end
# ── Route integration ───────────────────────────────────────
let(:app) { Sinatra::Application }
describe "GET /pages/:slug" do
before do
FileUtils.mkdir_p(pages_dir)
File.write(File.join(pages_dir, "1-about.md"), "# About\n\nWelcome.")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
it "renders a valid page with 200" do
get "/pages/about"
expect(last_response).to be_ok
expect(last_response.body).to include("About")
expect(last_response.body).to include("Welcome.")
expect(last_response.body).to include("static-page")
end
it "renders the page within the site layout" do
get "/pages/about"
expect(last_response.body).to include("site-header")
expect(last_response.body).to include("site-nav")
end
it "marks the page as active in nav" do
get "/pages/about"
expect(last_response.body).to include('aria-current="page"')
end
it "returns 404 for an unknown slug" do
get "/pages/nonexistent"
expect(last_response.status).to eq(404)
end
it "rejects path traversal attempts" do
get "/pages/..%2F..%2Fetc"
expect(last_response.status).to be >= 400
end
it "returns 400 for an uppercase slug" do
get "/pages/ABOUT"
expect(last_response.status).to eq(400)
end
it "returns 400 for a slug with encoded special characters" do
get "/pages/a%3Cb"
expect(last_response.status).to eq(400)
end
it "returns 500 when page content cannot be rendered" do
File.write(File.join(pages_dir, "1-about.md"), "# About")
PotatoMesh::App::Pages.clear_pages_cache!
allow(PotatoMesh::App::Pages).to receive(:render_page_content).and_return(nil)
get "/pages/about"
expect(last_response.status).to eq(500)
end
it "includes nav links for static pages" do
File.write(File.join(pages_dir, "2-contact.md"), "# Contact")
PotatoMesh::App::Pages.clear_pages_cache!
get "/pages/about"
expect(last_response.body).to include('href="/pages/about"')
expect(last_response.body).to include('href="/pages/contact"')
end
end
describe "static page nav links on other pages" do
before do
FileUtils.mkdir_p(pages_dir)
File.write(File.join(pages_dir, "1-info.md"), "# Info")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
it "shows page links in the dashboard nav" do
get "/"
expect(last_response.body).to include('href="/pages/info"')
expect(last_response.body).to include("Info")
end
end
end
+18 -7
View File
@@ -67,7 +67,8 @@
</head>
<% body_classes = ["dark"]
view_mode = (defined?(current_view_mode) && current_view_mode) ? current_view_mode.to_sym : :dashboard
full_screen_view = !%i[dashboard charts federation].include?(view_mode)
page_view = view_mode.to_s.start_with?("page_")
full_screen_view = !(%i[dashboard charts federation].include?(view_mode) || page_view)
body_classes << "view-#{view_mode}"
shell_classes = ["page-shell"]
shell_classes << "page-shell--full-screen" if full_screen_view
@@ -75,10 +76,10 @@
main_classes << "page-main--dashboard" if view_mode == :dashboard
main_classes << "page-main--full-screen" if full_screen_view
show_header = true
show_footer = !full_screen_view || %i[charts federation].include?(view_mode)
footer_slim = %i[charts federation].include?(view_mode)
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
show_meta_row = !%i[charts federation].include?(view_mode)
show_footer = !full_screen_view || %i[charts federation].include?(view_mode) || page_view
footer_slim = %i[charts federation].include?(view_mode) || page_view
show_filter_input = !(%i[node_detail charts federation].include?(view_mode) || page_view)
show_meta_row = !(%i[node_detail charts federation].include?(view_mode) || page_view)
nodes_nav_href = "/nodes"
nodes_nav_active = %i[nodes node_detail].include?(view_mode)
federation_nav_enabled = !private_mode && federation_enabled
@@ -99,8 +100,10 @@
<header class="site-header">
<div class="site-header__left">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
<a href="/" class="site-title__link">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</a>
</h1>
<% if federation_nav_enabled %>
<div class="header-federation">
@@ -123,6 +126,10 @@
<% if federation_nav_enabled %>
<a href="/federation" class="site-nav__link js-federation-nav<%= view_mode == :federation ? " is-active" : "" %>" data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
<% static_pages.each do |sp| %>
<% sp_active = view_mode.to_s == "page_#{sp.slug}" %>
<a href="/pages/<%= Rack::Utils.escape_path(sp.slug) %>" class="site-nav__link<%= sp_active ? " is-active" : "" %>"<%= sp_active ? ' aria-current="page"' : "" %>><%= Rack::Utils.escape_html(sp.title) %></a>
<% end %>
</nav>
<button
id="mobileMenuToggle"
@@ -154,6 +161,10 @@
<% if federation_nav_enabled %>
<a href="/federation" class="mobile-nav__link js-federation-nav<%= view_mode == :federation ? " is-active" : "" %>" data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
<% static_pages.each do |sp| %>
<% sp_active = view_mode.to_s == "page_#{sp.slug}" %>
<a href="/pages/<%= Rack::Utils.escape_path(sp.slug) %>" class="mobile-nav__link<%= sp_active ? " is-active" : "" %>"<%= sp_active ? ' aria-current="page"' : "" %>><%= Rack::Utils.escape_html(sp.title) %></a>
<% end %>
</nav>
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
<!--
Copyright © 2025-26 l5yth & contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<section class="static-page">
<div class="static-page__content markdown-body">
<%= page_content_html %>
</div>
</section>