mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-09 14:55:08 +02:00
Compare commits
3 Commits
v0.6.0-rc1
..
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 81e588e44c | |||
| 083de6418f | |||
| 5b9e6e3d48 |
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,6 +70,7 @@ _CONFIG_ATTRS = {
|
||||
"CHANNEL_INDEX",
|
||||
"DEBUG",
|
||||
"INSTANCE",
|
||||
"INSTANCES",
|
||||
"API_TOKEN",
|
||||
"ALLOWED_CHANNELS",
|
||||
"HIDDEN_CHANNELS",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`."""
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(' 0ac7'), 'should not add leading space');
|
||||
assert.ok(!html.includes('0ac7 '), '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 )
|
||||
assert.ok(html.includes(' \u26A1 '), 'emoji should have one space on each side');
|
||||
// Should NOT have double leading spaces
|
||||
assert.ok(!html.includes(' \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(' \uD83D\uDE43 '), '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(` ${zwj} `), '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(' ab '), '2-char name should have one space on each side');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1853,22 +1853,22 @@ export function initializeApp(config) {
|
||||
infoAttr = attrParts.join('');
|
||||
}
|
||||
if (!short) {
|
||||
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>? </span>`;
|
||||
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}> ? </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, ' ');
|
||||
const protocol = nodeData?.protocol ?? null;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user