diff --git a/README.md b/README.md index 11b3a31..6dba465 100644 --- a/README.md +++ b/README.md @@ -105,10 +105,32 @@ The web app can be configured with environment variables (defaults shown): | `HIDDEN_CHANNELS` | _unset_ | Comma-separated channel names the ingestor will ignore when forwarding packets. | | `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. | | `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. | +| `OG_IMAGE_URL` | _unset_ | Optional absolute URL for the social preview image. Must use an `http://` or `https://` scheme; values with other schemes are ignored. Most social platforms (Facebook, LinkedIn, Slack, iMessage) require **HTTPS** to render the card. When set, replaces the runtime-generated `/og-image.png` so deployments without Chromium (or with size-conscious images) can point at a CDN. | +| `OG_IMAGE_TTL_SECONDS` | `3600` | Cache lifetime for the runtime-generated dashboard screenshot served at `/og-image.png`. | +| `FERRUM_BROWSER_PATH` | `/usr/bin/chromium` (Docker) | Path to the headless Chromium binary used by the Open Graph preview generator. | The application derives SEO-friendly document titles, descriptions, and social -preview tags from these existing configuration values and reuses the bundled -logo for Open Graph and Twitter cards. +preview tags from these existing configuration values. `/robots.txt` and +`/sitemap.xml` are generated automatically and respect `PRIVATE`/`FEDERATION` +toggles; markdown files in `pages/` may declare optional YAML frontmatter +(`title`, `description`, `image`, `noindex`) for per-page overrides. The +`image:` frontmatter must be an absolute `http(s)://` URL; other schemes are +silently dropped to keep operators from accidentally leaking `data:` or +`javascript:` URIs into Open Graph tags. + +If `INSTANCE_DOMAIN` is unset in production the app emits a one-time `WARN` +at startup; canonical URLs and sitemap entries fall back to the inbound +`Host` header, which can be cache-poisoned by a misconfigured proxy. Set +`INSTANCE_DOMAIN` to your public hostname to silence the warning. + +#### Open Graph preview image + +The web container ships with Chromium so `/og-image.png` returns a fresh +screenshot of the live dashboard, cached on disk for `OG_IMAGE_TTL_SECONDS`. +Operators on size-constrained hosts can build a slim image by passing +`--build-arg WITH_OG_IMAGE=0` to `docker build`; the route then falls back to +the bundled `public/og-image-default.png`. Set `OG_IMAGE_URL` to an external +PNG/JPG (e.g. on a CDN) to avoid runtime capture entirely. Example: diff --git a/web/Dockerfile b/web/Dockerfile index 032c150..92abbff 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -48,12 +48,26 @@ RUN python3 -m venv /opt/meshtastic-venv && \ # Production stage FROM ruby:3.3-alpine AS production -# Install runtime dependencies +# Build-time toggle controlling whether Chromium is bundled into the image +# for runtime Open Graph preview rendering. Operators on size-constrained +# hosts can build with `--build-arg WITH_OG_IMAGE=0` to skip Chromium and +# its font/library payload (~150 MB). The web app falls back to the +# packaged default PNG when Chromium is missing, and operators can point +# `OG_IMAGE_URL` at a CDN-hosted preview instead. +ARG WITH_OG_IMAGE=1 +ENV WITH_OG_IMAGE=${WITH_OG_IMAGE} + +# Install runtime dependencies. Chromium powers the runtime Open Graph +# preview generator; the accompanying font and library packages are the +# minimum set required to render the dashboard headlessly on Alpine. RUN apk add --no-cache \ python3 \ sqlite \ tzdata \ - curl + curl \ + && if [ "$WITH_OG_IMAGE" = "1" ]; then \ + apk add --no-cache chromium nss freetype harfbuzz ttf-freefont; \ + fi # Create non-root user RUN addgroup -g 1000 -S potatomesh && \ @@ -107,6 +121,7 @@ ENV RACK_ENV=production \ MAP_ZOOM="" \ MAX_DISTANCE=42 \ CONTACT_LINK="#potatomesh:dod.ngo" \ + FERRUM_BROWSER_PATH=/usr/bin/chromium \ DEBUG=0 # Start the application diff --git a/web/Gemfile b/web/Gemfile index 624ade1..ce11067 100644 --- a/web/Gemfile +++ b/web/Gemfile @@ -22,6 +22,7 @@ gem "puma", "~> 7.0" gem "prometheus-client" gem "kramdown", "~> 2.4" gem "kramdown-parser-gfm", "~> 1.1" +gem "ferrum", "~> 0.17" group :test do gem "rspec", "~> 3.12" diff --git a/web/lib/potato_mesh/application.rb b/web/lib/potato_mesh/application.rb index e29001c..2551b28 100644 --- a/web/lib/potato_mesh/application.rb +++ b/web/lib/potato_mesh/application.rb @@ -40,6 +40,7 @@ require_relative "config" require_relative "sanitizer" require_relative "meta" require_relative "logging" +require_relative "og_image" require_relative "application/helpers" require_relative "application/errors" require_relative "application/database" diff --git a/web/lib/potato_mesh/application/helpers/config_helpers.rb b/web/lib/potato_mesh/application/helpers/config_helpers.rb index b1331c6..d26a9eb 100644 --- a/web/lib/potato_mesh/application/helpers/config_helpers.rb +++ b/web/lib/potato_mesh/application/helpers/config_helpers.rb @@ -74,9 +74,18 @@ module PotatoMesh # Generate the structured meta configuration for the UI. # + # @param view [Symbol, String, nil] logical view identifier used to + # tailor the title and description for non-dashboard pages. + # @param overrides [Hash, nil] explicit replacements for individual + # meta fields. See {PotatoMesh::Meta.configuration} for accepted + # keys. # @return [Hash] frozen configuration metadata. - def meta_configuration - PotatoMesh::Meta.configuration(private_mode: private_mode?) + def meta_configuration(view: nil, overrides: nil) + PotatoMesh::Meta.configuration( + private_mode: private_mode?, + view: view, + overrides: overrides, + ) end # Indicate whether private mode has been requested. diff --git a/web/lib/potato_mesh/application/identity.rb b/web/lib/potato_mesh/application/identity.rb index 496f398..fcdeb64 100644 --- a/web/lib/potato_mesh/application/identity.rb +++ b/web/lib/potato_mesh/application/identity.rb @@ -274,16 +274,29 @@ module PotatoMesh end # Emit a debug entry describing how the instance domain was derived. + # When +INSTANCE_DOMAIN+ is unset in production, also surface a + # warning because canonical URLs, sitemap entries, and JSON-LD + # metadata fall back to whatever +Host+ header the request arrived + # with — which can be cache-poisoned by a misconfigured proxy. # # @return [void] def log_instance_domain_resolution source = app_constant(:INSTANCE_DOMAIN_SOURCE) || :unknown + domain = app_constant(:INSTANCE_DOMAIN) debug_log( "Resolved instance domain", context: "identity.domain", source: source, - domain: app_constant(:INSTANCE_DOMAIN), + domain: domain, ) + if production_environment? && (domain.nil? || domain.to_s.strip.empty?) + warn_log( + "INSTANCE_DOMAIN is unset; canonical URLs and sitemap entries " \ + "will be derived from the inbound Host header", + context: "identity.domain", + source: source, + ) + end end end end diff --git a/web/lib/potato_mesh/application/pages.rb b/web/lib/potato_mesh/application/pages.rb index 0f79ee7..82e6da9 100644 --- a/web/lib/potato_mesh/application/pages.rb +++ b/web/lib/potato_mesh/application/pages.rb @@ -17,6 +17,7 @@ require "kramdown" require "kramdown-parser-gfm" require "sanitize" +require "yaml" module PotatoMesh module App @@ -36,10 +37,29 @@ module PotatoMesh # @!attribute [r] slug # @return [String] URL-safe identifier derived from the filename. # @!attribute [r] title - # @return [String] human-readable nav label. + # @return [String] human-readable nav label, optionally overridden + # via YAML frontmatter. # @!attribute [r] path # @return [String] absolute filesystem path to the Markdown source. - PageEntry = Struct.new(:sort_key, :slug, :title, :path, keyword_init: true) + # @!attribute [r] description + # @return [String, nil] meta-description override sourced from + # frontmatter, or +nil+ when the global default should be used. + # @!attribute [r] image + # @return [String, nil] absolute URL for the per-page social preview + # image, or +nil+ when the default OG image should be used. + # @!attribute [r] noindex + # @return [Boolean] +true+ when the operator marked the page with + # +noindex: true+ in frontmatter; instructs crawlers to skip it. + PageEntry = Struct.new( + :sort_key, + :slug, + :title, + :path, + :description, + :image, + :noindex, + keyword_init: true, + ) # Pattern matching a safe slug segment: lowercase alphanumeric words # separated by single hyphens. Used to validate both parsed slugs and @@ -54,6 +74,20 @@ module PotatoMesh # directory-bomb scenarios from consuming unbounded memory. MAX_PAGES = 50 + # Maximum number of bytes inspected when extracting frontmatter from a + # candidate file during directory scans. Keeps {load_static_pages} + # cheap for large markdown files. + FRONTMATTER_PROBE_BYTES = 4096 + + # Set of frontmatter keys that operators may use to influence how a + # page is presented to crawlers and social platforms. Any other key in + # the document is silently ignored to keep the surface area small and + # the parser predictable. + ALLOWED_FRONTMATTER_KEYS = %w[title description image noindex].freeze + + # Pattern used to recognise a leading YAML frontmatter block. + FRONTMATTER_PATTERN = /\A---\s*\n(.*?)\n---\s*(?:\n|\z)/m + # Kramdown options shared across all page renders. KRAMDOWN_OPTIONS = { input: "GFM", @@ -100,6 +134,151 @@ module PotatoMesh PageEntry.new(sort_key: sort_key, slug: slug, title: title, path: nil) end + # Extract the frontmatter block (if any) from raw markdown source. + # + # The first +---+ delimited block is parsed via {YAML.safe_load}; only + # keys listed in {ALLOWED_FRONTMATTER_KEYS} are kept and string values + # are stripped. Malformed YAML, unsupported types, and missing + # delimiters all result in an empty hash so the caller can fall back to + # filename-derived metadata without raising. + # + # @param content [String] raw file contents (UTF-8). + # @return [Hash{String=>Object}] permitted, normalised frontmatter + # values. + def parse_frontmatter(content) + return {} unless content.is_a?(String) + + match = content.match(FRONTMATTER_PATTERN) + return {} unless match + + begin + parsed = YAML.safe_load(match[1], permitted_classes: [], aliases: false) || {} + rescue Psych::Exception + return {} + end + return {} unless parsed.is_a?(Hash) + + parsed.each_with_object({}) do |(key, value), result| + string_key = key.to_s + next unless ALLOWED_FRONTMATTER_KEYS.include?(string_key) + + result[string_key] = normalise_frontmatter_value(string_key, value) + end + end + + # Strip a leading frontmatter block from the raw markdown body. + # + # @param content [String] file contents. + # @return [String] markdown body without frontmatter. + def strip_frontmatter(content) + return content unless content.is_a?(String) + + content.sub(FRONTMATTER_PATTERN, "") + end + + # Coerce frontmatter values into the canonical type expected for each + # supported key. String fields are trimmed; +noindex+ is forced into a + # strict boolean; +image+ additionally enforces an +http(s)+ scheme + # so an operator who pastes a +data:+, +javascript:+, or relative + # URI does not silently leak it into the +og:image+ tag. Unrecognised + # values fall through to +nil+/+false+ so the rest of the pipeline + # can rely on simple checks. + # + # @param key [String] supported frontmatter key. + # @param value [Object] raw parsed value from {YAML.safe_load}. + # @return [String, Boolean, nil] normalised value. + def normalise_frontmatter_value(key, value) + case key + when "noindex" + truthy_frontmatter?(value) + when "image" + normalise_image_url(value) + else + string = value.is_a?(String) ? value : value.to_s + stripped = string.strip + stripped.empty? ? nil : stripped + end + end + + # Validate an operator-supplied image URL. Only +http(s)+ schemes are + # accepted — +data:+, +javascript:+, relative paths, and other + # exotic forms are dropped silently because they would either fail + # to render in social-media link previews or open a content-security + # foot-gun. + # + # @param value [Object] raw frontmatter value. + # @return [String, nil] absolute URL or +nil+ when invalid/blank. + def normalise_image_url(value) + string = value.is_a?(String) ? value : value.to_s + stripped = string.strip + return nil if stripped.empty? + return nil unless stripped.match?(%r{\Ahttps?://}i) + + stripped + end + + # Decide whether a frontmatter scalar should be treated as truthy. + # + # Accepts native booleans as well as the common string aliases + # +"true"+, +"yes"+, +"1"+, +"on"+ (case-insensitive) so operators do + # not have to remember YAML's exact boolean coercion rules. + # + # @param value [Object] candidate value. + # @return [Boolean] +true+ when the value should map to truth. + def truthy_frontmatter?(value) + return value if value == true || value == false + + normalised = value.to_s.strip.downcase + %w[true yes 1 on].include?(normalised) + end + + # Read up to {FRONTMATTER_PROBE_BYTES} of the file at +path+ for + # frontmatter inspection during directory scans. Returns an empty + # string for unreadable or oversized inputs so the caller can treat + # them as having no frontmatter. + # + # The result is force-encoded to UTF-8 because YAML parsers refuse + # input declared as binary; for files that are already UTF-8 this + # is a no-op, and for files in another encoding it surfaces a + # decoding error to the YAML parser instead of silently producing + # gibberish that happens to match the frontmatter delimiters. + # + # @param path [String] absolute path to the markdown source. + # @return [String] candidate frontmatter prefix. + def read_frontmatter_probe(path) + return "" unless File.file?(path) && File.readable?(path) + + raw = File.open(path, "r:UTF-8") { |file| file.read(FRONTMATTER_PROBE_BYTES) || "" } + raw.force_encoding(Encoding::UTF_8) + rescue SystemCallError + "" + end + + # Apply parsed frontmatter values to a {PageEntry}, returning a new + # struct that preserves filename-derived defaults whenever a key is + # absent or blank. + # + # {parse_frontmatter} has already dropped blank string values for + # +title+/+description+/+image+, so this method can rely on truthy + # checks rather than re-validating each key. + # + # @param entry [PageEntry] base entry parsed from the filename. + # @param frontmatter [Hash] permitted frontmatter values. + # @return [PageEntry] enriched entry. + def apply_frontmatter(entry, frontmatter) + return entry unless entry + + PageEntry.new( + sort_key: entry.sort_key, + slug: entry.slug, + title: frontmatter["title"] || entry.title, + path: entry.path, + description: frontmatter["description"], + image: frontmatter["image"], + noindex: frontmatter["noindex"] == true, + ) + 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 @@ -116,12 +295,14 @@ module PotatoMesh entry = parse_page_filename(basename) next unless entry - PageEntry.new( + base_entry = PageEntry.new( sort_key: entry.sort_key, slug: entry.slug, title: entry.title, path: path, ) + frontmatter = parse_frontmatter(read_frontmatter_probe(path)) + apply_frontmatter(base_entry, frontmatter) end entries.sort_by!(&:sort_key) @@ -174,7 +355,8 @@ module PotatoMesh 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 + body = strip_frontmatter(content) + raw_html = Kramdown::Document.new(body, **KRAMDOWN_OPTIONS).to_html strip_unsafe_html(raw_html) rescue SystemCallError nil diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb index d9f8137..664d5ef 100644 --- a/web/lib/potato_mesh/application/routes/root.rb +++ b/web/lib/potato_mesh/application/routes/root.rb @@ -19,6 +19,18 @@ module PotatoMesh module Routes module Root module Helpers + # Map of XML predefined entities used by {#xml_escape}. + XML_ESCAPE_REPLACEMENTS = { + "&" => "&", + "<" => "<", + ">" => ">", + '"' => """, + "'" => "'", + }.freeze + + # Pattern matching any XML metacharacter that requires escaping. + XML_ESCAPE_PATTERN = Regexp.union(XML_ESCAPE_REPLACEMENTS.keys).freeze + # Return the fixed dark theme identifier. Light mode is no longer # supported; theme selection and cookie persistence have been removed. # @@ -31,19 +43,28 @@ module PotatoMesh # # @param template [Symbol] identifier for the ERB template. # @param view_mode [Symbol, String] logical view identifier for CSS hooks. + # @param view_meta [Symbol, String, nil] meta-tag selector. Defaults to + # +view_mode+ so most callers can omit it; pass an explicit value + # when the layout view differs from the meta archetype (e.g. the + # dynamic +/pages/:slug+ routes whose view_mode is per-slug). + # @param meta_overrides [Hash, nil] explicit replacements for + # individual meta values (title, description, image, noindex). # @param extra_locals [Hash] additional locals merged into the rendering context. # @return [String] rendered ERB output. - def render_root_view(template, view_mode: :dashboard, extra_locals: {}) - meta = meta_configuration + def render_root_view(template, view_mode: :dashboard, view_meta: nil, meta_overrides: nil, extra_locals: {}) + view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode + view_meta_sym = view_meta.nil? ? view_mode_sym : (view_meta.respond_to?(:to_sym) ? view_meta.to_sym : view_meta) + meta = meta_configuration(view: view_meta_sym, overrides: meta_overrides) config = frontend_app_config theme = resolve_initial_theme - view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode base_locals = { site_name: meta[:name], meta_title: meta[:title], meta_name: meta[:name], meta_description: meta[:description], + meta_image_url: meta[:image], + meta_noindex: meta[:noindex] == true, channel: sanitized_channel, frequency: sanitized_frequency, map_center_lat: PotatoMesh::Config.map_center_lat, @@ -135,6 +156,203 @@ module PotatoMesh "position" => position, } end + + # Resolve the canonical absolute base URL for the running request. + # Prefers an operator-supplied override (+INSTANCE_DOMAIN+) so + # generated absolute URLs match the public-facing hostname, falling + # back to the request's own +base_url+ for development. + # + # @return [String] base URL (scheme + authority) without trailing slash. + def public_base_url + domain = string_or_nil(app_constant(:INSTANCE_DOMAIN)) + return request.base_url unless domain + + scheme = request.scheme || "https" + "#{scheme}://#{domain}" + end + + # Construct the OG image URL referenced from the layout. Operators + # may provide an explicit override via +OG_IMAGE_URL+; otherwise + # the runtime-generated +/og-image.png+ URL is returned. + # + # The override is rejected unless it carries an +http(s)+ scheme. + # +data:+ and +javascript:+ URIs do not render in any social + # platform's link preview and would only serve as a content + # security foot-gun, so they are silently dropped in favour of + # the runtime URL. + # + # @return [String] absolute URL to the social preview image. + def og_image_url + override = string_or_nil(PotatoMesh::Config.og_image_url) + return override if override && override.match?(%r{\Ahttps?://}i) + + "#{public_base_url}/og-image.png" + end + + # Build the title segment for the node detail view from the data + # already resolved by {build_node_detail_reference}. + # + # @param short_name [String, nil] sanitized short identifier. + # @param long_name [String, nil] sanitized long name. + # @param canonical_id [String, nil] canonical "!hex" identifier. + # @return [String] human-friendly node label. + def node_detail_title_label(short_name:, long_name:, canonical_id:) + short = string_or_nil(short_name) + long = string_or_nil(long_name) + return "#{short} (#{long})" if short && long + return short if short + return long if long + return "Node #{canonical_id}" if canonical_id + + "Node detail" + end + + # Compose meta overrides for the +/nodes/:id+ view. + # + # @param short_name [String, nil] sanitized short identifier. + # @param long_name [String, nil] sanitized long name. + # @param canonical_id [String, nil] canonical "!hex" identifier. + # @return [Hash] override hash for {meta_configuration}. + def node_detail_meta_overrides(short_name:, long_name:, canonical_id:) + site = sanitized_site_name + label = node_detail_title_label( + short_name: short_name, + long_name: long_name, + canonical_id: canonical_id, + ) + description_subject = string_or_nil(short_name) || string_or_nil(long_name) || + canonical_id || "this node" + + { + title: site && !site.empty? ? "#{label} · #{site}" : label, + description: "Telemetry, position history, and live status for node #{description_subject} on #{site}.", + } + end + + # Compose meta overrides for the +/pages/:slug+ view from a static + # page entry plus any frontmatter the operator defined. + # + # Only keys that carry meaningful values are included so the + # downstream {meta_configuration} call is not asked to filter + # +nil+ values out of an otherwise sparse hash. + # + # @param page [PotatoMesh::App::Pages::PageEntry] resolved page entry. + # @return [Hash] override hash for {meta_configuration}. + def static_page_meta_overrides(page) + site = sanitized_site_name + title_segment = string_or_nil(page&.title) || "" + composed_title = if !title_segment.empty? && site && !site.empty? + "#{title_segment} · #{site}" + elsif !title_segment.empty? + title_segment + else + site + end + + overrides = { title: composed_title } + description = string_or_nil(page&.description) + overrides[:description] = description if description + image = string_or_nil(page&.image) + overrides[:image] = image if image + overrides[:noindex] = true if page&.noindex == true + overrides + end + + # Render the +robots.txt+ body honoring private-mode preferences. + # Private deployments emit a blanket disallow; public deployments + # whitelist the dashboard while disallowing instrumentation paths. + # + # @param sitemap_url [String] absolute URL of the public sitemap. + # @return [String] +robots.txt+ payload, terminated with a newline. + def build_robots_txt(sitemap_url) + if private_mode? + "User-agent: *\nDisallow: /\n" + else + <<~TXT + User-agent: * + Disallow: /metrics + Disallow: /api/ + + Sitemap: #{sitemap_url} + TXT + end + end + + # Build the URL list emitted by +/sitemap.xml+ for a public + # deployment. Each entry is a hash with +:loc+ and an optional + # +:lastmod+ / +:changefreq+ pair. + # + # +lastmod+ is intentionally omitted for top-level dashboard + # routes. The data behind those views changes continuously, so + # advertising +Time.now+ on every crawl trains crawlers to ignore + # the field (Google explicitly discourages noisy +lastmod+ + # values). Static pages keep a meaningful +lastmod+ derived from + # +File.mtime+. + # + # The handler at +/sitemap.xml+ already 404s in private mode, so + # this method does not need to filter chat — it is unreachable + # otherwise. + # + # @param base_url [String] absolute base URL prefix. + # @return [Array] ordered list of sitemap entries. + def build_sitemap_entries(base_url) + entries = [] + entries << { loc: "#{base_url}/", changefreq: "daily" } + entries << { loc: "#{base_url}/map", changefreq: "daily" } + entries << { loc: "#{base_url}/chat", changefreq: "daily" } + entries << { loc: "#{base_url}/charts", changefreq: "daily" } + entries << { loc: "#{base_url}/nodes", changefreq: "daily" } + entries << { loc: "#{base_url}/federation", changefreq: "weekly" } if federation_enabled? + + PotatoMesh::App::Pages.static_pages.each do |page| + next if page.noindex + next unless page.path + + lastmod = begin + File.mtime(page.path).utc.strftime("%Y-%m-%d") + rescue SystemCallError + nil + end + entry = { loc: "#{base_url}/pages/#{page.slug}", changefreq: "weekly" } + entry[:lastmod] = lastmod if lastmod + entries << entry + end + + entries + end + + # Escape a string for inclusion as XML character data. + # + # Replaces the five XML predefined entities in a single pass. + # Used by the sitemap renderer instead of + # {Rack::Utils.escape_html} so apostrophes become the canonical + # +'+ entity rather than an HTML-style numeric character + # reference. + # + # @param value [Object] input fragment; coerced to a string. + # @return [String] XML-safe representation. + def xml_escape(value) + value.to_s.gsub(XML_ESCAPE_PATTERN, XML_ESCAPE_REPLACEMENTS) + end + + # Render a sitemap entry list as +urlset+ XML. + # + # @param entries [Array] entries produced by + # {build_sitemap_entries}. + # @return [String] XML document body. + def render_sitemap_xml(entries) + lines = [%(), + %()] + entries.each do |entry| + lines << " " + lines << " #{xml_escape(entry[:loc])}" + lines << " #{xml_escape(entry[:lastmod])}" if entry[:lastmod] + lines << " #{xml_escape(entry[:changefreq])}" if entry[:changefreq] + lines << " " + end + lines << "" + lines.join("\n") + "\n" + end end def self.registered(app) @@ -160,6 +378,36 @@ module PotatoMesh send_file path end + app.get "/robots.txt" do + content_type "text/plain" + cache_control :public, max_age: 3600 + build_robots_txt("#{public_base_url}/sitemap.xml") + end + + app.get "/sitemap.xml" do + halt 404, "Not Found" if private_mode? + + content_type "application/xml" + cache_control :public, max_age: 3600 + render_sitemap_xml(build_sitemap_entries(public_base_url)) + end + + app.get "/og-image.png" do + override = string_or_nil(PotatoMesh::Config.og_image_url) + redirect override, 302 if override && override.match?(%r{\Ahttps?://}i) + + begin + payload = PotatoMesh::OgImage.serve(base_url: public_base_url) + rescue PotatoMesh::OgImage::CaptureError + halt 503, "Preview unavailable" + end + + content_type "image/png" + cache_control :public, max_age: payload[:max_age] + last_modified payload[:last_modified] if payload[:last_modified] + payload[:bytes] + end + app.get "/" do render_root_view(:index, view_mode: :dashboard) end @@ -194,6 +442,7 @@ module PotatoMesh render_root_view( :page, view_mode: :"page_#{slug}", + meta_overrides: static_page_meta_overrides(page), extra_locals: { page_title: page.title, page_content_html: page_html, @@ -215,6 +464,11 @@ module PotatoMesh render_root_view( :node_detail, view_mode: :node_detail, + meta_overrides: node_detail_meta_overrides( + short_name: short_name, + long_name: long_name, + canonical_id: canonical_id, + ), extra_locals: { node_reference_json: JSON.generate(reject_nil_values(reference_payload)), node_page_short_name: short_name, diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index 4386d6a..379ab85 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -47,6 +47,12 @@ module PotatoMesh DEFAULT_FEDERATION_CRAWL_COOLDOWN_SECONDS = 300 DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2 DEFAULT_FEDERATION_SEED_DOMAINS = %w[potatomesh.net potatomesh.jmrp.io mesh.qrp.ro].freeze + DEFAULT_OG_IMAGE_TTL_SECONDS = 3_600 + DEFAULT_OG_IMAGE_VIEWPORT_WIDTH = 1_200 + DEFAULT_OG_IMAGE_VIEWPORT_HEIGHT = 630 + DEFAULT_OG_IMAGE_NAVIGATION_TIMEOUT = 15 + DEFAULT_OG_IMAGE_NETWORK_IDLE_DURATION = 1.5 + DEFAULT_OG_IMAGE_NETWORK_IDLE_TIMEOUT = 8 # Retrieve the configured API token used for authenticated requests. # @@ -593,6 +599,85 @@ module PotatoMesh fetch_string("CONNECTION", "/dev/ttyACM0") end + # Optional absolute URL to use for the social share preview image. + # + # When set, the layout uses this URL verbatim for +og:image+ and + # +twitter:image+ and the runtime capture pipeline is skipped. Operators + # who do not want to ship Chromium in their container, or who prefer to + # host their own preview image on a CDN, can point at any reachable + # +https://+ URL. + # + # @return [String, nil] override URL or +nil+ when unset. + def og_image_url + fetch_string("OG_IMAGE_URL", nil) + end + + # Cache lifetime for runtime-generated +/og-image.png+ responses, in + # seconds. Successful captures are stored on disk and reused until the + # TTL elapses; the next request after expiry refreshes the cache + # synchronously while holding a process-wide mutex so concurrent + # requesters serialise rather than spawning multiple browsers. + # + # @return [Integer] positive cache duration in seconds. + def og_image_ttl_seconds + fetch_positive_integer("OG_IMAGE_TTL_SECONDS", DEFAULT_OG_IMAGE_TTL_SECONDS) + end + + # Viewport width used for the headless browser preview capture. + # + # @return [Integer] viewport width in CSS pixels. + def og_image_viewport_width + DEFAULT_OG_IMAGE_VIEWPORT_WIDTH + end + + # Viewport height used for the headless browser preview capture. + # + # @return [Integer] viewport height in CSS pixels. + def og_image_viewport_height + DEFAULT_OG_IMAGE_VIEWPORT_HEIGHT + end + + # Maximum time the headless browser may spend navigating to the + # capture target before the request is abandoned. + # + # @return [Integer] navigation timeout in seconds. + def og_image_navigation_timeout + DEFAULT_OG_IMAGE_NAVIGATION_TIMEOUT + end + + # Continuous duration of network silence required before the screenshot + # is taken. Acts as a heuristic for "page settled". + # + # @return [Float] idle window duration in seconds. + def og_image_network_idle_duration + DEFAULT_OG_IMAGE_NETWORK_IDLE_DURATION + end + + # Maximum time spent waiting for {og_image_network_idle_duration} of + # silence before the capture proceeds anyway. + # + # @return [Integer] idle wait ceiling in seconds. + def og_image_network_idle_timeout + DEFAULT_OG_IMAGE_NETWORK_IDLE_TIMEOUT + end + + # Filesystem path used to cache the most recent runtime-generated + # preview image. The directory is created lazily on first capture. + # + # @return [String] absolute cache file path. + def og_image_cache_path + File.join(data_directory, "og-image.png") + end + + # Filesystem path of the bundled fallback preview image served when no + # cached capture is available and the runtime generator is unable to + # produce one (e.g. Chromium missing, transient navigation failure). + # + # @return [String] absolute path to the packaged default PNG. + def og_image_default_path + File.join(web_root, "public", "og-image-default.png") + end + # Determine the best URL to represent the configured contact link. # # @return [String, nil] absolute URL when derivable, otherwise nil. diff --git a/web/lib/potato_mesh/meta.rb b/web/lib/potato_mesh/meta.rb index eb6d9cb..3296f91 100644 --- a/web/lib/potato_mesh/meta.rb +++ b/web/lib/potato_mesh/meta.rb @@ -66,17 +66,155 @@ module PotatoMesh sentences.join(" ") end + # Return the human-readable label associated with a logical view name. + # + # The label appears as the first segment of {.view_title} (e.g. the + # +"Map"+ portion of +"Map · PotatoMesh"+) and is omitted for views that + # should reuse the bare site name (such as the dashboard or detail pages + # whose title is built from per-record data). + # + # @param view [Symbol, String, nil] logical view identifier. + # @return [String, nil] navigation label or +nil+ when no label applies. + def view_label(view) + return nil if view.nil? + + symbol = view.respond_to?(:to_sym) ? view.to_sym : view + { + map: "Map", + chat: "Chat", + charts: "Charts", + nodes: "Nodes", + federation: "Federation", + }[symbol] + end + + # Compose the per-view document title using the +"Label · Site"+ pattern. + # + # @param view [Symbol, String, nil] logical view identifier. + # @param site [String] sanitized site name suffix. + # @return [String, nil] composed title or +nil+ when no view-specific + # label exists for the supplied identifier. + def view_title(view, site) + label = view_label(view) + return nil unless label + return label if site.nil? || site.empty? + + "#{label} · #{site}" + end + + # Build the per-view description string used for the ++ + # and Open Graph descriptions. + # + # @param view [Symbol, String, nil] logical view identifier. + # @param private_mode [Boolean] whether private mode is enabled. Drives + # suppression of chat-specific copy and other federation-aware text. + # @return [String, nil] description text or +nil+ when the view should + # inherit the global description. + def view_description(view, private_mode:) + return nil if view.nil? + + symbol = view.respond_to?(:to_sym) ? view.to_sym : view + site = Sanitizer.sanitized_site_name + channel = Sanitizer.sanitized_channel + frequency = Sanitizer.sanitized_frequency + + case symbol + when :map + map_view_description(site, channel, frequency) + when :chat + chat_view_description(site, channel, private_mode: private_mode) + when :charts + "Network activity charts for #{site}: nodes online, traffic, and signal quality." + when :nodes + "All Meshtastic and MeshCore nodes seen on #{site}, with last-heard time and metadata." + when :federation + "Federated PotatoMesh instances sharing node and message data with #{site}." + end + end + + # Compose the description sentence used by the +/map+ view. + # + # @param site [String] sanitized site name. + # @param channel [String] sanitized channel label. + # @param frequency [String] sanitized frequency identifier. + # @return [String] descriptive sentence with the available channel and + # frequency suffixes. + def map_view_description(site, channel, frequency) + lead = "Live coverage map of #{site}" + lead += if !channel.empty? && !frequency.empty? + " on #{channel} (#{frequency})" + elsif !channel.empty? + " on #{channel}" + elsif !frequency.empty? + " tuned to #{frequency}" + else + "" + end + "#{lead} — see node positions in real time." + end + + # Compose the description sentence used by the +/chat+ view. + # + # @param site [String] sanitized site name. + # @param channel [String] sanitized channel label. + # @param private_mode [Boolean] whether the instance is running in + # private mode; chat is hidden for private deployments. + # @return [String, nil] description copy or +nil+ when chat is disabled. + def chat_view_description(site, channel, private_mode:) + return nil if private_mode + + if channel.empty? + "Recent mesh chat traffic on #{site}." + else + "Recent mesh chat traffic on #{channel} for #{site}." + end + end + # Build a hash of meta configuration values used by templating layers. # # @param private_mode [Boolean] whether private mode is enabled. + # @param view [Symbol, String, nil] logical view identifier used to derive + # per-page title and description copy. When +nil+, the dashboard + # defaults are returned. + # @param overrides [Hash, nil] explicit values that take precedence over + # both view-specific and global defaults. Recognised keys: +:title+, + # +:description+, +:image+, +:noindex+. # @return [Hash] structured metadata for templates. - def configuration(private_mode:) + def configuration(private_mode:, view: nil, overrides: nil) site = Sanitizer.sanitized_site_name + base_description = description(private_mode: private_mode) + override_hash = overrides.is_a?(Hash) ? overrides : {} + + override_title = string_or_nil(override_hash[:title]) + override_description = string_or_nil(override_hash[:description]) + override_image = string_or_nil(override_hash[:image]) + override_noindex = override_hash[:noindex] == true + + resolved_title = override_title || view_title(view, site) || site + resolved_description = override_description || + view_description(view, private_mode: private_mode) || + base_description + { - title: site, + title: resolved_title, name: site, - description: description(private_mode: private_mode), + description: resolved_description, + image: override_image, + noindex: override_noindex, }.freeze end + + # Coerce arbitrary input into a trimmed non-empty string or +nil+. + # + # @param value [Object, nil] candidate value. + # @return [String, nil] non-empty string or +nil+ when the input is + # blank, missing, or coerces to an empty value. + def string_or_nil(value) + return nil if value.nil? + + str = value.is_a?(String) ? value : value.to_s + trimmed = str.strip + trimmed.empty? ? nil : trimmed + end end end diff --git a/web/lib/potato_mesh/og_image.rb b/web/lib/potato_mesh/og_image.rb new file mode 100644 index 0000000..1269e26 --- /dev/null +++ b/web/lib/potato_mesh/og_image.rb @@ -0,0 +1,364 @@ +# 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 "fileutils" + +require_relative "config" +require_relative "logging" + +module PotatoMesh + # Runtime generator and cache layer for the Open Graph / Twitter Card + # preview image served at +/og-image.png+. + # + # The module is responsible for: + # + # * Producing a 1200×630 PNG screenshot of the dashboard via + # {Ferrum} (Chrome DevTools Protocol). + # * Caching successful captures on disk so that subsequent crawler hits + # are cheap. + # * Falling back to the previous cache (or the bundled default PNG) when + # a capture cannot be performed — for example, because Chromium is + # unavailable in the runtime image. + # + # The capture step is encapsulated in {.invoke_capture} so test suites + # can substitute it with {.capture_strategy=} and exercise the cache and + # response paths without launching a real browser. + module OgImage + module_function + + # Raised when the capture pipeline could not produce a screenshot for + # any reason (Chromium missing, navigation timeout, transient network + # failure, etc.). Callers translate it into a fallback response. + class CaptureError < StandardError; end + + # Minimum interval between capture attempts after a failure. Prevents a + # tight loop of relaunching Chromium when a persistent error is in play + # (e.g. disk-full breaks {.write_cache} or the browser binary is + # missing). Subsequent crawler hits inside the window are answered from + # the cache or the bundled default PNG without re-attempting. + CAPTURE_FAILURE_BACKOFF_SECONDS = 60 + + # Module-level mutex guarding capture invocations to prevent a + # thundering-herd of concurrent crawler requests from spawning multiple + # browsers. Created once when the module loads. + @capture_mutex = Mutex.new + + # Optional override for the capture function. When set, {.invoke_capture} + # delegates to this callable instead of {.default_capture}; tests use + # this hook to inject deterministic byte payloads. + @capture_strategy = nil + + # Timestamp of the last failed capture attempt. Used by + # {.in_failure_backoff?} to throttle retries when capture or cache + # writes are persistently failing. + @last_failure_at = nil + + # Produce a response payload for the +/og-image.png+ route. + # + # @param base_url [String] absolute URL of the running application, used + # as the navigation target for the headless browser. + # @return [Hash] hash with +:bytes+ (binary PNG payload), + # +:last_modified+ ({Time}), and +:max_age+ (Integer seconds for the + # Cache-Control header). + def serve(base_url:) + bytes, last_modified = resolve_image_bytes(base_url: base_url) + { + bytes: bytes, + last_modified: last_modified, + max_age: PotatoMesh::Config.og_image_ttl_seconds, + } + end + + # Resolve the freshest image bytes available, capturing a new + # screenshot when the cache is empty or stale. + # + # @param base_url [String] dashboard URL captured by Ferrum. + # @return [Array(String, Time)] PNG payload and its last-modified + # timestamp. + def resolve_image_bytes(base_url:) + cache = read_cache + return [cache[:bytes], cache[:mtime]] if cache && cache_fresh?(cache[:mtime]) + + refreshed = attempt_refresh(base_url) + return refreshed if refreshed + + return [cache[:bytes], cache[:mtime]] if cache + + default = read_default + return default if default + + raise CaptureError, "no preview image available" + end + + # Try to capture a fresh screenshot, returning the new payload on + # success and +nil+ when the capture failed, another thread is already + # running one, or the backoff window from a recent failure is still + # active. + # + # @param base_url [String] dashboard URL captured by Ferrum. + # @return [Array(String, Time), nil] new bytes and timestamp, or +nil+. + def attempt_refresh(base_url) + return nil if in_failure_backoff? + + acquired = @capture_mutex.try_lock + return nil unless acquired + + begin + bytes = invoke_capture(base_url) + write_succeeded = write_cache(bytes) + @last_failure_at = write_succeeded ? nil : Time.now + [bytes, Time.now] + rescue StandardError => e + log_capture_error(e) + @last_failure_at = Time.now + nil + ensure + @capture_mutex.unlock if acquired + end + end + + # Determine whether a recent failure should suppress another capture + # attempt. The backoff is reset by the first successful capture and + # cache write. + # + # @return [Boolean] +true+ when capture attempts should be skipped. + def in_failure_backoff? + return false unless @last_failure_at + + (Time.now - @last_failure_at) < CAPTURE_FAILURE_BACKOFF_SECONDS + end + + # Invoke either the configured {.capture_strategy} or + # {.default_capture} to produce PNG bytes. + # + # @param base_url [String] navigation target. + # @return [String] binary PNG payload. + def invoke_capture(base_url) + strategy = @capture_strategy || method(:default_capture) + strategy.call(base_url) + end + + # Default capture implementation backed by the +ferrum+ gem. + # + # The browser is launched with the configured viewport, navigated to + # +base_url+, and given a brief idle window before the screenshot is + # taken. Errors raised by Ferrum are wrapped in {CaptureError} so the + # serve path can fall back gracefully. + # + # @param base_url [String] navigation target. + # @return [String] binary PNG payload. + # @raise [CaptureError] when the capture cannot be performed. + def default_capture(base_url) + browser = build_browser + begin + browser.goto(base_url.to_s) + wait_for_settled(browser) + bytes = browser.screenshot(format: "png", encoding: :binary, full: false) + bytes.is_a?(String) ? bytes : bytes.to_s + ensure + safely_quit_browser(browser) + end + rescue LoadError => e + raise CaptureError, "ferrum not installed: #{e.message}" + rescue StandardError => e + raise CaptureError, "capture failed: #{e.message}" + end + + # Construct a fresh Ferrum browser instance using configuration values. + # Loads the gem lazily so importing this module does not pull Chromium + # into environments that never need it. + # + # @return [Object] Ferrum::Browser instance. + def build_browser + require "ferrum" + Ferrum::Browser.new(browser_options) + end + + # Build the option hash passed to +Ferrum::Browser.new+. Extracted as + # a separate method so tests can verify the dimensions without + # launching the browser. + # + # The +--no-sandbox+ flag is required to launch Chromium as a non-root + # user inside an Alpine container without the kernel SETUID helper. + # This is only safe because the capture target is always the + # operator's own dashboard ({.serve} fetches +base_url+ from the + # +/og-image.png+ route, which derives it from the running app's + # public URL). DO NOT extend this code path to capture untrusted URLs + # — the disabled sandbox would turn a renderer-process exploit into a + # container escape. + # + # +--disable-dev-shm-usage+ avoids /dev/shm OOMs in small containers + # and +--disable-gpu+ prevents WebGL probing on machines without a + # GPU. Both are routine for headless Chromium captures. + # + # @return [Hash] keyword options for Ferrum::Browser. + def browser_options + options = { + headless: true, + window_size: [ + PotatoMesh::Config.og_image_viewport_width, + PotatoMesh::Config.og_image_viewport_height, + ], + timeout: PotatoMesh::Config.og_image_navigation_timeout, + process_timeout: PotatoMesh::Config.og_image_navigation_timeout, + browser_options: { + "no-sandbox": nil, + "disable-dev-shm-usage": nil, + "disable-gpu": nil, + }, + } + browser_path = ENV["FERRUM_BROWSER_PATH"] + options[:browser_path] = browser_path if browser_path && !browser_path.empty? + options + end + + # Wait for the dashboard to reach a stable state before capturing. + # Network-idle timeouts are tolerated because some dashboard widgets + # may continue polling indefinitely. + # + # @param browser [Object] Ferrum::Browser instance. + # @return [void] + def wait_for_settled(browser) + return unless browser.respond_to?(:network) + + browser.network.wait_for_idle( + duration: PotatoMesh::Config.og_image_network_idle_duration, + timeout: PotatoMesh::Config.og_image_network_idle_timeout, + ) + rescue StandardError + # Idle timeout — proceed with a best-effort capture. + end + + # Quit the browser, ignoring shutdown errors so a slow or already-dead + # browser does not mask the original exception. + # + # @param browser [Object, nil] Ferrum::Browser instance. + # @return [void] + def safely_quit_browser(browser) + return if browser.nil? + + browser.quit + rescue StandardError + # Best-effort cleanup — never let teardown raise. + end + + # Read the cached preview from disk when present and readable. + # + # @return [Hash{Symbol=>Object}, nil] hash with +:bytes+ and +:mtime+ + # keys, or +nil+ when no cache file exists. + def read_cache + path = PotatoMesh::Config.og_image_cache_path + return nil unless File.file?(path) && File.readable?(path) + + bytes = File.binread(path) + return nil if bytes.empty? + + { bytes: bytes, mtime: File.mtime(path) } + rescue SystemCallError + nil + end + + # Persist the freshly-captured PNG payload to the cache location. + # + # Returns +true+ on success so callers can clear the failure backoff + # only when the cache is actually durable. Empty/nil payloads count as + # a write failure so the backoff path triggers and we do not loop + # capturing without persisting. + # + # @param bytes [String] binary PNG payload. + # @return [Boolean] +true+ on success, +false+ otherwise. + def write_cache(bytes) + return false unless bytes.is_a?(String) && !bytes.empty? + + path = PotatoMesh::Config.og_image_cache_path + FileUtils.mkdir_p(File.dirname(path)) + File.binwrite(path, bytes) + true + rescue SystemCallError => e + log_capture_error(e) + false + end + + # Determine whether the cache mtime falls inside the configured TTL. + # + # @param mtime [Time] cache file modification time. + # @return [Boolean] +true+ when the cache is still fresh. + def cache_fresh?(mtime) + return false unless mtime.is_a?(Time) + + (Time.now - mtime) < PotatoMesh::Config.og_image_ttl_seconds + end + + # Read the bundled default PNG as a last-resort fallback. + # + # @return [Array(String, Time), nil] payload and modification time, or + # +nil+ when the default file is missing. + def read_default + path = PotatoMesh::Config.og_image_default_path + return nil unless File.file?(path) && File.readable?(path) + + [File.binread(path), File.mtime(path)] + rescue SystemCallError + nil + end + + # Override the capture strategy. Intended for test suites that need to + # exercise the serve/cache logic without spawning Chromium. + # + # @param callable [#call, nil] callable that accepts +base_url+ and + # returns PNG bytes, or +nil+ to restore {.default_capture}. + # @return [void] + def capture_strategy=(callable) + @capture_strategy = callable + end + + # Reset module state for use in tests. Releases the capture mutex if + # it is held, clears the configured strategy, removes the cache file, + # and clears the failure backoff timestamp so individual specs are + # isolated from each other. + # + # @return [void] + def reset_for_tests! + @capture_strategy = nil + @last_failure_at = nil + @capture_mutex.unlock if @capture_mutex.owned? + path = PotatoMesh::Config.og_image_cache_path + File.unlink(path) if File.exist?(path) + rescue SystemCallError + # Cache cleanup is best-effort; ignore filesystem errors. + end + + # Emit a structured warning when capture or cache I/O fails. Logging is + # best-effort: errors are swallowed when no logger is available so the + # serve path can continue to fall back without raising. + # + # @param error [Exception] caught error instance. + # @return [void] + def log_capture_error(error) + logger = PotatoMesh::Logging.logger_for + return unless logger + + PotatoMesh::Logging.log( + logger, + :warn, + "preview capture fell back to cache/default", + context: "og_image", + error: error.class.name, + message: error.message, + ) + end + end +end diff --git a/web/pages/1-about.md b/web/pages/1-about.md index 22b3ca7..64ac716 100644 --- a/web/pages/1-about.md +++ b/web/pages/1-about.md @@ -1,3 +1,10 @@ +--- +title: About +description: Community dashboard for the local mesh — what it is, how to join, and where to read more. +# image: https://example.com/your-page-preview.png +# noindex: true +--- + # 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. @@ -39,6 +46,25 @@ 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. +### Optional Frontmatter + +Each page may begin with a YAML frontmatter block to override the default +nav label and SEO meta tags. All keys are optional: + +``` +--- +title: About +description: Short summary shown to search engines and link previews. +image: https://example.com/about-preview.png +noindex: true +--- +``` + +- `title` — overrides the slug-derived nav label and the document title. +- `description` — replaces the global meta description for this page only. +- `image` — absolute URL to a per-page social preview image (1200×630 recommended). +- `noindex` — when truthy, emits `` and removes the page from `/sitemap.xml`. Useful for legal pages such as Impressum that should remain reachable but not indexed. + ### Filename Convention ``` diff --git a/web/public/og-image-default.png b/web/public/og-image-default.png new file mode 100644 index 0000000..749bf5e Binary files /dev/null and b/web/public/og-image-default.png differ diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 7da6740..d9c7427 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -1356,7 +1356,10 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(last_response.body).to include(%(meta name="description" content="#{expected_description}" />)) expect(last_response.body).to include('') expect(last_response.body).to include('') - expect(last_response.body).to include('') + expect(last_response.body).to include('') + expect(last_response.body).to include('') + expect(last_response.body).to include('') + expect(last_response.body).to include('') end it "does not include the removed auto-fit checkbox regardless of map zoom override" do diff --git a/web/spec/helpers/root_helpers_spec.rb b/web/spec/helpers/root_helpers_spec.rb new file mode 100644 index 0000000..b8a88ca --- /dev/null +++ b/web/spec/helpers/root_helpers_spec.rb @@ -0,0 +1,205 @@ +# 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::Routes::Root::Helpers do + let(:harness_class) do + Class.new do + include PotatoMesh::App::Helpers + include PotatoMesh::App::Routes::Root::Helpers + + attr_accessor :request + + def app_constant(name) + @constants ||= {} + @constants[name] + end + + def set_constant(name, value) + @constants ||= {} + @constants[name] = value + end + end + end + + let(:helper) { harness_class.new } + let(:request_double) { double("request", base_url: "http://upstream.example", scheme: "https") } + + before do + helper.request = request_double + end + + describe "#public_base_url" do + it "returns the instance domain when configured" do + helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net") + + expect(helper.public_base_url).to eq("https://potatomesh.net") + end + + it "honors the request scheme when present" do + helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net") + allow(request_double).to receive(:scheme).and_return("http") + + expect(helper.public_base_url).to eq("http://potatomesh.net") + end + + it "defaults to https when the scheme is missing" do + helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net") + allow(request_double).to receive(:scheme).and_return(nil) + + expect(helper.public_base_url).to eq("https://potatomesh.net") + end + + it "falls back to request.base_url when no instance domain is set" do + helper.set_constant(:INSTANCE_DOMAIN, nil) + + expect(helper.public_base_url).to eq("http://upstream.example") + end + end + + describe "#og_image_url" do + before do + helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net") + end + + it "returns the OG_IMAGE_URL override verbatim when set" do + allow(PotatoMesh::Config).to receive(:og_image_url).and_return("https://cdn.example.org/preview.png") + + expect(helper.og_image_url).to eq("https://cdn.example.org/preview.png") + end + + it "returns the runtime preview URL when no override is configured" do + allow(PotatoMesh::Config).to receive(:og_image_url).and_return(nil) + + expect(helper.og_image_url).to eq("https://potatomesh.net/og-image.png") + end + + it "treats blank overrides as unset" do + allow(PotatoMesh::Config).to receive(:og_image_url).and_return(" ") + + expect(helper.og_image_url).to eq("https://potatomesh.net/og-image.png") + end + end + + describe "#node_detail_title_label" do + it "combines short and long names when both are present" do + label = helper.node_detail_title_label(short_name: "ABCD", long_name: "Long Name", canonical_id: "!aabbccdd") + expect(label).to eq("ABCD (Long Name)") + end + + it "returns the short name alone when long is missing" do + label = helper.node_detail_title_label(short_name: "ABCD", long_name: nil, canonical_id: "!aabbccdd") + expect(label).to eq("ABCD") + end + + it "returns the long name when only that is present" do + label = helper.node_detail_title_label(short_name: nil, long_name: "Long", canonical_id: "!aabbccdd") + expect(label).to eq("Long") + end + + it "falls back to the canonical id when both names are blank" do + label = helper.node_detail_title_label(short_name: nil, long_name: nil, canonical_id: "!aabbccdd") + expect(label).to eq("Node !aabbccdd") + end + + it "uses a generic label when no identifier is available" do + label = helper.node_detail_title_label(short_name: nil, long_name: nil, canonical_id: nil) + expect(label).to eq("Node detail") + end + end + + describe "#static_page_meta_overrides" do + let(:page) do + PotatoMesh::App::Pages::PageEntry.new( + slug: "about", + title: "About", + description: "Custom description.", + image: "https://e.com/p.png", + noindex: true, + ) + end + + before do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_site_name).and_return("Test Mesh") + end + + it "includes only populated keys" do + result = helper.static_page_meta_overrides(page) + + expect(result[:title]).to eq("About · Test Mesh") + expect(result[:description]).to eq("Custom description.") + expect(result[:image]).to eq("https://e.com/p.png") + expect(result[:noindex]).to be(true) + end + + it "omits description, image, and noindex when frontmatter is empty" do + bare = PotatoMesh::App::Pages::PageEntry.new(slug: "about", title: "About") + + result = helper.static_page_meta_overrides(bare) + + expect(result.keys).to contain_exactly(:title) + expect(result[:title]).to eq("About · Test Mesh") + end + + it "uses the bare title when the site name is blank" do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_site_name).and_return("") + bare = PotatoMesh::App::Pages::PageEntry.new(slug: "about", title: "About") + + result = helper.static_page_meta_overrides(bare) + + expect(result[:title]).to eq("About") + end + + it "falls back to the site name when title is blank" do + bare = PotatoMesh::App::Pages::PageEntry.new(slug: "about", title: "") + + result = helper.static_page_meta_overrides(bare) + + expect(result[:title]).to eq("Test Mesh") + end + end + + describe "#xml_escape" do + it "escapes the five XML predefined entities" do + expect(helper.xml_escape("a&bd\"e'f")).to eq("a&b<c>d"e'f") + end + + it "coerces non-string input into a string before escaping" do + expect(helper.xml_escape(42)).to eq("42") + end + end + + describe "#build_robots_txt" do + it "returns a blanket disallow in private mode" do + allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true) + + result = helper.build_robots_txt("https://example.test/sitemap.xml") + + expect(result).to eq("User-agent: *\nDisallow: /\n") + end + + it "advertises the sitemap and instrumentation paths in public mode" do + allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false) + + result = helper.build_robots_txt("https://example.test/sitemap.xml") + + expect(result).to include("Disallow: /metrics") + expect(result).to include("Disallow: /api/") + expect(result).to include("Sitemap: https://example.test/sitemap.xml") + end + end +end diff --git a/web/spec/identity_spec.rb b/web/spec/identity_spec.rb index 9699065..4879f94 100644 --- a/web/spec/identity_spec.rb +++ b/web/spec/identity_spec.rb @@ -114,6 +114,58 @@ RSpec.describe PotatoMesh::App::Identity do end end + describe ".log_instance_domain_resolution" do + let(:logger) { instance_double(Logger, debug: nil, warn: nil) } + + before do + allow(PotatoMesh::Logging).to receive(:logger_for).and_return(logger) + end + + around do |example| + original_app_env = ENV["APP_ENV"] + original_rack_env = ENV["RACK_ENV"] + example.run + ensure + if original_app_env + ENV["APP_ENV"] = original_app_env + else + ENV.delete("APP_ENV") + end + ENV["RACK_ENV"] = original_rack_env if original_rack_env + end + + it "warns in production when the instance domain is unset" do + ENV["APP_ENV"] = "production" + stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil) + stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :unconfigured) + + PotatoMesh::Application.log_instance_domain_resolution + + expect(logger).to have_received(:warn).with(/INSTANCE_DOMAIN is unset/) + end + + it "stays quiet when the instance domain is configured" do + ENV["APP_ENV"] = "production" + stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "example.com") + stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :env) + + PotatoMesh::Application.log_instance_domain_resolution + + expect(logger).not_to have_received(:warn) + end + + it "stays quiet outside production even when the domain is unset" do + ENV["APP_ENV"] = "test" + ENV["RACK_ENV"] = "test" + stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil) + stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :unconfigured) + + PotatoMesh::Application.log_instance_domain_resolution + + expect(logger).not_to have_received(:warn) + end + end + describe ".refresh_well_known_document_if_stale" do let(:storage_dir) { Dir.mktmpdir } let(:well_known_path) do diff --git a/web/spec/meta_spec.rb b/web/spec/meta_spec.rb new file mode 100644 index 0000000..322f13b --- /dev/null +++ b/web/spec/meta_spec.rb @@ -0,0 +1,239 @@ +# 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::Meta do + before do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_site_name).and_return("Test Mesh") + allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("#TestCh") + allow(PotatoMesh::Sanitizer).to receive(:sanitized_frequency).and_return("868MHz") + allow(PotatoMesh::Sanitizer).to receive(:sanitized_contact_link).and_return("#chat:example.org") + allow(PotatoMesh::Sanitizer).to receive(:sanitized_max_distance_km).and_return(10.0) + end + + describe ".formatted_distance_km" do + it "drops trailing .0" do + expect(described_class.formatted_distance_km(42.0)).to eq("42") + end + + it "preserves single-decimal precision" do + expect(described_class.formatted_distance_km(42.5)).to eq("42.5") + end + end + + describe ".description" do + it "renders the standard description in public mode" do + result = described_class.description(private_mode: false) + expect(result).to include("Live Meshtastic mesh map for Test Mesh on #TestCh (868MHz).") + expect(result).to include("Track nodes, messages, and coverage in real time.") + expect(result).to include("within roughly 10 km") + expect(result).to include("Join the community in #chat:example.org via chat.") + end + + it "omits message coverage in private mode" do + result = described_class.description(private_mode: true) + expect(result).to include("Track nodes and coverage in real time.") + expect(result).not_to include("messages,") + end + + it "handles missing channel and frequency" do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("") + allow(PotatoMesh::Sanitizer).to receive(:sanitized_frequency).and_return("") + result = described_class.description(private_mode: false) + expect(result).to start_with("Live Meshtastic mesh map for Test Mesh.") + end + + it "tunes the description when only frequency is configured" do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("") + result = described_class.description(private_mode: false) + expect(result).to include("tuned to 868MHz") + end + + it "describes the channel when only the channel is configured" do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_frequency).and_return("") + result = described_class.description(private_mode: false) + expect(result).to include("on #TestCh") + end + + it "skips the radius sentence when no max distance is configured" do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_max_distance_km).and_return(nil) + result = described_class.description(private_mode: false) + expect(result).not_to include("within roughly") + end + + it "skips the contact sentence when no contact is configured" do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_contact_link).and_return(nil) + result = described_class.description(private_mode: false) + expect(result).not_to include("Join the community") + end + end + + describe ".view_label" do + it "returns labels for known views" do + expect(described_class.view_label(:map)).to eq("Map") + expect(described_class.view_label(:chat)).to eq("Chat") + expect(described_class.view_label(:charts)).to eq("Charts") + expect(described_class.view_label(:nodes)).to eq("Nodes") + expect(described_class.view_label(:federation)).to eq("Federation") + end + + it "accepts string view identifiers" do + expect(described_class.view_label("map")).to eq("Map") + end + + it "returns nil for unknown views" do + expect(described_class.view_label(:dashboard)).to be_nil + expect(described_class.view_label(nil)).to be_nil + end + end + + describe ".view_title" do + it "composes Label · Site for known views" do + expect(described_class.view_title(:map, "Test Mesh")).to eq("Map · Test Mesh") + end + + it "returns nil when no label exists for the view" do + expect(described_class.view_title(:dashboard, "Test Mesh")).to be_nil + end + + it "returns the bare label when site is blank" do + expect(described_class.view_title(:map, "")).to eq("Map") + end + end + + describe ".view_description" do + it "renders the map description with channel and frequency" do + result = described_class.view_description(:map, private_mode: false) + expect(result).to include("Live coverage map of Test Mesh on #TestCh (868MHz)") + end + + it "renders the map description with only channel" do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_frequency).and_return("") + result = described_class.view_description(:map, private_mode: false) + expect(result).to include("on #TestCh") + end + + it "renders the map description with only frequency" do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("") + result = described_class.view_description(:map, private_mode: false) + expect(result).to include("tuned to 868MHz") + end + + it "renders the bare map description without channel or frequency" do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("") + allow(PotatoMesh::Sanitizer).to receive(:sanitized_frequency).and_return("") + result = described_class.view_description(:map, private_mode: false) + expect(result).to start_with("Live coverage map of Test Mesh —") + end + + it "returns nil for the chat view in private mode" do + expect(described_class.view_description(:chat, private_mode: true)).to be_nil + end + + it "returns chat description with channel" do + expect(described_class.view_description(:chat, private_mode: false)).to include("on #TestCh") + end + + it "returns chat description without channel" do + allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("") + expect(described_class.view_description(:chat, private_mode: false)).to include("on Test Mesh") + end + + it "returns descriptions for charts, nodes, and federation" do + expect(described_class.view_description(:charts, private_mode: false)).to include("Network activity charts for Test Mesh") + expect(described_class.view_description(:nodes, private_mode: false)).to include("All Meshtastic and MeshCore nodes seen on Test Mesh") + expect(described_class.view_description(:federation, private_mode: false)).to include("Federated PotatoMesh instances") + end + + it "returns nil for unknown views" do + expect(described_class.view_description(:dashboard, private_mode: false)).to be_nil + expect(described_class.view_description(nil, private_mode: false)).to be_nil + end + end + + describe ".configuration" do + it "returns the dashboard defaults when no view is supplied" do + result = described_class.configuration(private_mode: false) + expect(result[:title]).to eq("Test Mesh") + expect(result[:name]).to eq("Test Mesh") + expect(result[:description]).to include("Live Meshtastic mesh map for Test Mesh") + expect(result[:image]).to be_nil + expect(result[:noindex]).to be(false) + end + + it "returns view-specific titles for known views" do + result = described_class.configuration(private_mode: false, view: :charts) + expect(result[:title]).to eq("Charts · Test Mesh") + expect(result[:description]).to include("Network activity charts") + end + + it "honours overrides over view defaults" do + result = described_class.configuration( + private_mode: false, + view: :charts, + overrides: { + title: "Custom Title", + description: "Custom description", + image: "https://x/p.png", + noindex: true, + }, + ) + expect(result[:title]).to eq("Custom Title") + expect(result[:description]).to eq("Custom description") + expect(result[:image]).to eq("https://x/p.png") + expect(result[:noindex]).to be(true) + end + + it "ignores blank overrides" do + result = described_class.configuration( + private_mode: false, + view: :charts, + overrides: { title: "", description: " " }, + ) + expect(result[:title]).to eq("Charts · Test Mesh") + expect(result[:description]).to include("Network activity charts") + end + + it "treats a non-Hash overrides argument as nothing" do + result = described_class.configuration(private_mode: false, overrides: :not_a_hash) + expect(result[:title]).to eq("Test Mesh") + end + + it "freezes the returned hash" do + result = described_class.configuration(private_mode: false) + expect(result).to be_frozen + end + end + + describe ".string_or_nil" do + it "returns nil for nil" do + expect(described_class.string_or_nil(nil)).to be_nil + end + + it "returns nil for blank strings" do + expect(described_class.string_or_nil(" ")).to be_nil + end + + it "trims and returns non-blank strings" do + expect(described_class.string_or_nil(" hello ")).to eq("hello") + end + + it "stringifies non-string input" do + expect(described_class.string_or_nil(42)).to eq("42") + end + end +end diff --git a/web/spec/og_image_spec.rb b/web/spec/og_image_spec.rb new file mode 100644 index 0000000..4a08c1c --- /dev/null +++ b/web/spec/og_image_spec.rb @@ -0,0 +1,443 @@ +# 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" + +# Unit tests for the runtime Open Graph image module. The capture strategy +# is replaced with deterministic stubs so the full cache/fallback matrix +# can be exercised without spawning Chromium. +RSpec.describe PotatoMesh::OgImage do + let(:cache_path) { File.join(SPEC_TMPDIR, "og-cache-#{SecureRandom.hex(4)}.png") } + let(:default_path) { File.join(SPEC_TMPDIR, "og-default-#{SecureRandom.hex(4)}.png") } + + before do + File.binwrite(default_path, "DEFAULT_BYTES") + allow(PotatoMesh::Config).to receive(:og_image_cache_path).and_return(cache_path) + allow(PotatoMesh::Config).to receive(:og_image_default_path).and_return(default_path) + described_class.reset_for_tests! + end + + after do + described_class.reset_for_tests! + rescue StandardError + # Cleanup is best effort; some specs intentionally stub File ops. + ensure + begin + File.unlink(default_path) if File.exist?(default_path) + rescue StandardError + nil + end + begin + File.unlink(cache_path) if File.exist?(cache_path) + rescue StandardError + nil + end + end + + describe ".serve" do + it "captures and caches a fresh image on first request" do + described_class.capture_strategy = ->(_) { "FRESH_BYTES" } + + payload = described_class.serve(base_url: "http://localhost:41447") + + expect(payload[:bytes]).to eq("FRESH_BYTES") + expect(payload[:max_age]).to eq(PotatoMesh::Config.og_image_ttl_seconds) + expect(File.binread(cache_path)).to eq("FRESH_BYTES") + end + + it "passes the supplied base_url to the capture strategy" do + received = nil + described_class.capture_strategy = ->(url) { received = url; "BYTES" } + + described_class.serve(base_url: "http://example.test") + + expect(received).to eq("http://example.test") + end + + it "returns the cached image while it remains fresh" do + File.binwrite(cache_path, "CACHED_BYTES") + described_class.capture_strategy = ->(_) { raise "should not be called" } + + payload = described_class.serve(base_url: "http://localhost") + + expect(payload[:bytes]).to eq("CACHED_BYTES") + end + + it "refreshes when the cache is older than the TTL" do + File.binwrite(cache_path, "STALE_BYTES") + stale_time = Time.now - PotatoMesh::Config.og_image_ttl_seconds - 60 + File.utime(stale_time, stale_time, cache_path) + described_class.capture_strategy = ->(_) { "REFRESHED_BYTES" } + + payload = described_class.serve(base_url: "http://localhost") + + expect(payload[:bytes]).to eq("REFRESHED_BYTES") + expect(File.binread(cache_path)).to eq("REFRESHED_BYTES") + end + + it "falls back to the cached image when capture raises" do + File.binwrite(cache_path, "STALE_BYTES") + stale_time = Time.now - PotatoMesh::Config.og_image_ttl_seconds - 60 + File.utime(stale_time, stale_time, cache_path) + described_class.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "browser exploded" } + + payload = described_class.serve(base_url: "http://localhost") + + expect(payload[:bytes]).to eq("STALE_BYTES") + end + + it "falls back to the default image when capture fails and no cache exists" do + described_class.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "no chromium" } + + payload = described_class.serve(base_url: "http://localhost") + + expect(payload[:bytes]).to eq("DEFAULT_BYTES") + end + + it "raises CaptureError when neither capture nor default are available" do + described_class.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "no chromium" } + File.unlink(default_path) + + expect { described_class.serve(base_url: "http://localhost") }.to raise_error(PotatoMesh::OgImage::CaptureError) + end + end + + describe ".attempt_refresh" do + it "returns nil when the capture mutex is already held" do + held = Mutex.new + original = described_class.instance_variable_get(:@capture_mutex) + described_class.instance_variable_set(:@capture_mutex, held) + held.lock + + begin + result = described_class.attempt_refresh("http://localhost") + expect(result).to be_nil + ensure + held.unlock + described_class.instance_variable_set(:@capture_mutex, original) + end + end + + it "logs and returns nil when capture raises" do + logger = instance_double(Logger, warn: nil) + allow(PotatoMesh::Logging).to receive(:logger_for).and_return(logger) + described_class.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "oops" } + + result = described_class.attempt_refresh("http://localhost") + + expect(result).to be_nil + expect(PotatoMesh::Logging).to have_received(:logger_for).at_least(:once) + end + + it "skips capture while the failure backoff window is active" do + described_class.instance_variable_set(:@last_failure_at, Time.now) + sentinel = ->(_) { raise "capture should not run during backoff" } + described_class.capture_strategy = sentinel + + expect(described_class.attempt_refresh("http://localhost")).to be_nil + end + + it "retries capture once the failure backoff window has elapsed" do + backoff = PotatoMesh::OgImage::CAPTURE_FAILURE_BACKOFF_SECONDS + 1 + described_class.instance_variable_set(:@last_failure_at, Time.now - backoff) + described_class.capture_strategy = ->(_) { "RECOVERED" } + + result = described_class.attempt_refresh("http://localhost") + + expect(result).not_to be_nil + expect(result.first).to eq("RECOVERED") + expect(described_class.instance_variable_get(:@last_failure_at)).to be_nil + end + + it "records a failure timestamp when the disk write fails" do + described_class.capture_strategy = ->(_) { "BYTES" } + allow(File).to receive(:binwrite).and_raise(Errno::ENOSPC) + + result = described_class.attempt_refresh("http://localhost") + + expect(result).not_to be_nil + expect(described_class.instance_variable_get(:@last_failure_at)).to be_a(Time) + end + end + + describe ".in_failure_backoff?" do + it "is false when no failure has been recorded" do + described_class.instance_variable_set(:@last_failure_at, nil) + expect(described_class.in_failure_backoff?).to be(false) + end + + it "is true while inside the backoff window" do + described_class.instance_variable_set(:@last_failure_at, Time.now) + expect(described_class.in_failure_backoff?).to be(true) + end + + it "is false once the backoff window has elapsed" do + backoff = PotatoMesh::OgImage::CAPTURE_FAILURE_BACKOFF_SECONDS + 1 + described_class.instance_variable_set(:@last_failure_at, Time.now - backoff) + expect(described_class.in_failure_backoff?).to be(false) + end + end + + describe ".invoke_capture" do + it "delegates to the configured strategy" do + described_class.capture_strategy = ->(url) { "BYTES_FOR_#{url}" } + + expect(described_class.invoke_capture("alpha")).to eq("BYTES_FOR_alpha") + end + + it "falls back to default_capture when no strategy is configured" do + described_class.capture_strategy = nil + expect(described_class).to receive(:default_capture).with("alpha").and_return("DEF") + + expect(described_class.invoke_capture("alpha")).to eq("DEF") + end + end + + describe ".browser_options" do + it "honors the configured viewport dimensions" do + options = described_class.browser_options + + expect(options[:window_size]).to eq([ + PotatoMesh::Config.og_image_viewport_width, + PotatoMesh::Config.og_image_viewport_height, + ]) + expect(options[:headless]).to be true + end + + # `--no-sandbox` is required for non-root Alpine containers; removing + # it would silently break Chromium launches in production. The + # corresponding assertion lives in security review (see comment in + # OgImage.browser_options). + it "passes the --no-sandbox flag" do + options = described_class.browser_options + + expect(options[:browser_options]).to have_key(:"no-sandbox") + end + + it "passes the --disable-dev-shm-usage flag" do + options = described_class.browser_options + + expect(options[:browser_options]).to have_key(:"disable-dev-shm-usage") + end + + it "passes the FERRUM_BROWSER_PATH env when present" do + original = ENV["FERRUM_BROWSER_PATH"] + ENV["FERRUM_BROWSER_PATH"] = "/custom/chromium" + begin + options = described_class.browser_options + expect(options[:browser_path]).to eq("/custom/chromium") + ensure + if original + ENV["FERRUM_BROWSER_PATH"] = original + else + ENV.delete("FERRUM_BROWSER_PATH") + end + end + end + + it "omits browser_path when the env var is unset" do + original = ENV["FERRUM_BROWSER_PATH"] + ENV.delete("FERRUM_BROWSER_PATH") + begin + options = described_class.browser_options + expect(options).not_to have_key(:browser_path) + ensure + ENV["FERRUM_BROWSER_PATH"] = original if original + end + end + end + + describe ".default_capture" do + it "wraps Ferrum errors in CaptureError" do + browser_double = double("browser") + allow(browser_double).to receive(:goto).and_raise(StandardError, "boom") + allow(browser_double).to receive(:quit) + allow(described_class).to receive(:build_browser).and_return(browser_double) + allow(described_class).to receive(:wait_for_settled) + + expect { described_class.default_capture("http://localhost") }.to raise_error(PotatoMesh::OgImage::CaptureError, /capture failed/) + end + + it "returns the screenshot bytes from the browser" do + browser_double = double("browser") + allow(browser_double).to receive(:goto) + allow(browser_double).to receive(:quit) + allow(browser_double).to receive(:screenshot).and_return("PNG") + allow(described_class).to receive(:build_browser).and_return(browser_double) + allow(described_class).to receive(:wait_for_settled) + + expect(described_class.default_capture("http://localhost")).to eq("PNG") + end + + it "wraps a missing ferrum gem in CaptureError" do + allow(described_class).to receive(:build_browser).and_raise(LoadError, "cannot load such file -- ferrum") + + expect { described_class.default_capture("http://localhost") }.to raise_error(PotatoMesh::OgImage::CaptureError, /ferrum not installed/) + end + end + + describe ".wait_for_settled" do + it "returns silently when the browser does not expose network" do + stub = double("browser") + allow(stub).to receive(:respond_to?).with(:network).and_return(false) + + expect { described_class.wait_for_settled(stub) }.not_to raise_error + end + + it "swallows idle timeouts" do + network = double("network") + allow(network).to receive(:wait_for_idle).and_raise(StandardError, "idle timeout") + stub = double("browser", network: network) + allow(stub).to receive(:respond_to?).with(:network).and_return(true) + + expect { described_class.wait_for_settled(stub) }.not_to raise_error + end + end + + describe ".safely_quit_browser" do + it "is a no-op when the browser is nil" do + expect { described_class.safely_quit_browser(nil) }.not_to raise_error + end + + it "ignores errors raised during quit" do + stub = double("browser") + allow(stub).to receive(:quit).and_raise(StandardError, "already dead") + + expect { described_class.safely_quit_browser(stub) }.not_to raise_error + end + end + + describe ".cache_fresh?" do + it "returns false when the mtime is not a Time" do + expect(described_class.cache_fresh?(nil)).to be(false) + end + + it "returns true for a recent mtime" do + expect(described_class.cache_fresh?(Time.now - 1)).to be(true) + end + + it "returns false for an mtime older than the TTL" do + old = Time.now - PotatoMesh::Config.og_image_ttl_seconds - 1 + expect(described_class.cache_fresh?(old)).to be(false) + end + end + + describe ".read_cache" do + it "returns nil when the cache file does not exist" do + expect(described_class.read_cache).to be_nil + end + + it "returns nil when the cache file is empty" do + File.binwrite(cache_path, "") + expect(described_class.read_cache).to be_nil + end + + it "returns the bytes and mtime when the file exists" do + File.binwrite(cache_path, "BYTES") + result = described_class.read_cache + expect(result[:bytes]).to eq("BYTES") + expect(result[:mtime]).to be_a(Time) + end + + it "returns nil on filesystem errors" do + File.binwrite(cache_path, "BYTES") + allow(File).to receive(:binread).with(cache_path).and_raise(Errno::EIO) + + expect(described_class.read_cache).to be_nil + end + end + + describe ".write_cache" do + it "returns false for empty input" do + expect(described_class.write_cache("")).to be(false) + expect(File.exist?(cache_path)).to be(false) + end + + it "returns false for non-string input" do + expect(described_class.write_cache(nil)).to be(false) + expect(File.exist?(cache_path)).to be(false) + end + + it "creates the cache directory when missing and returns true" do + nested_path = File.join(SPEC_TMPDIR, "og-nested-#{SecureRandom.hex(4)}", "img.png") + allow(PotatoMesh::Config).to receive(:og_image_cache_path).and_return(nested_path) + + expect(described_class.write_cache("PAYLOAD")).to be(true) + expect(File.binread(nested_path)).to eq("PAYLOAD") + ensure + FileUtils.rm_rf(File.dirname(nested_path)) if nested_path + end + + it "returns false and logs when the disk write fails" do + logger = instance_double(Logger, warn: nil) + allow(PotatoMesh::Logging).to receive(:logger_for).and_return(logger) + allow(File).to receive(:binwrite).and_raise(Errno::EIO) + + expect(described_class.write_cache("DATA")).to be(false) + end + end + + describe ".read_default" do + it "returns nil when the default file is missing" do + File.unlink(default_path) + expect(described_class.read_default).to be_nil + end + + it "returns nil on filesystem errors" do + allow(File).to receive(:binread).with(default_path).and_raise(Errno::EIO) + expect(described_class.read_default).to be_nil + end + + it "returns the bytes and mtime when present" do + bytes, mtime = described_class.read_default + expect(bytes).to eq("DEFAULT_BYTES") + expect(mtime).to be_a(Time) + end + end + + describe ".log_capture_error" do + it "is a no-op when no logger is available" do + allow(PotatoMesh::Logging).to receive(:logger_for).and_return(nil) + + expect { described_class.log_capture_error(StandardError.new("x")) }.not_to raise_error + end + + it "delegates to the logging helper when a logger is configured" do + logger = instance_double(Logger, warn: nil) + allow(PotatoMesh::Logging).to receive(:logger_for).and_return(logger) + + described_class.log_capture_error(StandardError.new("test")) + + expect(logger).to have_received(:warn).at_least(:once) + end + end + + describe ".reset_for_tests!" do + it "clears the cache file" do + File.binwrite(cache_path, "STALE") + described_class.reset_for_tests! + expect(File.exist?(cache_path)).to be(false) + end + + it "ignores filesystem errors" do + File.binwrite(cache_path, "STALE") + allow(File).to receive(:unlink).and_call_original + allow(File).to receive(:unlink).with(cache_path).and_raise(Errno::EIO) + + expect { described_class.reset_for_tests! }.not_to raise_error + end + end +end diff --git a/web/spec/pages_spec.rb b/web/spec/pages_spec.rb index a0078a9..d321d25 100644 --- a/web/spec/pages_spec.rb +++ b/web/spec/pages_spec.rb @@ -382,6 +382,241 @@ RSpec.describe PotatoMesh::App::Pages do end end + # ── parse_frontmatter ─────────────────────────────────────── + + describe ".parse_frontmatter" do + it "returns an empty hash when there is no frontmatter" do + expect(described_class.parse_frontmatter("# Just markdown")).to eq({}) + end + + it "returns an empty hash for non-string input" do + expect(described_class.parse_frontmatter(nil)).to eq({}) + end + + it "extracts whitelisted keys" do + doc = "---\ntitle: Example\ndescription: A short summary.\nimage: https://e.com/p.png\nnoindex: true\n---\nbody" + + result = described_class.parse_frontmatter(doc) + + expect(result["title"]).to eq("Example") + expect(result["description"]).to eq("A short summary.") + expect(result["image"]).to eq("https://e.com/p.png") + expect(result["noindex"]).to be(true) + end + + it "ignores keys outside the whitelist" do + doc = "---\ntitle: Example\nrandom: malicious\n---\nbody" + + result = described_class.parse_frontmatter(doc) + + expect(result).to have_key("title") + expect(result).not_to have_key("random") + end + + it "treats malformed YAML as having no frontmatter" do + doc = "---\ntitle: : :\n---\nbody" + + expect(described_class.parse_frontmatter(doc)).to eq({}) + end + + it "ignores frontmatter that does not parse into a Hash" do + doc = "---\n- one\n- two\n---\nbody" + + expect(described_class.parse_frontmatter(doc)).to eq({}) + end + + it "coerces non-string scalars into strings for text fields" do + doc = "---\ntitle: 42\n---\nbody" + + expect(described_class.parse_frontmatter(doc)["title"]).to eq("42") + end + + it "drops blank string values" do + doc = "---\ntitle: ' '\n---\nbody" + + expect(described_class.parse_frontmatter(doc)["title"]).to be_nil + end + + it "accepts an https image URL" do + doc = "---\nimage: https://e.com/p.png\n---\nbody" + + expect(described_class.parse_frontmatter(doc)["image"]).to eq("https://e.com/p.png") + end + + it "rejects javascript: image URLs" do + doc = "---\nimage: 'javascript:alert(1)'\n---\nbody" + + expect(described_class.parse_frontmatter(doc)["image"]).to be_nil + end + + it "rejects data: image URLs" do + doc = "---\nimage: 'data:image/png;base64,iVBORw0KGgo='\n---\nbody" + + expect(described_class.parse_frontmatter(doc)["image"]).to be_nil + end + + it "rejects relative image paths" do + doc = "---\nimage: /assets/p.png\n---\nbody" + + expect(described_class.parse_frontmatter(doc)["image"]).to be_nil + end + end + + # ── strip_frontmatter ─────────────────────────────────────── + + describe ".strip_frontmatter" do + it "removes the leading frontmatter block" do + input = "---\ntitle: A\n---\n# Body\n" + expect(described_class.strip_frontmatter(input)).to eq("# Body\n") + end + + it "passes through content without frontmatter unchanged" do + input = "# Body\n" + expect(described_class.strip_frontmatter(input)).to eq(input) + end + + it "passes non-string input through" do + expect(described_class.strip_frontmatter(nil)).to be_nil + end + end + + # ── truthy_frontmatter? ───────────────────────────────────── + + describe ".truthy_frontmatter?" do + it "passes booleans through" do + expect(described_class.truthy_frontmatter?(true)).to be(true) + expect(described_class.truthy_frontmatter?(false)).to be(false) + end + + it "matches common truthy strings" do + %w[true Yes 1 ON].each do |literal| + expect(described_class.truthy_frontmatter?(literal)).to be(true) + end + end + + it "returns false for unknown values" do + expect(described_class.truthy_frontmatter?("nope")).to be(false) + end + end + + # ── read_frontmatter_probe ────────────────────────────────── + + describe ".read_frontmatter_probe" do + let(:dir) { File.join(SPEC_TMPDIR, "probe-#{SecureRandom.hex(4)}") } + + before { FileUtils.mkdir_p(dir) } + after { FileUtils.rm_rf(dir) } + + it "returns an empty string for a missing file" do + expect(described_class.read_frontmatter_probe(File.join(dir, "missing.md"))).to eq("") + end + + it "reads the first FRONTMATTER_PROBE_BYTES bytes of the file" do + path = File.join(dir, "long.md") + File.write(path, "x" * (PotatoMesh::App::Pages::FRONTMATTER_PROBE_BYTES + 10)) + + probe = described_class.read_frontmatter_probe(path) + + expect(probe.length).to eq(PotatoMesh::App::Pages::FRONTMATTER_PROBE_BYTES) + end + + it "returns an empty string on filesystem errors" do + path = File.join(dir, "err.md") + File.write(path, "data") + allow(File).to receive(:open).with(path, "r:UTF-8").and_raise(Errno::EIO) + + expect(described_class.read_frontmatter_probe(path)).to eq("") + end + end + + # ── apply_frontmatter ─────────────────────────────────────── + + describe ".apply_frontmatter" do + it "returns the original entry when nothing is supplied" do + base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X") + + result = described_class.apply_frontmatter(base, {}) + + expect(result.title).to eq("X") + expect(result.description).to be_nil + expect(result.image).to be_nil + expect(result.noindex).to be(false) + end + + it "returns nil when the base entry is nil" do + expect(described_class.apply_frontmatter(nil, {})).to be_nil + end + + it "overrides title when frontmatter provides one" do + base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X") + + result = described_class.apply_frontmatter(base, "title" => "Custom") + + expect(result.title).to eq("Custom") + end + + it "keeps the filename-derived title when frontmatter omits one" do + base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X") + + result = described_class.apply_frontmatter(base, "description" => "Note") + + expect(result.title).to eq("X") + end + + it "captures noindex flag" do + base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X") + + result = described_class.apply_frontmatter(base, "noindex" => true) + + expect(result.noindex).to be(true) + end + + it "captures image and description fields" do + base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X") + + result = described_class.apply_frontmatter( + base, + "description" => "A summary.", + "image" => "https://e.com/p.png", + ) + + expect(result.description).to eq("A summary.") + expect(result.image).to eq("https://e.com/p.png") + end + end + + # ── load_static_pages with frontmatter ────────────────────── + + describe ".load_static_pages with frontmatter" do + it "applies frontmatter values during directory scans" do + File.write( + File.join(pages_dir, "1-about.md"), + "---\ntitle: About Page\ndescription: A summary.\nnoindex: true\n---\n# Body\n", + ) + + result = described_class.load_static_pages(pages_dir) + + expect(result.first.title).to eq("About Page") + expect(result.first.description).to eq("A summary.") + expect(result.first.noindex).to be(true) + end + end + + describe ".render_page_content with frontmatter" do + it "strips frontmatter before rendering" do + path = File.join(pages_dir, "1-test.md") + File.write(path, "---\ntitle: Doc\n---\n# Body Heading\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("Body Heading") + expect(html).not_to include("title: Doc") + end + end + # ── production_environment? ───────────────────────────────── describe ".production_environment?" do diff --git a/web/spec/seo_spec.rb b/web/spec/seo_spec.rb new file mode 100644 index 0000000..bbc5473 --- /dev/null +++ b/web/spec/seo_spec.rb @@ -0,0 +1,312 @@ +# 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" +require "rexml/document" + +# Acceptance suite for the search-engine and social-preview surfaces: +# +/robots.txt+, +/sitemap.xml+, per-route meta tags, the JSON-LD block on +# the dashboard, the Open Graph image override path, and the +noindex+ +# frontmatter behaviour. +RSpec.describe "SEO surface" do + let(:app) { Sinatra::Application } + + before do + PotatoMesh::App::Pages.clear_pages_cache! + PotatoMesh::OgImage.reset_for_tests! + PotatoMesh::OgImage.capture_strategy = ->(_) { "PNG_BYTES" } + end + + after do + PotatoMesh::App::Pages.clear_pages_cache! + PotatoMesh::OgImage.reset_for_tests! + end + + describe "GET /robots.txt" do + it "advertises the sitemap and disallows instrumentation in public mode" do + allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false) + + get "/robots.txt" + + expect(last_response).to be_ok + expect(last_response.headers["Content-Type"]).to include("text/plain") + expect(last_response.body).to include("User-agent: *") + expect(last_response.body).to include("Disallow: /metrics") + expect(last_response.body).to include("Disallow: /api/") + expect(last_response.body).to include("Sitemap: http://spec.mesh.test/sitemap.xml") + end + + it "blocks every path in private mode" do + allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true) + + get "/robots.txt" + + expect(last_response).to be_ok + expect(last_response.body).to include("User-agent: *") + expect(last_response.body).to include("Disallow: /") + expect(last_response.body).not_to include("Sitemap:") + end + + it "sets a one-hour cache window" do + get "/robots.txt" + + expect(last_response.headers["Cache-Control"]).to include("max-age=3600") + end + end + + describe "GET /sitemap.xml" do + let(:pages_dir) { File.join(SPEC_TMPDIR, "pages-sitemap-#{SecureRandom.hex(4)}") } + + before do + FileUtils.mkdir_p(pages_dir) + File.write( + File.join(pages_dir, "1-about.md"), + "---\ntitle: About\n---\n\n# About\n", + ) + File.write( + File.join(pages_dir, "5-impressum.md"), + "---\ntitle: Impressum\nnoindex: true\n---\n\n# Impressum\n", + ) + allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir) + PotatoMesh::App::Pages.clear_pages_cache! + end + + after do + FileUtils.rm_rf(pages_dir) + PotatoMesh::App::Pages.clear_pages_cache! + end + + it "returns well-formed XML listing the public dashboards" do + allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false) + allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true) + + get "/sitemap.xml" + + expect(last_response).to be_ok + expect(last_response.headers["Content-Type"]).to include("application/xml") + + doc = REXML::Document.new(last_response.body) + locs = REXML::XPath.match(doc, "//xmlns:loc", "xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9") + .map(&:text) + + expect(locs).to include("http://spec.mesh.test/") + expect(locs).to include("http://spec.mesh.test/map") + expect(locs).to include("http://spec.mesh.test/chat") + expect(locs).to include("http://spec.mesh.test/charts") + expect(locs).to include("http://spec.mesh.test/nodes") + expect(locs).to include("http://spec.mesh.test/federation") + expect(locs).to include("http://spec.mesh.test/pages/about") + end + + it "omits the federation entry when federation is disabled" do + allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false) + allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(false) + + get "/sitemap.xml" + + doc = REXML::Document.new(last_response.body) + locs = REXML::XPath.match(doc, "//xmlns:loc", "xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9") + .map(&:text) + expect(locs).to include("http://spec.mesh.test/chat") + expect(locs).not_to include("http://spec.mesh.test/federation") + end + + it "omits pages flagged with noindex frontmatter" do + get "/sitemap.xml" + + expect(last_response.body).to include("/pages/about") + expect(last_response.body).not_to include("/pages/impressum") + end + + it "omits lastmod for top-level routes but keeps it on pages" do + get "/sitemap.xml" + + doc = REXML::Document.new(last_response.body) + ns = { "xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9" } + url_nodes = REXML::XPath.match(doc, "//xmlns:url", ns) + + page_entry = url_nodes.find do |node| + REXML::XPath.first(node, "xmlns:loc", ns)&.text == "http://spec.mesh.test/pages/about" + end + dashboard_entry = url_nodes.find do |node| + REXML::XPath.first(node, "xmlns:loc", ns)&.text == "http://spec.mesh.test/" + end + + expect(REXML::XPath.first(page_entry, "xmlns:lastmod", ns)).not_to be_nil + expect(REXML::XPath.first(dashboard_entry, "xmlns:lastmod", ns)).to be_nil + end + + it "returns 404 in private mode" do + allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true) + + get "/sitemap.xml" + + expect(last_response.status).to eq(404) + end + end + + describe "per-route meta tags" do + let(:pages_dir) { File.join(SPEC_TMPDIR, "pages-meta-#{SecureRandom.hex(4)}") } + + before do + FileUtils.mkdir_p(pages_dir) + File.write( + File.join(pages_dir, "1-about.md"), + "---\ntitle: About Us\ndescription: Custom about description for SEO.\n---\n\n# About\n", + ) + File.write( + File.join(pages_dir, "2-impressum.md"), + "---\ntitle: Impressum\nnoindex: true\n---\n\n# Impressum\n", + ) + allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir) + PotatoMesh::App::Pages.clear_pages_cache! + end + + after do + FileUtils.rm_rf(pages_dir) + PotatoMesh::App::Pages.clear_pages_cache! + end + + it "uses a Map · Site title on the map view" do + allow(PotatoMesh::Config).to receive(:site_name).and_return("Test Mesh") + get "/map" + + expect(last_response.body).to include("Map · Test Mesh") + end + + it "uses a Charts · Site title on the charts view" do + allow(PotatoMesh::Config).to receive(:site_name).and_return("Test Mesh") + get "/charts" + + expect(last_response.body).to include("Charts · Test Mesh") + end + + it "honours frontmatter title and description on /pages/:slug" do + allow(PotatoMesh::Config).to receive(:site_name).and_return("Test Mesh") + + get "/pages/about" + + expect(last_response.body).to include("About Us · Test Mesh") + expect(last_response.body).to include('content="Custom about description for SEO."') + end + + it "uses the page-level image: frontmatter for og:image and twitter:image" do + File.write( + File.join(pages_dir, "3-press.md"), + "---\ntitle: Press\nimage: https://cdn.example.org/press.png\n---\n\n# Press kit\n", + ) + PotatoMesh::App::Pages.clear_pages_cache! + + get "/pages/press" + + expect(last_response.body).to include('') + expect(last_response.body).to include('') + expect(last_response.body).not_to include("og:image:width") + end + + it "drops non-https image: frontmatter values" do + File.write( + File.join(pages_dir, "4-evil.md"), + "---\ntitle: Bad\nimage: javascript:alert(1)\n---\n\n# nope\n", + ) + PotatoMesh::App::Pages.clear_pages_cache! + + get "/pages/evil" + + expect(last_response.body).not_to include("javascript:alert(1)") + expect(last_response.body).to include('') + end + + it "emits noindex meta when frontmatter requests it" do + get "/pages/impressum" + + expect(last_response.body).to include('') + end + + it "emits the JSON-LD WebSite schema only on the dashboard" do + get "/" + expect(last_response.body).to include(' + <% end %>