mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 10:52:55 +02:00
web: add seo improvements (#771)
* web: add seo improvements * web: address review comments * web: address review comments
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,18 @@ module PotatoMesh
|
||||
module Routes
|
||||
module Root
|
||||
module Helpers
|
||||
# Map of XML predefined entities used by {#xml_escape}.
|
||||
XML_ESCAPE_REPLACEMENTS = {
|
||||
"&" => "&",
|
||||
"<" => "<",
|
||||
">" => ">",
|
||||
'"' => """,
|
||||
"'" => "'",
|
||||
}.freeze
|
||||
|
||||
# Pattern matching any XML metacharacter that requires escaping.
|
||||
XML_ESCAPE_PATTERN = Regexp.union(XML_ESCAPE_REPLACEMENTS.keys).freeze
|
||||
|
||||
# Return the fixed dark theme identifier. Light mode is no longer
|
||||
# supported; theme selection and cookie persistence have been removed.
|
||||
#
|
||||
@@ -31,19 +43,28 @@ module PotatoMesh
|
||||
#
|
||||
# @param template [Symbol] identifier for the ERB template.
|
||||
# @param view_mode [Symbol, String] logical view identifier for CSS hooks.
|
||||
# @param view_meta [Symbol, String, nil] meta-tag selector. Defaults to
|
||||
# +view_mode+ so most callers can omit it; pass an explicit value
|
||||
# when the layout view differs from the meta archetype (e.g. the
|
||||
# dynamic +/pages/:slug+ routes whose view_mode is per-slug).
|
||||
# @param meta_overrides [Hash, nil] explicit replacements for
|
||||
# individual meta values (title, description, image, noindex).
|
||||
# @param extra_locals [Hash] additional locals merged into the rendering context.
|
||||
# @return [String] rendered ERB output.
|
||||
def render_root_view(template, view_mode: :dashboard, extra_locals: {})
|
||||
meta = meta_configuration
|
||||
def render_root_view(template, view_mode: :dashboard, view_meta: nil, meta_overrides: nil, extra_locals: {})
|
||||
view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode
|
||||
view_meta_sym = view_meta.nil? ? view_mode_sym : (view_meta.respond_to?(:to_sym) ? view_meta.to_sym : view_meta)
|
||||
meta = meta_configuration(view: view_meta_sym, overrides: meta_overrides)
|
||||
config = frontend_app_config
|
||||
theme = resolve_initial_theme
|
||||
view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode
|
||||
|
||||
base_locals = {
|
||||
site_name: meta[:name],
|
||||
meta_title: meta[:title],
|
||||
meta_name: meta[:name],
|
||||
meta_description: meta[:description],
|
||||
meta_image_url: meta[:image],
|
||||
meta_noindex: meta[:noindex] == true,
|
||||
channel: sanitized_channel,
|
||||
frequency: sanitized_frequency,
|
||||
map_center_lat: PotatoMesh::Config.map_center_lat,
|
||||
@@ -135,6 +156,203 @@ module PotatoMesh
|
||||
"position" => position,
|
||||
}
|
||||
end
|
||||
|
||||
# Resolve the canonical absolute base URL for the running request.
|
||||
# Prefers an operator-supplied override (+INSTANCE_DOMAIN+) so
|
||||
# generated absolute URLs match the public-facing hostname, falling
|
||||
# back to the request's own +base_url+ for development.
|
||||
#
|
||||
# @return [String] base URL (scheme + authority) without trailing slash.
|
||||
def public_base_url
|
||||
domain = string_or_nil(app_constant(:INSTANCE_DOMAIN))
|
||||
return request.base_url unless domain
|
||||
|
||||
scheme = request.scheme || "https"
|
||||
"#{scheme}://#{domain}"
|
||||
end
|
||||
|
||||
# Construct the OG image URL referenced from the layout. Operators
|
||||
# may provide an explicit override via +OG_IMAGE_URL+; otherwise
|
||||
# the runtime-generated +/og-image.png+ URL is returned.
|
||||
#
|
||||
# The override is rejected unless it carries an +http(s)+ scheme.
|
||||
# +data:+ and +javascript:+ URIs do not render in any social
|
||||
# platform's link preview and would only serve as a content
|
||||
# security foot-gun, so they are silently dropped in favour of
|
||||
# the runtime URL.
|
||||
#
|
||||
# @return [String] absolute URL to the social preview image.
|
||||
def og_image_url
|
||||
override = string_or_nil(PotatoMesh::Config.og_image_url)
|
||||
return override if override && override.match?(%r{\Ahttps?://}i)
|
||||
|
||||
"#{public_base_url}/og-image.png"
|
||||
end
|
||||
|
||||
# Build the title segment for the node detail view from the data
|
||||
# already resolved by {build_node_detail_reference}.
|
||||
#
|
||||
# @param short_name [String, nil] sanitized short identifier.
|
||||
# @param long_name [String, nil] sanitized long name.
|
||||
# @param canonical_id [String, nil] canonical "!hex" identifier.
|
||||
# @return [String] human-friendly node label.
|
||||
def node_detail_title_label(short_name:, long_name:, canonical_id:)
|
||||
short = string_or_nil(short_name)
|
||||
long = string_or_nil(long_name)
|
||||
return "#{short} (#{long})" if short && long
|
||||
return short if short
|
||||
return long if long
|
||||
return "Node #{canonical_id}" if canonical_id
|
||||
|
||||
"Node detail"
|
||||
end
|
||||
|
||||
# Compose meta overrides for the +/nodes/:id+ view.
|
||||
#
|
||||
# @param short_name [String, nil] sanitized short identifier.
|
||||
# @param long_name [String, nil] sanitized long name.
|
||||
# @param canonical_id [String, nil] canonical "!hex" identifier.
|
||||
# @return [Hash] override hash for {meta_configuration}.
|
||||
def node_detail_meta_overrides(short_name:, long_name:, canonical_id:)
|
||||
site = sanitized_site_name
|
||||
label = node_detail_title_label(
|
||||
short_name: short_name,
|
||||
long_name: long_name,
|
||||
canonical_id: canonical_id,
|
||||
)
|
||||
description_subject = string_or_nil(short_name) || string_or_nil(long_name) ||
|
||||
canonical_id || "this node"
|
||||
|
||||
{
|
||||
title: site && !site.empty? ? "#{label} · #{site}" : label,
|
||||
description: "Telemetry, position history, and live status for node #{description_subject} on #{site}.",
|
||||
}
|
||||
end
|
||||
|
||||
# Compose meta overrides for the +/pages/:slug+ view from a static
|
||||
# page entry plus any frontmatter the operator defined.
|
||||
#
|
||||
# Only keys that carry meaningful values are included so the
|
||||
# downstream {meta_configuration} call is not asked to filter
|
||||
# +nil+ values out of an otherwise sparse hash.
|
||||
#
|
||||
# @param page [PotatoMesh::App::Pages::PageEntry] resolved page entry.
|
||||
# @return [Hash] override hash for {meta_configuration}.
|
||||
def static_page_meta_overrides(page)
|
||||
site = sanitized_site_name
|
||||
title_segment = string_or_nil(page&.title) || ""
|
||||
composed_title = if !title_segment.empty? && site && !site.empty?
|
||||
"#{title_segment} · #{site}"
|
||||
elsif !title_segment.empty?
|
||||
title_segment
|
||||
else
|
||||
site
|
||||
end
|
||||
|
||||
overrides = { title: composed_title }
|
||||
description = string_or_nil(page&.description)
|
||||
overrides[:description] = description if description
|
||||
image = string_or_nil(page&.image)
|
||||
overrides[:image] = image if image
|
||||
overrides[:noindex] = true if page&.noindex == true
|
||||
overrides
|
||||
end
|
||||
|
||||
# Render the +robots.txt+ body honoring private-mode preferences.
|
||||
# Private deployments emit a blanket disallow; public deployments
|
||||
# whitelist the dashboard while disallowing instrumentation paths.
|
||||
#
|
||||
# @param sitemap_url [String] absolute URL of the public sitemap.
|
||||
# @return [String] +robots.txt+ payload, terminated with a newline.
|
||||
def build_robots_txt(sitemap_url)
|
||||
if private_mode?
|
||||
"User-agent: *\nDisallow: /\n"
|
||||
else
|
||||
<<~TXT
|
||||
User-agent: *
|
||||
Disallow: /metrics
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: #{sitemap_url}
|
||||
TXT
|
||||
end
|
||||
end
|
||||
|
||||
# Build the URL list emitted by +/sitemap.xml+ for a public
|
||||
# deployment. Each entry is a hash with +:loc+ and an optional
|
||||
# +:lastmod+ / +:changefreq+ pair.
|
||||
#
|
||||
# +lastmod+ is intentionally omitted for top-level dashboard
|
||||
# routes. The data behind those views changes continuously, so
|
||||
# advertising +Time.now+ on every crawl trains crawlers to ignore
|
||||
# the field (Google explicitly discourages noisy +lastmod+
|
||||
# values). Static pages keep a meaningful +lastmod+ derived from
|
||||
# +File.mtime+.
|
||||
#
|
||||
# The handler at +/sitemap.xml+ already 404s in private mode, so
|
||||
# this method does not need to filter chat — it is unreachable
|
||||
# otherwise.
|
||||
#
|
||||
# @param base_url [String] absolute base URL prefix.
|
||||
# @return [Array<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
|
||||
# +'+ 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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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&b<c>d"e'f")
|
||||
end
|
||||
|
||||
it "coerces non-string input into a string before escaping" do
|
||||
expect(helper.xml_escape(42)).to eq("42")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#build_robots_txt" do
|
||||
it "returns a blanket disallow in private mode" do
|
||||
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true)
|
||||
|
||||
result = helper.build_robots_txt("https://example.test/sitemap.xml")
|
||||
|
||||
expect(result).to eq("User-agent: *\nDisallow: /\n")
|
||||
end
|
||||
|
||||
it "advertises the sitemap and instrumentation paths in public mode" do
|
||||
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false)
|
||||
|
||||
result = helper.build_robots_txt("https://example.test/sitemap.xml")
|
||||
|
||||
expect(result).to include("Disallow: /metrics")
|
||||
expect(result).to include("Disallow: /api/")
|
||||
expect(result).to include("Sitemap: https://example.test/sitemap.xml")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user