feat(screenshots): cropped shots, sizes for the site, a site check
- shots.py crops to a region (clip) or to what one or more elements cover (element, pad), per-shot viewport; capture.py writes the published sizes to src/data/screenshots.json so the page reserves the right space. - anonymize.py no longer empties secrets that netOrk compares with each other (Wi-Fi keys on an SSID against the key read from the access point). Emptying them invented passphrase "drift" that never existed; a keyed hash keeps equal equal, reverses nothing, and its key lives for one run. - scripts/check/site.py checks the built site in both languages at four widths: sideways overflow, one h1, images with alt and size, console errors, requests to other origins, links to unknown routes, old-URL redirects, language detection, and word counts against the budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d585f9272f
commit
369f66afdc
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the built site the way a visitor meets it.
|
||||
|
||||
npm run build && npx vite preview --port 4173 &
|
||||
scripts/check/site.py [--base http://127.0.0.1:4173] [--out .check]
|
||||
|
||||
Every route in both languages at 360, 390, 768 and 1440 px:
|
||||
- no sideways scrolling, exactly one h1, every image loaded with alt and size
|
||||
- no console errors, no request to any other origin
|
||||
- internal links only to routes that exist
|
||||
Plus: the old URLs redirect, the language follows the browser until someone
|
||||
chooses, and the word count of each page (Home EN fails above its budget).
|
||||
Full-page PNGs land in --out for looking at.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROUTES = ["/", "/pricing", "/nis2", "/plugins", "/glossary", "/docs/getting-started",
|
||||
"/for/it-department", "/for/it-support", "/for/msp", "/impressum", "/datenschutz"]
|
||||
REDIRECTS = {"/features": "/#included", "/drivers": "/#hardware", "/roadmap": "/", "/nope": "/"}
|
||||
WIDTHS = [360, 390, 768, 1440]
|
||||
# EN word budgets from docs/PAGES.md; only Home is a hard failure.
|
||||
BUDGETS = {"/": 400, "/pricing": 300, "/nis2": 550, "/plugins": 450, "/docs/getting-started": 80,
|
||||
"/for/it-department": 320, "/for/it-support": 320, "/for/msp": 320}
|
||||
EXTERNAL_OK = ("https://license.netork.io/", "mailto:")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--base", default="http://127.0.0.1:4173")
|
||||
ap.add_argument("--out", type=Path, default=Path(".check"))
|
||||
args = ap.parse_args()
|
||||
base = args.base.rstrip("/")
|
||||
origin = urlparse(base).netloc
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
problems: list[str] = []
|
||||
words: dict[tuple[str, str], int] = {}
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
|
||||
for lang in ("en", "de"):
|
||||
for width in WIDTHS:
|
||||
ctx = browser.new_context(viewport={"width": width, "height": 900})
|
||||
ctx.add_init_script(f"localStorage.setItem('lang', '{lang}')")
|
||||
page = ctx.new_page()
|
||||
errors: list[str] = []
|
||||
foreign: set[str] = set()
|
||||
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
page.on("request", lambda r: foreign.add(r.url) if urlparse(r.url).netloc not in (origin, "") and not r.url.startswith("data:") else None)
|
||||
|
||||
for route in ROUTES:
|
||||
where = f"{route} [{lang} {width}px]"
|
||||
errors.clear()
|
||||
page.goto(base + route)
|
||||
page.wait_for_load_state("networkidle")
|
||||
# Scroll through so lazy images load.
|
||||
height = page.evaluate("document.documentElement.scrollHeight")
|
||||
for y in range(0, height, 600):
|
||||
page.evaluate(f"window.scrollTo(0, {y})")
|
||||
page.wait_for_timeout(40)
|
||||
page.wait_for_timeout(300)
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
|
||||
overflow = page.evaluate("document.documentElement.scrollWidth - document.documentElement.clientWidth")
|
||||
if overflow > 0:
|
||||
problems.append(f"{where}: {overflow}px sideways overflow")
|
||||
h1 = page.locator("h1").count()
|
||||
if h1 != 1:
|
||||
problems.append(f"{where}: {h1} h1 elements")
|
||||
bad_imgs = page.evaluate("""[...document.images].filter(i =>
|
||||
!i.alt || !i.getAttribute('width') || !i.getAttribute('height') || !i.complete || i.naturalWidth === 0
|
||||
).map(i => i.currentSrc || i.src)""")
|
||||
for src in bad_imgs:
|
||||
problems.append(f"{where}: image missing alt/size or not loaded: {src}")
|
||||
for e in errors:
|
||||
problems.append(f"{where}: console: {e[:160]}")
|
||||
hrefs = page.evaluate("[...document.querySelectorAll('a[href]')].map(a => a.getAttribute('href'))")
|
||||
for href in hrefs:
|
||||
if href.startswith(EXTERNAL_OK):
|
||||
continue
|
||||
if href.startswith(("http:", "https:")):
|
||||
problems.append(f"{where}: unexpected external link {href}")
|
||||
continue
|
||||
path = href.split("#")[0] or "/"
|
||||
if path.startswith("/") and path not in ROUTES:
|
||||
problems.append(f"{where}: link to unknown route {href}")
|
||||
if width == 1440:
|
||||
words[(route, lang)] = page.evaluate("document.querySelector('main').innerText.split(/\\s+/).filter(Boolean).length")
|
||||
name = route.strip("/").replace("/", "-") or "home"
|
||||
page.screenshot(path=str(args.out / f"{name}-{lang}.png"), full_page=True)
|
||||
if width == 390:
|
||||
name = route.strip("/").replace("/", "-") or "home"
|
||||
page.screenshot(path=str(args.out / f"{name}-{lang}-390.png"), full_page=True)
|
||||
|
||||
for url in sorted(foreign):
|
||||
problems.append(f"[{lang} {width}px]: request to another origin: {url}")
|
||||
ctx.close()
|
||||
|
||||
# Old URLs.
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
for old, target in REDIRECTS.items():
|
||||
page.goto(base + old)
|
||||
page.wait_for_load_state("networkidle")
|
||||
got = urlparse(page.url)
|
||||
landed = got.path + (f"#{got.fragment}" if got.fragment else "")
|
||||
if landed != target:
|
||||
problems.append(f"redirect {old}: landed on {landed}, expected {target}")
|
||||
ctx.close()
|
||||
|
||||
# Language: the browser decides until someone chooses.
|
||||
for locale, stored, expected in [("de-DE", None, "de"), ("en-US", None, "en"), ("fr-FR", None, "en"),
|
||||
("de-DE", "en", "en")]:
|
||||
ctx = browser.new_context(locale=locale)
|
||||
if stored:
|
||||
ctx.add_init_script(f"localStorage.setItem('lang', '{stored}')")
|
||||
page = ctx.new_page()
|
||||
page.goto(base + "/")
|
||||
page.wait_for_load_state("networkidle")
|
||||
got = page.evaluate("document.documentElement.lang")
|
||||
stored_after = page.evaluate("localStorage.getItem('lang')")
|
||||
if got != expected:
|
||||
problems.append(f"language: locale {locale}, stored {stored}: got {got}, expected {expected}")
|
||||
if not stored and stored_after is not None:
|
||||
problems.append(f"language: locale {locale}: a choice was stored without anyone choosing")
|
||||
ctx.close()
|
||||
|
||||
browser.close()
|
||||
|
||||
print("words in <main> (EN / DE, budget):")
|
||||
for route in ROUTES:
|
||||
budget = BUDGETS.get(route)
|
||||
en, de = words.get((route, "en"), 0), words.get((route, "de"), 0)
|
||||
flag = " OVER" if budget and en > budget else ""
|
||||
print(f" {route:24} {en:5} / {de:5} {budget or '-'}{flag}")
|
||||
if words.get(("/", "en"), 0) > BUDGETS["/"]:
|
||||
problems.append(f"Home EN has {words[('/', 'en')]} words, budget {BUDGETS['/']}")
|
||||
|
||||
if problems:
|
||||
print(f"\n{len(problems)} problems:")
|
||||
for pr in problems:
|
||||
print(f" {pr}")
|
||||
return 1
|
||||
print("\nno problems")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -16,6 +16,10 @@ Log in as `netork` / `netork-demo`.
|
||||
- Only the API and the UI run. There is no worker, no beat and no Redis, so
|
||||
nothing polls or reaches a device. Stored credentials are emptied, and the
|
||||
encryption key is random per start.
|
||||
- Secrets are emptied — except the ones netOrk compares with each other (Wi-Fi
|
||||
keys on an SSID against the key read from the access point). Those become a
|
||||
keyed hash, so equal stays equal and the drift view shows the real state
|
||||
instead of invented deviations. The key exists only for one run.
|
||||
- The mapping from real to demo names lives outside the repo in
|
||||
`~/.config/netork-screenshots/demo-map.json`, because it lists the real names.
|
||||
Domains become `example.demo`.
|
||||
|
||||
@@ -33,6 +33,7 @@ import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -183,9 +184,19 @@ class Mapper:
|
||||
return ".".join(mapped[::-1]) + ".in-addr.arpa"
|
||||
|
||||
# -- whole strings -------------------------------------------------------
|
||||
@staticmethod
|
||||
def _secret(m: re.Match) -> str:
|
||||
name, sep = m.group(1), m.group(2)
|
||||
if name in SECRET_JSON_KEEP:
|
||||
return m.group(0)
|
||||
raw = m.group(0)[m.group(0).index(sep) + len(sep) + 1:-1]
|
||||
if not raw or not COMPARED_SECRET.match(name):
|
||||
return f'"{name}"{sep}""'
|
||||
value = json.loads(f'"{raw}"') # the value as the column would hold it
|
||||
return f'"{name}"{sep}"{secret_token(value)}"'
|
||||
|
||||
def text(self, s: str) -> str:
|
||||
s = SECRET_JSON.sub(lambda m: m.group(0) if m.group(1) in SECRET_JSON_KEEP
|
||||
else f'"{m.group(1)}"{m.group(2)}""', s)
|
||||
s = SECRET_JSON.sub(self._secret, s)
|
||||
s = REVERSE.sub(self.reverse, s)
|
||||
s = EMAIL.sub(self.email, s)
|
||||
if self.domain_re:
|
||||
@@ -219,6 +230,18 @@ SECRET_JSON = re.compile(
|
||||
r'"((?:[A-Za-z0-9_]*_)?(?:key|psk|passphrase|password|passwd|secret|token|private_key|ft_key|sae_password))"'
|
||||
r'(\s*:\s*)"(?:[^"\\]|\\.)*"')
|
||||
SECRET_JSON_KEEP = {"public_key", "entry_key", "key_type", "is_secret", "ssh_key_id"}
|
||||
|
||||
# Secrets netOrk compares with each other (the Wi-Fi key stored on an SSID against
|
||||
# the key read from the access point). Emptying them would invent drift that never
|
||||
# existed, so they become a keyed hash instead: equal stays equal, nothing can be
|
||||
# reversed, and the key lives only for this run.
|
||||
COMPARED_SECRET = re.compile(r"^(passphrase|psk|ft_key|wpa_key|key|sae_password)$", re.I)
|
||||
RUN_KEY = secrets.token_hex(32)
|
||||
|
||||
|
||||
def secret_token(value: str) -> str:
|
||||
"""Same formula as the SQL in scrub_secrets: md5(run key || value)."""
|
||||
return "demo-" + hashlib.md5((RUN_KEY + value).encode()).hexdigest()[:16]
|
||||
SECRET_KEEP = {"hashed_password", "token_version", "title_tokens", "disable_password_auth"}
|
||||
|
||||
# Whole tables that only hold secrets or personal delivery data.
|
||||
@@ -284,14 +307,22 @@ async def scrub_secrets(con, dry: bool) -> None:
|
||||
continue
|
||||
if dt not in ("text", "character varying", "jsonb", "json", "bytea"):
|
||||
continue # flags like require_password are booleans
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
|
||||
if not n:
|
||||
continue
|
||||
if COMPARED_SECRET.match(c) and dt in ("text", "character varying"):
|
||||
print(f" {t}.{c}: {n} replaced by keyed hash")
|
||||
if not dry:
|
||||
await con.execute(
|
||||
f'UPDATE "{t}" SET "{c}" = \'demo-\' || left(md5($1 || "{c}"), 16) '
|
||||
f'WHERE "{c}" IS NOT NULL AND "{c}" <> \'\'', RUN_KEY)
|
||||
continue
|
||||
value = "NULL" if nullable == "YES" else ("'{}'" if dt in ("jsonb", "json") else "''")
|
||||
if dt == "bytea" and nullable != "YES":
|
||||
value = "''::bytea"
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
|
||||
if n:
|
||||
print(f" {t}.{c}: {n} emptied")
|
||||
if not dry:
|
||||
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
|
||||
print(f" {t}.{c}: {n} emptied")
|
||||
if not dry:
|
||||
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
|
||||
# Settings flagged secret keep their key, lose their value.
|
||||
if await con.fetchval("SELECT to_regclass('public.settings') IS NOT NULL"):
|
||||
n = await con.fetchval("SELECT count(*) FROM settings WHERE is_secret")
|
||||
|
||||
@@ -5,7 +5,7 @@ Normally that instance is the local demo copy from scripts/demo (anonymized
|
||||
production data), which this script logs into on its own:
|
||||
|
||||
capture.py --list-devices # prints IDs to pick for --var
|
||||
capture.py --var ap=<id> --var server=<id> [--only name ...]
|
||||
capture.py --var ap=<id> [--only name ...]
|
||||
|
||||
Against a real instance, log in by hand and cover what must not be seen:
|
||||
|
||||
@@ -42,7 +42,8 @@ STATE = Path(os.environ.get(
|
||||
# One term per line: site names, customer names, domains ... never committed.
|
||||
MASK_FILE = Path(os.environ.get(
|
||||
"NETORK_MASK_FILE", Path.home() / ".config" / "netork-screenshots" / "mask.txt"))
|
||||
OUT = Path(__file__).resolve().parents[2] / "public" / "screenshots"
|
||||
SITE = Path(__file__).resolve().parents[2]
|
||||
OUT = SITE / "public" / "screenshots"
|
||||
|
||||
VIEWPORT = {"width": 1600, "height": 1000}
|
||||
|
||||
@@ -123,13 +124,14 @@ def settle(page: Page) -> None:
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def publish(png: bytes, path: Path, width: int) -> None:
|
||||
def publish(png: bytes, path: Path, width: int) -> dict[str, int]:
|
||||
"""Scale the 2x capture down to its published width and store it as WebP."""
|
||||
img = Image.open(io.BytesIO(png)).convert("RGB")
|
||||
if img.width > width:
|
||||
img = img.resize((width, round(img.height * width / img.width)), Image.LANCZOS)
|
||||
img.save(path, "WEBP", quality=85, method=6)
|
||||
print(f" -> {path.name} {img.width}x{img.height}, {path.stat().st_size // 1024} KB")
|
||||
return {"width": img.width, "height": img.height}
|
||||
|
||||
|
||||
def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
@@ -137,6 +139,8 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
terms = mask_terms()
|
||||
tok = token() if LOCAL else None
|
||||
blocked: list[str] = []
|
||||
sizes_file = SITE / "src" / "data" / "screenshots.json"
|
||||
sizes: dict[str, dict[str, int]] = json.loads(sizes_file.read_text()) if sizes_file.exists() else {}
|
||||
|
||||
def guard(route):
|
||||
if route.request.method in ("GET", "HEAD", "OPTIONS"):
|
||||
@@ -163,6 +167,8 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
print(f"skip {shot.name}: needs --var {e.args[0]}=<id>")
|
||||
continue
|
||||
print(f"{shot.name}: {path}")
|
||||
vw, vh = shot.viewport or (VIEWPORT["width"], VIEWPORT["height"])
|
||||
page.set_viewport_size({"width": vw, "height": vh})
|
||||
page.goto(f"{BASE}{path}")
|
||||
if page.url.rstrip("/").endswith("/login"):
|
||||
sys.exit("Session expired; run --login again.")
|
||||
@@ -182,12 +188,33 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
masks += [page.get_by_text(PUBLIC_IPV4), page.get_by_text(EMAIL)]
|
||||
masks += [page.get_by_text(t) for t in terms]
|
||||
clip = None
|
||||
if shot.height:
|
||||
clip = {"x": 0, "y": 0, "width": VIEWPORT["width"], "height": shot.height}
|
||||
if shot.clip:
|
||||
x, y, w, h = shot.clip
|
||||
clip = {"x": x, "y": y, "width": w, "height": h}
|
||||
elif shot.element:
|
||||
boxes = []
|
||||
for sel in ([shot.element] if isinstance(shot.element, str) else shot.element):
|
||||
el = page.locator(sel).first
|
||||
el.scroll_into_view_if_needed()
|
||||
b = el.bounding_box()
|
||||
if not b:
|
||||
sys.exit(f"{shot.name}: element not found: {sel}")
|
||||
boxes.append(b)
|
||||
left = min(b["x"] for b in boxes)
|
||||
top = min(b["y"] for b in boxes)
|
||||
right = max(b["x"] + b["width"] for b in boxes)
|
||||
bottom = max(b["y"] + b["height"] for b in boxes)
|
||||
box = {"x": left, "y": top, "width": right - left, "height": bottom - top}
|
||||
x0, y0 = max(0, box["x"] - shot.pad), max(0, box["y"] - shot.pad)
|
||||
clip = {"x": x0, "y": y0,
|
||||
"width": min(vw - x0, box["width"] + 2 * shot.pad),
|
||||
"height": min(vh - y0, box["height"] + 2 * shot.pad)}
|
||||
png = page.screenshot(full_page=shot.full_page, clip=clip, mask=masks,
|
||||
mask_color="#334155", animations="disabled")
|
||||
publish(png, OUT / f"{shot.name}.webp", shot.width)
|
||||
sizes[shot.name] = publish(png, OUT / f"{shot.name}.webp", shot.width)
|
||||
browser.close()
|
||||
# The site reads these to reserve the right space for each image.
|
||||
sizes_file.write_text(json.dumps(dict(sorted(sizes.items())), indent=2) + "\n")
|
||||
if blocked:
|
||||
print("Blocked non-GET requests (nothing was sent):")
|
||||
for b in sorted(set(blocked)):
|
||||
|
||||
@@ -21,9 +21,15 @@ class Shot:
|
||||
wait_for: str = "main"
|
||||
mask: list[str] = field(default_factory=list)
|
||||
full_page: bool = False
|
||||
# Crop height in CSS pixels; None keeps the viewport height.
|
||||
height: int | None = None
|
||||
# Width of the published WebP in pixels (the capture is 3200 wide).
|
||||
# Crop to a region (x, y, width, height in CSS px of the viewport) ...
|
||||
clip: tuple[int, int, int, int] | None = None
|
||||
# ... or to one element, plus `pad` px around it. Cropping shows less of a
|
||||
# real screen; it never changes what is on it.
|
||||
element: str | list[str] | None = None # several: crop to what they cover together
|
||||
pad: int = 16
|
||||
# Viewport for this shot, (width, height) in CSS px; default in capture.py.
|
||||
viewport: tuple[int, int] | None = None
|
||||
# Width of the published WebP in pixels (captures are taken at 2x).
|
||||
width: int = 1600
|
||||
# Selectors clicked in order before the shot, first match each. Only for
|
||||
# controls that change the view (filters, tabs); the API guard in
|
||||
@@ -31,18 +37,26 @@ class Shot:
|
||||
clicks: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# The drift comparison on a device page: the summary line ("… (42 compared)") down to
|
||||
# the end of the table. The card around it stretches to the window height.
|
||||
DRIFT_CARD = ["xpath=//*[contains(text(), 'compared)')]", "xpath=//table[.//th[contains(., 'Expected')]]"]
|
||||
|
||||
SHOTS: list[Shot] = [
|
||||
Shot("devices", "/devices", width=2400),
|
||||
Shot("device-detail", "/devices/{ap}#networking/interfaces"),
|
||||
Shot("vlans", "/vlans"),
|
||||
Shot("device-security", "/devices/{server}#security/assessment"),
|
||||
Shot("vulnerabilities", "/vulnerabilities"),
|
||||
Shot("dashboard", "/"),
|
||||
# Home hero: an access point checked against its profile, wide and short.
|
||||
Shot("drift", "/devices/{ap}#drift", wait_for="main table", element=DRIFT_CARD,
|
||||
pad=28, viewport=(1440, 900), width=2400),
|
||||
# Phones: the same finding from "Parameter" to the status badge, readable at 390px.
|
||||
Shot("drift-narrow", "/devices/{ap}#drift", wait_for="main table",
|
||||
element=["xpath=//th[contains(., 'Parameter')]", "xpath=//th[contains(., 'Actual')]",
|
||||
"xpath=//tbody//td[contains(., 'Remote Syslog')]",
|
||||
"xpath=//tbody//span[contains(., 'Incomplete')]",
|
||||
"xpath=//tbody//*[starts-with(normalize-space(text()), 'Set this field')]"],
|
||||
pad=16, viewport=(1180, 900), width=1200),
|
||||
Shot("vulnerabilities", "/vulnerabilities", clip=(256, 40, 1344, 620), width=2400),
|
||||
# Background polls drown out what people did: filter the scheduler out,
|
||||
# the way a reader would (click a source badge, then flip it to exclude).
|
||||
Shot("audit-log", "/audit-log", clicks=[
|
||||
Shot("audit-log", "/audit-log", clip=(256, 40, 1344, 560), width=2400, clicks=[
|
||||
"tbody td >> text=scheduler",
|
||||
"button[title='Click to toggle include/exclude']",
|
||||
]),
|
||||
Shot("service-checks", "/monitoring/checks"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user