Compare commits

...

6 Commits

Author SHA1 Message Date
l5y
5bf48e3124 web: address review comments 2026-02-14 15:19:34 +01:00
l5y
da7d4e7cbf web: address review comments 2026-02-14 15:13:26 +01:00
l5y
d6bf8af6c4 Potential fix for code scanning alert no. 13: Incomplete multi-character sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-02-14 15:06:59 +01:00
l5y
8226118a96 Potential fix for code scanning alert no. 12: Incomplete multi-character sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-02-14 15:06:44 +01:00
l5y
66fe3bb923 Potential fix for code scanning alert no. 11: Incomplete multi-character sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-02-14 15:06:29 +01:00
l5y
ef8ab344bf web: parse imprint contact from mdx file at runtim 2026-02-14 15:03:54 +01:00
10 changed files with 528 additions and 3 deletions

View File

@@ -75,7 +75,10 @@ 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 --chmod=0555 web/content ./content
COPY --chown=potatomesh:potatomesh web/scripts ./scripts
RUN find /app/content -type d -exec chmod 0555 {} + && \
find /app/content -type f -exec chmod 0444 {} +
# Copy SQL schema files from data directory
COPY --chown=potatomesh:potatomesh data/*.sql /data/

32
web/content/imprint.mdx Normal file
View File

@@ -0,0 +1,32 @@
<!--
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.
-->
---
title: Imprint
contact: maintainer@potato-mesh.invalid
---
## Contact
For legal notices and service-related inquiries, contact:
- PotatoMesh maintainers
- Email: maintainer@potato-mesh.invalid
- Project: [github.com/l5yth/potato-mesh](https://github.com/l5yth/potato-mesh)
## Responsible For Content
PotatoMesh contributors maintain this service on a best-effort basis for community mesh visibility and troubleshooting.

View File

@@ -58,6 +58,7 @@ require_relative "application/meshtastic/payload_decoder"
require_relative "application/data_processing"
require_relative "application/filesystem"
require_relative "application/instances"
require_relative "application/content/mdx_page"
require_relative "application/routes/api"
require_relative "application/routes/ingest"
require_relative "application/routes/root"

View File

@@ -0,0 +1,207 @@
# 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 "yaml"
require "rack/utils"
module PotatoMesh
module App
module Content
# Parse MDX/Markdown content files with optional frontmatter and produce
# a sanitised HTML payload for static pages.
module MdxPage
module_function
DEFAULT_METADATA = {
"title" => "Imprint",
}.freeze
# Resolve the absolute source path for a page slug under web/content.
#
# @param slug [String] page identifier without extension.
# @return [String] absolute path to the corresponding .mdx file.
def source_path_for(slug)
File.expand_path("#{slug}.mdx", content_root)
end
# Load and render an MDX page by slug. Missing or unreadable files are
# mapped to a friendly fallback payload instead of raising.
#
# @param slug [String] page identifier without extension.
# @return [Hash] metadata/body payload ready for view rendering.
def load(slug)
path = source_path_for(slug)
return fallback_payload unless File.file?(path) && File.readable?(path)
raw_content = File.read(path)
metadata, body_markdown = extract_frontmatter(raw_content)
rendered_html = markdown_to_html(body_markdown)
{
"found" => true,
"title" => metadata["title"] || DEFAULT_METADATA["title"],
"metadata" => metadata,
"body_html" => PotatoMesh::Sanitizer.sanitize_rendered_html(rendered_html),
}
rescue Errno::EACCES, Errno::ENOENT
fallback_payload
end
# Extract optional YAML frontmatter from a markdown file.
#
# @param content [String] full source file content.
# @return [Array<Hash, String>] parsed metadata and markdown body.
def extract_frontmatter(content)
return [{}, content.to_s] unless content.is_a?(String)
normalized = content.dup
normalized.sub!(/\A\uFEFF/, "")
while (match = normalized.match(/\A\s*<!--.*?-->\s*/m))
normalized = normalized[match[0].length..]
end
frontmatter_match = normalized.match(/\A---\s*\n(?<meta>.*?)\n---\s*\n?/m)
return [{}, normalized] unless frontmatter_match
metadata = parse_metadata(frontmatter_match[:meta])
remaining = normalized[frontmatter_match[0].length..].to_s
[metadata, remaining]
end
# Convert YAML metadata into a flat string hash used by templates.
#
# @param raw_frontmatter [String] YAML frontmatter body.
# @return [Hash] parsed key/value metadata.
def parse_metadata(raw_frontmatter)
parsed = YAML.safe_load(raw_frontmatter, permitted_classes: [], aliases: false)
return {} unless parsed.is_a?(Hash)
parsed.each_with_object({}) do |(key, value), metadata|
next if key.nil?
next if value.nil?
metadata[key.to_s] = value.to_s
end
rescue Psych::Exception
{}
end
# Render markdown to HTML while disabling embedded raw HTML parsing in
# the markdown stage; output is sanitised by the caller.
#
# @param markdown [String] markdown source content.
# @return [String] rendered HTML output.
def markdown_to_html(markdown)
lines = markdown.to_s.gsub("\r\n", "\n").split("\n")
rendered = []
in_list = false
lines.each do |line|
content = line.strip
if content.empty?
if in_list
rendered << "</ul>"
in_list = false
end
next
end
heading_match = content.match(/\A(#{Regexp.union("###", "##", "#")})\s+(.+)\z/)
if heading_match
if in_list
rendered << "</ul>"
in_list = false
end
level = heading_match[1].length
rendered << "<h#{level}>#{render_inline_markdown(heading_match[2])}</h#{level}>"
next
end
list_match = content.match(/\A[-*]\s+(.+)\z/)
if list_match
unless in_list
rendered << "<ul>"
in_list = true
end
rendered << "<li>#{render_inline_markdown(list_match[1])}</li>"
next
end
if in_list
rendered << "</ul>"
in_list = false
end
rendered << "<p>#{render_inline_markdown(content)}</p>"
end
rendered << "</ul>" if in_list
rendered.join("\n")
end
# Render inline markdown links while escaping plain text segments.
#
# @param text [String] source markdown text for a single block.
# @return [String] escaped inline HTML fragment.
def render_inline_markdown(text)
source = text.to_s
index = 0
output = +""
link_pattern = /\[([^\]]+)\]\(([^)]+)\)/
while (match = link_pattern.match(source, index))
output << Rack::Utils.escape_html(source[index...match.begin(0)])
label = Rack::Utils.escape_html(match[1].to_s)
href = match[2].to_s.strip
if href.match?(/\Ahttps?:\/\/[^\s]+\z/i)
output << "<a href=\"#{Rack::Utils.escape_html(href)}\" target=\"_blank\" rel=\"noreferrer noopener\">#{label}</a>"
else
output << Rack::Utils.escape_html(match[0].to_s)
end
index = match.end(0)
end
output << Rack::Utils.escape_html(source[index..].to_s)
output
end
# Return the absolute content directory path relative to the web app
# root, so loading works in local runs and container builds.
#
# @return [String] absolute content directory path.
def content_root
app_root = File.expand_path("../../../../", __dir__)
File.expand_path("content", app_root)
end
# Build a stable fallback payload used when content is unavailable.
#
# @return [Hash] fallback response consumed by the imprint view.
def fallback_payload
{
"found" => false,
"title" => DEFAULT_METADATA["title"],
"metadata" => {},
"body_html" => PotatoMesh::Sanitizer.sanitize_rendered_html(
"<p>The imprint content is currently unavailable. Please try again later.</p>",
),
}
end
end
end
end
end

View File

@@ -77,6 +77,13 @@ module PotatoMesh
erb template, layout: :"layouts/app", locals: merged_locals
end
# Load static imprint content from the repository-backed MDX source.
#
# @return [Hash] rendered imprint payload with title/body metadata.
def imprint_content_payload
PotatoMesh::App::Content::MdxPage.load("imprint")
end
# Remove keys with +nil+ values from the provided hash, returning a
# shallow copy. Hash#compact is only available in newer Ruby
# versions; this helper keeps behaviour consistent across supported
@@ -191,6 +198,20 @@ module PotatoMesh
render_root_view(:federation, view_mode: :federation)
end
app.get %r{/imprint/?} do
imprint_payload = imprint_content_payload
render_root_view(
:imprint,
view_mode: :imprint,
extra_locals: {
imprint_title: imprint_payload["title"],
imprint_metadata: imprint_payload["metadata"],
imprint_body_html: imprint_payload["body_html"],
imprint_content_found: imprint_payload["found"],
},
)
end
app.get "/nodes/:id" do
node_ref = params.fetch("id", nil)
reference_payload = build_node_detail_reference(node_ref)

View File

@@ -26,6 +26,9 @@ module PotatoMesh
module Sanitizer
module_function
SANITIZED_STATIC_TAGS = %w[p h1 h2 h3 ul ol li].freeze
SANITIZED_ALLOWED_TAGS = (SANITIZED_STATIC_TAGS + ["a"]).freeze
# Coerce an arbitrary value into a trimmed string unless the content is
# empty.
#
@@ -246,5 +249,86 @@ module PotatoMesh
distance
end
# Apply a lightweight HTML sanitization pass for static page content.
# The sanitizer applies an allowlist of static content tags and rewrites
# anchor tags to a safe canonical form.
#
# @param html [Object] generated HTML string.
# @return [String] sanitized HTML safe for rendering in templates.
def sanitize_rendered_html(html)
source = html.to_s
output = +""
cursor = 0
while cursor < source.length
open_index = source.index("<", cursor)
unless open_index
output << Rack::Utils.escape_html(source[cursor..].to_s)
break
end
output << Rack::Utils.escape_html(source[cursor...open_index].to_s)
close_index = source.index(">", open_index + 1)
unless close_index
output << Rack::Utils.escape_html(source[open_index..].to_s)
break
end
token = source[open_index..close_index]
output << sanitize_html_tag_token(token)
cursor = close_index + 1
end
output
end
# Sanitize a raw HTML tag token by rebuilding only allowlisted tags.
#
# @param token [String] raw token including surrounding angle brackets.
# @return [String] sanitized token or escaped literal text.
def sanitize_html_tag_token(token)
inner = token[1..-2].to_s.strip
return Rack::Utils.escape_html(token) if inner.empty?
if inner.start_with?("/")
name = inner[1..].to_s.strip.downcase
return "</#{name}>" if SANITIZED_ALLOWED_TAGS.include?(name) && name.match?(/\A[a-z0-9]+\z/)
return Rack::Utils.escape_html(token)
end
name, attributes = inner.split(/\s+/, 2)
tag_name = name.to_s.downcase
return "<#{tag_name}>" if SANITIZED_STATIC_TAGS.include?(tag_name) && attributes.nil?
return sanitized_anchor_open_tag(attributes.to_s) if tag_name == "a"
Rack::Utils.escape_html(token)
end
# Build a safe canonical anchor opening tag from arbitrary raw attributes.
#
# @param raw_attributes [String] source attributes from the original token.
# @return [String] safe anchor opening tag.
def sanitized_anchor_open_tag(raw_attributes)
href = extract_sanitized_anchor_href(raw_attributes)
return "<a>" unless href
%(<a href="#{Rack::Utils.escape_html(href)}" target="_blank" rel="noreferrer noopener">)
end
# Extract a validated HTTP/HTTPS href from an anchor attribute string.
#
# @param raw_attributes [String] source attributes from an anchor tag.
# @return [String, nil] validated href when present and safe.
def extract_sanitized_anchor_href(raw_attributes)
match = raw_attributes.to_s.match(/\bhref\s*=\s*(['"])(.*?)\1/i)
return nil unless match
href = match[2].to_s.strip
return nil unless href.match?(/\Ahttps?:\/\/[^\s]+\z/i)
href
end
end
end

View File

@@ -1190,6 +1190,39 @@ body.dark .node-detail-overlay__close:hover {
color: #c62828;
}
.imprint-page {
padding: 24px var(--pad) 40px;
max-width: 860px;
margin: 0 auto;
}
.imprint-page__intro h2 {
margin: 0 0 8px;
}
.imprint-page__status {
margin: 0;
color: var(--muted);
}
.imprint-page__content {
margin-top: 20px;
line-height: 1.6;
}
.imprint-page__content h1,
.imprint-page__content h2,
.imprint-page__content h3 {
margin-top: 1.2em;
margin-bottom: 0.45em;
}
.imprint-page__content p,
.imprint-page__content ul,
.imprint-page__content ol {
margin: 0 0 0.9em;
}
.node-detail {
width: 100%;
margin: 0;

View File

@@ -1258,6 +1258,73 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
describe "content rendering helpers" do
describe "PotatoMesh::App::Content::MdxPage" do
let(:mdx_page) { PotatoMesh::App::Content::MdxPage }
it "returns empty metadata for invalid YAML frontmatter" do
metadata = mdx_page.parse_metadata("title: [")
expect(metadata).to eq({})
end
it "returns empty metadata when parsed YAML is not a hash" do
metadata = mdx_page.parse_metadata("- one\n- two\n")
expect(metadata).to eq({})
end
it "extracts frontmatter and strips leading comments" do
content = <<~MDX
<!-- header comment -->
---
title: Example
contact: contact@example.org
---
## Hello
MDX
metadata, body = mdx_page.extract_frontmatter(content)
expect(metadata).to include("title" => "Example", "contact" => "contact@example.org")
expect(body).to include("## Hello")
end
it "renders markdown and keeps non-http markdown links as escaped text" do
html = mdx_page.markdown_to_html("## Heading\n- one\n- [unsafe](ftp://example.org)")
expect(html).to include("<h2>Heading</h2>")
expect(html).to include("<ul>")
expect(html).to include("[unsafe](ftp://example.org)")
end
it "falls back when reading a source file raises a permissions error" do
allow(mdx_page).to receive(:source_path_for).with("imprint").and_return("/tmp/imprint.mdx")
allow(File).to receive(:file?).with("/tmp/imprint.mdx").and_return(true)
allow(File).to receive(:readable?).with("/tmp/imprint.mdx").and_return(true)
allow(File).to receive(:read).with("/tmp/imprint.mdx").and_raise(Errno::EACCES)
payload = mdx_page.load("imprint")
expect(payload["found"]).to eq(false)
expect(payload["title"]).to eq("Imprint")
end
end
describe "#sanitize_rendered_html" do
it "escapes incomplete tags and unsupported tokens while preserving allowed tags" do
html = PotatoMesh::Sanitizer.sanitize_rendered_html("<p>Hello<script>x</script><strong>no</strong><")
expect(html).to include("<p>Hello")
expect(html).to include("&lt;script&gt;x&lt;/script&gt;")
expect(html).to include("&lt;strong&gt;no&lt;/strong&gt;")
expect(html).to include("&lt;")
end
it "normalizes safe anchors and degrades unsafe href values" do
safe = PotatoMesh::Sanitizer.sanitize_rendered_html('<a href="https://example.org">ok</a>')
unsafe = PotatoMesh::Sanitizer.sanitize_rendered_html('<a href="javascript:alert(1)">bad</a>')
expect(safe).to include('href="https://example.org"')
expect(safe).to include('rel="noreferrer noopener"')
expect(unsafe).to include("<a>bad</a>")
end
end
end
describe "GET /potatomesh-logo.svg" do
it "serves the cached SVG asset when present" do
get "/potatomesh-logo.svg"
@@ -1402,6 +1469,53 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
describe "GET /imprint" do
it "renders the imprint page content" do
get "/imprint"
expect(last_response).to be_ok
expect(last_response.body).to include("Contact")
expect(last_response.body).to include("maintainer@potato-mesh.invalid")
expect(last_response.body).to include('<section class="imprint-page">')
end
it "supports an optional trailing slash" do
get "/imprint/"
expect(last_response).to be_ok
expect(last_response.body).to include('<section class="imprint-page">')
end
it "renders a friendly fallback when the source file is missing" do
allow(PotatoMesh::App::Content::MdxPage).to receive(:source_path_for)
.with("imprint")
.and_return("/tmp/potatomesh-missing-imprint.mdx")
get "/imprint"
expect(last_response).to be_ok
expect(last_response.body).to include("The configured imprint document could not be read.")
expect(last_response.body).to include("The imprint content is currently unavailable. Please try again later.")
end
it "marks imprint as active in desktop and mobile navigation" do
get "/imprint"
expect(last_response).to be_ok
expect(last_response.body).to include('href="/imprint" class="site-nav__link is-active"')
expect(last_response.body).to include('href="/imprint" class="mobile-nav__link is-active"')
expect(last_response.body).to include('href="/imprint" class="site-nav__link is-active" aria-current="page"')
expect(last_response.body).to include('href="/imprint" class="mobile-nav__link is-active" aria-current="page"')
end
it "shows imprint links in both navigation menus and footer" do
get "/"
expect(last_response).to be_ok
expect(last_response.body.scan('href="/imprint"').length).to be >= 3
end
end
describe "GET /chat" do
it "renders the chat container when chat is enabled" do
get "/chat"

26
web/views/imprint.erb Normal file
View File

@@ -0,0 +1,26 @@
<!--
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="imprint-page">
<header class="imprint-page__intro">
<h2><%= Rack::Utils.escape_html(imprint_title.to_s) %></h2>
<% unless imprint_content_found %>
<p class="imprint-page__status">The configured imprint document could not be read.</p>
<% end %>
</header>
<article class="imprint-page__content">
<%= imprint_body_html %>
</article>
</section>

View File

@@ -68,7 +68,7 @@
<% body_classes = []
body_classes << "dark" if initial_theme == "dark"
view_mode = (defined?(current_view_mode) && current_view_mode) ? current_view_mode.to_sym : :dashboard
full_screen_view = view_mode != :dashboard
full_screen_view = !%i[dashboard imprint].include?(view_mode)
body_classes << "view-#{view_mode}"
shell_classes = ["page-shell"]
shell_classes << "page-shell--full-screen" if full_screen_view
@@ -77,12 +77,12 @@
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_refresh_controls = !%i[federation imprint].include?(view_mode)
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_filter_input = !%i[node_detail charts federation imprint].include?(view_mode)
show_auto_refresh_toggle = show_auto_refresh_controls
show_refresh_actions = show_auto_refresh_controls || view_mode == :federation
nodes_nav_href = "/nodes"
@@ -133,6 +133,7 @@
<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="/imprint" class="site-nav__link<%= view_mode == :imprint ? " is-active" : "" %>"<%= view_mode == :imprint ? ' aria-current="page"' : "" %>>Imprint</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 %>
@@ -164,6 +165,7 @@
<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="/imprint" class="mobile-nav__link<%= view_mode == :imprint ? " is-active" : "" %>"<%= view_mode == :imprint ? ' aria-current="page"' : "" %>>Imprint</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 %>
@@ -267,6 +269,8 @@
<span class="footer-links">
GitHub:
<a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
<span class="footer-separator" aria-hidden="true">—</span>
<a href="/imprint">Imprint</a>
<% if contact_link && !contact_link.empty? %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-contact">