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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user