web: add markdown static pages (#723)

* web: add markdown static pages

* web: add tests and docker

* web: improve wording and configs

* web: add tests

* web: address review comments

* web: address review comments

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

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

* web: address review comments

* web: address review comments

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
l5yandCopilot Autofix powered by AI authored and GitHub committed 2026-04-08 16:42:13 +02:00
1 parent 083de6418f
commit 81e588e44c
16 files changed
+1088 -6

No files matched your search

+3
View File
@@ -74,6 +74,9 @@ web/.config
node_modules/
web/node_modules/
# Operator-customised static pages (keep only the shipped default)
web/pages/*.md
# Debug symbols
ignored.txt
ignored-*.txt
+12
View File
@@ -81,6 +81,18 @@ the container. This path stores the instance private key and staged
of container lifecycle events, generated credentials are not replaced on reboot
or re-deploy.
The `potatomesh_pages` volume mounts to `/app/pages` and holds operator-managed
Markdown files that are rendered as static content pages in the web UI. On first
start the default `1-about.md` page is copied from the image into the volume.
You can add, edit, or remove `.md` files in this volume to customise your
instance's navigation. To use a host directory instead of a named volume, replace
the volume entry with a bind mount:
```yaml
volumes:
- ./my-pages:/app/pages
```
## Start the stack
From the directory containing the Compose file:
+22
View File
@@ -128,6 +128,28 @@ well-known document is staged in
The database can be found in `$XDG_DATA_HOME/potato-mesh`.
### Custom Pages
Instance operators can publish static content pages (contact details, mesh
protocol information, legal notices, etc.) by placing Markdown files in the
`pages/` directory inside `web/`. Each `.md` file automatically becomes a nav
entry and a route under `/pages/<slug>`.
Files are named `<sort-prefix>-<slug>.md` — the numeric prefix controls
navigation order and the slug becomes the URL path and nav label:
| Filename | Nav Label | URL |
| ---------------------- | -------------- | ----------------------- |
| `1-about.md` | About | `/pages/about` |
| `5-rules.md` | Rules | `/pages/rules` |
| `9-contact.md` | Contact | `/pages/contact` |
| `20-impressum.md` | Impressum | `/pages/impressum` |
A default `1-about.md` ships with the app. In Docker deployments the directory
is exposed as the `potatomesh_pages` volume (mounted at `/app/pages`) so you can
add or edit pages without rebuilding the image. The pages directory can also be
overridden with the `PAGES_DIR` environment variable.
### Federation
PotatoMesh instances can optionally federate by publishing signed metadata and
+3
View File
@@ -34,6 +34,7 @@ x-web-base: &web-base
- potatomesh_data:/app/.local/share/potato-mesh
- potatomesh_config:/app/.config/potato-mesh
- potatomesh_logs:/app/logs
- potatomesh_pages:/app/pages
restart: unless-stopped
deploy:
resources:
@@ -160,6 +161,8 @@ volumes:
driver: local
potatomesh_logs:
driver: local
potatomesh_pages:
driver: local
potatomesh_matrix_bridge_state:
driver: local
+3 -1
View File
@@ -76,6 +76,7 @@ COPY --chown=potatomesh:potatomesh web/spec ./spec
COPY --chown=potatomesh:potatomesh web/public ./public
COPY --chown=potatomesh:potatomesh web/views ./views
COPY --chown=potatomesh:potatomesh web/scripts ./scripts
COPY --chown=potatomesh:potatomesh web/pages ./pages
# Copy SQL schema files from data directory
COPY --chown=potatomesh:potatomesh data/*.sql /data/
@@ -84,7 +85,8 @@ COPY --chown=potatomesh:potatomesh data/mesh_ingestor/decode_payload.py /app/dat
# Create data and configuration directories with correct ownership
RUN mkdir -p /app/.local/share/potato-mesh \
&& mkdir -p /app/.config/potato-mesh/well-known \
&& chown -R potatomesh:potatomesh /app/.local/share /app/.config
&& mkdir -p /app/pages \
&& chown -R potatomesh:potatomesh /app/.local/share /app/.config /app/pages
# Switch to non-root user
USER potatomesh
+4
View File
@@ -20,6 +20,8 @@ gem "sqlite3", "~> 1.7"
gem "rackup", "~> 2.2"
gem "puma", "~> 7.0"
gem "prometheus-client"
gem "kramdown", "~> 2.4"
gem "kramdown-parser-gfm", "~> 1.1"
group :test do
gem "rspec", "~> 3.12"
@@ -29,3 +31,5 @@ group :test do
gem "simplecov_json_formatter", "~> 0.1", require: false
gem "rspec_junit_formatter", "~> 0.6", require: false
end
gem "sanitize", "7.0.0"
+4
View File
@@ -57,6 +57,7 @@ require_relative "application/meshtastic/cipher"
require_relative "application/meshtastic/payload_decoder"
require_relative "application/data_processing"
require_relative "application/filesystem"
require_relative "application/pages"
require_relative "application/instances"
require_relative "application/routes/api"
require_relative "application/routes/ingest"
@@ -74,6 +75,7 @@ module PotatoMesh
extend App::Queries
extend App::DataProcessing
extend App::Filesystem
extend App::Pages
helpers App::Helpers
include App::Database
@@ -85,6 +87,7 @@ module PotatoMesh
include App::Queries
include App::DataProcessing
include App::Filesystem
include App::Pages
register App::Routes::Api
register App::Routes::Ingest
@@ -210,6 +213,7 @@ SELF_INSTANCE_ID = PotatoMesh::Application::SELF_INSTANCE_ID unless defined?(SEL
PotatoMesh::App::Prometheus,
PotatoMesh::App::Queries,
PotatoMesh::App::DataProcessing,
PotatoMesh::App::Pages,
].each do |mod|
Object.include(mod) unless Object < mod
end
+226
View File
@@ -0,0 +1,226 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "kramdown"
require "kramdown-parser-gfm"
require "sanitize"
module PotatoMesh
module App
# Discovers, parses, and renders operator-managed Markdown pages from the
# configured pages directory. Files are named with an optional numeric
# prefix for ordering (e.g. +1-about.md+, +9-contact.md+) and exposed as
# navigable routes under +/pages/:slug+.
module Pages
module_function
# Lightweight value object describing a single static page discovered on
# disk. Fields are populated by {parse_page_filename} and consumed by
# route handlers and layout templates.
#
# @!attribute [r] sort_key
# @return [String] filename stem used for alphabetical ordering.
# @!attribute [r] slug
# @return [String] URL-safe identifier derived from the filename.
# @!attribute [r] title
# @return [String] human-readable nav label.
# @!attribute [r] path
# @return [String] absolute filesystem path to the Markdown source.
PageEntry = Struct.new(:sort_key, :slug, :title, :path, keyword_init: true)
# Pattern matching a safe slug segment: lowercase alphanumeric words
# separated by single hyphens. Used to validate both parsed slugs and
# incoming route parameters.
SLUG_PATTERN = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
# Pattern used to split a page filename into an optional numeric sort
# prefix and the slug portion.
FILENAME_PATTERN = /\A(\d+)-(.+)\z/
# Maximum number of pages loaded from disk. Prevents accidental
# directory-bomb scenarios from consuming unbounded memory.
MAX_PAGES = 50
# Kramdown options shared across all page renders.
KRAMDOWN_OPTIONS = {
input: "GFM",
hard_wrap: false,
}.freeze
# HTML tags allowed in rendered markdown output. Tags not in this list
# are stripped after rendering to prevent XSS from operator content.
ALLOWED_TAGS = Set.new(%w[
h1 h2 h3 h4 h5 h6 p a em strong b i u s del code pre br hr
ul ol li dl dt dd blockquote table thead tbody tfoot tr th td
img span div sup sub abbr mark small details summary
]).freeze
@pages_cache = nil
@pages_cache_mutex = Mutex.new
# Parse a Markdown filename into a {PageEntry} without the filesystem
# path populated.
#
# Filenames are expected to follow the pattern +<digits>-<slug>.md+ where
# the numeric prefix controls navigation order. Files without a prefix
# are accepted, using the full stem as both sort key and slug.
#
# @param basename [String] bare filename (e.g. +"9-contact.md"+).
# @return [PageEntry, nil] parsed entry or +nil+ when the filename is
# invalid or contains an unsafe slug.
def parse_page_filename(basename)
stem = basename.sub(/\.md\z/i, "")
return nil if stem.empty?
match = stem.match(FILENAME_PATTERN)
if match
slug = match[2].downcase
sort_key = stem
else
slug = stem.downcase
sort_key = stem
end
return nil unless slug.match?(SLUG_PATTERN)
title = slug.split("-").map(&:capitalize).join(" ")
PageEntry.new(sort_key: sort_key, slug: slug, title: title, path: nil)
end
# Scan the pages directory and return a sorted list of page entries.
#
# The directory is read once per call; results are not cached here (see
# {static_pages} for the cached interface). Non-+.md+ files and entries
# with invalid filenames are silently skipped.
#
# @param directory [String] absolute path to the pages directory.
# @return [Array<PageEntry>] frozen, sort-key-ordered list of pages.
def load_static_pages(directory = PotatoMesh::Config.pages_directory)
return [].freeze unless directory && File.directory?(directory)
entries = Dir.glob(File.join(directory, "*.md")).filter_map do |path|
basename = File.basename(path)
entry = parse_page_filename(basename)
next unless entry
PageEntry.new(
sort_key: entry.sort_key,
slug: entry.slug,
title: entry.title,
path: path,
)
end
entries.sort_by!(&:sort_key)
entries.uniq!(&:slug)
entries.take(MAX_PAGES).freeze
end
# Return the current set of static pages, reloading from disk when the
# cache has expired.
#
# The TTL is short in non-production environments (1 second) so that
# newly added files appear almost immediately during development.
#
# @return [Array<PageEntry>] cached page entries.
def static_pages
@pages_cache_mutex.synchronize do
if @pages_cache.nil? || Time.now > @pages_cache[:expires_at]
ttl = production_environment? ? 60 : 1
@pages_cache = {
entries: load_static_pages,
expires_at: Time.now + ttl,
}
end
@pages_cache[:entries]
end
end
# Look up a page entry by its URL slug.
#
# @param slug [String] URL slug to search for.
# @return [PageEntry, nil] matching entry or +nil+.
def find_page_by_slug(slug)
static_pages.find { |entry| entry.slug == slug }
end
# Read and render a page's Markdown source to HTML.
#
# Files exceeding {Config.max_page_file_bytes} are rejected to guard
# against accidental out-of-memory conditions. Raw HTML blocks are
# disabled at the parser level to prevent XSS.
#
# @param page_entry [PageEntry] entry whose +path+ points to the source.
# @return [String, nil] sanitised HTML string, or +nil+ when the file
# cannot be read.
def render_page_content(page_entry)
return nil unless page_entry&.path
return nil unless File.file?(page_entry.path) && File.readable?(page_entry.path)
size = File.size(page_entry.path)
return nil if size > PotatoMesh::Config.max_page_file_bytes
content = File.read(page_entry.path, encoding: "utf-8")
raw_html = Kramdown::Document.new(content, **KRAMDOWN_OPTIONS).to_html
strip_unsafe_html(raw_html)
rescue SystemCallError
nil
end
# Remove HTML tags not present in {ALLOWED_TAGS} and strip dangerous
# attributes (event handlers, javascript: URIs) from the rendered output.
# This provides a safety net against XSS when operators include raw HTML
# in their Markdown source.
#
# @param html [String] raw HTML produced by kramdown.
# @return [String] HTML with disallowed tags and attributes stripped.
def strip_unsafe_html(html)
# Delegate to the sanitize gem for robust HTML and attribute
# sanitization instead of relying on ad-hoc regular expressions.
Sanitize.fragment(
html,
elements: ALLOWED_TAGS,
attributes: {
:all => %w[id class title alt],
"a" => %w[href],
"img" => %w[src width height loading decoding],
},
protocols: {
"a" => { "href" => ["http", "https", "mailto"] },
"img" => { "src" => ["http", "https"] },
},
)
end
# Invalidate the in-memory page cache so the next call to
# {static_pages} re-scans the directory. Intended for test teardown.
#
# @return [void]
def clear_pages_cache!
@pages_cache_mutex.synchronize { @pages_cache = nil }
end
# Determine whether the application is running in a production-like
# environment.
#
# @return [Boolean] true when +RACK_ENV+ or +APP_ENV+ is +"production"+.
def production_environment?
%w[production].include?(ENV.fetch("RACK_ENV", nil)) ||
%w[production].include?(ENV.fetch("APP_ENV", nil))
end
end
end
end
@@ -59,6 +59,7 @@ module PotatoMesh
initial_theme: theme,
current_view_mode: view_mode_sym,
map_zoom: PotatoMesh::Config.map_zoom,
static_pages: PotatoMesh::App::Pages.static_pages,
}
sanitized_locals = extra_locals.is_a?(Hash) ? extra_locals : {}
merged_locals = base_locals.merge(sanitized_locals)
@@ -180,6 +181,26 @@ module PotatoMesh
render_root_view(:federation, view_mode: :federation)
end
app.get "/pages/:slug" do
slug = params.fetch("slug", "")
halt 400, "Bad Request" unless slug.match?(PotatoMesh::App::Pages::SLUG_PATTERN)
page = PotatoMesh::App::Pages.find_page_by_slug(slug)
halt 404, "Not Found" unless page
page_html = PotatoMesh::App::Pages.render_page_content(page)
halt 500, "Internal Server Error" unless page_html
render_root_view(
:page,
view_mode: :"page_#{slug}",
extra_locals: {
page_title: page.title,
page_content_html: page_html,
},
)
end
app.get "/nodes/:id" do
node_ref = params.fetch("id", nil)
reference_payload = build_node_detail_reference(node_ref)
+20
View File
@@ -84,6 +84,26 @@ module PotatoMesh
value.to_s.strip != "0"
end
# Resolve the absolute path to the operator-managed static pages directory.
#
# The directory defaults to +pages/+ at the application root and can be
# overridden with the +PAGES_DIR+ environment variable.
#
# @return [String] absolute filesystem path to the pages directory.
def pages_directory
custom = fetch_string("PAGES_DIR", nil)
return File.expand_path(custom) if custom
File.join(web_root, "pages")
end
# Maximum file size in bytes accepted when reading a static page.
#
# @return [Integer] byte ceiling for markdown files.
def max_page_file_bytes
512 * 1024
end
# Resolve the absolute path to the web application root directory.
#
# @return [String] absolute filesystem path of the web folder.
+73
View File
@@ -0,0 +1,73 @@
# About This Mesh
Welcome to this [PotatoMesh](https://github.com/l5yth/potato-mesh) instance - a community dashboard for off-grid mesh networks. This is an example page, please modify it before deploying.
## What Is Meshtastic?
[Meshtastic](https://meshtastic.org) is an open-source project that turns
affordable LoRa radios into a decentralised, long-range communication network.
No cellular service or internet connection is required - nodes relay messages
across the mesh automatically.
## What Is Meshcore?
[Meshcore](https://meshcore.co.uk) is a firmware for LoRa radios focused on
reliable, low-power mesh networking. It provides a public channel system and
supports narrow-band presets optimised for long range in dense environments.
## Network Details
| Setting | Meshtastic | Meshcore |
| --------- | --------------- | ----------------- |
| Channel | #MediumFast | Public |
| Frequency | 869.525 MHz | 869.618 MHz |
| Bandwidth | 250 kHz | 62.5 kHz |
| SF | 8 | 8 |
| CR | 4/5 | 4/8 |
| Preset | Medium / Fast | EU/UK Narrow |
> Adjust this table to match the configuration of your local mesh.
## Contact
- **Public chat:** [#potatomesh:dod.ngo](https://matrix.to/#/#potatomesh:dod.ngo)
- **Source code:** [github.com/l5yth/potato-mesh](https://github.com/l5yth/potato-mesh)
## Custom Pages
Instance operators can add, edit, or remove pages by placing Markdown files in
the `pages/` directory (mounted as a Docker volume at `/app/pages`). Each file
becomes a new entry in the navigation bar.
### Filename Convention
```
<sort-prefix>-<slug>.md
```
- **Sort prefix** - a number that controls the order in the nav bar (e.g. `1`,
`5`, `10`). Files are sorted alphabetically by their full filename.
- **Slug** - lowercase, hyphen-separated words that become the URL path and nav
label. `contact` becomes `/pages/contact` with the label "Contact";
`privacy-policy` becomes `/pages/privacy-policy` labelled "Privacy Policy".
### Examples
| Filename | Nav Label | URL |
| --------------------- | ---------------- | ---------------------- |
| `1-about.md` | About | `/pages/about` |
| `5-rules.md` | Rules | `/pages/rules` |
| `9-contact.md` | Contact | `/pages/contact` |
| `10-privacy-policy.md`| Privacy Policy | `/pages/privacy-policy`|
### Impressum / Legal Notice
Operators subject to legal disclosure requirements (e.g. the German
Telemediengesetz) can create an `impressum.md` page:
```
20-impressum.md
```
Fill it with your legally required contact details - name, address, email, phone
- and it will appear in the navigation as "Impressum".
+137
View File
@@ -248,6 +248,7 @@ h1 {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
height: 1.6em;
padding: 0 var(--pad);
border-radius: 999px;
@@ -2340,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; }
}
+25
View File
@@ -627,6 +627,31 @@ RSpec.describe PotatoMesh::Config do
end
end
describe ".pages_directory" do
it "defaults to pages/ under the web root" do
within_env("PAGES_DIR" => nil) do
expect(described_class.pages_directory).to eq(
File.join(described_class.web_root, "pages"),
)
end
end
it "uses PAGES_DIR when set" do
Dir.mktmpdir do |dir|
within_env("PAGES_DIR" => dir) do
expect(described_class.pages_directory).to eq(File.expand_path(dir))
end
end
end
end
describe ".max_page_file_bytes" do
it "returns a positive integer" do
expect(described_class.max_page_file_bytes).to be_a(Integer)
expect(described_class.max_page_file_bytes).to be > 0
end
end
# Execute the provided block with temporary environment overrides.
#
# @param values [Hash{String=>String, nil}] key/value pairs to set in ENV.
+501
View File
@@ -0,0 +1,501 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
RSpec.describe PotatoMesh::App::Pages do
let(:pages_dir) { File.join(SPEC_TMPDIR, "pages-#{SecureRandom.hex(4)}") }
before do
FileUtils.mkdir_p(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
after do
FileUtils.rm_rf(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
# ── parse_page_filename ──────────────────────────────────────
describe ".parse_page_filename" do
it "parses a numeric-prefixed filename" do
entry = described_class.parse_page_filename("9-contact.md")
expect(entry).not_to be_nil
expect(entry.sort_key).to eq("9-contact")
expect(entry.slug).to eq("contact")
expect(entry.title).to eq("Contact")
end
it "parses a multi-word slug" do
entry = described_class.parse_page_filename("10-privacy-policy.md")
expect(entry.slug).to eq("privacy-policy")
expect(entry.title).to eq("Privacy Policy")
end
it "parses a filename without numeric prefix" do
entry = described_class.parse_page_filename("readme.md")
expect(entry).not_to be_nil
expect(entry.sort_key).to eq("readme")
expect(entry.slug).to eq("readme")
expect(entry.title).to eq("Readme")
end
it "parses a multi-digit prefix" do
entry = described_class.parse_page_filename("100-faq.md")
expect(entry.sort_key).to eq("100-faq")
expect(entry.slug).to eq("faq")
expect(entry.title).to eq("Faq")
end
it "rejects empty basename" do
expect(described_class.parse_page_filename(".md")).to be_nil
end
it "downcases uppercase slugs" do
entry = described_class.parse_page_filename("1-About.md")
expect(entry).not_to be_nil
expect(entry.slug).to eq("about")
end
it "rejects slugs with underscores" do
expect(described_class.parse_page_filename("1-my_page.md")).to be_nil
end
it "rejects slugs with path traversal" do
expect(described_class.parse_page_filename("../../etc.md")).to be_nil
end
it "rejects slugs starting with a hyphen" do
expect(described_class.parse_page_filename("1--bad.md")).to be_nil
end
it "rejects slugs ending with a hyphen" do
expect(described_class.parse_page_filename("bad-.md")).to be_nil
end
it "sets path to nil" do
entry = described_class.parse_page_filename("1-about.md")
expect(entry.path).to be_nil
end
end
# ── load_static_pages ────────────────────────────────────────
describe ".load_static_pages" do
it "returns an empty array when the directory does not exist" do
result = described_class.load_static_pages("/nonexistent/dir")
expect(result).to eq([])
expect(result).to be_frozen
end
it "returns an empty array when the directory argument is nil" do
result = described_class.load_static_pages(nil)
expect(result).to eq([])
expect(result).to be_frozen
end
it "returns an empty array when the directory is empty" do
result = described_class.load_static_pages(pages_dir)
expect(result).to eq([])
end
it "discovers and sorts markdown files" do
File.write(File.join(pages_dir, "5-beta.md"), "# Beta")
File.write(File.join(pages_dir, "1-alpha.md"), "# Alpha")
File.write(File.join(pages_dir, "9-gamma.md"), "# Gamma")
result = described_class.load_static_pages(pages_dir)
expect(result.map(&:slug)).to eq(%w[alpha beta gamma])
expect(result.map(&:sort_key)).to eq(%w[1-alpha 5-beta 9-gamma])
end
it "populates the path field" do
File.write(File.join(pages_dir, "1-test.md"), "# Test")
result = described_class.load_static_pages(pages_dir)
expect(result.first.path).to eq(File.join(pages_dir, "1-test.md"))
end
it "ignores non-md files" do
File.write(File.join(pages_dir, "1-about.md"), "# About")
File.write(File.join(pages_dir, "notes.txt"), "text")
File.write(File.join(pages_dir, "image.png"), "binary")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.slug).to eq("about")
end
it "skips files with invalid filenames" do
File.write(File.join(pages_dir, "1-good.md"), "# Good")
File.write(File.join(pages_dir, "1-bad_name.md"), "# Bad")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.slug).to eq("good")
end
it "deduplicates entries with the same slug keeping the first" do
File.write(File.join(pages_dir, "1-about.md"), "# First")
File.write(File.join(pages_dir, "2-about.md"), "# Second")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.sort_key).to eq("1-about")
end
it "limits entries to MAX_PAGES" do
(1..55).each do |i|
File.write(File.join(pages_dir, "#{i}-page#{i}.md"), "# Page #{i}")
end
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(PotatoMesh::App::Pages::MAX_PAGES)
end
it "returns a frozen array" do
File.write(File.join(pages_dir, "1-test.md"), "# Test")
result = described_class.load_static_pages(pages_dir)
expect(result).to be_frozen
end
end
# ── render_page_content ──────────────────────────────────────
describe ".render_page_content" do
it "renders markdown headings to HTML" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "# Hello World\n\nSome text.")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<h1")
expect(html).to include("Hello World")
expect(html).to include("<p>Some text.</p>")
end
it "renders links" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "[example](https://example.com)")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include('href="https://example.com"')
expect(html).to include("example")
end
it "renders fenced code blocks" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "```\ncode here\n```")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<code")
expect(html).to include("code here")
end
it "renders tables" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "| A | B |\n| - | - |\n| 1 | 2 |")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<table")
expect(html).to include("<td>")
end
it "does not pass through raw HTML script tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "<script>alert('xss')</script>\n\nSafe text.")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("<script>")
end
it "does not pass through raw HTML iframe tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<iframe src="https://evil.com"></iframe>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("<iframe")
end
it "returns nil for a nil entry" do
expect(described_class.render_page_content(nil)).to be_nil
end
it "returns nil for a missing file" do
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-gone", slug: "gone", title: "Gone",
path: File.join(pages_dir, "missing.md"),
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil when the file exceeds the size limit" do
path = File.join(pages_dir, "1-big.md")
File.write(path, "x" * (PotatoMesh::Config.max_page_file_bytes + 1))
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-big", slug: "big", title: "Big", path: path,
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil when path is nil" do
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: nil,
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil on a filesystem error" do
path = File.join(pages_dir, "1-err.md")
File.write(path, "# Error")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-err", slug: "err", title: "Err", path: path,
)
allow(File).to receive(:read).with(path, encoding: "utf-8").and_raise(Errno::EIO)
expect(described_class.render_page_content(entry)).to be_nil
end
it "strips event-handler attributes from allowed tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="https://example.com" onclick="alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include('href="https://example.com"')
expect(html).not_to include("onclick")
end
it "strips nested event-handler bypass attempts" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="#" oonnclick="alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("onclick")
end
it "strips javascript: URIs from href attributes" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="javascript:alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("javascript:")
end
it "preserves allowed HTML tags while stripping disallowed ones" do
path = File.join(pages_dir, "1-mixed.md")
File.write(path, "<strong>bold</strong> <script>bad</script>")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-mixed", slug: "mixed", title: "Mixed", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<strong>")
expect(html).not_to include("<script")
end
end
# ── find_page_by_slug ───────────────────────────────────────
describe ".find_page_by_slug" do
it "finds a page by slug" do
File.write(File.join(pages_dir, "1-alpha.md"), "# Alpha")
File.write(File.join(pages_dir, "2-beta.md"), "# Beta")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
page = described_class.find_page_by_slug("beta")
expect(page).not_to be_nil
expect(page.slug).to eq("beta")
end
it "returns nil for an unknown slug" do
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
expect(described_class.find_page_by_slug("nonexistent")).to be_nil
end
end
# ── static_pages (caching) ──────────────────────────────────
describe ".static_pages" do
it "returns cached entries from the configured directory" do
File.write(File.join(pages_dir, "1-cached.md"), "# Cached")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
result = described_class.static_pages
expect(result.length).to eq(1)
expect(result.first.slug).to eq("cached")
end
it "clears the cache when clear_pages_cache! is called" do
File.write(File.join(pages_dir, "1-first.md"), "# First")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
first = described_class.static_pages
expect(first.length).to eq(1)
File.write(File.join(pages_dir, "2-second.md"), "# Second")
described_class.clear_pages_cache!
second = described_class.static_pages
expect(second.length).to eq(2)
end
end
# ── production_environment? ─────────────────────────────────
describe ".production_environment?" do
it "returns false in the test environment" do
expect(described_class.production_environment?).to be false
end
it "returns true when RACK_ENV is production" do
original = ENV["RACK_ENV"]
begin
ENV["RACK_ENV"] = "production"
expect(described_class.production_environment?).to be true
ensure
ENV["RACK_ENV"] = original
end
end
it "returns true when APP_ENV is production" do
original_rack = ENV["RACK_ENV"]
original_app = ENV["APP_ENV"]
begin
ENV["RACK_ENV"] = "test"
ENV["APP_ENV"] = "production"
expect(described_class.production_environment?).to be true
ensure
ENV["RACK_ENV"] = original_rack
ENV["APP_ENV"] = original_app
end
end
end
# ── Route integration ───────────────────────────────────────
let(:app) { Sinatra::Application }
describe "GET /pages/:slug" do
before do
FileUtils.mkdir_p(pages_dir)
File.write(File.join(pages_dir, "1-about.md"), "# About\n\nWelcome.")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
it "renders a valid page with 200" do
get "/pages/about"
expect(last_response).to be_ok
expect(last_response.body).to include("About")
expect(last_response.body).to include("Welcome.")
expect(last_response.body).to include("static-page")
end
it "renders the page within the site layout" do
get "/pages/about"
expect(last_response.body).to include("site-header")
expect(last_response.body).to include("site-nav")
end
it "marks the page as active in nav" do
get "/pages/about"
expect(last_response.body).to include('aria-current="page"')
end
it "returns 404 for an unknown slug" do
get "/pages/nonexistent"
expect(last_response.status).to eq(404)
end
it "rejects path traversal attempts" do
get "/pages/..%2F..%2Fetc"
expect(last_response.status).to be >= 400
end
it "returns 400 for an uppercase slug" do
get "/pages/ABOUT"
expect(last_response.status).to eq(400)
end
it "returns 400 for a slug with encoded special characters" do
get "/pages/a%3Cb"
expect(last_response.status).to eq(400)
end
it "returns 500 when page content cannot be rendered" do
File.write(File.join(pages_dir, "1-about.md"), "# About")
PotatoMesh::App::Pages.clear_pages_cache!
allow(PotatoMesh::App::Pages).to receive(:render_page_content).and_return(nil)
get "/pages/about"
expect(last_response.status).to eq(500)
end
it "includes nav links for static pages" do
File.write(File.join(pages_dir, "2-contact.md"), "# Contact")
PotatoMesh::App::Pages.clear_pages_cache!
get "/pages/about"
expect(last_response.body).to include('href="/pages/about"')
expect(last_response.body).to include('href="/pages/contact"')
end
end
describe "static page nav links on other pages" do
before do
FileUtils.mkdir_p(pages_dir)
File.write(File.join(pages_dir, "1-info.md"), "# Info")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
it "shows page links in the dashboard nav" do
get "/"
expect(last_response.body).to include('href="/pages/info"')
expect(last_response.body).to include("Info")
end
end
end
+14 -5
View File
@@ -67,7 +67,8 @@
</head>
<% body_classes = ["dark"]
view_mode = (defined?(current_view_mode) && current_view_mode) ? current_view_mode.to_sym : :dashboard
full_screen_view = !%i[dashboard charts federation].include?(view_mode)
page_view = view_mode.to_s.start_with?("page_")
full_screen_view = !(%i[dashboard charts federation].include?(view_mode) || page_view)
body_classes << "view-#{view_mode}"
shell_classes = ["page-shell"]
shell_classes << "page-shell--full-screen" if full_screen_view
@@ -75,10 +76,10 @@
main_classes << "page-main--dashboard" if view_mode == :dashboard
main_classes << "page-main--full-screen" if full_screen_view
show_header = true
show_footer = !full_screen_view || %i[charts federation].include?(view_mode)
footer_slim = %i[charts federation].include?(view_mode)
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
show_meta_row = !%i[node_detail charts federation].include?(view_mode)
show_footer = !full_screen_view || %i[charts federation].include?(view_mode) || page_view
footer_slim = %i[charts federation].include?(view_mode) || page_view
show_filter_input = !(%i[node_detail charts federation].include?(view_mode) || page_view)
show_meta_row = !(%i[node_detail charts federation].include?(view_mode) || page_view)
nodes_nav_href = "/nodes"
nodes_nav_active = %i[nodes node_detail].include?(view_mode)
federation_nav_enabled = !private_mode && federation_enabled
@@ -125,6 +126,10 @@
<% if federation_nav_enabled %>
<a href="/federation" class="site-nav__link js-federation-nav<%= view_mode == :federation ? " is-active" : "" %>" data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
<% static_pages.each do |sp| %>
<% sp_active = view_mode.to_s == "page_#{sp.slug}" %>
<a href="/pages/<%= Rack::Utils.escape_path(sp.slug) %>" class="site-nav__link<%= sp_active ? " is-active" : "" %>"<%= sp_active ? ' aria-current="page"' : "" %>><%= Rack::Utils.escape_html(sp.title) %></a>
<% end %>
</nav>
<button
id="mobileMenuToggle"
@@ -156,6 +161,10 @@
<% if federation_nav_enabled %>
<a href="/federation" class="mobile-nav__link js-federation-nav<%= view_mode == :federation ? " is-active" : "" %>" data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
<% static_pages.each do |sp| %>
<% sp_active = view_mode.to_s == "page_#{sp.slug}" %>
<a href="/pages/<%= Rack::Utils.escape_path(sp.slug) %>" class="mobile-nav__link<%= sp_active ? " is-active" : "" %>"<%= sp_active ? ' aria-current="page"' : "" %>><%= Rack::Utils.escape_html(sp.title) %></a>
<% end %>
</nav>
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
<!--
Copyright © 2025-26 l5yth & contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<section class="static-page">
<div class="static-page__content markdown-body">
<%= page_content_html %>
</div>
</section>