Skip to content

Commit 0266ba6

Browse files
Staacksclaude
andcommitted
Serve everything from the site itself, and keep it that way
Reading the docs made every visitor's browser contact Google for Roboto and GitHub for star counts. Neither is something a publicly funded project should hand a classroom's IP addresses to for a documentation page. Material requests the font unless theme.font is false, and mounts a component on data-md-component="source" that calls api.github.com per page view; overrides/partials/source.html is the theme's partial with that attribute removed, so the repository link stays and only the counts go. Text now uses the system font stack. The Swagger validator badge was already off by default. Since all three are defaults that return on their own, tools/hooks.py now walks the generated site after each build and fails if any link, script, image, frame or CSS url() points at an absolute URL. Hyperlinks are exempt - a visitor following one is not the site phoning home. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6a54f43 commit 0266ba6

5 files changed

Lines changed: 132 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,34 @@ and the rules common to all modules.
9191
This structure is deliberately the one phase 3's generator should emit, so that generated pages can
9292
be diffed against the hand-written ones page by page.
9393

94+
## The site makes no third-party requests
95+
96+
Reading this site must not make a visitor's browser contact anyone but the host serving it. phyphox
97+
is used in classrooms, largely in Europe, by a publicly funded project — a documentation page is
98+
not a reason to hand anyone's IP address to a third party.
99+
100+
Three defaults had to be turned off, and each would come back on its own:
101+
102+
- **Google Fonts.** Material requests Roboto from `fonts.googleapis.com` on every page unless
103+
`theme.font` is `false`. Any font *name* there reintroduces the request; the site uses the system
104+
stack instead.
105+
- **GitHub stars.** With `repo_url` set, Material mounts a component on
106+
`data-md-component="source"` that calls `api.github.com` per page view.
107+
`overrides/partials/source.html` is a copy of the theme's partial with that attribute removed —
108+
the link and icon stay, only the counts go. **It is a copy, so re-check it against the theme when
109+
bumping mkdocs-material.**
110+
- **The Swagger validator badge.** Swagger UI posts the spec URL to `validator.swagger.io` by
111+
default. `mkdocs-swagger-ui-tag` already defaults `validatorUrl` to `none`; do not set it to
112+
anything else.
113+
114+
`tools/hooks.py` enforces this in `on_post_build`: it walks the generated site and fails the build
115+
if any `<link>`, `<script>`, `<img>`, `<iframe>` or CSS `url()`/`@import` points at an absolute URL.
116+
Ordinary hyperlinks are fine — a visitor choosing to follow one is not the site phoning home — so
117+
`rel` values that describe a relationship without fetching (`canonical` and friends) are exempt.
118+
119+
If a future feature genuinely needs an external asset, vendor it into `docs/assets` rather than
120+
relaxing the check.
121+
94122
## The inconsistency mechanism
95123

96124
phyphox is implemented independently several times over — Android, iOS, the Blockly editor, the

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ python3 -m venv .venv
2020
.venv/bin/mkdocs build # static HTML into site/
2121
```
2222

23+
The built site is fully self-contained: reading it makes a visitor's browser
24+
contact nothing but the host serving it. No web fonts, no analytics, no
25+
star-count lookups, no CDN. The build fails if that stops being true, so if you
26+
add something that pulls in an external asset, vendor the asset instead.
27+
2328
CI builds with `--strict`, which turns broken internal links and unknown
2429
navigation entries into errors, so build that way before pushing:
2530

mkdocs.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ validation:
2121
theme:
2222
name: material
2323
language: en
24+
# overrides/partials/source.html drops the mount point Material uses to fetch
25+
# star counts from api.github.com on every page view. See the file for why.
26+
custom_dir: overrides
27+
# Material otherwise pulls Roboto from fonts.googleapis.com on every page,
28+
# which makes every visitor's browser contact Google. This site must not load
29+
# anything from a third party, so the system font stack is used instead.
30+
# Do not set this to a font name: any value here reintroduces the request.
31+
font: false
2432
features:
2533
- navigation.instant
2634
- navigation.tracking

overrides/partials/source.html

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{#-
2+
Overrides the theme's partial of the same name.
3+
4+
The only change is that `data-md-component="source"` is gone. Material mounts
5+
a component on that attribute which fetches https://api.github.com/repos/...
6+
on every page view, to show star and fork counts next to the repository link.
7+
This site must not make visitors' browsers contact a third party, so the mount
8+
point is removed; the link and icon are unaffected, only the counts disappear.
9+
10+
Keep this file in step with the theme's version when upgrading
11+
mkdocs-material - it is a copy, not an extension.
12+
-#}
13+
<a href="{{ config.repo_url }}" title="{{ lang.t('source') }}" class="md-source">
14+
<div class="md-source__icon md-icon">
15+
{% set icon = config.theme.icon.repo or "fontawesome/brands/git-alt" %}
16+
{% include ".icons/" ~ icon ~ ".svg" %}
17+
</div>
18+
<div class="md-source__repository">
19+
{{ config.repo_name }}
20+
</div>
21+
</a>

tools/hooks.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
cannot silently leave a page with a dangling warning.
1313
4. Apply the same check to the `x-phyphox-inconsistency` keys in the OpenAPI
1414
description, so the spec and the to-do list cannot drift either.
15+
5. Fail the build if the generated site would make a visitor's browser fetch
16+
anything from a third party.
1517
"""
1618

1719
import os
@@ -209,3 +211,71 @@ def _render_list():
209211
if e.get("issue"):
210212
out.append(f"[Tracking issue]({e['issue']})\n")
211213
return "\n".join(out)
214+
215+
216+
# --------------------------------------------------------------------------
217+
# No third-party requests
218+
# --------------------------------------------------------------------------
219+
#
220+
# Visitors must be able to read this site without their browser contacting
221+
# anyone but the host serving it. That is easy to lose by accident: Material
222+
# pulls Roboto from fonts.googleapis.com unless `font: false` is set, mounts a
223+
# component that calls api.github.com when repo_url is present, and Swagger UI
224+
# ships a validator badge that posts the spec URL to validator.swagger.io. All
225+
# three are switched off - this check is what stops them coming back unnoticed
226+
# on the next dependency bump.
227+
#
228+
# Only *automatic* fetches count. Ordinary hyperlinks are fine; a visitor
229+
# choosing to follow one is not the site phoning home.
230+
231+
_RESOURCE_TAG = re.compile(
232+
r"<(link|script|img|iframe|source|video|audio|embed|object)\b([^>]*)>", re.I)
233+
_URL_ATTR = re.compile(r"(?:src|href|data)\s*=\s*[\"']([^\"']+)[\"']", re.I)
234+
_REL_ATTR = re.compile(r"rel\s*=\s*[\"']([^\"']+)[\"']", re.I)
235+
_CSS_URL = re.compile(r"(?:url\(\s*[\"']?|@import\s+[\"'])(https?:)?//([^)\"'\s]+)")
236+
237+
# rel values that describe a relationship without fetching anything.
238+
_NON_FETCHING_RELS = {"canonical", "alternate", "author", "license", "me",
239+
"nofollow", "noopener", "noreferrer"}
240+
241+
242+
def _is_absolute(url):
243+
return url.startswith(("http://", "https://", "//"))
244+
245+
246+
def on_post_build(config, **kwargs):
247+
site_dir = config["site_dir"]
248+
offenders = []
249+
250+
for root, _, files in os.walk(site_dir):
251+
for fn in files:
252+
path = os.path.join(root, fn)
253+
rel_path = os.path.relpath(path, site_dir)
254+
if fn.endswith((".html", ".htm")):
255+
with open(path, encoding="utf-8", errors="ignore") as f:
256+
text = f.read()
257+
for m in _RESOURCE_TAG.finditer(text):
258+
tag, attrs = m.group(1).lower(), m.group(2)
259+
url = _URL_ATTR.search(attrs)
260+
if not url or not _is_absolute(url.group(1)):
261+
continue
262+
rel = _REL_ATTR.search(attrs)
263+
rels = set((rel.group(1) if rel else "").lower().split())
264+
if rels & _NON_FETCHING_RELS:
265+
continue
266+
offenders.append(f"{rel_path}: <{tag}> {url.group(1)}")
267+
elif fn.endswith(".css"):
268+
with open(path, encoding="utf-8", errors="ignore") as f:
269+
text = f.read()
270+
for m in _CSS_URL.finditer(text):
271+
offenders.append(f"{rel_path}: css url() //{m.group(2)}")
272+
273+
if offenders:
274+
shown = "\n".join(f" {o}" for o in sorted(set(offenders))[:20])
275+
more = len(set(offenders)) - 20
276+
raise ValueError(
277+
"The built site would make visitors' browsers fetch from a third "
278+
"party:\n" + shown
279+
+ (f"\n ... and {more} more" if more > 0 else "")
280+
+ "\n\nEverything the site needs must be served from the site "
281+
"itself. See the 'No third-party requests' note in tools/hooks.py.")

0 commit comments

Comments
 (0)