Skip to content

Commit 4c935ec

Browse files
authored
Merge pull request #78 from marklynch/claude/package-repo-options-bc13ra
2 parents 23b0b0f + a869be4 commit 4c935ec

4 files changed

Lines changed: 129 additions & 57 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ The page offers the **5 most recent releases** in a version picker, defaulting t
392392
The page source lives in `installer/`:
393393

394394
- `installer/index.html` — the installer page. Static; it reads `versions.json` at runtime to build the picker, so it needs no build step.
395+
- `installer/app.js` — the page's script. Kept out of the HTML so the page's CSP can be `script-src 'self'` with no `'unsafe-inline'`.
395396
- `installer/manifest.template.json` — the ESP Web Tools manifest, with `__VERSION__` / `__BINARY__` placeholders stamped once per offered version.
396397
- `installer/build-site.sh` — assembles the deployable site.
397398
- `installer/preview.sh` — builds it and serves it locally.
@@ -402,6 +403,20 @@ Releases without a `pool-controller-full-*.bin` asset are skipped, and the scrip
402403

403404
A deploy always publishes the newest 5 releases regardless of which tag triggered it, so re-running an old tag's build can't roll the public installer back to that version.
404405

406+
### Vendored flasher
407+
408+
ESP Web Tools is **vendored into the site**, not loaded from a CDN. The page holds a live Web Serial port and writes a full flash image, so the JavaScript it loads decides which bytes reach the device — that code is pinned and verified rather than resolved at page load from a third-party origin. A floating `@10` CDN range would mean every visitor executing whatever was published to npm most recently, with no review and no deploy step in between.
409+
410+
`build-site.sh` downloads the exact `EWT_VERSION` tarball from the npm registry, checks it against the registry's published `dist.integrity` hash pinned in `EWT_INTEGRITY`, and unpacks it to `ewt/`. A mismatch aborts the build. To upgrade, bump both:
411+
412+
```bash
413+
curl -s https://registry.npmjs.org/esp-web-tools/<version> | jq -r .dist.integrity
414+
```
415+
416+
then re-test flashing on real hardware before merging.
417+
418+
With every asset same-origin, the page sets a `default-src 'none'` CSP allowing `script-src 'self'` and no external origins, so nothing off-origin can inject code into the flashing page.
419+
405420
**One-time setup:** in Settings → Pages, set the source to **GitHub Actions**. Setting it to "Deploy from a branch" cannot work — that serves the sources raw, with no `manifest.json` and no binary, and the page fails with *"Failed to download manifest"*. (`installer/` is named that way rather than `docs/` precisely so it isn't offered as a branch publishing directory.)
406421

407422
### Deploy triggers

installer/app.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// versions.json is generated by installer/build-site.sh, newest release first,
2+
// with one manifest and one firmware binary per version. ESP Web Tools reads
3+
// the manifest when the button is clicked, so switching versions is just a
4+
// matter of re-pointing the button's manifest attribute.
5+
//
6+
// Kept in its own file rather than inline so the page's Content-Security-Policy
7+
// can be script-src 'self' with no 'unsafe-inline'.
8+
const select = document.getElementById('version');
9+
const installer = document.getElementById('installer');
10+
const notes = document.getElementById('notes');
11+
const older = document.getElementById('older');
12+
const olderLatest = document.getElementById('older-latest');
13+
14+
function apply(entry, latest) {
15+
installer.setAttribute('manifest', './' + entry.manifest);
16+
notes.href = entry.notes;
17+
olderLatest.textContent = latest;
18+
older.hidden = entry.version === latest;
19+
}
20+
21+
fetch('./versions.json')
22+
.then((r) => {
23+
if (!r.ok) throw new Error('versions.json: HTTP ' + r.status);
24+
return r.json();
25+
})
26+
.then((data) => {
27+
const list = data.versions || [];
28+
if (!list.length) throw new Error('no versions listed');
29+
30+
select.textContent = '';
31+
for (const entry of list) {
32+
const opt = document.createElement('option');
33+
opt.value = entry.version;
34+
opt.textContent =
35+
entry.version +
36+
(entry.version === data.latest ? ' (latest)' : '') +
37+
(entry.published ? ' — ' + entry.published : '');
38+
select.append(opt);
39+
}
40+
select.disabled = list.length < 2;
41+
select.value = data.latest;
42+
apply(list.find((e) => e.version === data.latest) || list[0], data.latest);
43+
44+
select.addEventListener('change', () => {
45+
const entry = list.find((e) => e.version === select.value);
46+
if (entry) apply(entry, data.latest);
47+
});
48+
})
49+
.catch(() => {
50+
// Leave the button on manifest.json, which build-site.sh always writes as
51+
// a copy of the latest release's manifest, so installing still works.
52+
select.textContent = '';
53+
const opt = document.createElement('option');
54+
opt.textContent = 'latest';
55+
select.append(opt);
56+
select.disabled = true;
57+
});

installer/build-site.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ OUT=""
1919
LOCAL_BIN=""
2020
LOCAL_VERSION=""
2121

22+
# ESP Web Tools is vendored into the site rather than loaded from a CDN. The
23+
# page holds a live Web Serial port and writes a full flash image, so whatever
24+
# JavaScript it loads decides which bytes reach the device — that code must be
25+
# pinned and verified, not resolved at page load from a third-party origin.
26+
#
27+
# EWT_INTEGRITY is the npm registry's published dist.integrity for this exact
28+
# version. Published npm versions are immutable, so a mismatch means the
29+
# tarball was tampered with in transit or the registry was compromised; either
30+
# way the build fails rather than shipping it.
31+
#
32+
# To upgrade: bump EWT_VERSION, then take the new hash from
33+
# curl -s https://registry.npmjs.org/esp-web-tools/<version> | jq -r .dist.integrity
34+
# and re-test flashing before merging.
35+
EWT_VERSION="10.4.0"
36+
EWT_INTEGRITY="sha512-3pwkeFFm5Fj7UQo8SJNYK5RXrtNCpq6X9QoI6bMT4GBZWgrJqjn0YvM9ihG74BtMoSFYXfmDtkehuxe50PTMPQ=="
37+
2238
while [ $# -gt 0 ]; do
2339
case "$1" in
2440
--out) OUT="$2"; shift 2 ;;
@@ -112,7 +128,36 @@ with open(os.path.join(out, "versions.json"), "w") as f:
112128
PY
113129

114130
cp "$SRC/index.html" "$OUT/index.html"
131+
cp "$SRC/app.js" "$OUT/app.js"
115132
# Kept so a direct link to manifest.json still resolves to the current release.
116133
cp "$OUT/manifest-${LATEST}.json" "$OUT/manifest.json"
117134

135+
# --- Vendor ESP Web Tools ---------------------------------------------------
136+
TMP="$(mktemp -d)"
137+
trap 'rm -rf "$TMP"' EXIT
138+
139+
echo "Fetching esp-web-tools ${EWT_VERSION}"
140+
curl -fsSL --retry 3 \
141+
"https://registry.npmjs.org/esp-web-tools/-/esp-web-tools-${EWT_VERSION}.tgz" \
142+
-o "$TMP/ewt.tgz"
143+
144+
ACTUAL=$(python3 -c '
145+
import base64, hashlib, sys
146+
print("sha512-" + base64.b64encode(hashlib.sha512(open(sys.argv[1], "rb").read()).digest()).decode())
147+
' "$TMP/ewt.tgz")
148+
149+
if [ "$ACTUAL" != "$EWT_INTEGRITY" ]; then
150+
echo "esp-web-tools integrity check FAILED — refusing to build." >&2
151+
echo " expected: $EWT_INTEGRITY" >&2
152+
echo " actual: $ACTUAL" >&2
153+
exit 1
154+
fi
155+
156+
tar xzf "$TMP/ewt.tgz" -C "$TMP" package/dist/web
157+
mkdir -p "$OUT/ewt"
158+
cp -R "$TMP/package/dist/web/." "$OUT/ewt/"
159+
# The page loads this entry point; the rest are its lazily imported chunks.
160+
test -s "$OUT/ewt/install-button.js"
161+
echo " vendored esp-web-tools ${EWT_VERSION} (integrity verified)"
162+
118163
echo "Built $OUT — latest ${LATEST}, $(grep -c '"version"' "$OUT/versions.json") version(s)."

installer/index.html

Lines changed: 12 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
<head>
44
<meta charset="utf-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<!-- Everything the page needs is served from this origin: the flasher is
7+
vendored into ewt/ by installer/build-site.sh, the page script is app.js,
8+
and the manifests and firmware binaries sit alongside them. No external
9+
origin can supply code to a page that writes firmware over Web Serial.
10+
style-src allows inline because the page styles are in a <style> block and
11+
ESP Web Tools falls back to injecting <style> where adoptedStyleSheets is
12+
unavailable; blob: is allowed for connect because the flasher's dialog
13+
creates object URLs. frame-ancestors is omitted: it is ignored in a meta
14+
CSP and needs a real header. -->
15+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' blob:; form-action 'self'; base-uri 'none'">
616
<title>Pool Controller — Web Installer</title>
717
<meta name="description" content="Flash the Connect 10 pool controller firmware to an ESP32-C6 straight from your browser.">
818
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%231565C0' d='M2,15C3.67,14.25 5.33,13.5 7,13.17V5A3,3 0 0,1 10,2C11.31,2 12.42,2.83 12.83,4H10A1,1 0 0,0 9,5V6H14V5A3,3 0 0,1 17,2C18.31,2 19.42,2.83 19.83,4H17A1,1 0 0,0 16,5V14.94C18,14.62 20,13 22,13V15C19.78,15 17.56,17 15.33,17C13.11,17 10.89,15 8.67,15C6.44,15 4.22,16 2,17V15M14,8H9V10H14V8M14,12H9V13C10.67,13.16 12.33,14.31 14,14.79V12M2,19C4.22,18 6.44,17 8.67,17C10.89,17 13.11,19 15.33,19C17.56,19 19.78,17 22,17V19C19.78,19 17.56,21 15.33,21C13.11,21 10.89,19 8.67,19C6.44,19 4.22,20 2,21V19Z'/%3E%3C/svg%3E">
@@ -178,62 +188,7 @@ <h2>Project links</h2>
178188
</footer>
179189
</main>
180190

181-
<script type="module" src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js"></script>
182-
<script>
183-
// versions.json is generated by installer/build-site.sh, newest release first,
184-
// with one manifest and one firmware binary per version. ESP Web Tools reads
185-
// the manifest when the button is clicked, so switching versions is just a
186-
// matter of re-pointing the button's manifest attribute.
187-
const select = document.getElementById('version');
188-
const installer = document.getElementById('installer');
189-
const notes = document.getElementById('notes');
190-
const older = document.getElementById('older');
191-
const olderLatest = document.getElementById('older-latest');
192-
193-
function apply(entry, latest) {
194-
installer.setAttribute('manifest', './' + entry.manifest);
195-
notes.href = entry.notes;
196-
olderLatest.textContent = latest;
197-
older.hidden = entry.version === latest;
198-
}
199-
200-
fetch('./versions.json')
201-
.then((r) => {
202-
if (!r.ok) throw new Error('versions.json: HTTP ' + r.status);
203-
return r.json();
204-
})
205-
.then((data) => {
206-
const list = data.versions || [];
207-
if (!list.length) throw new Error('no versions listed');
208-
209-
select.textContent = '';
210-
for (const entry of list) {
211-
const opt = document.createElement('option');
212-
opt.value = entry.version;
213-
opt.textContent =
214-
entry.version +
215-
(entry.version === data.latest ? ' (latest)' : '') +
216-
(entry.published ? ' — ' + entry.published : '');
217-
select.append(opt);
218-
}
219-
select.disabled = list.length < 2;
220-
select.value = data.latest;
221-
apply(list.find((e) => e.version === data.latest) || list[0], data.latest);
222-
223-
select.addEventListener('change', () => {
224-
const entry = list.find((e) => e.version === select.value);
225-
if (entry) apply(entry, data.latest);
226-
});
227-
})
228-
.catch(() => {
229-
// Leave the button on manifest.json, which build-site.sh always writes as
230-
// a copy of the latest release's manifest, so installing still works.
231-
select.textContent = '';
232-
const opt = document.createElement('option');
233-
opt.textContent = 'latest';
234-
select.append(opt);
235-
select.disabled = true;
236-
});
237-
</script>
191+
<script type="module" src="./ewt/install-button.js"></script>
192+
<script src="./app.js"></script>
238193
</body>
239194
</html>

0 commit comments

Comments
 (0)