web: add seo improvements (#771)

* web: add seo improvements

* web: address review comments

* web: address review comments
This commit is contained in:
l5y
2026-04-29 10:33:33 +02:00
committed by GitHub
parent c4dd825d72
commit 43a5724b7f
21 changed files with 2649 additions and 27 deletions
+24 -2
View File
@@ -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:
+17 -2
View File
@@ -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
+1
View File
@@ -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"
+1
View File
@@ -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"
@@ -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.
+14 -1
View File
@@ -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
+186 -4
View File
@@ -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
+257 -3
View File
@@ -19,6 +19,18 @@ module PotatoMesh
module Routes
module Root
module Helpers
# Map of XML predefined entities used by {#xml_escape}.
XML_ESCAPE_REPLACEMENTS = {
"&" => "&",
"<" => "&lt;",
">" => "&gt;",
'"' => "&quot;",
"'" => "&apos;",
}.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<Hash>] 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
# +&apos;+ 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<Hash>] entries produced by
# {build_sitemap_entries}.
# @return [String] XML document body.
def render_sitemap_xml(entries)
lines = [%(<?xml version="1.0" encoding="UTF-8"?>),
%(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)]
entries.each do |entry|
lines << " <url>"
lines << " <loc>#{xml_escape(entry[:loc])}</loc>"
lines << " <lastmod>#{xml_escape(entry[:lastmod])}</lastmod>" if entry[:lastmod]
lines << " <changefreq>#{xml_escape(entry[:changefreq])}</changefreq>" if entry[:changefreq]
lines << " </url>"
end
lines << "</urlset>"
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,
+85
View File
@@ -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.
+141 -3
View File
@@ -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 +<meta name="description">+
# 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
+364
View File
@@ -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
+26
View File
@@ -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 `<meta name="robots" content="noindex,nofollow">` and removes the page from `/sitemap.xml`. Useful for legal pages such as Impressum that should remain reachable but not indexed.
### Filename Convention
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

+4 -1
View File
@@ -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('<meta property="og:title" content="Spec Mesh Title" />')
expect(last_response.body).to include('<meta property="og:site_name" content="Spec Mesh Title" />')
expect(last_response.body).to include('<meta name="twitter:image" content="http://example.org/potatomesh-logo.svg" />')
expect(last_response.body).to include('<meta name="twitter:card" content="summary_large_image" />')
expect(last_response.body).to include('<meta name="twitter:image" content="http://spec.mesh.test/og-image.png" />')
expect(last_response.body).to include('<meta property="og:image:width" content="1200" />')
expect(last_response.body).to include('<meta property="og:image:height" content="630" />')
end
it "does not include the removed auto-fit checkbox regardless of map zoom override" do
+205
View File
@@ -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&b<c>d\"e'f")).to eq("a&amp;b&lt;c&gt;d&quot;e&apos;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
+52
View File
@@ -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
+239
View File
@@ -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
+443
View File
@@ -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
+235
View File
@@ -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
+312
View File
@@ -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("<title>Map · Test Mesh</title>")
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("<title>Charts · Test Mesh</title>")
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("<title>About Us · Test Mesh</title>")
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('<meta property="og:image" content="https://cdn.example.org/press.png" />')
expect(last_response.body).to include('<meta name="twitter:image" content="https://cdn.example.org/press.png" />')
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('<meta property="og:image" content="http://spec.mesh.test/og-image.png" />')
end
it "emits noindex meta when frontmatter requests it" do
get "/pages/impressum"
expect(last_response.body).to include('<meta name="robots" content="noindex,nofollow" />')
end
it "emits the JSON-LD WebSite schema only on the dashboard" do
get "/"
expect(last_response.body).to include('<script type="application/ld+json">')
expect(last_response.body).to include('"@type":"WebSite"')
get "/map"
expect(last_response.body).not_to include('<script type="application/ld+json">')
end
it "emits og:image dimensions only when serving the runtime PNG" do
get "/"
expect(last_response.body).to include('<meta property="og:image:width" content="1200" />')
expect(last_response.body).to include('<meta property="og:image:height" content="630" />')
expect(last_response.body).to include('<meta property="og:image:type" content="image/png" />')
end
it "omits og:image dimensions when OG_IMAGE_URL points at an external image" do
allow(PotatoMesh::Config).to receive(:og_image_url).and_return("https://cdn.example.org/og.svg")
get "/"
expect(last_response.body).to include('<meta property="og:image" content="https://cdn.example.org/og.svg" />')
expect(last_response.body).not_to include("og:image:width")
expect(last_response.body).not_to include("og:image:height")
expect(last_response.body).not_to include("og:image:type")
end
end
describe "GET /og-image.png" do
it "returns the captured PNG bytes when the strategy succeeds" do
PotatoMesh::OgImage.capture_strategy = ->(_) { "FAKE_PNG_DATA" }
get "/og-image.png"
expect(last_response).to be_ok
expect(last_response.headers["Content-Type"]).to eq("image/png")
expect(last_response.body).to eq("FAKE_PNG_DATA")
end
it "redirects to the configured OG_IMAGE_URL override" do
allow(PotatoMesh::Config).to receive(:og_image_url).and_return("https://cdn.example.org/og.png")
get "/og-image.png"
expect(last_response.status).to eq(302)
expect(last_response.headers["Location"]).to eq("https://cdn.example.org/og.png")
end
it "ignores OG_IMAGE_URL overrides that are not http(s)" do
allow(PotatoMesh::Config).to receive(:og_image_url).and_return("javascript:alert(1)")
get "/og-image.png"
expect(last_response).to be_ok
expect(last_response.headers["Content-Type"]).to eq("image/png")
end
it "falls back to the default PNG when capture fails and no cache exists" do
PotatoMesh::OgImage.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "no chromium" }
get "/og-image.png"
expect(last_response).to be_ok
expect(last_response.body.bytesize).to eq(File.size(PotatoMesh::Config.og_image_default_path))
end
it "returns 503 when neither capture nor default are available" do
PotatoMesh::OgImage.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "no chromium" }
allow(PotatoMesh::Config).to receive(:og_image_default_path).and_return("/nonexistent/og-image.png")
get "/og-image.png"
expect(last_response.status).to eq(503)
end
end
end
+32 -9
View File
@@ -23,31 +23,54 @@
<% meta_name_html = Rack::Utils.escape_html(meta_name) %>
<% meta_description_html = Rack::Utils.escape_html(meta_description) %>
<% request_path = request.path.to_s.empty? ? "/" : request.path %>
<% canonical_url = "#{request.base_url}#{request_path}" %>
<% canonical_base = (defined?(public_base_url) ? public_base_url : request.base_url) %>
<% canonical_url = "#{canonical_base}#{request_path}" %>
<% canonical_html = Rack::Utils.escape_html(canonical_url) %>
<% logo_url = "#{request.base_url}/potatomesh-logo.svg" %>
<% logo_url_html = Rack::Utils.escape_html(logo_url) %>
<% logo_alt_html = Rack::Utils.escape_html("#{meta_name} logo") %>
<% default_meta_image_url = "#{canonical_base}/og-image.png" %>
<% page_meta_image_url = (defined?(meta_image_url) && meta_image_url && !meta_image_url.empty?) ? meta_image_url : (defined?(og_image_url) ? og_image_url : default_meta_image_url) %>
<% page_meta_image_runtime = (page_meta_image_url == default_meta_image_url) %>
<% page_meta_image_html = Rack::Utils.escape_html(page_meta_image_url) %>
<% page_meta_image_alt_html = Rack::Utils.escape_html("#{meta_name} preview") %>
<% page_meta_noindex = defined?(meta_noindex) && meta_noindex %>
<title><%= meta_title_html %></title>
<meta name="application-name" content="<%= meta_name_html %>" />
<meta name="apple-mobile-web-app-title" content="<%= meta_name_html %>" />
<meta name="description" content="<%= meta_description_html %>" />
<% if page_meta_noindex %>
<meta name="robots" content="noindex,nofollow" />
<% end %>
<link rel="canonical" href="<%= canonical_html %>" />
<meta property="og:title" content="<%= meta_title_html %>" />
<meta property="og:site_name" content="<%= meta_name_html %>" />
<meta property="og:description" content="<%= meta_description_html %>" />
<meta property="og:type" content="website" />
<meta property="og:url" content="<%= canonical_html %>" />
<meta property="og:image" content="<%= logo_url_html %>" />
<meta property="og:image:alt" content="<%= logo_alt_html %>" />
<meta name="twitter:card" content="summary" />
<meta property="og:image" content="<%= page_meta_image_html %>" />
<meta property="og:image:alt" content="<%= page_meta_image_alt_html %>" />
<% if page_meta_image_runtime %>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/png" />
<% end %>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="<%= meta_title_html %>" />
<meta name="twitter:description" content="<%= meta_description_html %>" />
<meta name="twitter:image" content="<%= logo_url_html %>" />
<meta name="twitter:image:alt" content="<%= logo_alt_html %>" />
<meta name="twitter:image" content="<%= page_meta_image_html %>" />
<meta name="twitter:image:alt" content="<%= page_meta_image_alt_html %>" />
<link rel="icon" type="image/png" sizes="256x256" href="/favicon.png" />
<link rel="icon" type="image/svg+xml" sizes="any" href="/potatomesh-logo.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
<% if (defined?(current_view_mode) ? current_view_mode : nil).to_s == "dashboard" %>
<script type="application/ld+json">
<%= JSON.generate({
"@context" => "https://schema.org",
"@type" => "WebSite",
"name" => meta_name,
"url" => canonical_base,
"description" => meta_description,
}) %>
</script>
<% end %>
<link rel="stylesheet" href="/assets/styles/base.css" />
<script src="/assets/js/theme.js" defer></script>
<script src="/assets/js/background.js" defer></script>