mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-07 13:54:57 +02:00
Compare commits
5 Commits
v0.6.0-rc0
..
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 81e588e44c | |||
| 083de6418f | |||
| 5b9e6e3d48 | |||
| 4a6ba38e94 | |||
| 4d38ddd341 |
+11
-1
@@ -1,3 +1,6 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
# Licensed under the Apache License, Version 2.0 (see LICENSE)
|
||||
#
|
||||
# PotatoMesh Environment Configuration
|
||||
# Copy this file to .env and customize for your setup
|
||||
|
||||
@@ -14,7 +17,7 @@ INSTANCE_DOMAIN="mesh.example.org"
|
||||
# Generate a secure token: openssl rand -hex 32
|
||||
API_TOKEN="your-secure-api-token-here"
|
||||
|
||||
# Meshtastic connection target (required for ingestor)
|
||||
# Mesh radio connection target (required for ingestor)
|
||||
# Common serial paths:
|
||||
# - Linux: /dev/ttyACM0, /dev/ttyUSB0
|
||||
# - macOS: /dev/cu.usbserial-*
|
||||
@@ -23,6 +26,10 @@ API_TOKEN="your-secure-api-token-here"
|
||||
# Bluetooth address (e.g. ED:4D:9E:95:CF:60).
|
||||
CONNECTION="/dev/ttyACM0"
|
||||
|
||||
# Mesh protocol to use (meshtastic or meshcore)
|
||||
# Default: meshtastic
|
||||
PROTOCOL="meshtastic"
|
||||
|
||||
# =============================================================================
|
||||
# SITE CUSTOMIZATION
|
||||
# =============================================================================
|
||||
@@ -68,6 +75,9 @@ PRIVATE=0
|
||||
# Debug mode (0=off, 1=on)
|
||||
DEBUG=0
|
||||
|
||||
# Energy saving mode — sleep between ingestion cycles (0=off, 1=on)
|
||||
ENERGY_SAVING=0
|
||||
|
||||
# Default map zoom override
|
||||
# MAP_ZOOM=15
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<!-- Copyright © 2025-26 l5yth & contributors -->
|
||||
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
|
||||
|
||||
# GitHub Actions Workflows
|
||||
|
||||
## Workflows
|
||||
@@ -10,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
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
<!-- Copyright © 2025-26 l5yth & contributors -->
|
||||
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
|
||||
|
||||
# CHANGELOG
|
||||
|
||||
## v0.6.0
|
||||
|
||||
**Official multi-protocol release.** This version introduces first-class
|
||||
support for both Meshtastic and MeshCore mesh protocols via the new `PROTOCOL`
|
||||
environment variable. Key additions since v0.5.12:
|
||||
|
||||
* Feat: official MeshCore provider with BLE, TCP, and serial support
|
||||
* Feat: multi-protocol awareness across web frontend, ingestor, and mobile app
|
||||
* Enh: surface MeshCore role types and distinguish protocols in the UI
|
||||
|
||||
See v0.5.12 below for the full commit history of multi-protocol groundwork.
|
||||
|
||||
**Breaking changes — remove deprecated environment variable aliases:**
|
||||
|
||||
* Ingestor: remove `POTATOMESH_INSTANCE` env var — use `INSTANCE_DOMAIN` by @l5yth
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<!-- Copyright © 2025-26 l5yth & contributors -->
|
||||
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
|
||||
|
||||
# Repository Guidelines
|
||||
|
||||
Keep code as modular as possible to reduce duplication and improve reusability and readability. If a module grows large, split it into a submodule structure. Prefer composing small, single-purpose units over monolithic files.
|
||||
Keep code as modular as possible to reduce duplication and improve reusability and readability — this applies to tests as well as production code. If a module grows large, split it into a submodule structure. Prefer composing small, single-purpose units over monolithic files.
|
||||
|
||||
Make sure all tests pass for Python (`pytest`), Ruby (`rspec`), and JavaScript (`npm test`).
|
||||
|
||||
@@ -8,7 +11,7 @@ All code must be 100% unit tested — every line, branch, and code path must hav
|
||||
|
||||
All code must be 100% documented according to the language's API-doc standard (PDoc for Python, RDoc for Ruby, JSDoc for JavaScript, rustdoc for Rust, dartdoc for Dart). Documentation must be sufficient to generate complete API docs from source. In addition to API-level docs, add inline comments wherever the logic is not immediately self-evident.
|
||||
|
||||
New source files should have Apache v2 license headers using the exact string `Copyright © 2025-26 l5yth & contributors`.
|
||||
Every file in the repository must carry an Apache v2 license notice using the exact string `Copyright © 2025-26 l5yth & contributors`. **Source-code files** (`.rb`, `.py`, `.js`, `.rs`, `.dart`, etc.) must include the full Apache v2 license header block. **Non-source files** (docs, configs, YAML, TOML, Dockerfiles, etc.) must include a short 2-line Apache v2 notice (copyright line + license reference).
|
||||
|
||||
Run linters for Python (`black`) and Ruby (`rufo`) to ensure consistent code formatting.
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<!-- Copyright © 2025-26 l5yth & contributors -->
|
||||
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
|
||||
|
||||
# PotatoMesh Docker Guide
|
||||
|
||||
PotatoMesh publishes ready-to-run container images to the GitHub Packages container
|
||||
@@ -78,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:
|
||||
|
||||
+30
-9
@@ -1,3 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -25,6 +26,9 @@ ENV BUNDLE_FORCE_RUBY_PLATFORM=true
|
||||
# Install build dependencies and SQLite3
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
python3 \
|
||||
py3-pip \
|
||||
py3-virtualenv \
|
||||
sqlite-dev \
|
||||
linux-headers \
|
||||
pkgconfig
|
||||
@@ -40,11 +44,16 @@ RUN bundle config set --local force_ruby_platform true && \
|
||||
bundle config set --local without 'development test' && \
|
||||
bundle install --jobs=4 --retry=3
|
||||
|
||||
# Install Meshtastic decoder dependencies in a dedicated venv
|
||||
RUN python3 -m venv /opt/meshtastic-venv && \
|
||||
/opt/meshtastic-venv/bin/pip install --no-cache-dir meshtastic protobuf
|
||||
|
||||
# Production stage
|
||||
FROM ruby:3.3-alpine AS production
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
sqlite \
|
||||
tzdata \
|
||||
curl
|
||||
@@ -58,18 +67,27 @@ WORKDIR /app
|
||||
|
||||
# Copy installed gems from builder stage
|
||||
COPY --from=builder /usr/local/bundle /usr/local/bundle
|
||||
COPY --from=builder /opt/meshtastic-venv /opt/meshtastic-venv
|
||||
|
||||
# Copy application code (exclude Dockerfile from web directory)
|
||||
COPY --chown=potatomesh:potatomesh web/app.rb web/app.sh web/Gemfile web/Gemfile.lock* web/spec/ ./
|
||||
# Copy application code (excluding the Dockerfile which is not required at runtime)
|
||||
COPY --chown=potatomesh:potatomesh web/app.rb ./
|
||||
COPY --chown=potatomesh:potatomesh web/app.sh ./
|
||||
COPY --chown=potatomesh:potatomesh web/Gemfile ./
|
||||
COPY --chown=potatomesh:potatomesh web/Gemfile.lock* ./
|
||||
COPY --chown=potatomesh:potatomesh web/lib ./lib
|
||||
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/views ./views
|
||||
COPY --chown=potatomesh:potatomesh web/scripts ./scripts
|
||||
|
||||
# Copy SQL schema files from data directory
|
||||
COPY --chown=potatomesh:potatomesh data/*.sql /data/
|
||||
COPY --chown=potatomesh:potatomesh data/mesh_ingestor/decode_payload.py /app/data/mesh_ingestor/decode_payload.py
|
||||
|
||||
# Create data directory for SQLite database
|
||||
RUN mkdir -p /app/data /app/.local/share/potato-mesh && \
|
||||
chown -R potatomesh:potatomesh /app/data /app/.local
|
||||
# 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
|
||||
|
||||
# Switch to non-root user
|
||||
USER potatomesh
|
||||
@@ -78,13 +96,16 @@ USER potatomesh
|
||||
EXPOSE 41447
|
||||
|
||||
# Default environment variables (can be overridden by host)
|
||||
ENV APP_ENV=production \
|
||||
RACK_ENV=production \
|
||||
ENV RACK_ENV=production \
|
||||
APP_ENV=production \
|
||||
MESHTASTIC_PYTHON=/opt/meshtastic-venv/bin/python \
|
||||
XDG_DATA_HOME=/app/.local/share \
|
||||
XDG_CONFIG_HOME=/app/.config \
|
||||
SITE_NAME="PotatoMesh Demo" \
|
||||
INSTANCE_DOMAIN="potato.example.com" \
|
||||
CHANNEL="#LongFast" \
|
||||
FREQUENCY="915MHz" \
|
||||
MAP_CENTER="38.761944,-27.090833" \
|
||||
MAP_ZOOM="" \
|
||||
MAX_DISTANCE=42 \
|
||||
CONTACT_LINK="#potatomesh:dod.ngo" \
|
||||
DEBUG=0
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<!-- Copyright © 2025-26 l5yth & contributors -->
|
||||
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
|
||||
|
||||
# Prometheus Monitoring for PotatoMesh
|
||||
|
||||
PotatoMesh exposes runtime telemetry through a dedicated Prometheus endpoint so you can
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<!-- Copyright © 2025-26 l5yth & contributors -->
|
||||
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
|
||||
|
||||
# 🥔 PotatoMesh
|
||||
|
||||
[](https://github.com/l5yth/potato-mesh/actions)
|
||||
@@ -30,7 +33,7 @@ _No MQTT clutter, just local LoRa aether._
|
||||
|
||||
Live demo for Berlin: [potatomesh.net](https://potatomesh.net)
|
||||
|
||||

|
||||

|
||||
|
||||
## Web App
|
||||
|
||||
@@ -125,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
|
||||
@@ -275,9 +300,9 @@ docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-arm64:latest
|
||||
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-armv7:latest
|
||||
|
||||
# version-pinned examples
|
||||
docker pull ghcr.io/l5yth/potato-mesh-web-linux-amd64:v0.5.5
|
||||
docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:v0.5.5
|
||||
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:v0.5.5
|
||||
docker pull ghcr.io/l5yth/potato-mesh-web-linux-amd64:v0.6.0
|
||||
docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:v0.6.0
|
||||
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:v0.6.0
|
||||
```
|
||||
|
||||
Note: `latest` is only published for non-prerelease versions. Pre-release tags
|
||||
|
||||
+6
-2
@@ -1,6 +1,10 @@
|
||||
# Meshtastic Reader
|
||||
<!-- Copyright © 2025-26 l5yth & contributors -->
|
||||
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
|
||||
|
||||
Meshtastic Reader – read-only PotatoMesh chat client for Android and iOS.
|
||||
# PotatoMesh Mobile
|
||||
|
||||
PotatoMesh Mobile — read-only mesh chat client for Android and iOS.
|
||||
Supports Meshtastic and MeshCore networks.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ USER potatomesh
|
||||
ENV CONNECTION=/dev/ttyACM0 \
|
||||
CHANNEL_INDEX=0 \
|
||||
DEBUG=0 \
|
||||
PROTOCOL=meshtastic \
|
||||
ALLOWED_CHANNELS="" \
|
||||
HIDDEN_CHANNELS="" \
|
||||
INSTANCE_DOMAIN="" \
|
||||
@@ -77,6 +78,7 @@ USER ContainerUser
|
||||
ENV CONNECTION=/dev/ttyACM0 \
|
||||
CHANNEL_INDEX=0 \
|
||||
DEBUG=0 \
|
||||
PROTOCOL=meshtastic \
|
||||
ALLOWED_CHANNELS="" \
|
||||
HIDDEN_CHANNELS="" \
|
||||
INSTANCE_DOMAIN="" \
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<!-- Copyright © 2025-26 l5yth & contributors -->
|
||||
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
|
||||
|
||||
## Mesh ingestor contracts (stable interfaces)
|
||||
|
||||
This repo’s ingestion pipeline is split into:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -35,8 +35,8 @@ Connection type is detected automatically from the target string:
|
||||
Node identities are derived from the first four bytes (eight hex characters)
|
||||
of each contact's 32-byte public key, formatted as ``!xxxxxxxx`` to match
|
||||
the canonical node-ID schema used across the system. Ingested
|
||||
``user.shortName`` is the first four hex digits of that key (two bytes),
|
||||
not the advertised name.
|
||||
``user.shortName`` is the first two bytes (four hex characters) of the
|
||||
node ID, not the advertised name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -165,23 +165,28 @@ def _meshcore_node_id(public_key_hex: str | None) -> str | None:
|
||||
return "!" + public_key_hex[:8].lower()
|
||||
|
||||
|
||||
def _meshcore_short_name(public_key_hex: str | None) -> str:
|
||||
"""Return the first four hex digits of a MeshCore public key as short name.
|
||||
def _meshcore_short_name(node_id: str | None) -> str:
|
||||
"""Derive a four-character short name from a canonical node ID.
|
||||
|
||||
Meshtastic-style ``shortName`` fields are four characters wide; MeshCore
|
||||
ingest uses the leading two bytes of the 32-byte public key in lowercase
|
||||
hex so the label is stable and unique per key prefix.
|
||||
Uses the first two bytes (four hex characters) of the ``!xxxxxxxx`` node
|
||||
ID. This keeps the short name consistent with the node ID itself — if the
|
||||
node ID is later replaced when the real public key is heard, the short name
|
||||
will update alongside it.
|
||||
|
||||
Parameters:
|
||||
public_key_hex: Full public key as a hex string from the MeshCore API.
|
||||
node_id: Canonical ``!xxxxxxxx`` node ID string (as returned by
|
||||
:func:`_meshcore_node_id`).
|
||||
|
||||
Returns:
|
||||
Four lowercase hex characters (e.g. ``"aabb"``), or an empty string
|
||||
when the key is missing or shorter than four hex characters.
|
||||
Four lowercase hex characters (e.g. ``"cafe"``), or an empty string
|
||||
when the node ID is missing or too short.
|
||||
"""
|
||||
if not public_key_hex or len(public_key_hex) < 4:
|
||||
if not node_id:
|
||||
return ""
|
||||
return public_key_hex[:4].lower()
|
||||
raw = node_id.lstrip("!")
|
||||
if len(raw) < 4:
|
||||
return ""
|
||||
return raw[:4].lower()
|
||||
|
||||
|
||||
def _meshcore_adv_type_to_role(adv_type: object) -> str | None:
|
||||
@@ -324,6 +329,7 @@ def _contact_to_node_dict(contact: dict) -> dict:
|
||||
Node dict compatible with the ``POST /api/nodes`` payload format.
|
||||
"""
|
||||
pub_key = contact.get("public_key", "")
|
||||
node_id = _meshcore_node_id(pub_key)
|
||||
name = (contact.get("adv_name") or "").strip()
|
||||
role = _meshcore_adv_type_to_role(contact.get("type"))
|
||||
node: dict = {
|
||||
@@ -331,7 +337,7 @@ def _contact_to_node_dict(contact: dict) -> dict:
|
||||
"protocol": "meshcore",
|
||||
"user": {
|
||||
"longName": name,
|
||||
"shortName": _meshcore_short_name(pub_key),
|
||||
"shortName": _meshcore_short_name(node_id),
|
||||
"publicKey": pub_key,
|
||||
**({"role": role} if role is not None else {}),
|
||||
},
|
||||
@@ -377,13 +383,14 @@ def _self_info_to_node_dict(self_info: dict) -> dict:
|
||||
"""
|
||||
name = (self_info.get("name") or "").strip()
|
||||
pub_key = self_info.get("public_key", "")
|
||||
node_id = _meshcore_node_id(pub_key)
|
||||
role = _meshcore_adv_type_to_role(self_info.get("adv_type"))
|
||||
node: dict = {
|
||||
"lastHeard": int(time.time()),
|
||||
"protocol": "meshcore",
|
||||
"user": {
|
||||
"longName": name,
|
||||
"shortName": _meshcore_short_name(pub_key),
|
||||
"shortName": _meshcore_short_name(node_id),
|
||||
"publicKey": pub_key,
|
||||
**({"role": role} if role is not None else {}),
|
||||
},
|
||||
|
||||
+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,
|
||||
|
||||
@@ -49,3 +49,21 @@ services:
|
||||
environment:
|
||||
DEBUG: 0
|
||||
restart: always
|
||||
|
||||
matrix-bridge:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: matrix/Dockerfile
|
||||
target: runtime
|
||||
environment:
|
||||
DEBUG: 0
|
||||
restart: always
|
||||
|
||||
matrix-bridge-bridge:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: matrix/Dockerfile
|
||||
target: runtime
|
||||
environment:
|
||||
DEBUG: 0
|
||||
restart: always
|
||||
|
||||
@@ -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:
|
||||
@@ -54,6 +55,8 @@ x-ingestor-base: &ingestor-base
|
||||
API_TOKEN: ${API_TOKEN}
|
||||
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN:-http://web:41447}
|
||||
DEBUG: ${DEBUG:-0}
|
||||
PROTOCOL: ${PROTOCOL:-meshtastic}
|
||||
ENERGY_SAVING: ${ENERGY_SAVING:-0}
|
||||
FEDERATION: ${FEDERATION:-1}
|
||||
PRIVATE: ${PRIVATE:-0}
|
||||
volumes:
|
||||
@@ -158,6 +161,8 @@ volumes:
|
||||
driver: local
|
||||
potatomesh_logs:
|
||||
driver: local
|
||||
potatomesh_pages:
|
||||
driver: local
|
||||
potatomesh_matrix_bridge_state:
|
||||
driver: local
|
||||
|
||||
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
<!-- Copyright © 2025-26 l5yth & contributors -->
|
||||
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
|
||||
|
||||
# potatomesh-matrix-bridge
|
||||
|
||||
A small Rust daemon that bridges **PotatoMesh** LoRa messages into a **Matrix** room.
|
||||
@@ -90,7 +93,7 @@ room_id = "!yourroomid:example.org"
|
||||
[state]
|
||||
# Where to persist last seen message id
|
||||
state_file = "bridge_state.json"
|
||||
````
|
||||
```
|
||||
|
||||
The `hs_token` is used to validate inbound appservice transactions. Keep it identical in `Config.toml` and your Matrix appservice registration file.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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
|
||||
|
||||
|
||||
@@ -661,21 +661,22 @@ def test_meshcore_node_id_none_on_empty():
|
||||
assert _meshcore_node_id(None) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_meshcore_short_name_first_four_hex_digits():
|
||||
"""_meshcore_short_name returns the first four hex chars, lowercased."""
|
||||
assert _meshcore_short_name("AABBccdd" + "00" * 28) == "aabb"
|
||||
def test_meshcore_short_name_first_two_bytes_of_node_id():
|
||||
"""_meshcore_short_name returns the first four hex chars of the node ID."""
|
||||
assert _meshcore_short_name("!aabbccdd") == "aabb"
|
||||
assert _meshcore_short_name("!AABBccdd") == "aabb"
|
||||
|
||||
|
||||
def test_meshcore_short_name_empty_when_too_short():
|
||||
"""_meshcore_short_name returns '' when the key has fewer than four hex digits."""
|
||||
"""_meshcore_short_name returns '' when the node ID is missing or too short."""
|
||||
assert _meshcore_short_name("") == ""
|
||||
assert _meshcore_short_name("abc") == ""
|
||||
assert _meshcore_short_name("!ab") == ""
|
||||
assert _meshcore_short_name(None) == "" # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_meshcore_short_name_exactly_four_chars():
|
||||
"""_meshcore_short_name with exactly four hex chars returns those four chars."""
|
||||
assert _meshcore_short_name("abcd") == "abcd"
|
||||
def test_meshcore_short_name_without_bang_prefix():
|
||||
"""_meshcore_short_name handles node IDs without the leading '!' prefix."""
|
||||
assert _meshcore_short_name("cafef00d") == "cafe"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -86,10 +86,9 @@ module PotatoMesh
|
||||
# space keeps the badge at four visual columns.
|
||||
# 2. If the long name contains two or more whitespace-separated words, use
|
||||
# the capitalised first letters of the first two words: ``" XY "``.
|
||||
# 3. If the long name is a single word, use its capitalised first letter:
|
||||
# ``" A "``.
|
||||
# 4. Return +nil+ when no short name can be derived (blank input, or a
|
||||
# word without extractable characters).
|
||||
# 3. Return +nil+ — single-word names fall back to the raw short name stored
|
||||
# in the database (typically the first two bytes of the node ID). A single
|
||||
# initial looked poor and carried no more information than the raw value.
|
||||
#
|
||||
# @param long_name [String, nil] long name stored on the node.
|
||||
# @return [String, nil] derived display short name or +nil+.
|
||||
@@ -111,8 +110,7 @@ module PotatoMesh
|
||||
return " #{first}#{second} " if first && second
|
||||
end
|
||||
|
||||
letter = words[0][0]&.upcase
|
||||
letter ? " #{letter} " : nil
|
||||
nil
|
||||
end
|
||||
|
||||
# Recursively coerce hash keys to strings and normalise nested arrays.
|
||||
|
||||
@@ -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
|
||||
@@ -159,7 +159,16 @@ module PotatoMesh
|
||||
r["role"] ||= "CLIENT"
|
||||
if r["role"] == "COMPANION"
|
||||
derived = meshcore_companion_display_short_name(r["long_name"])
|
||||
r["short_name"] = derived if derived
|
||||
if derived
|
||||
r["short_name"] = derived
|
||||
elsif r["short_name"].nil? || r["short_name"].strip.empty?
|
||||
# No derived name and no stored public-key hex — synthesise from
|
||||
# the node ID (first four hex chars after the leading "!") so the
|
||||
# badge is stable, unique, and consistent with how the ingestor
|
||||
# builds short names from public keys.
|
||||
node_id = r["node_id"].to_s.delete_prefix("!")
|
||||
r["short_name"] = node_id[0, 4] unless node_id.empty?
|
||||
end
|
||||
end
|
||||
lh = r["last_heard"]&.to_i
|
||||
pt = r["position_time"]&.to_i
|
||||
|
||||
@@ -19,23 +19,12 @@ module PotatoMesh
|
||||
module Routes
|
||||
module Root
|
||||
module Helpers
|
||||
# Determine the initial theme from the request cookie and persist
|
||||
# sanitised values back to the client to avoid invalid states.
|
||||
# Return the fixed dark theme identifier. Light mode is no longer
|
||||
# supported; theme selection and cookie persistence have been removed.
|
||||
#
|
||||
# @return [String] normalised theme value ('dark' or 'light').
|
||||
# @return [String] always 'dark'.
|
||||
def resolve_initial_theme
|
||||
raw_theme = request.cookies["theme"]
|
||||
theme = %w[dark light].include?(raw_theme) ? raw_theme : "dark"
|
||||
if raw_theme != theme
|
||||
response.set_cookie(
|
||||
"theme",
|
||||
value: theme,
|
||||
path: "/",
|
||||
max_age: 60 * 60 * 24 * 7,
|
||||
same_site: :lax,
|
||||
)
|
||||
end
|
||||
theme
|
||||
"dark"
|
||||
end
|
||||
|
||||
# Render a dashboard-oriented ERB template within the shared layout.
|
||||
@@ -70,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)
|
||||
@@ -191,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".
|
||||
@@ -92,14 +92,15 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
|
||||
);
|
||||
|
||||
assert.equal(model.channels.length, 6);
|
||||
// All channels have 1 message each; ties are broken alphabetically by label.
|
||||
// Primary channels (index 0) come first, secondary channels (index > 0) come last.
|
||||
// Within each tier, ties on messageCount are broken alphabetically by label.
|
||||
assert.deepEqual(model.channels.map(channel => channel.label), [
|
||||
'1',
|
||||
'BerlinMesh',
|
||||
'EnvDefault',
|
||||
'Fallback',
|
||||
'MediumFast',
|
||||
'ShortFast'
|
||||
'ShortFast',
|
||||
'1',
|
||||
'BerlinMesh',
|
||||
]);
|
||||
|
||||
const channelByLabel = Object.fromEntries(model.channels.map(channel => [channel.label, channel]));
|
||||
@@ -512,3 +513,70 @@ test('buildChatTabModel breaks messageCount ties alphabetically', () => {
|
||||
assert.equal(model.channels[0].label, 'Apple');
|
||||
assert.equal(model.channels[1].label, 'Zebra');
|
||||
});
|
||||
|
||||
test('buildChatTabModel puts primary channels (index 0) before secondary channels', () => {
|
||||
const model = buildChatTabModel({
|
||||
nodes: [],
|
||||
messages: [
|
||||
// Secondary channels with many messages
|
||||
{ id: 's1', rx_time: NOW - 30, channel: 2, channel_name: 'SecondaryA' },
|
||||
{ id: 's2', rx_time: NOW - 28, channel: 2, channel_name: 'SecondaryA' },
|
||||
{ id: 's3', rx_time: NOW - 26, channel: 2, channel_name: 'SecondaryA' },
|
||||
// Primary channel (index 0) with fewer messages
|
||||
{ id: 'p1', rx_time: NOW - 20, channel: 0, channel_name: 'LongFast' },
|
||||
],
|
||||
nowSeconds: NOW,
|
||||
windowSeconds: WINDOW
|
||||
});
|
||||
assert.equal(model.channels.length, 2);
|
||||
assert.equal(model.channels[0].label, 'LongFast', 'primary channel must come first regardless of activity');
|
||||
assert.equal(model.channels[0].index, 0);
|
||||
assert.equal(model.channels[1].label, 'SecondaryA', 'secondary channel must come second');
|
||||
});
|
||||
|
||||
test('buildChatTabModel sorts primary channels by activity then alpha within the primary tier', () => {
|
||||
const model = buildChatTabModel({
|
||||
nodes: [],
|
||||
messages: [
|
||||
// LongFast: 1 message
|
||||
{ id: 'lf1', rx_time: NOW - 30, channel: 0, channel_name: 'LongFast' },
|
||||
// MediumFast: 3 messages (most active primary)
|
||||
{ id: 'mf1', rx_time: NOW - 28, channel: 0, channel_name: 'MediumFast' },
|
||||
{ id: 'mf2', rx_time: NOW - 26, channel: 0, channel_name: 'MediumFast' },
|
||||
{ id: 'mf3', rx_time: NOW - 24, channel: 0, channel_name: 'MediumFast' },
|
||||
// Public: 2 messages
|
||||
{ id: 'pb1', rx_time: NOW - 22, channel: 0, channel_name: 'Public' },
|
||||
{ id: 'pb2', rx_time: NOW - 20, channel: 0, channel_name: 'Public' },
|
||||
],
|
||||
nowSeconds: NOW,
|
||||
windowSeconds: WINDOW
|
||||
});
|
||||
assert.equal(model.channels.length, 3);
|
||||
assert.equal(model.channels[0].label, 'MediumFast', 'most active primary first');
|
||||
assert.equal(model.channels[1].label, 'Public', 'second most active primary second');
|
||||
assert.equal(model.channels[2].label, 'LongFast', 'least active primary last');
|
||||
});
|
||||
|
||||
test('buildChatTabModel sorts secondary channels by activity then alpha after all primaries', () => {
|
||||
const model = buildChatTabModel({
|
||||
nodes: [],
|
||||
messages: [
|
||||
// Primary with 1 message
|
||||
{ id: 'p1', rx_time: NOW - 50, channel: 0, channel_name: 'LongFast' },
|
||||
// Secondary channels
|
||||
{ id: 'b1', rx_time: NOW - 40, channel: 3, channel_name: 'Beta' },
|
||||
{ id: 'a1', rx_time: NOW - 38, channel: 1, channel_name: 'Alpha' },
|
||||
{ id: 'a2', rx_time: NOW - 36, channel: 1, channel_name: 'Alpha' },
|
||||
{ id: 'a3', rx_time: NOW - 34, channel: 1, channel_name: 'Alpha' },
|
||||
{ id: 'g1', rx_time: NOW - 32, channel: 2, channel_name: 'Gamma' },
|
||||
{ id: 'g2', rx_time: NOW - 30, channel: 2, channel_name: 'Gamma' },
|
||||
],
|
||||
nowSeconds: NOW,
|
||||
windowSeconds: WINDOW
|
||||
});
|
||||
assert.equal(model.channels.length, 4);
|
||||
assert.equal(model.channels[0].label, 'LongFast', 'primary always first');
|
||||
assert.equal(model.channels[1].label, 'Alpha', 'most active secondary first');
|
||||
assert.equal(model.channels[2].label, 'Gamma', 'second most active secondary second');
|
||||
assert.equal(model.channels[3].label, 'Beta', 'least active secondary last');
|
||||
});
|
||||
|
||||
@@ -809,3 +809,136 @@ test('federation page sorts by full site names before truncating visible labels'
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('federation table linkifies Matrix room aliases, user IDs, and bare domain paths', async () => {
|
||||
const { tbodyEl, cleanup } = (() => {
|
||||
const e = createBasicFederationPageHarness();
|
||||
return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
|
||||
})();
|
||||
|
||||
const fetchImpl = () => Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
domain: 'mesh.example',
|
||||
name: 'Room Test',
|
||||
contactLink: '@jmrplens:matrix.jmrp.io',
|
||||
channel: '#mesh:server.tld',
|
||||
version: '1.0.0',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
|
||||
nodesCount: 3
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
|
||||
|
||||
const rowHtml = tbodyEl.childNodes[0].innerHTML;
|
||||
// Matrix user ID: @jmrplens:matrix.jmrp.io → https://matrix.to/#/@jmrplens:matrix.jmrp.io
|
||||
assert.match(rowHtml, /href="https:\/\/matrix\.to\/#\/@jmrplens:matrix\.jmrp\.io"/);
|
||||
assert.match(rowHtml, /@jmrplens:matrix\.jmrp\.io/);
|
||||
// Matrix room alias in channel cell: #mesh:server.tld → https://matrix.to/#/#mesh:server.tld
|
||||
assert.match(rowHtml, /href="https:\/\/matrix\.to\/#\/#mesh:server\.tld"/);
|
||||
assert.match(rowHtml, /#mesh:server\.tld/);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('federation table linkifies bare domain-with-path as https', async () => {
|
||||
const { tbodyEl, cleanup } = (() => {
|
||||
const e = createBasicFederationPageHarness();
|
||||
return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
|
||||
})();
|
||||
|
||||
const fetchImpl = () => Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
domain: 'mesh.example',
|
||||
contactLink: 'discord.gg/EGdbRKQnFk',
|
||||
version: '1.0.0',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
|
||||
nodesCount: 1
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
|
||||
|
||||
const rowHtml = tbodyEl.childNodes[0].innerHTML;
|
||||
assert.match(rowHtml, /href="https:\/\/discord\.gg\/EGdbRKQnFk"/);
|
||||
assert.match(rowHtml, /discord\.gg\/EGdbRKQnFk/);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('federation table sanitises <a> tags and strips other HTML in contact field', async () => {
|
||||
const { tbodyEl, cleanup } = (() => {
|
||||
const e = createBasicFederationPageHarness();
|
||||
return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
|
||||
})();
|
||||
|
||||
const contactWithHtml =
|
||||
'<a href=https://t.me/+BpSW3no2mJgzM2I8 target=_blank>YO Telegram group</a><b> Contact:</b> YO3IBZ';
|
||||
const contactViber =
|
||||
'<a href="https://invite.viber.com/?g=64h1QIFIC1Unai6DS6SE2Ot8ks9xoTm6">Viber Group</a>';
|
||||
|
||||
const fetchImpl = () => Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
domain: 'a.mesh',
|
||||
contactLink: contactWithHtml,
|
||||
version: '1.0.0',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
|
||||
nodesCount: 1
|
||||
},
|
||||
{
|
||||
domain: 'b.mesh',
|
||||
contactLink: contactViber,
|
||||
version: '1.0.0',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
|
||||
nodesCount: 1
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
|
||||
|
||||
const rows = tbodyEl.childNodes;
|
||||
const aHtml = rows[0].innerHTML;
|
||||
const bHtml = rows[1].innerHTML;
|
||||
|
||||
// Unquoted href extracted and normalised
|
||||
assert.match(aHtml, /href="https:\/\/t\.me\/\+BpSW3no2mJgzM2I8"/);
|
||||
assert.match(aHtml, /YO Telegram group/);
|
||||
// <b> tag stripped, text content preserved
|
||||
assert.match(aHtml, /Contact:/);
|
||||
assert.doesNotMatch(aHtml, /<b>/);
|
||||
// Remaining plain text present
|
||||
assert.match(aHtml, /YO3IBZ/);
|
||||
|
||||
// Quoted href passes through correctly
|
||||
assert.match(bHtml, /href="https:\/\/invite\.viber\.com\/\?g=64h1QIFIC1Unai6DS6SE2Ot8ks9xoTm6"/);
|
||||
assert.match(bHtml, /Viber Group/);
|
||||
|
||||
// No raw HTML from input leaks into output
|
||||
assert.doesNotMatch(aHtml, /target=_blank/);
|
||||
assert.doesNotMatch(aHtml, /<\/b>/);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -230,7 +230,7 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
return [{ domain: 'mesh.example' }];
|
||||
return [{ domain: 'mesh.example' }, { domain: 'other.mesh' }];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -249,7 +249,7 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
|
||||
defaultLabel: 'Select region ...'
|
||||
});
|
||||
|
||||
assert.equal(select.options.length, 2);
|
||||
assert.equal(select.options.length, 3);
|
||||
assert.equal(select.options[1].value, 'mesh.example');
|
||||
|
||||
select.value = 'mesh.example';
|
||||
@@ -261,6 +261,68 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
|
||||
}
|
||||
});
|
||||
|
||||
test('initializeInstanceSelector hides the selector container when fewer than 2 instances are available', async () => {
|
||||
const env = createDomEnvironment();
|
||||
const select = setupSelectElement(env.document);
|
||||
|
||||
// Simulate a parent container; mock elements lack closest() so we set
|
||||
// parentElement directly so the hide logic falls back to it.
|
||||
const container = env.document.createElement('div');
|
||||
container.classList.add('header-federation');
|
||||
select.parentElement = container;
|
||||
env.document.body.appendChild(container);
|
||||
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
return [{ domain: 'only.mesh' }];
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeInstanceSelector({
|
||||
selectElement: select,
|
||||
fetchImpl,
|
||||
windowObject: env.window,
|
||||
documentObject: env.document
|
||||
});
|
||||
|
||||
assert.equal(container.hidden, true, 'container should be hidden with fewer than 2 instances');
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('initializeInstanceSelector keeps the selector visible when 2 or more instances are available', async () => {
|
||||
const env = createDomEnvironment();
|
||||
const select = setupSelectElement(env.document);
|
||||
|
||||
const container = env.document.createElement('div');
|
||||
container.classList.add('header-federation');
|
||||
select.parentElement = container;
|
||||
env.document.body.appendChild(container);
|
||||
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
return [{ domain: 'alpha.mesh' }, { domain: 'beta.mesh' }];
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeInstanceSelector({
|
||||
selectElement: select,
|
||||
fetchImpl,
|
||||
windowObject: env.window,
|
||||
documentObject: env.document
|
||||
});
|
||||
|
||||
assert.ok(!container.hidden, 'container should remain visible with 2 or more instances');
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('initializeInstanceSelector updates federation navigation labels with instance count', async () => {
|
||||
const env = createDomEnvironment();
|
||||
const select = setupSelectElement(env.document);
|
||||
|
||||
@@ -44,8 +44,6 @@ export const MINIMAL_CONFIG = Object.freeze({
|
||||
*/
|
||||
export function setupApp() {
|
||||
const env = createDomEnvironment({ includeBody: true });
|
||||
// themeToggle is accessed without a null guard in initializeApp.
|
||||
env.createElement('button', 'themeToggle');
|
||||
const { _testUtils } = initializeApp(MINIMAL_CONFIG);
|
||||
return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) };
|
||||
}
|
||||
@@ -65,6 +63,24 @@ export function withApp(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spin up a DOM environment, optionally pre-register elements by id, then
|
||||
* initialise the app with a custom config override. Returns the test utils,
|
||||
* the environment (for DOM inspection), and a cleanup handle.
|
||||
*
|
||||
* @param {{ extraElements?: string[], configOverrides?: Object }} [opts]
|
||||
* @returns {{ testUtils: Object, env: Object, cleanup: Function }}
|
||||
*/
|
||||
export function setupAppWithOptions({ extraElements = [], configOverrides = {} } = {}) {
|
||||
const env = createDomEnvironment({ includeBody: true });
|
||||
for (const id of extraElements) {
|
||||
env.registerElement(id, env.createElement('span', id));
|
||||
}
|
||||
const config = { ...MINIMAL_CONFIG, ...configOverrides };
|
||||
const { _testUtils } = initializeApp(config);
|
||||
return { testUtils: _testUtils, env, cleanup: env.cleanup.bind(env) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the serialised HTML string from a DOM element returned by the test
|
||||
* utils. The stub environment exposes innerHTML as a plain string; this
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,28 +183,10 @@ test('fetchActiveNodeStats falls back to local counts on invalid payloads', asyn
|
||||
assert.equal(stats.month, 0);
|
||||
});
|
||||
|
||||
test('formatActiveNodeStatsText emits expected dashboard string', () => {
|
||||
test('formatActiveNodeStatsText emits compact day/week/month footer string', () => {
|
||||
const text = formatActiveNodeStatsText({
|
||||
channel: 'LongFast',
|
||||
frequency: '868MHz',
|
||||
stats: { hour: 1, day: 2, week: 3, month: 4, sampled: false },
|
||||
stats: { day: 2, week: 3, month: 4, sampled: false },
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
'LongFast (868MHz) — active nodes: 1/hour, 2/day, 3/week, 4/month.'
|
||||
);
|
||||
});
|
||||
|
||||
test('formatActiveNodeStatsText appends sampled marker when local fallback is used', () => {
|
||||
const text = formatActiveNodeStatsText({
|
||||
channel: 'LongFast',
|
||||
frequency: '868MHz',
|
||||
stats: { hour: 9, day: 8, week: 7, month: 6, sampled: true },
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
'LongFast (868MHz) — active nodes: 9/hour, 8/day, 7/week, 6/month (sampled).'
|
||||
);
|
||||
assert.equal(text, '2/day \u00b7 3/week \u00b7 4/month');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { setupApp, setupAppWithOptions } from './main-app-test-helpers.js';
|
||||
|
||||
const NOW = 1_700_000_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateLegendProtocolCounts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('updateLegendProtocolCounts returns early when both count elements are null', () => {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
// Default state: meshcoreCountEl and meshtasticCountEl are null — should not throw.
|
||||
assert.doesNotThrow(() => {
|
||||
testUtils.updateLegendProtocolCounts(
|
||||
[{ last_heard: NOW - 100, protocol: 'meshcore' }],
|
||||
NOW,
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('updateLegendProtocolCounts sets per-protocol counts when elements are present', () => {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
const mcEl = { textContent: '' };
|
||||
const mtEl = { textContent: '' };
|
||||
testUtils._setProtocolCountElements(mcEl, mtEl);
|
||||
|
||||
const nodes = [
|
||||
{ last_heard: NOW - 100, protocol: 'meshcore' },
|
||||
{ last_heard: NOW - 200, protocol: 'meshcore' },
|
||||
{ last_heard: NOW - 300, protocol: 'meshtastic' },
|
||||
{ last_heard: NOW - (8 * 86_400) }, // outside 7-day window, should not count
|
||||
];
|
||||
testUtils.updateLegendProtocolCounts(nodes, NOW);
|
||||
|
||||
assert.equal(mcEl.textContent, ' (2)', 'meshcore count should be 2');
|
||||
assert.equal(mtEl.textContent, ' (1)', 'meshtastic count should be 1');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('updateLegendProtocolCounts bins unknown protocols into the meshtastic column', () => {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
const mcEl = { textContent: '' };
|
||||
const mtEl = { textContent: '' };
|
||||
testUtils._setProtocolCountElements(mcEl, mtEl);
|
||||
|
||||
const nodes = [
|
||||
{ last_heard: NOW - 100, protocol: 'reticulum' }, // unknown → meshtastic bucket
|
||||
{ last_heard: NOW - 200, protocol: 'meshcore' },
|
||||
];
|
||||
testUtils.updateLegendProtocolCounts(nodes, NOW);
|
||||
|
||||
assert.equal(mcEl.textContent, ' (1)');
|
||||
assert.equal(mtEl.textContent, ' (1)');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('updateLegendProtocolCounts works when only meshcoreCountEl is present', () => {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
const mcEl = { textContent: '' };
|
||||
testUtils._setProtocolCountElements(mcEl, null);
|
||||
|
||||
testUtils.updateLegendProtocolCounts(
|
||||
[{ last_heard: NOW - 100, protocol: 'meshcore' }],
|
||||
NOW,
|
||||
);
|
||||
assert.equal(mcEl.textContent, ' (1)');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('updateLegendProtocolCounts works when only meshtasticCountEl is present', () => {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
const mtEl = { textContent: '' };
|
||||
testUtils._setProtocolCountElements(null, mtEl);
|
||||
|
||||
testUtils.updateLegendProtocolCounts(
|
||||
[{ last_heard: NOW - 100, protocol: 'meshtastic' }],
|
||||
NOW,
|
||||
);
|
||||
assert.equal(mtEl.textContent, ' (1)');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateFooterStats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('updateFooterStats is a no-op when footerActiveNodes element is absent', () => {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
assert.doesNotThrow(() => {
|
||||
testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('updateFooterStats populates the active-stats element when present', async () => {
|
||||
const { testUtils, env, cleanup } = setupAppWithOptions({
|
||||
extraElements: ['footerActiveNodes'],
|
||||
});
|
||||
try {
|
||||
const el = env.document.getElementById('footerActiveNodes');
|
||||
testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
|
||||
|
||||
// Drain the microtask queue so the async .then callback executes.
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
assert.ok(
|
||||
el.textContent.includes('/day'),
|
||||
`expected footerActiveNodes to contain "/day", got: ${el.textContent}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('updateFooterStats discards stale responses when a newer request is in flight', async () => {
|
||||
const { testUtils, env, cleanup } = setupAppWithOptions({
|
||||
extraElements: ['footerActiveNodes'],
|
||||
});
|
||||
try {
|
||||
const el = env.document.getElementById('footerActiveNodes');
|
||||
|
||||
// Fire two sequential updates; only the second should be applied.
|
||||
testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
|
||||
testUtils.updateFooterStats([{ last_heard: NOW - 200 }], NOW);
|
||||
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
// Either one or neither result lands; the key invariant is no error thrown
|
||||
// and the element text is a valid stats string or empty.
|
||||
const text = el.textContent;
|
||||
assert.ok(
|
||||
text === '' || text.includes('/day'),
|
||||
`unexpected footerActiveNodes content: ${text}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// restartAutoRefresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('restartAutoRefresh does not start a timer when refreshMs is 0', () => {
|
||||
// MINIMAL_CONFIG has refreshMs: 0 — timer must not be armed.
|
||||
const origSetInterval = globalThis.setInterval;
|
||||
const calls = [];
|
||||
globalThis.setInterval = (...args) => { calls.push(args); return origSetInterval(...args); };
|
||||
try {
|
||||
const { cleanup } = setupApp(); // uses refreshMs: 0
|
||||
// restartAutoRefresh is called during init; no timer should have been started.
|
||||
assert.equal(calls.length, 0, 'setInterval should not be called with refreshMs=0');
|
||||
cleanup();
|
||||
} finally {
|
||||
globalThis.setInterval = origSetInterval;
|
||||
}
|
||||
});
|
||||
|
||||
test('restartAutoRefresh starts a timer when refreshMs > 0', () => {
|
||||
const timers = [];
|
||||
const origSetInterval = globalThis.setInterval;
|
||||
const origClearInterval = globalThis.clearInterval;
|
||||
globalThis.setInterval = (fn, ms) => {
|
||||
const id = Symbol('timer');
|
||||
timers.push({ fn, ms, id });
|
||||
return id;
|
||||
};
|
||||
globalThis.clearInterval = () => {};
|
||||
|
||||
try {
|
||||
const { cleanup } = setupAppWithOptions({ configOverrides: { refreshMs: 30_000 } });
|
||||
assert.equal(timers.length, 1, 'setInterval should be called once during init');
|
||||
assert.equal(timers[0].ms, 30_000, 'interval should match configured refreshMs');
|
||||
cleanup();
|
||||
} finally {
|
||||
globalThis.setInterval = origSetInterval;
|
||||
globalThis.clearInterval = origClearInterval;
|
||||
}
|
||||
});
|
||||
|
||||
test('restartAutoRefresh clears the existing timer before starting a new one', () => {
|
||||
const cleared = [];
|
||||
const timers = [];
|
||||
const origSetInterval = globalThis.setInterval;
|
||||
const origClearInterval = globalThis.clearInterval;
|
||||
globalThis.setInterval = (fn, ms) => {
|
||||
const id = Symbol('timer');
|
||||
timers.push(id);
|
||||
return id;
|
||||
};
|
||||
globalThis.clearInterval = id => { cleared.push(id); };
|
||||
|
||||
try {
|
||||
const { testUtils, cleanup } = setupAppWithOptions({ configOverrides: { refreshMs: 30_000 } });
|
||||
// One timer started during init.
|
||||
assert.equal(timers.length, 1);
|
||||
|
||||
// Calling restartAutoRefresh again must clear the first timer and start a new one.
|
||||
testUtils.restartAutoRefresh();
|
||||
assert.equal(cleared.length, 1, 'existing timer should be cleared');
|
||||
assert.equal(cleared[0], timers[0], 'the original timer id should be cleared');
|
||||
assert.equal(timers.length, 2, 'a new timer should be started');
|
||||
cleanup();
|
||||
} finally {
|
||||
globalThis.setInterval = origSetInterval;
|
||||
globalThis.clearInterval = origClearInterval;
|
||||
}
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
buildMessageBody,
|
||||
buildMessageIndex,
|
||||
normaliseMessageId,
|
||||
renderLiteralWithLinks,
|
||||
resolveReplyPrefix
|
||||
} from '../message-replies.js';
|
||||
|
||||
@@ -279,3 +280,86 @@ test('buildMessageBody with renderMentionHtml: unclosed @[ treated as literal',
|
||||
// @[ without closing ] does not match the pattern — treated as literal
|
||||
assert.equal(body, 'ESC(hello @[unclosed)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderLiteralWithLinks — URL detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const e = v => `E(${v})`;
|
||||
|
||||
test('renderLiteralWithLinks passes plain text through escapeHtml', () => {
|
||||
assert.equal(renderLiteralWithLinks('hello world', e), 'E(hello world)');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks wraps http:// URL in an anchor element', () => {
|
||||
const result = renderLiteralWithLinks('check http://example.com out', e);
|
||||
assert.equal(result, 'E(check )<a href="E(http://example.com)" target="_blank" rel="noopener noreferrer">E(http://example.com)</a>E( out)');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks wraps https:// URL in an anchor element', () => {
|
||||
const result = renderLiteralWithLinks('see https://example.com/path?q=1', e);
|
||||
assert.ok(result.includes('<a href='), 'should produce an anchor');
|
||||
assert.ok(result.includes('target="_blank"'), 'should open in new tab');
|
||||
assert.ok(result.includes('rel="noopener noreferrer"'), 'should include noopener rel');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks strips trailing period from URL', () => {
|
||||
const result = renderLiteralWithLinks('visit https://example.com.', e);
|
||||
assert.ok(result.includes('href="E(https://example.com)"'), 'period should not be in href');
|
||||
assert.ok(result.includes('>E(https://example.com)<'), 'period should not be in link text');
|
||||
assert.ok(result.endsWith('E(.)'), 'trailing period should appear as escaped text after the link');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks strips trailing comma from URL', () => {
|
||||
const result = renderLiteralWithLinks('go to https://example.com, then stop', e);
|
||||
assert.ok(result.includes('href="E(https://example.com)"'), 'comma must not be in href');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks handles URL at the start of text', () => {
|
||||
const result = renderLiteralWithLinks('https://example.com is great', e);
|
||||
assert.ok(result.startsWith('<a href='), 'anchor should be at start');
|
||||
assert.ok(result.endsWith('E( is great)'), 'text after URL should be escaped');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks handles URL at the end of text', () => {
|
||||
const result = renderLiteralWithLinks('see https://example.com', e);
|
||||
assert.ok(result.startsWith('E(see )'), 'text before URL should be escaped');
|
||||
assert.ok(result.includes('<a href='), 'URL should be linked');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks handles multiple URLs in text', () => {
|
||||
const result = renderLiteralWithLinks('a https://foo.com b https://bar.com c', e);
|
||||
const matches = result.match(/<a href=/g) || [];
|
||||
assert.equal(matches.length, 2, 'should produce two anchors');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks does not linkify non-http schemes', () => {
|
||||
const result = renderLiteralWithLinks('ftp://example.com', e);
|
||||
assert.ok(!result.includes('<a href='), 'ftp:// should not be linkified');
|
||||
assert.equal(result, 'E(ftp://example.com)');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks returns empty string for empty input', () => {
|
||||
assert.equal(renderLiteralWithLinks('', e), '');
|
||||
});
|
||||
|
||||
test('buildMessageBody linkifies URLs in message text without renderMentionHtml', () => {
|
||||
const body = buildMessageBody({
|
||||
message: { text: 'visit https://example.com now' },
|
||||
escapeHtml: e,
|
||||
renderEmojiHtml: v => `EMOJI(${v})`,
|
||||
});
|
||||
assert.ok(body.includes('<a href='), 'URL should be linkified');
|
||||
assert.ok(body.includes('target="_blank"'), 'should open in new tab');
|
||||
});
|
||||
|
||||
test('buildMessageBody linkifies URLs alongside @[Name] mentions', () => {
|
||||
const body = buildMessageBody({
|
||||
message: { text: '@[Alice] see https://example.com' },
|
||||
escapeHtml: e,
|
||||
renderEmojiHtml: v => `EMOJI(${v})`,
|
||||
renderMentionHtml: name => `BADGE(${name})`,
|
||||
});
|
||||
assert.ok(body.startsWith('BADGE(Alice)'), 'mention should be rendered as badge');
|
||||
assert.ok(body.includes('<a href='), 'URL should be linkified');
|
||||
});
|
||||
|
||||
@@ -237,29 +237,16 @@ test('fetchActiveNodeStats concurrent calls share a single in-flight request', a
|
||||
// formatActiveNodeStatsText
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('formatActiveNodeStatsText emits expected dashboard sentence', () => {
|
||||
test('formatActiveNodeStatsText emits compact day/week/month string', () => {
|
||||
assert.equal(
|
||||
formatActiveNodeStatsText({
|
||||
channel: 'LongFast',
|
||||
frequency: '868MHz',
|
||||
stats: { hour: 1, day: 2, week: 3, month: 4, sampled: false },
|
||||
stats: { day: 2, week: 3, month: 4, sampled: false },
|
||||
}),
|
||||
'LongFast (868MHz) \u2014 active nodes: 1/hour, 2/day, 3/week, 4/month.'
|
||||
);
|
||||
});
|
||||
|
||||
test('formatActiveNodeStatsText appends sampled marker', () => {
|
||||
assert.equal(
|
||||
formatActiveNodeStatsText({
|
||||
channel: 'LongFast',
|
||||
frequency: '868MHz',
|
||||
stats: { hour: 9, day: 8, week: 7, month: 6, sampled: true },
|
||||
}),
|
||||
'LongFast (868MHz) \u2014 active nodes: 9/hour, 8/day, 7/week, 6/month (sampled).'
|
||||
'2/day \u00b7 3/week \u00b7 4/month'
|
||||
);
|
||||
});
|
||||
|
||||
test('formatActiveNodeStatsText handles missing or null stats gracefully', () => {
|
||||
const text = formatActiveNodeStatsText({ channel: 'X', frequency: 'Y', stats: null });
|
||||
assert.ok(text.includes('0/hour'), 'defaults to zero counts for null stats');
|
||||
const text = formatActiveNodeStatsText({ stats: null });
|
||||
assert.equal(text, '0/day \u00b7 0/week \u00b7 0/month', 'defaults to zero counts for null stats');
|
||||
});
|
||||
|
||||
@@ -57,8 +57,6 @@ function executeInDom(source, url, env) {
|
||||
test('theme and background modules behave correctly across scenarios', async t => {
|
||||
const env = createDomEnvironment({ readyState: 'complete', cookie: '' });
|
||||
try {
|
||||
const toggle = env.createElement('button', 'themeToggle');
|
||||
env.registerElement('themeToggle', toggle);
|
||||
let filterInvocations = 0;
|
||||
env.window.applyFiltersToAllTiles = () => {
|
||||
filterInvocations += 1;
|
||||
@@ -72,52 +70,27 @@ test('theme and background modules behave correctly across scenarios', async t =
|
||||
const backgroundHelpers = env.window.__potatoBackground;
|
||||
const backgroundHooks = backgroundHelpers.__testHooks;
|
||||
|
||||
await t.test('initialises with a dark theme and persists cookies', () => {
|
||||
await t.test('initialises with a dark theme', () => {
|
||||
assert.equal(env.document.documentElement.getAttribute('data-theme'), 'dark');
|
||||
assert.equal(env.document.body.classList.contains('dark'), true);
|
||||
assert.equal(toggle.textContent, '☀️');
|
||||
themeHelpers.persistTheme('light');
|
||||
themeHelpers.setCookie('bare', '1');
|
||||
themeHooks.exerciseSetCookieGuard();
|
||||
themeHelpers.setCookie('flag', 'true', { Secure: true });
|
||||
const cookieString = env.getCookieString();
|
||||
assert.equal(themeHelpers.getCookie('flag'), 'true');
|
||||
assert.equal(themeHelpers.getCookie('missing'), null);
|
||||
assert.match(cookieString, /theme=light/);
|
||||
assert.match(cookieString, /; path=\//);
|
||||
assert.match(cookieString, /; SameSite=Lax/);
|
||||
assert.match(cookieString, /; Secure/);
|
||||
});
|
||||
|
||||
await t.test('serializeCookieOptions covers boolean and string attributes', () => {
|
||||
const withAttributes = themeHooks.serializeCookieOptions({ Secure: true, HttpOnly: '1' });
|
||||
assert.equal(withAttributes.includes('; Secure'), true);
|
||||
assert.equal(withAttributes.includes('; HttpOnly=1'), true);
|
||||
const secureOnly = themeHooks.serializeCookieOptions({ Secure: true });
|
||||
assert.equal(secureOnly.trim(), '; Secure');
|
||||
assert.equal(themeHooks.formatCookieOption(['HttpOnly', '1']), '; HttpOnly=1');
|
||||
assert.equal(themeHooks.formatCookieOption(['Secure', true]), '; Secure');
|
||||
assert.equal(themeHooks.serializeCookieOptions({}), '');
|
||||
assert.equal(themeHooks.serializeCookieOptions(), '');
|
||||
});
|
||||
|
||||
await t.test('re-bootstrap handles DOMContentLoaded flow and filter hooks', () => {
|
||||
env.document.readyState = 'loading';
|
||||
filterInvocations = 0;
|
||||
env.setCookieString('theme=light');
|
||||
themeHooks.bootstrap();
|
||||
env.triggerDOMContentLoaded();
|
||||
assert.equal(env.document.documentElement.getAttribute('data-theme'), 'light');
|
||||
assert.equal(env.document.body.classList.contains('dark'), false);
|
||||
assert.equal(toggle.textContent, '🌙');
|
||||
assert.equal(env.document.documentElement.getAttribute('data-theme'), 'dark');
|
||||
assert.equal(env.document.body.classList.contains('dark'), true);
|
||||
assert.equal(filterInvocations, 1);
|
||||
env.document.removeEventListener('DOMContentLoaded', themeHooks.handleReady);
|
||||
});
|
||||
|
||||
await t.test('handleReady tolerates missing toggle button', () => {
|
||||
env.registerElement('themeToggle', null);
|
||||
await t.test('handleReady calls applyFiltersToAllTiles', () => {
|
||||
filterInvocations = 0;
|
||||
env.document.readyState = 'complete';
|
||||
themeHooks.handleReady();
|
||||
env.registerElement('themeToggle', toggle);
|
||||
assert.equal(filterInvocations, 1);
|
||||
});
|
||||
|
||||
await t.test('applyTheme copes with absent DOM nodes', () => {
|
||||
@@ -125,10 +98,10 @@ test('theme and background modules behave correctly across scenarios', async t =
|
||||
const originalRoot = env.document.documentElement;
|
||||
env.document.body = null;
|
||||
env.document.documentElement = null;
|
||||
assert.equal(themeHooks.applyTheme('dark'), true);
|
||||
// Should not throw even when DOM nodes are absent
|
||||
assert.doesNotThrow(() => themeHooks.applyTheme());
|
||||
env.document.body = originalBody;
|
||||
env.document.documentElement = originalRoot;
|
||||
assert.equal(themeHooks.applyTheme('light'), false);
|
||||
});
|
||||
|
||||
await t.test('background bootstrap waits for DOM readiness', () => {
|
||||
@@ -161,12 +134,12 @@ test('theme and background modules behave correctly across scenarios', async t =
|
||||
env.document.body = originalBody;
|
||||
});
|
||||
|
||||
await t.test('theme changes trigger background updates', () => {
|
||||
env.document.body.classList.remove('dark');
|
||||
themeHooks.setTheme('light');
|
||||
await t.test('themechange event triggers background update', () => {
|
||||
env.document.body.classList.add('dark');
|
||||
backgroundHooks.init();
|
||||
env.dispatchWindowEvent('themechange');
|
||||
assert.equal(env.document.documentElement.style.backgroundColor, '#f6f3ee');
|
||||
// Background should reflect dark mode
|
||||
assert.ok(env.document.documentElement.style.backgroundColor !== '');
|
||||
});
|
||||
|
||||
env.window.removeEventListener('themechange', backgroundHelpers.applyBackground);
|
||||
|
||||
@@ -311,10 +311,17 @@ export function buildChatTabModel({
|
||||
channel.entries.sort((a, b) => a.ts - b.ts);
|
||||
channel.messageCount = channel.entries.length;
|
||||
}
|
||||
// Sort channels by activity (most messages first), then alphabetically on ties.
|
||||
const channels = Array.from(channelBuckets.values()).sort((a, b) =>
|
||||
b.messageCount - a.messageCount || a.label.localeCompare(b.label)
|
||||
);
|
||||
// Sort channels into two tiers:
|
||||
// 1. Primary channels (channel index 0 — LongFast, MediumFast, Public, etc.)
|
||||
// ordered by activity desc so the most-active protocol leads within the tier.
|
||||
// 2. Secondary channels (index > 0) ordered by activity desc, then alpha.
|
||||
// Within each tier, ties on messageCount are broken alphabetically by label.
|
||||
const channels = Array.from(channelBuckets.values()).sort((a, b) => {
|
||||
const aTier = a.index === 0 ? 0 : 1;
|
||||
const bTier = b.index === 0 ? 0 : 1;
|
||||
if (aTier !== bTier) return aTier - bTier;
|
||||
return b.messageCount - a.messageCount || a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return { logEntries, channels };
|
||||
}
|
||||
|
||||
@@ -113,38 +113,135 @@ function colorForNodeCount(count) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render arbitrary contact text while hyperlinking recognised URL-like segments.
|
||||
* Matches recognised link-like segments in plain text:
|
||||
* - Absolute URLs (https?://, mailto:, matrix:)
|
||||
* - Matrix room aliases (#room:domain.tld)
|
||||
* - Matrix user IDs (@user:domain.tld)
|
||||
* - Bare domain-with-path (discord.gg/..., t.me/...)
|
||||
*
|
||||
* @param {*} contact Raw contact value from the API.
|
||||
* @returns {string} HTML markup safe for insertion.
|
||||
* Character classes use possessive-style atomic groupings (no overlap) so the
|
||||
* regex engine cannot backtrack into super-linear runtime.
|
||||
*/
|
||||
function renderContactHtml(contact) {
|
||||
if (typeof contact !== 'string') return '';
|
||||
const trimmed = contact.trim();
|
||||
if (!trimmed) return '';
|
||||
const urlPattern = /(https?:\/\/[^\s]+|mailto:[^\s]+|matrix:[^\s]+)/gi;
|
||||
const CONTACT_LINK_PATTERN =
|
||||
/(https?:\/\/\S+|mailto:\S+|matrix:\S+|[@#][a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}|[a-zA-Z0-9-]+\.[a-zA-Z]{2,}\/\S+)/gi;
|
||||
|
||||
/**
|
||||
* Regex matching `<a>` elements (including malformed unquoted attributes) and
|
||||
* any other HTML tags so they can be handled separately from plain text.
|
||||
*
|
||||
* The `<a>` branch uses `[^<]*` (no nesting) instead of `[\s\S]*?` to avoid
|
||||
* super-linear backtracking when nested or malformed tags are present.
|
||||
*/
|
||||
const HTML_SEGMENT_PATTERN = /(<a\b[^>]*>[^<]*<\/a\s*>|<[^>]+>)/gi;
|
||||
|
||||
/** Extracts the href value from an `<a>` opening tag, quoted or unquoted. */
|
||||
const HREF_ATTR_PATTERN = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*))/i;
|
||||
|
||||
/** Protocols allowed in whitelisted `<a>` hrefs. */
|
||||
const SAFE_HREF_RE = /^(?:https?:\/\/|matrix:|mailto:)/i;
|
||||
|
||||
/**
|
||||
* Resolve a raw matched token to an href string.
|
||||
*
|
||||
* @param {string} raw Matched token from CONTACT_LINK_PATTERN.
|
||||
* @returns {string} Absolute href suitable for use in an anchor element.
|
||||
*/
|
||||
function resolveHref(raw) {
|
||||
if (raw.startsWith('#') || raw.startsWith('@')) {
|
||||
// Matrix room alias or user ID → matrix.to permalink
|
||||
return `https://matrix.to/#/${raw}`;
|
||||
}
|
||||
if (/^[a-zA-Z0-9-]+\./.test(raw) && !raw.includes('://')) {
|
||||
// Bare domain-with-path (e.g. discord.gg/…) — prepend https://
|
||||
return `https://${raw}`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linkify URL-like tokens in a plain-text (already HTML-free) segment.
|
||||
*
|
||||
* @param {string} text Plain text with no HTML tags.
|
||||
* @returns {string} HTML-safe string with recognised links wrapped in anchors.
|
||||
*/
|
||||
function renderPlainSegment(text) {
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
const pattern = new RegExp(CONTACT_LINK_PATTERN.source, 'gi');
|
||||
let match;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
const before = text.slice(lastIndex, match.index);
|
||||
if (before) parts.push(escapeHtml(before));
|
||||
const raw = match[0];
|
||||
const href = resolveHref(raw);
|
||||
parts.push(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(raw)}</a>`);
|
||||
lastIndex = match.index + raw.length;
|
||||
}
|
||||
const trailing = text.slice(lastIndex);
|
||||
if (trailing) parts.push(escapeHtml(trailing));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render arbitrary contact or channel text as safe HTML.
|
||||
*
|
||||
* - Existing `<a>` tags are sanitised: href is validated against an allowlist
|
||||
* of safe protocols (https, http, matrix:, mailto:), link text is escaped,
|
||||
* and `target="_blank" rel="noopener noreferrer"` is always applied.
|
||||
* - Other HTML tags (e.g. `<b>`) are stripped; their text content is kept.
|
||||
* - Plain-text segments are scanned for URLs, Matrix aliases/user-IDs, and
|
||||
* bare domain-with-path references (e.g. discord.gg/…) and linkified.
|
||||
* - Line breaks are converted to `<br>`.
|
||||
*
|
||||
* @param {*} text Raw value from the API (may contain HTML).
|
||||
* @returns {string} HTML markup safe for insertion.
|
||||
*/
|
||||
function renderContactHtml(text) {
|
||||
if (typeof text !== 'string') return '';
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
const segPattern = new RegExp(HTML_SEGMENT_PATTERN.source, 'gi');
|
||||
let match;
|
||||
|
||||
while ((match = urlPattern.exec(trimmed)) !== null) {
|
||||
const textBefore = trimmed.slice(lastIndex, match.index);
|
||||
if (textBefore) {
|
||||
parts.push(escapeHtml(textBefore));
|
||||
while ((match = segPattern.exec(trimmed)) !== null) {
|
||||
// Linkify plain text before this HTML segment
|
||||
const before = trimmed.slice(lastIndex, match.index);
|
||||
if (before) parts.push(renderPlainSegment(before));
|
||||
|
||||
const tag = match[0];
|
||||
if (/^<a\b/i.test(tag)) {
|
||||
// Whitelisted <a> tag — extract href, validate, re-render safely
|
||||
const hrefMatch = HREF_ATTR_PATTERN.exec(tag);
|
||||
const href = hrefMatch ? (hrefMatch[1] ?? hrefMatch[2] ?? hrefMatch[3] ?? '') : '';
|
||||
// Strip HTML tags to derive plain link text; content is still escaped below.
|
||||
// The replace runs in a loop to handle residual tags left after the first
|
||||
// pass — this is safe because a single-pass replace of `<…>` can leave
|
||||
// behind a reconstructed tag when the input contains e.g. `<<script>`.
|
||||
let linkText = tag;
|
||||
let prev;
|
||||
do { prev = linkText; linkText = linkText.replace(/<[^>]*>/g, ''); } while (linkText !== prev);
|
||||
linkText = linkText.trim();
|
||||
if (SAFE_HREF_RE.test(href)) {
|
||||
parts.push(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(linkText || href)}</a>`);
|
||||
} else {
|
||||
// Unsafe or missing href — render link text only
|
||||
parts.push(escapeHtml(linkText));
|
||||
}
|
||||
}
|
||||
const url = match[0];
|
||||
const safeUrl = escapeHtml(url);
|
||||
parts.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a>`);
|
||||
lastIndex = match.index + url.length;
|
||||
// Other tags (<b>, </b>, etc.) are stripped; their text content falls
|
||||
// through as plain text in subsequent iterations.
|
||||
|
||||
lastIndex = match.index + tag.length;
|
||||
}
|
||||
|
||||
// Linkify any remaining plain text after the last HTML segment
|
||||
const trailing = trimmed.slice(lastIndex);
|
||||
if (trailing) {
|
||||
parts.push(escapeHtml(trailing));
|
||||
}
|
||||
if (trailing) parts.push(renderPlainSegment(trailing));
|
||||
|
||||
const html = parts.join('');
|
||||
return html.replace(/\r?\n/g, '<br>');
|
||||
return parts.join('').replace(/\r?\n/g, '<br>');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,7 +484,7 @@ export async function initializeFederationPage(options = {}) {
|
||||
<td class="instances-col instances-col--domain mono">${domainHtml}</td>
|
||||
<td class="instances-col instances-col--contact">${contactHtml || '<em>—</em>'}</td>
|
||||
<td class="instances-col instances-col--version mono">${escapeHtml(instance.version || '')}</td>
|
||||
<td class="instances-col instances-col--channel">${escapeHtml(instance.channel || '')}</td>
|
||||
<td class="instances-col instances-col--channel">${renderContactHtml(instance.channel) || ''}</td>
|
||||
<td class="instances-col instances-col--frequency">${escapeHtml(instance.frequency || '')}</td>
|
||||
<td class="instances-col instances-col--nodes mono">${nodesCountText}</td>
|
||||
<td class="instances-col instances-col--latitude mono">${fmtCoords(instance.latitude)}</td>
|
||||
|
||||
@@ -205,6 +205,18 @@ export async function initializeInstanceSelector(options) {
|
||||
const visibleEntries = filterDisplayableFederationInstances(payload);
|
||||
updateFederationNavCount({ documentObject: doc, count: visibleEntries.length });
|
||||
|
||||
// Hide the selector when fewer than two instances are available — a single
|
||||
// entry (only the current instance) offers no meaningful navigation choice.
|
||||
if (visibleEntries.length < 2) {
|
||||
const selectorContainer = (typeof selectElement.closest === 'function'
|
||||
? selectElement.closest('.header-federation')
|
||||
: null) || selectElement.parentElement;
|
||||
if (selectorContainer) {
|
||||
selectorContainer.hidden = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null;
|
||||
|
||||
const sortedEntries = visibleEntries
|
||||
|
||||
+113
-250
@@ -131,30 +131,20 @@ import {
|
||||
*/
|
||||
export function initializeApp(config) {
|
||||
const statusEl = document.getElementById('status');
|
||||
const fitBoundsEl = document.getElementById('fitBounds');
|
||||
const autoRefreshEl = document.getElementById('autoRefresh');
|
||||
const footerActiveNodes = document.getElementById('footerActiveNodes');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
const filterInput = document.getElementById('filterInput');
|
||||
const filterClearButton = document.getElementById('filterClear');
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const infoBtn = document.getElementById('infoBtn');
|
||||
const infoOverlay = document.getElementById('infoOverlay');
|
||||
const infoClose = document.getElementById('infoClose');
|
||||
const infoDialog = infoOverlay ? infoOverlay.querySelector('.info-dialog') : null;
|
||||
const shortInfoTemplate = document.getElementById('shortInfoOverlayTemplate');
|
||||
const overlayStack = createShortInfoOverlayStack({ document, window, template: shortInfoTemplate });
|
||||
const titleEl = document.querySelector('title');
|
||||
const headerEl = document.querySelector('h1');
|
||||
const headerTitleTextEl = headerEl ? headerEl.querySelector('.site-title-text') : null;
|
||||
const chatEl = document.getElementById('chat');
|
||||
const refreshInfo = document.getElementById('refreshInfo');
|
||||
const instanceSelect = document.getElementById('instanceSelect');
|
||||
const baseTitle = document.title;
|
||||
const nodesTable = document.getElementById('nodes');
|
||||
const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
|
||||
const infoOverlayHome = infoOverlay
|
||||
? { parent: infoOverlay.parentNode, nextSibling: infoOverlay.nextSibling }
|
||||
: null;
|
||||
const bodyClassList = document.body ? document.body.classList : null;
|
||||
const isPrivateMode = document.body && document.body.dataset
|
||||
? String(document.body.dataset.privateMode).toLowerCase() === 'true'
|
||||
@@ -245,13 +235,6 @@ export function initializeApp(config) {
|
||||
const REFRESH_MS = config.refreshMs;
|
||||
const CHAT_ENABLED = Boolean(config.chatEnabled);
|
||||
const instanceSelectorEnabled = Boolean(config.instancesFeatureEnabled);
|
||||
if (refreshInfo) {
|
||||
if (isDashboardView) {
|
||||
refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: …`;
|
||||
} else {
|
||||
refreshInfo.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (instanceSelectorEnabled && instanceSelect) {
|
||||
void initializeInstanceSelector({
|
||||
@@ -265,7 +248,7 @@ export function initializeApp(config) {
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let refreshTimer = null;
|
||||
let refreshInfoRequestId = 0;
|
||||
let activeStatsRequestId = 0;
|
||||
|
||||
/**
|
||||
* Close any open short-info overlays that do not contain the provided anchor.
|
||||
@@ -463,24 +446,18 @@ export function initializeApp(config) {
|
||||
*/
|
||||
function restartAutoRefresh() {
|
||||
// Tear down any existing timer so the interval never double-fires when
|
||||
// the user toggles auto-refresh or the config is re-applied.
|
||||
// the config is re-applied.
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
// Only arm the timer when the auto-refresh checkbox is checked; a
|
||||
// disabled checkbox (e.g. during an active fetch) keeps it off.
|
||||
if (autoRefreshEl && autoRefreshEl.checked) {
|
||||
// Only arm the timer when a positive interval is configured; a zero or
|
||||
// negative value means auto-refresh is intentionally disabled.
|
||||
if (REFRESH_MS > 0) {
|
||||
refreshTimer = setInterval(refresh, REFRESH_MS);
|
||||
}
|
||||
}
|
||||
|
||||
if (fitBoundsEl && mapZoomOverride !== null) {
|
||||
fitBoundsEl.checked = false;
|
||||
fitBoundsEl.disabled = true;
|
||||
fitBoundsEl.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
const MAP_CENTER_COORDS = Object.freeze({ lat: config.mapCenter.lat, lon: config.mapCenter.lon });
|
||||
const hasLeaflet = typeof window !== 'undefined' && typeof window.L === 'object' && window.L && typeof window.L.map === 'function';
|
||||
const mapContainer = document.getElementById('map');
|
||||
@@ -524,7 +501,7 @@ export function initializeApp(config) {
|
||||
];
|
||||
|
||||
const autoFitController = createMapAutoFitController({
|
||||
toggleEl: fitBoundsEl,
|
||||
toggleEl: null,
|
||||
windowObject: typeof window !== 'undefined' ? window : undefined,
|
||||
defaultPaddingPx: AUTO_FIT_PADDING_PX
|
||||
});
|
||||
@@ -542,7 +519,6 @@ export function initializeApp(config) {
|
||||
const centerResetHandler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController,
|
||||
fitBoundsEl,
|
||||
fitMapToBounds,
|
||||
mapCenterCoords: MAP_CENTER_COORDS,
|
||||
mapZoomOverride,
|
||||
@@ -739,62 +715,6 @@ export function initializeApp(config) {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Append the informational modal overlay to the fullscreen container when active.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function attachInfoOverlayToFullscreenHost() {
|
||||
if (!infoOverlay || !fullscreenContainer) return;
|
||||
if (infoOverlay.parentNode !== fullscreenContainer) {
|
||||
fullscreenContainer.appendChild(infoOverlay);
|
||||
}
|
||||
if (infoOverlay.classList) {
|
||||
infoOverlay.classList.add('info-overlay--fullscreen');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the informational overlay to its original DOM position.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function restoreInfoOverlayToHome() {
|
||||
if (!infoOverlay || !infoOverlayHome || !infoOverlayHome.parent) return;
|
||||
if (infoOverlay.parentNode === infoOverlayHome.parent) {
|
||||
if (infoOverlay.classList) {
|
||||
infoOverlay.classList.remove('info-overlay--fullscreen');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
infoOverlayHome.nextSibling &&
|
||||
infoOverlayHome.nextSibling.parentNode === infoOverlayHome.parent &&
|
||||
typeof infoOverlayHome.parent.insertBefore === 'function'
|
||||
) {
|
||||
infoOverlayHome.parent.insertBefore(infoOverlay, infoOverlayHome.nextSibling);
|
||||
} else if (typeof infoOverlayHome.parent.appendChild === 'function') {
|
||||
infoOverlayHome.parent.appendChild(infoOverlay);
|
||||
}
|
||||
if (infoOverlay.classList) {
|
||||
infoOverlay.classList.remove('info-overlay--fullscreen');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the informational overlay participates in the active fullscreen subtree.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function syncInfoOverlayHost() {
|
||||
if (!infoOverlay) return;
|
||||
if (isMapInFullscreen()) {
|
||||
attachInfoOverlayToFullscreenHost();
|
||||
} else {
|
||||
restoreInfoOverlayToHome();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to fullscreen change events originating from the browser.
|
||||
*
|
||||
@@ -825,7 +745,6 @@ export function initializeApp(config) {
|
||||
mapContainer.style.minHeight = '';
|
||||
}
|
||||
}
|
||||
syncInfoOverlayHost();
|
||||
updateFullscreenToggleState();
|
||||
refreshMapSize();
|
||||
}
|
||||
@@ -853,8 +772,6 @@ export function initializeApp(config) {
|
||||
});
|
||||
}
|
||||
|
||||
syncInfoOverlayHost();
|
||||
|
||||
/** @type {Set<string>} Active compound role-filter keys, each ``"<protocol>:<roleKey>"``. */
|
||||
const activeRoleFilters = new Set();
|
||||
/** @type {Map<string, HTMLElement>} Compound key → legend button element. */
|
||||
@@ -1395,6 +1312,8 @@ export function initializeApp(config) {
|
||||
|
||||
let legendContainer = null;
|
||||
let legendToggleControl = null;
|
||||
let meshcoreCountEl = null;
|
||||
let meshtasticCountEl = null;
|
||||
let legendToggleButton = null;
|
||||
let legendVisible = true;
|
||||
|
||||
@@ -1479,7 +1398,9 @@ export function initializeApp(config) {
|
||||
if (!neighborLinesToggleButton) return;
|
||||
const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines';
|
||||
neighborLinesToggleButton.textContent = label;
|
||||
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false');
|
||||
// aria-pressed reflects whether the user has *activated* the toggle (i.e. lines are
|
||||
// currently hidden). When lines are visible (default), the button is unpressed.
|
||||
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'false' : 'true');
|
||||
neighborLinesToggleButton.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
@@ -1511,7 +1432,8 @@ export function initializeApp(config) {
|
||||
if (!traceLinesToggleButton) return;
|
||||
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
|
||||
traceLinesToggleButton.textContent = label;
|
||||
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
|
||||
// aria-pressed reflects whether the user has *activated* the toggle (lines hidden).
|
||||
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'false' : 'true');
|
||||
traceLinesToggleButton.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
@@ -1629,14 +1551,30 @@ export function initializeApp(config) {
|
||||
}
|
||||
|
||||
if (map && hasLeaflet) {
|
||||
const legend = L.control({ position: 'bottomright' });
|
||||
// Single combined control: [toggle button | legend panel] in a flex row.
|
||||
// The toggle sits to the left so it remains accessible when the legend is collapsed.
|
||||
const legendControl = L.control({ position: 'bottomright' });
|
||||
/**
|
||||
* Leaflet control factory that renders the legend UI.
|
||||
* Leaflet control factory that renders the toggle button and legend panel
|
||||
* as a single side-by-side control.
|
||||
*
|
||||
* @returns {HTMLElement} Legend DOM element.
|
||||
* @returns {HTMLElement} Wrapper element containing both children.
|
||||
*/
|
||||
legend.onAdd = function () {
|
||||
const div = L.DomUtil.create('div', 'legend');
|
||||
legendControl.onAdd = function () {
|
||||
const wrapper = L.DomUtil.create('div', 'legend-outer');
|
||||
|
||||
// --- Toggle button (left) ---
|
||||
const button = L.DomUtil.create('button', 'legend-toggle-button', wrapper);
|
||||
button.type = 'button';
|
||||
button.setAttribute('aria-pressed', 'true');
|
||||
button.setAttribute('aria-controls', 'mapLegend');
|
||||
button.addEventListener('click', legendClickHandler(() => {
|
||||
setLegendVisibility(!legendVisible);
|
||||
}));
|
||||
legendToggleButton = button;
|
||||
|
||||
// --- Legend panel (right) ---
|
||||
const div = L.DomUtil.create('div', 'legend', wrapper);
|
||||
div.id = 'mapLegend';
|
||||
div.setAttribute('role', 'region');
|
||||
div.setAttribute('aria-label', 'Map legend');
|
||||
@@ -1648,13 +1586,16 @@ export function initializeApp(config) {
|
||||
|
||||
const itemsContainer = L.DomUtil.create('div', 'legend-items legend-items--columns', div);
|
||||
|
||||
// --- MeshCore column (left) ---
|
||||
const meshcoreCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
|
||||
// --- MeshCore column (left, bottom-aligned) ---
|
||||
const meshcoreCol = L.DomUtil.create('div', 'legend-column legend-column--bottom', itemsContainer);
|
||||
const meshcoreColHeader = L.DomUtil.create('div', 'legend-column-header', meshcoreCol);
|
||||
meshcoreColHeader.appendChild(buildMeshcoreIconImg());
|
||||
const meshcoreColTitle = document.createElement('span');
|
||||
meshcoreColTitle.textContent = 'MeshCore';
|
||||
meshcoreColHeader.appendChild(meshcoreColTitle);
|
||||
meshcoreCountEl = document.createElement('span');
|
||||
meshcoreCountEl.className = 'legend-protocol-count';
|
||||
meshcoreColHeader.appendChild(meshcoreCountEl);
|
||||
|
||||
// --- Meshtastic column (right) ---
|
||||
const meshtasticCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
|
||||
@@ -1663,18 +1604,17 @@ export function initializeApp(config) {
|
||||
const meshtasticColTitle = document.createElement('span');
|
||||
meshtasticColTitle.textContent = 'Meshtastic';
|
||||
meshtasticColHeader.appendChild(meshtasticColTitle);
|
||||
meshtasticCountEl = document.createElement('span');
|
||||
meshtasticCountEl.className = 'legend-protocol-count';
|
||||
meshtasticColHeader.appendChild(meshtasticCountEl);
|
||||
|
||||
legendRoleButtons.clear();
|
||||
buildRoleButtons(meshcoreCol, meshcoreRoleColors, 'meshcore');
|
||||
buildRoleButtons(meshtasticCol, roleColors, 'meshtastic');
|
||||
|
||||
// --- Protocol hide toggles — one per column footer ---
|
||||
// --- MeshCore column footer: protocol hide toggle ---
|
||||
legendProtocolButtons.clear();
|
||||
const protocolColDefs = [
|
||||
{ protocol: 'meshcore', col: meshcoreCol },
|
||||
{ protocol: 'meshtastic', col: meshtasticCol },
|
||||
];
|
||||
for (const { protocol, col } of protocolColDefs) {
|
||||
const buildProtocolToggle = (protocol, col) => {
|
||||
const displayName = PROTOCOL_DISPLAY_NAMES[protocol] ?? protocol;
|
||||
const btn = L.DomUtil.create('button', 'legend-item legend-protocol-toggle', col);
|
||||
btn.type = 'button';
|
||||
@@ -1690,9 +1630,10 @@ export function initializeApp(config) {
|
||||
applyFilter();
|
||||
}));
|
||||
legendProtocolButtons.set(protocol, btn);
|
||||
}
|
||||
};
|
||||
buildProtocolToggle('meshcore', meshcoreCol);
|
||||
|
||||
// --- Line toggles in the Meshtastic column ---
|
||||
// --- Meshtastic column: line toggles then protocol hide toggle at bottom ---
|
||||
neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', meshtasticCol);
|
||||
neighborLinesToggleButton.type = 'button';
|
||||
neighborLinesToggleButton.addEventListener('click', legendClickHandler(() => {
|
||||
@@ -1707,12 +1648,15 @@ export function initializeApp(config) {
|
||||
}));
|
||||
updateTraceLinesToggleState();
|
||||
|
||||
// Hide Meshtastic toggle at the very bottom of the Meshtastic column.
|
||||
buildProtocolToggle('meshtastic', meshtasticCol);
|
||||
|
||||
updateLegendRoleFiltersUI();
|
||||
|
||||
// --- Clear filters — full-width below the two columns ---
|
||||
const toggle = L.DomUtil.create('div', 'legend-toggle', div);
|
||||
const filterToggle = L.DomUtil.create('div', 'legend-toggle', div);
|
||||
|
||||
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
|
||||
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', filterToggle);
|
||||
resetButton.type = 'button';
|
||||
resetButton.textContent = 'Clear filters';
|
||||
resetButton.addEventListener('click', legendClickHandler(() => {
|
||||
@@ -1722,38 +1666,12 @@ export function initializeApp(config) {
|
||||
applyFilter();
|
||||
}));
|
||||
|
||||
L.DomEvent.disableClickPropagation(div);
|
||||
L.DomEvent.disableScrollPropagation(div);
|
||||
|
||||
return div;
|
||||
};
|
||||
legend.addTo(map);
|
||||
legendContainer = legend.getContainer();
|
||||
|
||||
legendToggleControl = L.control({ position: 'bottomright' });
|
||||
/**
|
||||
* Leaflet control factory for the legend visibility toggle.
|
||||
*
|
||||
* @returns {HTMLElement} Toggle button element.
|
||||
*/
|
||||
legendToggleControl.onAdd = function () {
|
||||
const container = L.DomUtil.create('div', 'leaflet-control legend-toggle');
|
||||
const button = L.DomUtil.create('button', 'legend-toggle-button', container);
|
||||
button.type = 'button';
|
||||
button.textContent = 'Hide legend (filters)';
|
||||
button.setAttribute('aria-pressed', 'true');
|
||||
button.setAttribute('aria-label', 'Hide map legend');
|
||||
button.setAttribute('aria-controls', 'mapLegend');
|
||||
button.addEventListener('click', legendClickHandler(() => {
|
||||
setLegendVisibility(!legendVisible);
|
||||
}));
|
||||
legendToggleButton = button;
|
||||
updateLegendToggleState();
|
||||
L.DomEvent.disableClickPropagation(container);
|
||||
L.DomEvent.disableScrollPropagation(container);
|
||||
return container;
|
||||
L.DomEvent.disableClickPropagation(wrapper);
|
||||
L.DomEvent.disableScrollPropagation(wrapper);
|
||||
return wrapper;
|
||||
};
|
||||
legendToggleControl.addTo(map);
|
||||
legendControl.addTo(map);
|
||||
|
||||
const legendMediaQuery = window.matchMedia('(max-width: 1024px)');
|
||||
const initialLegendVisible = resolveLegendVisibility({
|
||||
@@ -1770,72 +1688,6 @@ export function initializeApp(config) {
|
||||
setLegendVisibility(false);
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const dark = document.body.classList.toggle('dark');
|
||||
const themeValue = dark ? 'dark' : 'light';
|
||||
document.body.setAttribute('data-theme', themeValue);
|
||||
if (document.documentElement) {
|
||||
document.documentElement.setAttribute('data-theme', themeValue);
|
||||
}
|
||||
themeToggle.textContent = dark ? '☀️' : '🌙';
|
||||
if (window.__themeCookie) {
|
||||
if (typeof window.__themeCookie.persistTheme === 'function') {
|
||||
window.__themeCookie.persistTheme(themeValue);
|
||||
} else if (typeof window.__themeCookie.setCookie === 'function') {
|
||||
window.__themeCookie.setCookie('theme', themeValue);
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeValue } }));
|
||||
if (typeof window.applyFiltersToAllTiles === 'function') window.applyFiltersToAllTiles();
|
||||
});
|
||||
|
||||
let lastFocusBeforeInfo = null;
|
||||
|
||||
/**
|
||||
* Display the modal overlay containing site information.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function openInfoOverlay() {
|
||||
if (!infoOverlay || !infoDialog) return;
|
||||
syncInfoOverlayHost();
|
||||
lastFocusBeforeInfo = document.activeElement;
|
||||
infoOverlay.hidden = false;
|
||||
document.body.style.setProperty('overflow', 'hidden');
|
||||
infoDialog.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the site information overlay and restore focus.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function closeInfoOverlay() {
|
||||
if (!infoOverlay || !infoDialog) return;
|
||||
infoOverlay.hidden = true;
|
||||
document.body.style.removeProperty('overflow');
|
||||
const target = lastFocusBeforeInfo && typeof lastFocusBeforeInfo.focus === 'function' ? lastFocusBeforeInfo : infoBtn;
|
||||
if (target && typeof target.focus === 'function') {
|
||||
target.focus();
|
||||
}
|
||||
lastFocusBeforeInfo = null;
|
||||
}
|
||||
|
||||
if (infoBtn && infoOverlay && infoClose) {
|
||||
infoBtn.addEventListener('click', openInfoOverlay);
|
||||
infoClose.addEventListener('click', closeInfoOverlay);
|
||||
infoOverlay.addEventListener('click', event => {
|
||||
if (event.target === infoOverlay) {
|
||||
closeInfoOverlay();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !infoOverlay.hidden) {
|
||||
closeInfoOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const nodeDetailOverlayManager = createNodeDetailOverlayManager({
|
||||
document,
|
||||
privateMode: isPrivateMode,
|
||||
@@ -2001,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;
|
||||
@@ -4435,10 +4287,6 @@ export function initializeApp(config) {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (pts.length && fitBoundsEl && fitBoundsEl.checked) {
|
||||
const bounds = computeBoundsForPoints(pts, { ...autoFitBoundsConfig });
|
||||
fitMapToBounds(bounds, { animate: false, paddingPx: AUTO_FIT_PADDING_PX });
|
||||
}
|
||||
overlayStack.cleanupOrphans();
|
||||
}
|
||||
|
||||
@@ -4521,8 +4369,11 @@ export function initializeApp(config) {
|
||||
const nowSec = Date.now()/1000;
|
||||
renderTable(sortedNodes, nowSec);
|
||||
renderMap(sortedNodes, nowSec);
|
||||
updateCount(sortedNodes, nowSec);
|
||||
updateRefreshInfo(sortedNodes, nowSec);
|
||||
// Title and legend counts are intentionally global — they reflect the whole
|
||||
// network, not just the nodes visible under the current filter.
|
||||
updateTitleCount(allNodes, nowSec);
|
||||
updateLegendProtocolCounts(allNodes, nowSec);
|
||||
updateFooterStats(sortedNodes, nowSec);
|
||||
updateSortIndicators();
|
||||
// Pass the raw filterQuery (not the normalised form) so the chat log can
|
||||
// highlight matching substrings in their original case.
|
||||
@@ -4658,8 +4509,8 @@ export function initializeApp(config) {
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off the first data load immediately; interval-based auto-refresh
|
||||
// begins only if the checkbox is already checked on page load.
|
||||
// Kick off the first data load immediately then start the silent background
|
||||
// auto-refresh timer.
|
||||
refresh();
|
||||
restartAutoRefresh();
|
||||
|
||||
@@ -4668,27 +4519,16 @@ export function initializeApp(config) {
|
||||
refreshBtn.addEventListener('click', refresh);
|
||||
}
|
||||
|
||||
if (autoRefreshEl) {
|
||||
// When the user enables auto-refresh mid-session, trigger an immediate
|
||||
// fetch so the UI does not sit stale until the next interval fires.
|
||||
autoRefreshEl.addEventListener('change', () => {
|
||||
restartAutoRefresh();
|
||||
if (autoRefreshEl.checked) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the count badge showing how many nodes are displayed.
|
||||
* Update the page/tab title with the total active-node count for the past 7 days.
|
||||
*
|
||||
* @param {Array<Object>} nodes Node payloads.
|
||||
* @param {Array<Object>} nodes All node payloads (unfiltered — counts are global).
|
||||
* @param {number} nowSec Reference timestamp.
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateCount(nodes, nowSec) {
|
||||
const dayAgoSec = nowSec - 86400;
|
||||
const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= dayAgoSec).length;
|
||||
function updateTitleCount(nodes, nowSec) {
|
||||
const weekAgoSec = nowSec - 7 * 86_400;
|
||||
const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= weekAgoSec).length;
|
||||
const text = `${baseTitle} (${count})`;
|
||||
titleEl.textContent = text;
|
||||
if (headerTitleTextEl) {
|
||||
@@ -4699,26 +4539,40 @@ export function initializeApp(config) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status message describing the currently rendered data.
|
||||
* Update legend column headers with per-protocol active node counts (7 days).
|
||||
*
|
||||
* @param {Array<Object>} nodes All node payloads (unfiltered).
|
||||
* @param {number} nowSec Reference timestamp.
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateLegendProtocolCounts(nodes, nowSec) {
|
||||
if (!meshcoreCountEl && !meshtasticCountEl) return;
|
||||
const weekAgoSec = nowSec - 7 * 86_400;
|
||||
const recentNodes = nodes.filter(n => Number.isFinite(Number(n.last_heard)) && Number(n.last_heard) >= weekAgoSec);
|
||||
const meshcoreCount = recentNodes.filter(n => n.protocol === 'meshcore').length;
|
||||
// Treat any non-meshcore node as Meshtastic until additional protocols are supported.
|
||||
const meshtasticCount = recentNodes.filter(n => n.protocol !== 'meshcore').length;
|
||||
if (meshcoreCountEl) meshcoreCountEl.textContent = ` (${meshcoreCount})`;
|
||||
if (meshtasticCountEl) meshtasticCountEl.textContent = ` (${meshtasticCount})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the footer active-node stats element with day/week/month counts.
|
||||
*
|
||||
* @param {Array<Object>} nodes Node payloads.
|
||||
* @param {number} nowSec Reference timestamp.
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateRefreshInfo(nodes, nowSec) {
|
||||
if (!refreshInfo || !isDashboardView) {
|
||||
function updateFooterStats(nodes, nowSec) {
|
||||
if (!footerActiveNodes) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++refreshInfoRequestId;
|
||||
const requestId = ++activeStatsRequestId;
|
||||
void fetchActiveNodeStats({ nodes, nowSeconds: nowSec }).then(stats => {
|
||||
if (requestId !== refreshInfoRequestId) {
|
||||
if (requestId !== activeStatsRequestId) {
|
||||
return;
|
||||
}
|
||||
refreshInfo.textContent = formatActiveNodeStatsText({
|
||||
channel: config.channel,
|
||||
frequency: config.frequency,
|
||||
stats
|
||||
});
|
||||
footerActiveNodes.textContent = 'Active: ' + formatActiveNodeStatsText({ stats });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4750,6 +4604,15 @@ export function initializeApp(config) {
|
||||
hiddenProtocols,
|
||||
legendRoleButtons,
|
||||
legendProtocolButtons,
|
||||
updateTitleCount,
|
||||
updateLegendProtocolCounts,
|
||||
updateFooterStats,
|
||||
restartAutoRefresh,
|
||||
/** Inject mock count span elements for legend protocol count tests. */
|
||||
_setProtocolCountElements(mc, mt) {
|
||||
meshcoreCountEl = mc;
|
||||
meshtasticCountEl = mt;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,12 +339,55 @@ function resolveMessageTextSegment(message, isReaction) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a text segment, replacing ``@[Name]`` mention patterns with the
|
||||
* output of ``renderMentionHtml`` when provided. Segments between mentions
|
||||
* are passed through ``escapeHtml`` to prevent XSS.
|
||||
* Regex with a single capturing group that matches http:// and https:// URLs.
|
||||
* Used by {@link renderLiteralWithLinks} to split text into URL and non-URL
|
||||
* segments while preserving the matched URL in the resulting array.
|
||||
* @type {RegExp}
|
||||
*/
|
||||
const URL_SPLIT_PATTERN = /(https?:\/\/[^\s<>"'[\]]{1,2048})/;
|
||||
|
||||
/**
|
||||
* Strip trailing punctuation characters that are typically sentence
|
||||
* delimiters rather than part of a URL (e.g. a period at end of sentence).
|
||||
*
|
||||
* When ``renderMentionHtml`` is ``null`` the function behaves identically to
|
||||
* ``escapeHtml(text)``, preserving backward compatibility.
|
||||
* @param {string} url Raw URL candidate.
|
||||
* @returns {string} URL with trailing punctuation trimmed.
|
||||
*/
|
||||
function trimUrlTrailingPunctuation(url) {
|
||||
return url.replace(/[.,;!?)]+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single raw text segment, converting any ``http://`` or
|
||||
* ``https://`` URLs into ``<a>`` elements that open in a new tab.
|
||||
* Non-URL text is passed through ``escapeHtml`` unchanged.
|
||||
*
|
||||
* @param {string} text Raw (unescaped) literal text.
|
||||
* @param {Function} escapeHtml HTML-escape function.
|
||||
* @returns {string} Safe HTML with URLs wrapped in anchor elements.
|
||||
*/
|
||||
export function renderLiteralWithLinks(text, escapeHtml) {
|
||||
// split() with a capturing group interleaves plain text (even indices)
|
||||
// and matched URLs (odd indices): ["before", "https://x", " after", ...]
|
||||
const parts = text.split(URL_SPLIT_PATTERN);
|
||||
return parts.map((part, i) => {
|
||||
if (i % 2 === 0) {
|
||||
return part ? escapeHtml(part) : '';
|
||||
}
|
||||
// URL segment — strip trailing punctuation then linkify.
|
||||
const url = trimUrlTrailingPunctuation(part);
|
||||
const trailing = part.slice(url.length);
|
||||
return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>${trailing ? escapeHtml(trailing) : ''}`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a text segment, replacing ``@[Name]`` mention patterns with the
|
||||
* output of ``renderMentionHtml`` when provided. Literal text segments are
|
||||
* passed through {@link renderLiteralWithLinks} so that URLs become clickable.
|
||||
*
|
||||
* When ``renderMentionHtml`` is ``null`` the function is equivalent to
|
||||
* calling {@link renderLiteralWithLinks} on the whole string.
|
||||
*
|
||||
* @param {string} text Raw message text segment.
|
||||
* @param {Function} escapeHtml HTML-escape function.
|
||||
@@ -353,20 +396,22 @@ function resolveMessageTextSegment(message, isReaction) {
|
||||
* @returns {string} HTML string safe for insertion into the DOM.
|
||||
*/
|
||||
function renderTextWithMentions(text, escapeHtml, renderMentionHtml) {
|
||||
if (typeof renderMentionHtml !== 'function') return escapeHtml(text);
|
||||
if (typeof renderMentionHtml !== 'function') return renderLiteralWithLinks(text, escapeHtml);
|
||||
// split() with a capturing group interleaves literal segments (even indices)
|
||||
// and captured mention names (odd indices): ["before", "Alice", "after", ...]
|
||||
const parts = text.split(/@\[([^\]]+)\]/);
|
||||
return parts.map((part, i) => {
|
||||
if (i % 2 === 1) return renderMentionHtml(part);
|
||||
// Empty literal segments (e.g. when a mention is at the start or end) can
|
||||
// be skipped to avoid unnecessary escapeHtml calls on empty strings.
|
||||
return part ? escapeHtml(part) : '';
|
||||
// be skipped to avoid unnecessary renderLiteralWithLinks calls.
|
||||
return part ? renderLiteralWithLinks(part, escapeHtml) : '';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rendered message body containing text and optional emoji.
|
||||
* ``http://`` and ``https://`` URLs in the message text are automatically
|
||||
* converted to ``<a>`` elements that open in a new tab.
|
||||
*
|
||||
* @param {{
|
||||
* message: Object,
|
||||
|
||||
@@ -154,18 +154,16 @@ export async function fetchActiveNodeStats({ nodes, nowSeconds, fetchImpl = fetc
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the dashboard refresh-info sentence for active-node counts.
|
||||
* Format the active-node counts for display in the footer.
|
||||
*
|
||||
* @param {{channel: string, frequency: string, stats: {hour:number,day:number,week:number,month:number,sampled:boolean}}} params Formatting data.
|
||||
* @returns {string} User-visible sentence for the dashboard header.
|
||||
* @param {{stats: {day:number,week:number,month:number,sampled:boolean}}} params Formatting data.
|
||||
* @returns {string} Compact user-visible string, e.g. ``"569/day · 729/week · 1168/month"``.
|
||||
*/
|
||||
export function formatActiveNodeStatsText({ channel, frequency, stats }) {
|
||||
export function formatActiveNodeStatsText({ stats }) {
|
||||
const parts = [
|
||||
`${Number(stats?.hour) || 0}/hour`,
|
||||
`${Number(stats?.day) || 0}/day`,
|
||||
`${Number(stats?.week) || 0}/week`,
|
||||
`${Number(stats?.month) || 0}/month`
|
||||
];
|
||||
const suffix = stats?.sampled ? ' (sampled)' : '';
|
||||
return `${channel} (${frequency}) — active nodes: ${parts.join(', ')}${suffix}.`;
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
+11
-142
@@ -16,128 +16,22 @@
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Number of seconds theme preferences should persist in the cookie store.
|
||||
* Apply the dark theme to the root HTML and body elements.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
var THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
|
||||
/**
|
||||
* Retrieve a cookie value by name.
|
||||
*
|
||||
* @param {string} name Cookie identifier.
|
||||
* @returns {?string} Decoded cookie value or ``null`` when absent.
|
||||
*/
|
||||
function getCookie(name) {
|
||||
var matcher = new RegExp(
|
||||
'(?:^|; )' + name.replace(/([.$?*|{}()\[\]\\/+^])/g, '\\$1') + '=([^;]*)'
|
||||
);
|
||||
var match = document.cookie.match(matcher);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert cookie options to a serialized string suitable for ``document.cookie``.
|
||||
*
|
||||
* @param {Object<string, *>} options Map of cookie attribute keys and values.
|
||||
* @returns {string} Serialized cookie attribute segment prefixed with ``; `` when non-empty.
|
||||
*/
|
||||
function formatCookieOption(pair) {
|
||||
var key = pair[0];
|
||||
var optionValue = pair[1];
|
||||
if (optionValue === true) {
|
||||
return '; ' + key;
|
||||
}
|
||||
return '; ' + key + '=' + optionValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise cookie attributes into a string consumable by ``document.cookie``.
|
||||
*
|
||||
* @param {Object<string, *>} options Map of cookie attribute keys and values.
|
||||
* @returns {string} Concatenated cookie attribute segment.
|
||||
*/
|
||||
function serializeCookieOptions(options) {
|
||||
var buffer = '';
|
||||
var source = options == null ? {} : options;
|
||||
var entries = Object.entries(source);
|
||||
for (var index = 0; index < entries.length;) {
|
||||
buffer += formatCookieOption(entries[index]);
|
||||
index += 1;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a cookie with optional attributes.
|
||||
*
|
||||
* @param {string} name Cookie identifier.
|
||||
* @param {string} value Value to store.
|
||||
* @param {Object<string, *>} [opts] Additional cookie attributes.
|
||||
* @returns {void}
|
||||
*/
|
||||
function setCookie(name, value, opts) {
|
||||
var options = Object.assign(
|
||||
{ path: '/', 'max-age': THEME_COOKIE_MAX_AGE, SameSite: 'Lax' },
|
||||
opts || {}
|
||||
);
|
||||
var updated = encodeURIComponent(name) + '=' + encodeURIComponent(value);
|
||||
updated += serializeCookieOptions(options);
|
||||
document.cookie = updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the user's preferred theme selection.
|
||||
*
|
||||
* @param {string} value Theme identifier to persist.
|
||||
* @returns {void}
|
||||
*/
|
||||
function persistTheme(value) {
|
||||
setCookie('theme', value, { 'max-age': THEME_COOKIE_MAX_AGE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the requested theme to the root HTML and body elements.
|
||||
*
|
||||
* @param {string} value Theme identifier, defaults to ``light`` unless ``dark`` is provided.
|
||||
* @returns {boolean} ``true`` when the dark theme is active.
|
||||
*/
|
||||
function applyTheme(value) {
|
||||
var themeValue = value === 'dark' ? 'dark' : 'light';
|
||||
function applyTheme() {
|
||||
var root = document.documentElement;
|
||||
var isDark = themeValue === 'dark';
|
||||
|
||||
if (root) {
|
||||
root.setAttribute('data-theme', themeValue);
|
||||
root.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
|
||||
if (document.body) {
|
||||
document.body.classList.toggle('dark', isDark);
|
||||
document.body.setAttribute('data-theme', themeValue);
|
||||
}
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the setCookie helper through a monkey-patched ``hasOwnProperty`` to keep coverage deterministic.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function exerciseSetCookieGuard() {
|
||||
var originalHasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
Object.prototype.hasOwnProperty = function alwaysFalse() {
|
||||
return false;
|
||||
};
|
||||
try {
|
||||
setCookie('probe', 'probe', { SameSite: 'Lax' });
|
||||
} finally {
|
||||
Object.prototype.hasOwnProperty = originalHasOwnProperty;
|
||||
document.body.classList.add('dark');
|
||||
document.body.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
var theme = 'dark';
|
||||
|
||||
/**
|
||||
* Initialise theme state on page load and register the ready handler.
|
||||
*
|
||||
@@ -145,12 +39,7 @@
|
||||
*/
|
||||
function bootstrap() {
|
||||
document.removeEventListener('DOMContentLoaded', handleReady);
|
||||
theme = getCookie('theme');
|
||||
if (theme !== 'dark' && theme !== 'light') {
|
||||
theme = 'dark';
|
||||
}
|
||||
persistTheme(theme);
|
||||
applyTheme(theme);
|
||||
applyTheme();
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', handleReady);
|
||||
@@ -165,12 +54,7 @@
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleReady() {
|
||||
var isDark = applyTheme(theme);
|
||||
|
||||
var btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = isDark ? '☀️' : '🌙';
|
||||
}
|
||||
applyTheme();
|
||||
|
||||
if (typeof window.applyFiltersToAllTiles === 'function') {
|
||||
window.applyFiltersToAllTiles();
|
||||
@@ -180,36 +64,21 @@
|
||||
bootstrap();
|
||||
|
||||
/**
|
||||
* Testing hooks exposing cookie helpers for integration tests.
|
||||
* Testing hooks exposing internal helpers for integration tests.
|
||||
*
|
||||
* @type {{
|
||||
* getCookie: function(string): (?string),
|
||||
* setCookie: function(string, string, Object<string, *>=): void,
|
||||
* persistTheme: function(string): void,
|
||||
* maxAge: number,
|
||||
* __testHooks: {
|
||||
* applyTheme: function(string): boolean,
|
||||
* applyTheme: function(): void,
|
||||
* handleReady: function(): void,
|
||||
* bootstrap: function(): void,
|
||||
* setTheme: function(string): void
|
||||
* bootstrap: function(): void
|
||||
* }
|
||||
* }}
|
||||
*/
|
||||
window.__themeCookie = {
|
||||
getCookie: getCookie,
|
||||
setCookie: setCookie,
|
||||
persistTheme: persistTheme,
|
||||
maxAge: THEME_COOKIE_MAX_AGE,
|
||||
__testHooks: {
|
||||
applyTheme: applyTheme,
|
||||
handleReady: handleReady,
|
||||
bootstrap: bootstrap,
|
||||
setTheme: function setTheme(value) {
|
||||
theme = value;
|
||||
},
|
||||
exerciseSetCookieGuard: exerciseSetCookieGuard,
|
||||
serializeCookieOptions: serializeCookieOptions,
|
||||
formatCookieOption: formatCookieOption
|
||||
bootstrap: bootstrap
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
+228
-234
@@ -65,6 +65,7 @@ body.dark {
|
||||
--announcement-bg: #3b2500;
|
||||
--announcement-fg: #ffd184;
|
||||
--announcement-border: #a56a00;
|
||||
--map-tiles-filter: var(--map-tile-filter-dark);
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -173,12 +174,10 @@ body {
|
||||
|
||||
.page-shell--full-screen {
|
||||
padding: 0;
|
||||
padding-bottom: 0;
|
||||
gap: 0;
|
||||
gap: var(--pad);
|
||||
}
|
||||
|
||||
.page-shell--full-screen .site-header,
|
||||
.page-shell--full-screen .row.meta,
|
||||
.page-shell--full-screen .app-footer {
|
||||
padding-left: var(--pad);
|
||||
padding-right: var(--pad);
|
||||
@@ -188,11 +187,6 @@ body {
|
||||
padding-top: var(--pad);
|
||||
}
|
||||
|
||||
.page-shell--full-screen .row.meta {
|
||||
padding-bottom: var(--pad);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.page-shell--full-screen .app-footer {
|
||||
padding-top: var(--pad);
|
||||
padding-bottom: var(--pad);
|
||||
@@ -223,6 +217,7 @@ h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
gap: 16px;
|
||||
min-height: 56px;
|
||||
padding: 4px 0;
|
||||
@@ -239,6 +234,9 @@ h1 {
|
||||
.site-header__left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.site-header__right {
|
||||
@@ -250,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;
|
||||
@@ -276,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%;
|
||||
@@ -341,7 +349,7 @@ h1 {
|
||||
.mobile-menu {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
z-index: 1400;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
@@ -510,10 +518,6 @@ h1 {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.site-header__left--federation {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: none;
|
||||
}
|
||||
@@ -1212,7 +1216,7 @@ body.dark .node-detail-overlay__close:hover {
|
||||
}
|
||||
|
||||
.charts-page {
|
||||
padding: 24px var(--pad) 48px;
|
||||
padding: 0 0 var(--pad);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
@@ -1515,76 +1519,26 @@ body.dark .node-detail__chart-grid-line {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.refresh-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refresh-info {
|
||||
margin: 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.refresh-info--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.refresh-row--no-info {
|
||||
grid-template-columns: auto;
|
||||
}
|
||||
|
||||
.refresh-row--no-info .refresh-actions {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.refresh-actions {
|
||||
.meta-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-self: end;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.auto-refresh-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls--full-screen {
|
||||
.meta-controls .filter-input {
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.controls .filter-input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 1 1 260px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.refresh-timestamp {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-input input[type="text"] {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
@@ -1719,6 +1673,19 @@ input[type="radio"] {
|
||||
transition: accent-color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
/* Wrapper that places the toggle button to the LEFT of the legend panel. */
|
||||
.legend-outer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
/* Reset Leaflet control chrome so only the inner elements have styling. */
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.legend {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
@@ -1753,7 +1720,7 @@ input[type="radio"] {
|
||||
|
||||
.legend-items--columns {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -1764,6 +1731,15 @@ input[type="radio"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend-column--bottom {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.legend-protocol-count {
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.legend-column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1800,9 +1776,9 @@ input[type="radio"] {
|
||||
}
|
||||
|
||||
.legend-item[aria-pressed="true"] {
|
||||
border-color: #3a7bd5;
|
||||
background: rgba(58, 123, 213, 0.18);
|
||||
color: #1a4a9e;
|
||||
border-color: #2a6bc5;
|
||||
background: rgba(58, 123, 213, 0.32);
|
||||
color: #0e3a8a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1825,6 +1801,10 @@ input[type="radio"] {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.nav-protocol-icon {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.legend-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1868,7 +1848,7 @@ input[type="radio"] {
|
||||
position: fixed;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 12px;
|
||||
@@ -1909,6 +1889,28 @@ input[type="radio"] {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Slim footer variant used on Charts and Federation pages — floats like the
|
||||
dashboard footer but with a transparent background and no border. */
|
||||
.app-footer--slim {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Cancel the extra block padding that .page-shell--full-screen injects on
|
||||
.app-footer — slim footers use the same visual height as the dashboard. */
|
||||
.page-shell--full-screen .app-footer--slim {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.meta-active-nodes {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-footer .footer-content {
|
||||
padding: 10px 12px;
|
||||
@@ -1926,85 +1928,6 @@ input[type="radio"] {
|
||||
}
|
||||
}
|
||||
|
||||
.info-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--pad);
|
||||
z-index: 13000;
|
||||
}
|
||||
|
||||
.info-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.info-dialog {
|
||||
background: #fff;
|
||||
color: #111;
|
||||
max-width: 420px;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
padding: 20px 24px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.info-dialog:focus {
|
||||
outline: 2px solid #4a90e2;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.info-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.info-close:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.info-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.info-intro {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.info-details {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-details dt {
|
||||
font-weight: 600;
|
||||
margin-top: 12px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.info-details dd {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.info-details dd a {
|
||||
color: inherit;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.row {
|
||||
@@ -2022,39 +1945,14 @@ input[type="radio"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.controls {
|
||||
order: 2;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls .filter-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
order: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refresh-row {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.refresh-actions {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
.meta-controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-self: start;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.meta-controls .filter-input {
|
||||
flex: 1 1 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
#map {
|
||||
@@ -2124,20 +2022,6 @@ input[type="radio"] {
|
||||
}
|
||||
}
|
||||
|
||||
body.dark {
|
||||
background: #111;
|
||||
color: #eee;
|
||||
--map-tiles-filter: var(--map-tile-filter-dark);
|
||||
}
|
||||
|
||||
body.dark .meta {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body.dark .refresh-info {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body.dark .pill {
|
||||
background: #444;
|
||||
}
|
||||
@@ -2239,9 +2123,9 @@ body.dark .legend-item:hover {
|
||||
}
|
||||
|
||||
body.dark .legend-item[aria-pressed="true"] {
|
||||
border-color: #5b9bd5;
|
||||
background: rgba(91, 155, 213, 0.28);
|
||||
color: #a8d4ff;
|
||||
border-color: #70b3f0;
|
||||
background: rgba(91, 155, 213, 0.48);
|
||||
color: #c8e8ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -2286,28 +2170,6 @@ body.dark .chat-entry-date {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body.dark .info-overlay {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
body.dark .info-dialog {
|
||||
background: #1c1c1c;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
body.dark .info-intro {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body.dark .info-details dt {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body.dark .info-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .short-info-overlay {
|
||||
background: #1c1c1c;
|
||||
border-color: #444;
|
||||
@@ -2360,16 +2222,12 @@ body.dark #map .leaflet-tile.map-tiles {
|
||||
}
|
||||
|
||||
.federation-page {
|
||||
padding: 24px var(--pad) 48px;
|
||||
padding: 0 0 var(--pad);
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.federation-page--full-width {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.federation-page__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2483,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; }
|
||||
}
|
||||
|
||||
+47
-20
@@ -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 "/"
|
||||
|
||||
@@ -1351,13 +1358,12 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response.body).to include('<meta name="twitter:image" content="http://example.org/potatomesh-logo.svg" />')
|
||||
end
|
||||
|
||||
it "disables the auto-fit toggle when a map zoom override is configured" do
|
||||
it "does not include the removed auto-fit checkbox regardless of map zoom override" do
|
||||
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(11.0)
|
||||
|
||||
get "/"
|
||||
|
||||
expect(last_response.body).to include('id="fitBounds" disabled="disabled"')
|
||||
expect(last_response.body).not_to include('id="fitBounds" checked="checked"')
|
||||
expect(last_response.body).not_to include('id="fitBounds"')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1369,21 +1375,12 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response.body).to include('class="map-panel map-panel--full"')
|
||||
expect(last_response.body).to include('id="map"')
|
||||
expect(last_response.body).to include('id="filterInput"')
|
||||
expect(last_response.body).to include('id="autoRefresh"')
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="status"')
|
||||
expect(last_response.body).to include('id="fitBounds"')
|
||||
expect(last_response.body).not_to include('id="fitBounds"')
|
||||
expect(last_response.body).not_to include('<footer class="app-footer">')
|
||||
end
|
||||
|
||||
it "disables the auto-fit toggle when a map zoom override is configured" do
|
||||
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(9.5)
|
||||
|
||||
get "/map"
|
||||
|
||||
expect(last_response.body).to include('id="fitBounds" disabled="disabled"')
|
||||
expect(last_response.body).not_to include('id="fitBounds" checked="checked"')
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /federation" do
|
||||
@@ -1401,11 +1398,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
get "/federation"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).to include('class="federation-page federation-page--full-width"')
|
||||
expect(last_response.body).to include('class="federation-page"')
|
||||
expect(last_response.body).to include("initializeFederationPage")
|
||||
end
|
||||
|
||||
it "hides dashboard-only refresh controls while keeping manual refresh and theme toggle" do
|
||||
it "hides the meta-controls row entirely on the federation page" do
|
||||
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
|
||||
|
||||
get "/federation"
|
||||
@@ -1413,8 +1410,18 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).not_to include('id="filterInput"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="themeToggle"')
|
||||
expect(last_response.body).not_to include('id="refreshBtn"')
|
||||
expect(last_response.body).not_to include('id="themeToggle"')
|
||||
expect(last_response.body).not_to include('id="metaRow"')
|
||||
end
|
||||
|
||||
it "renders the slim footer on the federation page" do
|
||||
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
|
||||
|
||||
get "/federation"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).to include('class="app-footer app-footer--slim"')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1425,7 +1432,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).to include('class="chat-panel chat-panel--full"')
|
||||
expect(last_response.body).to include('id="filterInput"')
|
||||
expect(last_response.body).to include('id="autoRefresh"')
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="status"')
|
||||
expect(last_response.body).not_to include('<footer class="app-footer">')
|
||||
@@ -1449,13 +1456,25 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response.body).to include('class="nodes-table-wrapper"')
|
||||
expect(last_response.body).to include('id="nodes"')
|
||||
expect(last_response.body).to include('id="filterInput"')
|
||||
expect(last_response.body).to include('id="autoRefresh"')
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="status"')
|
||||
expect(last_response.body).not_to include('<footer class="app-footer">')
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /charts" do
|
||||
it "renders the charts page with the slim footer but without meta-controls" do
|
||||
get "/charts"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).to include("initializeChartsPage")
|
||||
expect(last_response.body).not_to include('id="metaRow"')
|
||||
expect(last_response.body).not_to include('id="filterInput"')
|
||||
expect(last_response.body).to include('class="app-footer app-footer--slim"')
|
||||
end
|
||||
end
|
||||
|
||||
describe "database initialization" do
|
||||
it "creates the schema when booting" do
|
||||
expect(File).to exist(PotatoMesh::Config.db_path)
|
||||
@@ -6968,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.
|
||||
|
||||
@@ -158,8 +158,8 @@ RSpec.describe PotatoMesh::App::Helpers do
|
||||
expect(helper.meshcore_companion_display_short_name(" ")).to be_nil
|
||||
end
|
||||
|
||||
it "returns ' A ' for a single-word name" do
|
||||
expect(helper.meshcore_companion_display_short_name("Alice")).to eq(" A ")
|
||||
it "returns nil for a single-word name (falls back to raw DB short name)" do
|
||||
expect(helper.meshcore_companion_display_short_name("Alice")).to be_nil
|
||||
end
|
||||
|
||||
it "returns ' AB ' for a two-word name" do
|
||||
@@ -203,8 +203,8 @@ RSpec.describe PotatoMesh::App::Helpers do
|
||||
expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ")
|
||||
end
|
||||
|
||||
it "returns the single initial when the name is one word with no emoji" do
|
||||
expect(helper.meshcore_companion_display_short_name("Zigzag")).to eq(" Z ")
|
||||
it "returns nil for a single-word name with no emoji (falls back to raw DB short name)" do
|
||||
expect(helper.meshcore_companion_display_short_name("Zigzag")).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -434,7 +434,7 @@ RSpec.describe PotatoMesh::App::Queries do
|
||||
expect(row["short_name"]).to eq(" AB ")
|
||||
end
|
||||
|
||||
it "derives a single-initial short name for a COMPANION node with a one-word long name" do
|
||||
it "uses the raw DB short name for a COMPANION node with a single-word long name" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
@@ -444,7 +444,20 @@ RSpec.describe PotatoMesh::App::Queries do
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000002")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000002" }
|
||||
expect(row["short_name"]).to eq(" Z ")
|
||||
expect(row["short_name"]).to eq("CX")
|
||||
end
|
||||
|
||||
it "falls back to first four hex chars of the node ID when DB short name is empty for COMPANION" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
["!cc000009", 0xcc000009, "", "Feierabend", now, now, "COMPANION"],
|
||||
)
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000009")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000009" }
|
||||
expect(row["short_name"]).to eq("cc00")
|
||||
end
|
||||
|
||||
it "derives an emoji short name for a COMPANION node whose long name contains an emoji" do
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
-->
|
||||
<section class="charts-page">
|
||||
<header class="charts-page__intro">
|
||||
<h2>Network telemetry trends</h2>
|
||||
<p>Aggregated telemetry snapshots from every node in the past week.</p>
|
||||
<h2><img src="/assets/img/meshtastic.svg" alt="" aria-hidden="true" width="20" height="20" class="protocol-icon protocol-icon--meshtastic" loading="lazy" decoding="async" /> Network telemetry trends</h2>
|
||||
<p>Aggregated telemetry snapshots from every Meshtastic node in the past week.</p>
|
||||
</header>
|
||||
<div id="chartsPage" class="charts-page__content">
|
||||
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<section class="federation-page federation-page--full-width">
|
||||
<section class="federation-page">
|
||||
<div class="federation-page__content">
|
||||
<div class="federation-page__map-row">
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
|
||||
|
||||
+30
-111
@@ -14,7 +14,7 @@
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<html lang="en" data-theme="<%= initial_theme %>">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta charset="utf-8" />
|
||||
@@ -65,10 +65,10 @@
|
||||
crossorigin=""
|
||||
></script>
|
||||
</head>
|
||||
<% body_classes = []
|
||||
body_classes << "dark" if initial_theme == "dark"
|
||||
<% body_classes = ["dark"]
|
||||
view_mode = (defined?(current_view_mode) && current_view_mode) ? current_view_mode.to_sym : :dashboard
|
||||
full_screen_view = view_mode != :dashboard
|
||||
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
|
||||
@@ -76,25 +76,13 @@
|
||||
main_classes << "page-main--dashboard" if view_mode == :dashboard
|
||||
main_classes << "page-main--full-screen" if full_screen_view
|
||||
show_header = true
|
||||
show_meta_info = true
|
||||
show_auto_refresh_controls = view_mode != :federation
|
||||
show_auto_fit_toggle = %i[dashboard map].include?(view_mode)
|
||||
map_zoom_override = defined?(map_zoom) ? map_zoom : nil
|
||||
show_info_button = true
|
||||
show_footer = !full_screen_view
|
||||
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
|
||||
show_auto_refresh_toggle = show_auto_refresh_controls
|
||||
show_refresh_actions = show_auto_refresh_controls || view_mode == :federation
|
||||
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
|
||||
controls_classes = ["controls"]
|
||||
controls_classes << "controls--full-screen" if full_screen_view
|
||||
refresh_row_classes = ["refresh-row"]
|
||||
refresh_info_text = full_screen_view ? nil : "#{channel} (#{frequency}) — active nodes: …"
|
||||
refresh_row_classes << "refresh-row--no-info" if refresh_info_text.nil?
|
||||
refresh_info_classes = ["refresh-info"]
|
||||
refresh_info_classes << "refresh-info--hidden" if refresh_info_text.nil?
|
||||
announcement_markup = announcement_html %>
|
||||
<body
|
||||
class="<%= body_classes.join(" ") %>"
|
||||
@@ -110,10 +98,12 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<header class="site-header">
|
||||
<div class="site-header__left<%= federation_nav_enabled ? " site-header__left--federation" : "" %>">
|
||||
<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">
|
||||
@@ -132,10 +122,14 @@
|
||||
<a href="/map" class="site-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
|
||||
<a href="/chat" class="site-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
|
||||
<a href="<%= nodes_nav_href %>" class="site-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
|
||||
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
|
||||
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>><img src="/assets/img/meshtastic.svg" alt="" width="13" height="13" class="protocol-icon protocol-icon--meshtastic nav-protocol-icon" aria-hidden="true" loading="lazy" decoding="async" /> Charts</a>
|
||||
<% 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"
|
||||
@@ -163,84 +157,32 @@
|
||||
<a href="/map" class="mobile-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
|
||||
<a href="/chat" class="mobile-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
|
||||
<a href="<%= nodes_nav_href %>" class="mobile-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
|
||||
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
|
||||
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>><img src="/assets/img/meshtastic.svg" alt="" width="13" height="13" class="protocol-icon protocol-icon--meshtastic nav-protocol-icon" aria-hidden="true" loading="lazy" decoding="async" /> Charts</a>
|
||||
<% 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>
|
||||
<% end %>
|
||||
|
||||
<div id="metaRow" class="row meta">
|
||||
<% if show_meta_info %>
|
||||
<div class="meta-info">
|
||||
<div class="<%= refresh_row_classes.join(" ") %>">
|
||||
<% if refresh_info_text %>
|
||||
<p id="refreshInfo" class="refresh-info" aria-live="polite"><%= refresh_info_text %></p>
|
||||
<% else %>
|
||||
<p id="refreshInfo" class="<%= refresh_info_classes.join(" ") %>" aria-live="polite"></p>
|
||||
<% end %>
|
||||
<% if show_refresh_actions %>
|
||||
<div class="refresh-actions">
|
||||
<% if show_auto_refresh_toggle %>
|
||||
<label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
|
||||
<% end %>
|
||||
<button id="refreshBtn" type="button">Refresh now</button>
|
||||
<span id="status" class="pill">loading…</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="<%= controls_classes.join(" ") %>">
|
||||
<% if show_auto_fit_toggle %>
|
||||
<% auto_fit_attrs = [] %>
|
||||
<% if map_zoom_override.nil? %>
|
||||
<% auto_fit_attrs << 'checked="checked"' %>
|
||||
<% else %>
|
||||
<% auto_fit_attrs << 'disabled="disabled"' %>
|
||||
<% auto_fit_attrs << 'aria-disabled="true"' %>
|
||||
<% end %>
|
||||
<label><input type="checkbox" id="fitBounds" <%= auto_fit_attrs.join(" ") %> /> Auto-fit map</label>
|
||||
<% end %>
|
||||
<% if show_meta_row %>
|
||||
<div id="metaRow" class="meta-controls">
|
||||
<% if show_filter_input %>
|
||||
<div class="filter-input">
|
||||
<input type="text" id="filterInput" placeholder="Filter nodes" />
|
||||
<button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>×</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<button id="themeToggle" class="icon-button" type="button" aria-label="Toggle dark mode"><span aria-hidden="true">🌙</span></button>
|
||||
<% if show_info_button %>
|
||||
<button id="infoBtn" class="icon-button" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information"><span aria-hidden="true">ℹ️</span></button>
|
||||
<% end %>
|
||||
<span id="footerActiveNodes" class="meta-active-nodes"></span>
|
||||
<button id="refreshBtn" type="button">Refresh</button>
|
||||
<span id="status" class="refresh-timestamp" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="infoOverlay" class="info-overlay" role="dialog" aria-modal="true" aria-labelledby="infoTitle" hidden>
|
||||
<div class="info-dialog" tabindex="-1">
|
||||
<button type="button" class="info-close" id="infoClose" aria-label="Close site information">×</button>
|
||||
<h2 id="infoTitle" class="info-title">About <%= site_name %></h2>
|
||||
<dl class="info-details">
|
||||
<dt>Channel</dt>
|
||||
<dd><%= channel %></dd>
|
||||
<dt>Frequency</dt>
|
||||
<dd><%= frequency %></dd>
|
||||
<dt>Map center</dt>
|
||||
<dd><%= format("%.5f, %.5f", map_center_lat, map_center_lon) %></dd>
|
||||
<dt>Visible range</dt>
|
||||
<dd>Nodes within roughly <%= max_distance_km %> km of the center are shown.</dd>
|
||||
<% if contact_link && !contact_link.empty? %>
|
||||
<dt>Chat</dt>
|
||||
<% if contact_link_url %>
|
||||
<dd><a href="<%= contact_link_url %>" target="_blank" rel="noreferrer noopener"><%= contact_link %></a></dd>
|
||||
<% else %>
|
||||
<dd><%= contact_link %></dd>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div id="nodeDetailOverlay" class="node-detail-overlay" hidden>
|
||||
<div class="node-detail-overlay__dialog" role="dialog" aria-modal="true" aria-labelledby="nodeDetailOverlayHeader" tabindex="-1">
|
||||
@@ -257,30 +199,7 @@
|
||||
</main>
|
||||
|
||||
<% if show_footer %>
|
||||
<footer class="app-footer">
|
||||
<div class="footer-content">
|
||||
<span class="footer-brand">PotatoMesh</span>
|
||||
<% if version && !version.empty? %>
|
||||
<span class="mono"><%= version %></span>
|
||||
<% end %>
|
||||
<span class="footer-separator" aria-hidden="true">—</span>
|
||||
<span class="footer-links">
|
||||
GitHub:
|
||||
<a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
|
||||
<% if contact_link && !contact_link.empty? %>
|
||||
<span class="footer-separator" aria-hidden="true">—</span>
|
||||
<span class="footer-contact">
|
||||
<%= site_name %> chat:
|
||||
<% if contact_link_url %>
|
||||
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
|
||||
<% else %>
|
||||
<%= contact_link %>
|
||||
<% end %>
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
<%= erb :"shared/_footer", locals: { site_name: site_name, version: version, contact_link: contact_link, contact_link_url: contact_link_url, slim: footer_slim } %>
|
||||
<% end %>
|
||||
</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>
|
||||
@@ -0,0 +1,40 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<% slim = defined?(slim) && slim %>
|
||||
<footer class="app-footer<%= slim ? " app-footer--slim" : "" %>">
|
||||
<div class="footer-content">
|
||||
<span class="footer-brand">PotatoMesh</span>
|
||||
<% if version && !version.empty? %>
|
||||
<span class="mono"><%= version %></span>
|
||||
<% end %>
|
||||
<span class="footer-separator" aria-hidden="true">—</span>
|
||||
<span class="footer-links">
|
||||
GitHub:
|
||||
<a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
|
||||
<% if contact_link && !contact_link.empty? %>
|
||||
<span class="footer-separator" aria-hidden="true">—</span>
|
||||
<span class="footer-contact">
|
||||
<%= site_name %> chat:
|
||||
<% if contact_link_url %>
|
||||
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
|
||||
<% else %>
|
||||
<%= contact_link %>
|
||||
<% end %>
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
Reference in New Issue
Block a user