web: parse imprint contact from mdx file at runtim

This commit is contained in:
l5y
2026-02-14 15:03:54 +01:00
parent e32b098be4
commit ef8ab344bf
10 changed files with 383 additions and 3 deletions
+1
View File
@@ -75,6 +75,7 @@ COPY --chown=potatomesh:potatomesh web/lib ./lib
COPY --chown=potatomesh:potatomesh web/spec ./spec
COPY --chown=potatomesh:potatomesh web/public ./public
COPY --chown=potatomesh:potatomesh web/views ./views
COPY --chown=potatomesh:potatomesh web/content ./content
COPY --chown=potatomesh:potatomesh web/scripts ./scripts
# Copy SQL schema files from data directory
+32
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.
+1
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"
@@ -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
@@ -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 "/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)
+15
View File
@@ -246,5 +246,20 @@ module PotatoMesh
distance
end
# Apply a lightweight HTML sanitization pass for static page content.
# The sanitizer removes executable tags and event handlers, and prevents
# javascript/data/file URLs from being emitted in href/src attributes.
#
# @param html [Object] generated HTML string.
# @return [String] sanitized HTML safe for rendering in templates.
def sanitize_rendered_html(html)
value = html.to_s.dup
value.gsub!(%r{<\s*(script|style|iframe|object|embed)[^>]*>.*?<\s*/\s*\1\s*>}mi, "")
value.gsub!(/\s+on[a-z]+\s*=\s*(['"]).*?\1/mi, "")
value.gsub!(/\s+on[a-z]+\s*=\s*[^\s>]+/mi, "")
value.gsub!(/\s+(href|src)\s*=\s*(['"])\s*(?:javascript|data|file):.*?\2/mi, "")
value
end
end
end
+33
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;
+40
View File
@@ -1402,6 +1402,46 @@ 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 "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
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>
+7 -3
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">