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