mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
web: sandbox the OG capture and harden frontend escaping and lookups (#994)
This commit is contained in:
20 files changed
+293
-57
No files matched your search
@@ -87,6 +87,9 @@ MESHTASTIC_FREQ="915MHz"
|
||||
# Absolute http(s) URL for the social preview image. Replaces the generated
|
||||
# /og-image.png for deployments without Chromium.
|
||||
# OG_IMAGE_URL="https://cdn.example.org/preview.png"
|
||||
# Set to 1 to run the Chromium that renders /og-image.png without its sandbox (the Docker images set 1)
|
||||
OG_IMAGE_NO_SANDBOX=1
|
||||
|
||||
|
||||
# Map center coordinates (latitude, longitude)
|
||||
# Berlin, Germany: 52.502889, 13.404194
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
* Data: `TRANSPORT=udp` listens on both Meshtastic multicast groups by default, `239.0.0.69` (firmware 2.8+) and `224.0.0.69`; `MESH_UDP_GROUP` takes a comma-separated list. If you set `MESH_UDP_GROUP=224.0.0.69` before, remove it to also hear firmware 2.8+ nodes (#903)
|
||||
* Data: `MESHCORE_TELEMETRY_POLL_24H_EXEMPT` reaches every packaged deployment - `docker-compose.yml`, both `data/Dockerfile` stages and the NixOS module (`meshcoreTelemetryPoll24hExempt`); setting it in `.env` had no effect under Compose or NixOS (SPEC PX5)
|
||||
* Web: live refreshes keep map overlays, line tooltips and waypoint cards open, and repaint only the table, map or chat whose data changed (#881, SPEC DR2/DR4)
|
||||
* Web: a node role or modem preset named like a built-in object property, such as `constructor`, gets its protocol's base color and its derived preset code instead of an uncolored badge or a `[FU]` tag (SPEC FE1)
|
||||
* Web: a node id containing `"` no longer stops the live-update flashes of a refresh (SPEC FE2)
|
||||
* Web: the Chromium that renders `/og-image.png` keeps its sandbox unless `OG_IMAGE_NO_SANDBOX=1`, which the Docker images and the Compose default set, and keeps the same-origin policy and site isolation on (SPEC FE3/FE4)
|
||||
* Web: the federation page shows a numeric frequency or version instead of an empty cell (SPEC FE5)
|
||||
* Docs: the README documents `MESHTASTIC_PSK_B64` (SPEC PX7)
|
||||
* CI: the env-surface check runs in pytest whenever a deployment surface changes, and also checks names with digits, names read through helpers, and retired names (SPEC PX6/PX8)
|
||||
* CI: test results reach Codecov also when tests fail; workflows use codecov-action v7 and checkout v7; Dependabot opens up to 20 GitHub Actions update PRs at a time (SPEC DW1/DW2/DW4)
|
||||
|
||||
@@ -147,6 +147,7 @@ ENV RACK_ENV=production \
|
||||
ANNOUNCEMENT="" \
|
||||
EVENTS=1 \
|
||||
OG_IMAGE_URL="" \
|
||||
OG_IMAGE_NO_SANDBOX=1 \
|
||||
PAGES_DIR=/app/pages \
|
||||
PROM_REPORT_IDS="" \
|
||||
FEDERATION=1 \
|
||||
|
||||
@@ -106,6 +106,7 @@ The web app can be configured with environment variables (defaults shown):
|
||||
| `MIN_THREADS` | `16` | Minimum Puma worker threads kept warm. |
|
||||
| `MAX_THREADS` | `96` | Maximum Puma worker threads. Each active `/api/events` SSE stream pins one thread, so keep this above your peak concurrent SSE clients plus API/ingest headroom. |
|
||||
| `OG_IMAGE_URL` | _unset_ | Absolute `http(s)://` URL for the social preview image; other schemes are ignored. Replaces the generated `/og-image.png`. Use HTTPS - most platforms won't render an HTTP preview. |
|
||||
| `OG_IMAGE_NO_SANDBOX` | `0` | Set to `1` to run the Chromium that renders `/og-image.png` without its sandbox. The Docker images set `1`. With `0` where Chromium cannot start its sandbox, no preview is captured and `/og-image.png` serves its fallback image. |
|
||||
| `PAGES_DIR` | `./pages` | The directory for static, custom-content pages. |
|
||||
| `PROM_REPORT_IDS` | _unset_ | Comma-separated node ids to expose as per-node Prometheus gauges. Empty exports none. Opted-out nodes, and hidden clients under `PRIVATE=1`, are never exported. |
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ x-web-base: &web-base
|
||||
ANNOUNCEMENT: ${ANNOUNCEMENT:-}
|
||||
EVENTS: ${EVENTS:-1}
|
||||
OG_IMAGE_URL: ${OG_IMAGE_URL:-}
|
||||
OG_IMAGE_NO_SANDBOX: ${OG_IMAGE_NO_SANDBOX:-1}
|
||||
PAGES_DIR: ${PAGES_DIR:-/app/pages}
|
||||
PROM_REPORT_IDS: ${PROM_REPORT_IDS:-}
|
||||
command: ["ruby", "app.rb", "-p", "41447", "-o", "0.0.0.0"]
|
||||
|
||||
@@ -299,6 +299,12 @@
|
||||
description = "Absolute http(s) URL for the social preview image, replacing the generated /og-image.png";
|
||||
};
|
||||
|
||||
ogImageNoSandbox = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Run the Chromium that renders /og-image.png without its sandbox";
|
||||
};
|
||||
|
||||
pagesDir = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
@@ -525,6 +531,8 @@
|
||||
ANNOUNCEMENT = cfg.announcement;
|
||||
} // lib.optionalAttrs (cfg.ogImageUrl != null) {
|
||||
OG_IMAGE_URL = cfg.ogImageUrl;
|
||||
} // lib.optionalAttrs cfg.ogImageNoSandbox {
|
||||
OG_IMAGE_NO_SANDBOX = "1";
|
||||
} // lib.optionalAttrs (cfg.pagesDir != null) {
|
||||
PAGES_DIR = cfg.pagesDir;
|
||||
} // lib.optionalAttrs (cfg.promReportIds != null) {
|
||||
|
||||
@@ -144,6 +144,7 @@ ENV RACK_ENV=production \
|
||||
ANNOUNCEMENT="" \
|
||||
EVENTS=1 \
|
||||
OG_IMAGE_URL="" \
|
||||
OG_IMAGE_NO_SANDBOX=1 \
|
||||
PAGES_DIR=/app/pages \
|
||||
PROM_REPORT_IDS="" \
|
||||
FEDERATION=1 \
|
||||
|
||||
@@ -900,6 +900,21 @@ module PotatoMesh
|
||||
fetch_string("OG_IMAGE_URL", nil)
|
||||
end
|
||||
|
||||
# Determine whether the +/og-image.png+ capture launches Chromium
|
||||
# without its sandbox (+--no-sandbox+).
|
||||
#
|
||||
# Off unless +OG_IMAGE_NO_SANDBOX=1+: the capture renders the dashboard,
|
||||
# which loads third-party map tiles and shows mesh-supplied text, so
|
||||
# Chromium keeps its sandbox wherever it can start one. Set it where
|
||||
# Chromium cannot, such as a container with neither unprivileged user
|
||||
# namespaces nor a setuid +chrome-sandbox+ helper; the Docker images set
|
||||
# +1+ (see {PotatoMesh::OgImage.chromium_flags}).
|
||||
#
|
||||
# @return [Boolean] true when OG_IMAGE_NO_SANDBOX=1 in the environment.
|
||||
def og_image_no_sandbox?
|
||||
fetch_string("OG_IMAGE_NO_SANDBOX", "0") == "1"
|
||||
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
|
||||
|
||||
@@ -187,22 +187,25 @@ module PotatoMesh
|
||||
Ferrum::Browser.new(browser_options)
|
||||
end
|
||||
|
||||
# Ferrum's default Chrome flags that switch off Chromium's same-origin
|
||||
# policy (+disable-web-security+) and site isolation
|
||||
# (+disable-site-isolation-trials+). {.chromium_flags} drops both.
|
||||
SECURITY_OFF_FLAGS = %w[disable-web-security disable-site-isolation-trials].freeze
|
||||
|
||||
# Site-isolation features on Ferrum's default +--disable-features+ list.
|
||||
# {.chromium_flags} keeps them enabled.
|
||||
SITE_ISOLATION_FEATURES = %w[site-per-process IsolateOrigins].freeze
|
||||
|
||||
# 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.
|
||||
# Ferrum's own default flags are switched off
|
||||
# (+ignore_default_browser_options+): Ferrum 0.18 can add a flag to its
|
||||
# defaults but not remove one, so {.chromium_flags} passes the defaults
|
||||
# itself, minus the ones that disable Chromium's security model. Ferrum's
|
||||
# +FERRUM_CHROME_DOCKERIZE+ therefore no longer applies; the sandbox
|
||||
# follows +OG_IMAGE_NO_SANDBOX+ alone.
|
||||
#
|
||||
# @return [Hash] keyword options for Ferrum::Browser.
|
||||
def browser_options
|
||||
@@ -214,17 +217,48 @@ module PotatoMesh
|
||||
],
|
||||
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,
|
||||
},
|
||||
ignore_default_browser_options: true,
|
||||
browser_options: chromium_flags,
|
||||
}
|
||||
browser_path = ENV["FERRUM_BROWSER_PATH"]
|
||||
options[:browser_path] = browser_path if browser_path && !browser_path.empty?
|
||||
options
|
||||
end
|
||||
|
||||
# Command-line flags for the capture's Chromium: Ferrum's default Chrome
|
||||
# flags without {SECURITY_OFF_FLAGS} and without the
|
||||
# {SITE_ISOLATION_FEATURES} entries of +--disable-features+.
|
||||
#
|
||||
# The capture renders the dashboard, which loads third-party map tiles
|
||||
# and shows mesh-supplied text, so Chromium keeps its sandbox, its
|
||||
# same-origin policy and its site isolation. The capture needs none of
|
||||
# them off: it loads one page and takes a screenshot.
|
||||
#
|
||||
# +--no-sandbox+ is added only when +OG_IMAGE_NO_SANDBOX=1+
|
||||
# ({PotatoMesh::Config.og_image_no_sandbox?}), for hosts where Chromium
|
||||
# cannot start its sandbox; the Docker images set it. Without the
|
||||
# sandbox a renderer exploit runs with the web app's own privileges, so
|
||||
# DO NOT extend this code path to capture untrusted URLs: {.serve}
|
||||
# receives +base_url+ from the +/og-image.png+ route, which derives it
|
||||
# from the running app's public URL.
|
||||
#
|
||||
# +--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{String=>String, nil}] flag names without the leading
|
||||
# +--+, each mapped to its value or to +nil+ for a bare switch.
|
||||
def chromium_flags
|
||||
require "ferrum"
|
||||
flags = Ferrum::Browser::Options::Chrome.options.except(*SECURITY_OFF_FLAGS)
|
||||
features = flags.fetch("disable-features", "").split(",") - SITE_ISOLATION_FEATURES
|
||||
flags["disable-features"] = features.join(",")
|
||||
flags["disable-dev-shm-usage"] = nil
|
||||
flags["disable-gpu"] = nil
|
||||
flags["no-sandbox"] = nil if PotatoMesh::Config.og_image_no_sandbox?
|
||||
flags
|
||||
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.
|
||||
|
||||
@@ -187,6 +187,15 @@ test('abbreviatePreset maps known presets to codes', () => {
|
||||
assert.equal(abbreviatePreset('X'), 'X?');
|
||||
});
|
||||
|
||||
test('abbreviatePreset ignores inherited Object.prototype keys', () => {
|
||||
// `constructor` is a member of every plain object, so the bare lookup in the
|
||||
// preset table returned the Object constructor instead of falling through
|
||||
// to the derived initials, and the chat tag read `[FU]` ("function").
|
||||
assert.equal(abbreviatePreset('constructor'), 'CO');
|
||||
assert.equal(abbreviatePreset('Constructor'), 'CO');
|
||||
assert.equal(formatChatPresetTag(extractChatMessageMetadata({ modem_preset: 'constructor' })), '[CO]');
|
||||
});
|
||||
|
||||
test('derivePresetInitials falls back to segmented tokens', () => {
|
||||
assert.equal(derivePresetInitials('Long Moderate'), 'LM');
|
||||
assert.equal(derivePresetInitials('ShortTurbo'), 'ST');
|
||||
|
||||
@@ -793,6 +793,30 @@ test('federation page suppresses spammy site names and truncates long names in v
|
||||
}
|
||||
});
|
||||
|
||||
test('federation cells and popups render a numeric frequency and version', async () => {
|
||||
// The page escaped through a private copy that returned '' for a non-string,
|
||||
// so a number left the cell and the popup line empty. The shared escapeHtml
|
||||
// stringifies first.
|
||||
const { cleanup, tbodyEl } = createBasicFederationPageHarness();
|
||||
const markerPopups = [];
|
||||
const instance = {
|
||||
domain: 'num.mesh', name: 'Numeric Mesh', frequency: 868, version: 7,
|
||||
latitude: 1, longitude: 1, lastUpdateTime: Math.floor(Date.now() / 1000) - 30,
|
||||
};
|
||||
const fetchImpl = async () => ({ ok: true, json: async () => [instance] });
|
||||
|
||||
try {
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub({ markerPopups }) });
|
||||
|
||||
const rowHtml = tbodyEl.childNodes[0].innerHTML;
|
||||
assert.match(rowHtml, /instances-col--frequency">868</);
|
||||
assert.match(rowHtml, /instances-col--version mono">7</);
|
||||
assert.match(markerPopups[0], /Frequency: 868<br>Version: 7/);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('federation page sorts by full site names before truncating visible labels', async () => {
|
||||
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
|
||||
const { document, createElement, registerElement, cleanup } = env;
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
*
|
||||
* Only the selector forms the dashboard and these tests use are supported:
|
||||
* `tag`, `#id`, `.class`, `[attr]`, `[attr="value"]`, compounds of those, the
|
||||
* descendant combinator and comma lists.
|
||||
* descendant combinator and comma lists. A quoted value may carry the CSS
|
||||
* escapes `cssEscape` emits without `CSS.escape` (see {@link decodeCssEscapes});
|
||||
* hex escapes, and whitespace or a comma inside a value, are not supported.
|
||||
*
|
||||
* @module app/__tests__/live-dom-model
|
||||
*/
|
||||
@@ -94,7 +96,24 @@ function datasetAttribute(prop) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one compound selector (`tr.nodes-subrow[data-x="y"]`).
|
||||
* Decode the CSS escapes of one quoted selector value, as a browser reads it:
|
||||
* a backslash before a character that is neither a hex digit nor whitespace
|
||||
* stands for that character (`\"` is `"`, `\!` is `!`). That is the only form
|
||||
* `cssEscape` (`main/format-utils.js`) emits without `CSS.escape`; the
|
||||
* selector grammar in {@link parseCompound} admits no other.
|
||||
*
|
||||
* @param {string} value Quoted value without its quotes.
|
||||
* @returns {string} The value the selector compares attributes against.
|
||||
*/
|
||||
function decodeCssEscapes(value) {
|
||||
return value.replace(/\\([\s\S])/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one compound selector (`tr.nodes-subrow[data-x="y"]`). A quoted
|
||||
* attribute value is compared with its CSS escapes decoded
|
||||
* ({@link decodeCssEscapes}); a raw `"` inside it ends the value, so the rest
|
||||
* of the selector fails to parse, as in a browser.
|
||||
*
|
||||
* @param {string} source Compound selector text.
|
||||
* @returns {{tag: ?string, id: ?string, classes: Array<string>, attrs: Array<{name: string, value: ?string}>}}
|
||||
@@ -109,13 +128,15 @@ function parseCompound(source) {
|
||||
parts.tag = tag[1] === '*' ? null : tag[1].toUpperCase();
|
||||
rest = rest.slice(tag[0].length);
|
||||
}
|
||||
const simple = /^(?:\.([a-zA-Z0-9_-]+)|#([a-zA-Z0-9_-]+)|\[([a-zA-Z0-9_-]+)(?:="([^"]*)")?\])/;
|
||||
// A quoted value runs to the first unescaped `"`; each backslash escapes
|
||||
// the next character, which must not be a hex digit or whitespace.
|
||||
const simple = /^(?:\.([a-zA-Z0-9_-]+)|#([a-zA-Z0-9_-]+)|\[([a-zA-Z0-9_-]+)(?:="((?:[^"\\]|\\[^0-9a-fA-F\s])*)")?\])/;
|
||||
while (rest.length) {
|
||||
const match = simple.exec(rest);
|
||||
if (!match) throw new Error(`live-dom-model: unsupported selector "${source}"`);
|
||||
if (match[1]) parts.classes.push(match[1]);
|
||||
else if (match[2]) parts.id = match[2];
|
||||
else parts.attrs.push({ name: match[3], value: match[4] === undefined ? null : match[4] });
|
||||
else parts.attrs.push({ name: match[3], value: match[4] === undefined ? null : decodeCssEscapes(match[4]) });
|
||||
rest = rest.slice(match[0].length);
|
||||
}
|
||||
return parts;
|
||||
|
||||
@@ -209,6 +209,21 @@ test('an unknown role falls back within its own protocol palette (SPEC RA9)', ()
|
||||
assert.equal(getRoleColor('UNKNOWN_ROLE', 'meshtastic'), roleColors.CLIENT);
|
||||
});
|
||||
|
||||
test('a role named after an Object.prototype member takes its protocol base colour (SPEC RA9)', () => {
|
||||
// The palettes are plain objects, so a bare `palette[role]` lookup found
|
||||
// inherited members and returned a function or an object where a colour
|
||||
// belongs. No palette defines these roles, so each takes its protocol's
|
||||
// base colour like any other unknown role.
|
||||
assert.equal(getRoleColor('__proto__'), roleColors.CLIENT);
|
||||
assert.equal(getRoleColor('constructor'), roleColors.CLIENT);
|
||||
assert.equal(getRoleColor('hasOwnProperty', 'meshtastic'), roleColors.CLIENT);
|
||||
assert.equal(getRoleColor('toString', 'meshcore'), meshcoreRoleColors.COMPANION);
|
||||
assert.equal(getRoleColor('constructor', 'reticulum'), reticulumRoleColors.PEER);
|
||||
// The badge text and the live-update fade follow the base colour.
|
||||
assert.equal(getRoleTextColor('constructor'), getRoleTextColor('CLIENT'));
|
||||
assert.equal(getRoleFlashColor('constructor'), 'rgba(243, 239, 116, 0.55)');
|
||||
});
|
||||
|
||||
test('defaultRoleFor names each protocol base role (SPEC RA9)', () => {
|
||||
assert.equal(defaultRoleFor('meshtastic'), 'CLIENT');
|
||||
assert.equal(defaultRoleFor('meshcore'), 'COMPANION');
|
||||
|
||||
@@ -301,7 +301,8 @@ function abbreviatePreset(preset, freqMHz = null) {
|
||||
return resolved.shortCode;
|
||||
}
|
||||
const token = preset.replace(/[^A-Za-z]/g, '').toLowerCase();
|
||||
if (token && PRESET_ABBREVIATIONS[token]) {
|
||||
// Own keys only: `constructor` is inherited by every plain object.
|
||||
if (token && Object.hasOwn(PRESET_ABBREVIATIONS, token)) {
|
||||
return PRESET_ABBREVIATIONS[token];
|
||||
}
|
||||
return derivePresetInitials(preset);
|
||||
|
||||
@@ -25,6 +25,7 @@ import { mergeConfig } from './settings.js';
|
||||
import { roleColors } from './role-helpers.js';
|
||||
import { meshcoreIconHtml, meshtasticIconHtml, reticulumIconHtml } from './protocol-helpers.js';
|
||||
import { createBasemapLayer } from './basemap-config.js';
|
||||
import { escapeHtml } from './utils.js';
|
||||
import { timeAgoSuffixed } from './main/format-utils.js';
|
||||
import {
|
||||
startRelativeTimeTicker,
|
||||
@@ -74,22 +75,6 @@ export function getFederationRelativeTimeTicker() {
|
||||
return relativeTimeTicker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS.
|
||||
*
|
||||
* @param {string} str Raw string to escape.
|
||||
* @returns {string} Escaped string safe for HTML insertion.
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (typeof str !== 'string') return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a coordinate value to fixed decimal places.
|
||||
*
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
emitMarkerWave,
|
||||
emitNodeWaves,
|
||||
} from '../flash.js';
|
||||
import { cssEscape } from '../format-utils.js';
|
||||
import { createLiveDocument } from '../../__tests__/live-dom-model.js';
|
||||
|
||||
/** A minimal element exposing a tracked classList. */
|
||||
function fakeElement() {
|
||||
@@ -181,7 +183,8 @@ test('flashNodeTargets flashes each node row and marker', () => {
|
||||
markerFlashed = true;
|
||||
},
|
||||
};
|
||||
const documentRef = { querySelectorAll: (sel) => (sel.includes('"!a"') ? [rowA] : []) };
|
||||
const queried = [];
|
||||
const documentRef = { querySelectorAll: (sel) => { queried.push(sel); return [rowA]; } };
|
||||
const markerByNodeId = new Map([['!a', markerA]]);
|
||||
|
||||
const count = flashNodeTargets(['!a'], {
|
||||
@@ -190,6 +193,8 @@ test('flashNodeTargets flashes each node row and marker', () => {
|
||||
flashOptions: { schedule: () => {} },
|
||||
});
|
||||
|
||||
// The row selector carries the id CSS-escaped inside its quoted value.
|
||||
assert.deepEqual(queried, [`[data-node-row="${cssEscape('!a')}"]`]);
|
||||
assert.equal(rowA.classList.contains(FLASH_CLASS), true, 'row flashed');
|
||||
assert.equal(markerFlashed, true, 'marker flashed');
|
||||
assert.equal(count, 2); // one row + one marker
|
||||
@@ -202,6 +207,42 @@ test('flashNodeTargets skips a missing document and unmapped markers', () => {
|
||||
assert.equal(flashNodeTargets(['!x'], { documentRef: { querySelectorAll: () => [] } }), 0);
|
||||
});
|
||||
|
||||
test('an id containing a double quote flashes its row, message and tab without throwing', () => {
|
||||
// A node id is whatever key an ingestor posted. Interpolated raw, its `"`
|
||||
// closed the quoted selector value early, querySelectorAll threw a
|
||||
// SyntaxError, and the refresh skipped every flash after it. The live DOM
|
||||
// model parses selectors as a browser does and rejects the raw form too.
|
||||
const { body, model } = createLiveDocument();
|
||||
const element = (tag, attribute, value) => {
|
||||
const el = model.createElement(tag);
|
||||
el.setAttribute(attribute, value);
|
||||
body.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
const row = element('tr', 'data-node-row', '!a"b');
|
||||
const prefixRow = element('tr', 'data-node-row', '!a');
|
||||
const message = element('div', 'data-message-id', '7"8');
|
||||
const tab = element('button', 'data-tab-id', 'tab"x');
|
||||
const flashOptions = { schedule: () => 0 };
|
||||
|
||||
assert.equal(flashNodeTargets(['!a"b'], { documentRef: body, flashOptions }), 1);
|
||||
const messageTabId = new Map([['7"8', 'tab"x']]);
|
||||
assert.equal(flashMessageTargets(['7"8'], { documentRef: body, messageTabId, flashOptions }), 2);
|
||||
assert.equal(row.classList.contains(FLASH_CLASS), true, 'the node row flashed');
|
||||
assert.equal(message.classList.contains(FLASH_CLASS), true, 'the message row flashed');
|
||||
assert.equal(tab.classList.contains(FLASH_CLASS), true, 'the tab header flashed');
|
||||
assert.equal(prefixRow.classList.contains(FLASH_CLASS), false, 'only the exact id flashes');
|
||||
});
|
||||
|
||||
test('a numeric message id still flashes its row (stringified before escaping)', () => {
|
||||
const { body, model } = createLiveDocument();
|
||||
const row = model.createElement('div');
|
||||
row.setAttribute('data-message-id', '42');
|
||||
body.appendChild(row);
|
||||
assert.equal(flashMessageTargets([42], { documentRef: body, flashOptions: { schedule: () => 0 } }), 1);
|
||||
assert.equal(row.classList.contains(FLASH_CLASS), true);
|
||||
});
|
||||
|
||||
test('flashNodeTargets returns 0 for nullish or non-iterable ids', () => {
|
||||
assert.equal(flashNodeTargets(null), 0);
|
||||
assert.equal(flashNodeTargets(123), 0);
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
* @module main/flash
|
||||
*/
|
||||
|
||||
import { cssEscape } from './format-utils.js';
|
||||
|
||||
/** CSS class that drives the one-shot highlight animation. */
|
||||
export const FLASH_CLASS = 'live-flash';
|
||||
|
||||
@@ -211,6 +213,10 @@ export function emitNodeWaves(nodeIds, options = {}) {
|
||||
* document or Leaflet. Each is optional — a caller on a page without the node
|
||||
* table (or without a map) simply passes one of them.
|
||||
*
|
||||
* The id is CSS-escaped inside the selector's quoted value: node ids come from
|
||||
* ingest, a raw `"` makes `querySelectorAll` throw, and a raw `\` changes
|
||||
* what the selector matches.
|
||||
*
|
||||
* @param {?Iterable<string>} nodeIds Canonical node ids to flash.
|
||||
* @param {Object} [options] Lookups + flash overrides.
|
||||
* @param {?{querySelectorAll: Function}} [options.documentRef] Document to query rows in.
|
||||
@@ -224,7 +230,8 @@ export function flashNodeTargets(nodeIds, options = {}) {
|
||||
let count = 0;
|
||||
for (const id of nodeIds) {
|
||||
if (documentRef && typeof documentRef.querySelectorAll === 'function') {
|
||||
count += flashElements(documentRef.querySelectorAll(`[data-node-row="${id}"]`), flashOptions);
|
||||
// String() first: cssEscape returns '' for anything but a string.
|
||||
count += flashElements(documentRef.querySelectorAll(`[data-node-row="${cssEscape(String(id))}"]`), flashOptions);
|
||||
}
|
||||
const marker = markerByNodeId && typeof markerByNodeId.get === 'function'
|
||||
? markerByNodeId.get(id)
|
||||
@@ -238,7 +245,8 @@ export function flashNodeTargets(nodeIds, options = {}) {
|
||||
* Flash every UI target for a set of changed messages: each message's chat
|
||||
* row(s) (`[data-message-id="<id>"]`, present in the Log tab and the channel
|
||||
* tab) and the header of each affected channel tab (`[data-tab-id="<id>"]`)
|
||||
* once (SPEC VF3).
|
||||
* once (SPEC VF3). Both ids are CSS-escaped inside the selectors' quoted
|
||||
* values, as in {@link flashNodeTargets}.
|
||||
*
|
||||
* @param {?Iterable<string>} messageIds Message ids that changed.
|
||||
* @param {Object} [options] Lookups + flash overrides.
|
||||
@@ -255,7 +263,7 @@ export function flashMessageTargets(messageIds, options = {}) {
|
||||
const tabIds = new Set();
|
||||
for (const id of messageIds) {
|
||||
if (canQuery) {
|
||||
count += flashElements(documentRef.querySelectorAll(`[data-message-id="${id}"]`), flashOptions);
|
||||
count += flashElements(documentRef.querySelectorAll(`[data-message-id="${cssEscape(String(id))}"]`), flashOptions);
|
||||
}
|
||||
const tabId = messageTabId && typeof messageTabId.get === 'function' ? messageTabId.get(id) : null;
|
||||
if (tabId) tabIds.add(tabId);
|
||||
@@ -263,7 +271,7 @@ export function flashMessageTargets(messageIds, options = {}) {
|
||||
// Flash each affected channel tab header exactly once.
|
||||
if (canQuery) {
|
||||
for (const tabId of tabIds) {
|
||||
count += flashElements(documentRef.querySelectorAll(`[data-tab-id="${tabId}"]`), flashOptions);
|
||||
count += flashElements(documentRef.querySelectorAll(`[data-tab-id="${cssEscape(String(tabId))}"]`), flashOptions);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
|
||||
@@ -321,14 +321,19 @@ export function normalizeRole(role, protocol = null) {
|
||||
/**
|
||||
* Resolve the canonical role key used for colour lookup tables.
|
||||
*
|
||||
* Only the palette's own keys count: a role named after an `Object.prototype`
|
||||
* member (`constructor`, `toString`) is not a palette key (SPEC RA9).
|
||||
*
|
||||
* @param {*} role Raw role value from the API.
|
||||
* @param {string|null|undefined} [protocol] Protocol whose base role applies
|
||||
* when the value is absent.
|
||||
* @returns {string} Canonical role identifier.
|
||||
*/
|
||||
export function getRoleKey(role, protocol = null) {
|
||||
const normalized = normalizeRole(role, protocol);
|
||||
if (roleColors[normalized]) return normalized;
|
||||
if (Object.hasOwn(roleColors, normalized)) return normalized;
|
||||
const upper = normalized.toUpperCase();
|
||||
if (roleColors[upper]) return upper;
|
||||
if (Object.hasOwn(roleColors, upper)) return upper;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -347,10 +352,13 @@ export function getRoleKey(role, protocol = null) {
|
||||
export function getRoleColor(role, protocol = null) {
|
||||
const colors = getRoleColors(protocol);
|
||||
const key = getRoleKey(role, protocol);
|
||||
// Only the palette's own keys are colours: a bare `colors[key]` also finds
|
||||
// inherited `Object.prototype` members (a function for `constructor`).
|
||||
const own = Object.hasOwn(colors, key) ? colors[key] : null;
|
||||
// Fall back within the node's own palette before the Meshtastic one, so a
|
||||
// role-less Meshcore or Reticulum node takes its protocol's base colour
|
||||
// rather than Meshtastic blue.
|
||||
return colors[key] || colors[defaultRoleFor(protocol)] || roleColors.CLIENT || '#3388ff';
|
||||
return own || colors[defaultRoleFor(protocol)] || roleColors.CLIENT || '#3388ff';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -942,6 +942,26 @@ RSpec.describe PotatoMesh::Config do
|
||||
end
|
||||
end
|
||||
|
||||
describe ".og_image_no_sandbox?" do
|
||||
it "returns false when OG_IMAGE_NO_SANDBOX is unset" do
|
||||
within_env("OG_IMAGE_NO_SANDBOX" => nil) do
|
||||
expect(described_class.og_image_no_sandbox?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns false when OG_IMAGE_NO_SANDBOX=0" do
|
||||
within_env("OG_IMAGE_NO_SANDBOX" => "0") do
|
||||
expect(described_class.og_image_no_sandbox?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns true when OG_IMAGE_NO_SANDBOX=1, ignoring surrounding whitespace" do
|
||||
within_env("OG_IMAGE_NO_SANDBOX" => " 1 ") do
|
||||
expect(described_class.og_image_no_sandbox?).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def within_env(values)
|
||||
original = {}
|
||||
values.each do |key, value|
|
||||
|
||||
+47
-11
@@ -207,6 +207,18 @@ RSpec.describe PotatoMesh::OgImage do
|
||||
end
|
||||
|
||||
describe ".browser_options" do
|
||||
# The command-line flags Chromium would receive for
|
||||
# +described_class.browser_options+, composed by Ferrum's own command
|
||||
# builder. No browser starts: the builder only needs a browser path, and
|
||||
# this one does not exist.
|
||||
#
|
||||
# @return [Array<String>] Chromium flags, without the binary path.
|
||||
def launched_flags
|
||||
require "ferrum"
|
||||
options = described_class.browser_options.merge(browser_path: "/nonexistent/chromium")
|
||||
Ferrum::Browser::Command.build(Ferrum::Browser::Options.new(options), SPEC_TMPDIR).to_a.drop(1)
|
||||
end
|
||||
|
||||
it "honors the configured viewport dimensions" do
|
||||
options = described_class.browser_options
|
||||
|
||||
@@ -217,20 +229,44 @@ RSpec.describe PotatoMesh::OgImage do
|
||||
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")
|
||||
# Chromium keeps its sandbox unless the operator opts out; the Docker
|
||||
# images opt out with OG_IMAGE_NO_SANDBOX=1 (see OgImage.chromium_flags).
|
||||
it "omits --no-sandbox while OG_IMAGE_NO_SANDBOX is unset" do
|
||||
original = ENV["OG_IMAGE_NO_SANDBOX"]
|
||||
ENV.delete("OG_IMAGE_NO_SANDBOX")
|
||||
begin
|
||||
expect(launched_flags).not_to include("--no-sandbox")
|
||||
ensure
|
||||
ENV["OG_IMAGE_NO_SANDBOX"] = original if original
|
||||
end
|
||||
end
|
||||
|
||||
it "passes the --disable-dev-shm-usage flag" do
|
||||
options = described_class.browser_options
|
||||
it "passes --no-sandbox when OG_IMAGE_NO_SANDBOX=1" do
|
||||
original = ENV["OG_IMAGE_NO_SANDBOX"]
|
||||
ENV["OG_IMAGE_NO_SANDBOX"] = "1"
|
||||
begin
|
||||
expect(launched_flags).to include("--no-sandbox")
|
||||
ensure
|
||||
if original
|
||||
ENV["OG_IMAGE_NO_SANDBOX"] = original
|
||||
else
|
||||
ENV.delete("OG_IMAGE_NO_SANDBOX")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
expect(options[:browser_options]).to have_key(:"disable-dev-shm-usage")
|
||||
it "keeps the same-origin policy and site isolation on, and Ferrum's other defaults" do
|
||||
flags = launched_flags
|
||||
disabled_features = flags.find { |flag| flag.start_with?("--disable-features=") }.split("=", 2).last.split(",")
|
||||
|
||||
expect(flags).not_to include("--disable-web-security", "--disable-site-isolation-trials")
|
||||
expect(disabled_features).not_to include("site-per-process", "IsolateOrigins")
|
||||
expect(disabled_features).to include("Translate", "MediaRouter")
|
||||
expect(flags).to include("--headless", "--disable-background-networking", "--remote-allow-origins=*")
|
||||
end
|
||||
|
||||
it "passes the --disable-dev-shm-usage and --disable-gpu flags" do
|
||||
expect(launched_flags).to include("--disable-dev-shm-usage", "--disable-gpu")
|
||||
end
|
||||
|
||||
it "passes the FERRUM_BROWSER_PATH env when present" do
|
||||
|
||||
Reference in new issue
Block a user