diff --git a/web/Dockerfile b/web/Dockerfile index d92070d4..16becf1a 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -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 diff --git a/web/content/imprint.mdx b/web/content/imprint.mdx new file mode 100644 index 00000000..634fb21b --- /dev/null +++ b/web/content/imprint.mdx @@ -0,0 +1,32 @@ + + +--- +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. diff --git a/web/lib/potato_mesh/application.rb b/web/lib/potato_mesh/application.rb index ac245bb2..a505150d 100644 --- a/web/lib/potato_mesh/application.rb +++ b/web/lib/potato_mesh/application.rb @@ -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" diff --git a/web/lib/potato_mesh/application/content/mdx_page.rb b/web/lib/potato_mesh/application/content/mdx_page.rb new file mode 100644 index 00000000..2c9fc3b4 --- /dev/null +++ b/web/lib/potato_mesh/application/content/mdx_page.rb @@ -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] 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(?.*?)\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 << "" + in_list = false + end + next + end + + heading_match = content.match(/\A(#{Regexp.union("###", "##", "#")})\s+(.+)\z/) + if heading_match + if in_list + rendered << "" + in_list = false + end + level = heading_match[1].length + rendered << "#{render_inline_markdown(heading_match[2])}" + next + end + + list_match = content.match(/\A[-*]\s+(.+)\z/) + if list_match + unless in_list + rendered << "" + in_list = false + end + rendered << "

#{render_inline_markdown(content)}

" + end + + rendered << "" 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 << "#{label}" + 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( + "

The imprint content is currently unavailable. Please try again later.

", + ), + } + end + end + end + end +end diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb index 01e643aa..f79ee788 100644 --- a/web/lib/potato_mesh/application/routes/root.rb +++ b/web/lib/potato_mesh/application/routes/root.rb @@ -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) diff --git a/web/lib/potato_mesh/sanitizer.rb b/web/lib/potato_mesh/sanitizer.rb index 2dad09b0..10cea36c 100644 --- a/web/lib/potato_mesh/sanitizer.rb +++ b/web/lib/potato_mesh/sanitizer.rb @@ -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 diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index cb6cac71..2bd6c07f 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -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; diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 4f80ac85..fc0a58f3 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -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('
') + 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" diff --git a/web/views/imprint.erb b/web/views/imprint.erb new file mode 100644 index 00000000..38902551 --- /dev/null +++ b/web/views/imprint.erb @@ -0,0 +1,26 @@ + +
+
+

<%= Rack::Utils.escape_html(imprint_title.to_s) %>

+ <% unless imprint_content_found %> +

The configured imprint document could not be read.

+ <% end %> +
+
+ <%= imprint_body_html %> +
+
diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index 11c57049..4f9ce9c0 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -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 @@ "<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat "<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes "<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts + "<%= view_mode == :imprint ? ' aria-current="page"' : "" %>>Imprint <% if federation_nav_enabled %> " data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation <% end %> @@ -164,6 +165,7 @@ "<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat "<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes "<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts + "<%= view_mode == :imprint ? ' aria-current="page"' : "" %>>Imprint <% if federation_nav_enabled %> " data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation <% end %> @@ -267,6 +269,8 @@ GitHub: l5yth/potato-mesh + + Imprint <% if contact_link && !contact_link.empty? %>