Reuse parsed credits and fingerprint shared stylesheet
10 files changed,
+32
−23
.amadan/ci.d/10-gate+1 −0go.mod+2 −2go.sum+2 −6internal/docs/app.go+0 −1internal/docs/credits.go+7 −9internal/docs/credits_test.go+13 −1internal/docs/render.go+2 −0internal/docs/views/credits.html+1 −1scripts/build-release.sh+1 −0scripts/credits.py+3 −3
CI passed — run details
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate| index 11b8464..decb9f3 100755 |
| --- a/.amadan/ci.d/10-gate |
| +++ b/.amadan/ci.d/10-gate |
| @@ -3,6 +3,7 @@ |
| # squash-merge, in one script so CI and what you run locally are one |
| # definition rather than two that drift. |
| set -euo pipefail |
| +export GOTOOLCHAIN=go1.26.5 |
| # rustup installs to ~/.cargo/bin, which is not on the PATH of a systemd |
| # user unit and is not on the PATH of a login shell when rustup was run |
diff --git a/go.mod b/go.mod| index 437bfbb..f637ff5 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,7 +3,7 @@ module github.com/carlosframework/docs |
| go 1.26.5 |
| require ( |
| - amadan.net/oficina/estilo.git v0.0.0-20260911162556-f5fa1128b794 |
| + amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556 |
| @@ -11,6 +11,7 @@ require ( |
| github.com/go-chi/chi/v5 v5.3.2 |
| github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 |
| github.com/google/uuid v1.6.0 |
| + github.com/keymaildev/signin v0.1.1 |
| github.com/tetratelabs/wazero v1.12.0 |
| golang.org/x/text v0.20.0 |
| gorm.io/gorm v1.31.2 |
| @@ -21,7 +22,6 @@ require ( |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
| - github.com/keymaildev/signin v0.1.1 // indirect |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
diff --git a/go.sum b/go.sum| index ff43fb6..4e349a1 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,13 +1,9 @@ |
| -amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg= |
| -amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| -amadan.net/oficina/estilo.git v0.0.0-20260911162556-f5fa1128b794 h1:2oBeIneg/f+m3/n8qLsQGlpEhdnAfW3kOI80yRv60pw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260911162556-f5fa1128b794/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| +amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d h1:3HsB96tGs+P0tGf2VzqpqA8yqYBhJ+apBh2txAoz9qI= |
| +amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf h1:SRKjCUgtk42WwR7VSn+V0eLNk20mfiY8dQmzdw13Sbo= |
| amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf/go.mod h1:gqshuXyDtFZUCYbhuZyV3Epc51pUcsLbpqAqiA5UiXg= |
| -amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
| -amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556 h1:/vHMeq9T+PZmUACixGkg32YEjyM8s31+bfAqqrMha0A= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index 7328502..422b895 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -489,7 +489,6 @@ func New(cfg Config) (*App, error) { |
| // "/signin" and "/signout". |
| r.Use(homeCSRF(cfg.Origin, home)) |
| r.Get("/help/open-source", h.credits) |
| - r.Get("/static/credits.css", serveCreditsCSS) |
| // Unauthenticated: the sign-in page and its four handlers, and the |
| // static assets that page (and the shell around it) needs to |
| // render at all. |
diff --git a/internal/docs/credits.go b/internal/docs/credits.go| index 95673a2..c79c602 100644 |
| --- a/internal/docs/credits.go |
| +++ b/internal/docs/credits.go |
| @@ -23,21 +23,19 @@ type creditsPage struct { |
| Credits credits.PageData |
| } |
| -func (h *handlers) credits(w http.ResponseWriter, r *http.Request) { |
| +var embeddedCredits = func() creditInventory { |
| var inventory creditInventory |
| if err := json.Unmarshal(creditNotices, &inventory); err != nil { |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| - return |
| + panic(err) |
| } |
| + return inventory |
| +}() |
| + |
| +func (h *handlers) credits(w http.ResponseWriter, r *http.Request) { |
| title := rastrillo.T(r, "docs.credits.title") |
| w.Header().Set("Cache-Control", "no-store") |
| w.Header().Set("Referrer-Policy", "no-referrer") |
| - if err := h.render.page(w, r, "credits", creditsPage{Title: title, Brand: rastrillo.T(r, "docs.list.title"), Credits: credits.PageData{Title: title, Components: inventory.Components}}); err != nil { |
| + if err := h.render.page(w, r, "credits", creditsPage{Title: title, Brand: rastrillo.T(r, "docs.list.title"), Credits: credits.PageData{Title: title, MissingNoticeLabel: rastrillo.T(r, "docs.credits.missing"), Components: embeddedCredits.Components}}); err != nil { |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| } |
| } |
| - |
| -func serveCreditsCSS(w http.ResponseWriter, r *http.Request) { |
| - w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| - w.Write(credits.CSS()) |
| -} |
diff --git a/internal/docs/credits_test.go b/internal/docs/credits_test.go| index 114992a..d4742ea 100644 |
| --- a/internal/docs/credits_test.go |
| +++ b/internal/docs/credits_test.go |
| @@ -29,16 +29,28 @@ func TestCreditsArePublicAndContainDependencyNotices(t *testing.T) { |
| if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Referrer-Policy") != "no-referrer" { |
| t.Fatal("credits shell may retain account context") |
| } |
| - for _, wanted := range []string{"estilo-credits", "Open source", "Lucide and Feather", "gorm.io/gorm", "gormlite/LICENSE", "prosemirror-view", "yrs", "Rust standard library", "Liberation Fonts", "/static/credits.css"} { |
| + for _, wanted := range []string{"estilo-credits", "Open source", "Lucide and Feather", "gorm.io/gorm", "gormlite/LICENSE", "prosemirror-view", "yrs", "Rust standard library", "Liberation Fonts", "No notice file is included in this release."} { |
| if !strings.Contains(response.Body.String(), wanted) { |
| t.Fatal("missing credit", wanted) |
| } |
| } |
| + match := regexp.MustCompile(`href="(/static/credits\.[a-z0-9]+\.css)"`).FindStringSubmatch(response.Body.String()) |
| + if len(match) != 2 { |
| + t.Fatal("credits stylesheet is not fingerprinted") |
| + } |
| + fingerprinted := httptest.NewRecorder() |
| + app.ServeHTTP(fingerprinted, httptest.NewRequest(http.MethodGet, "http://example.com"+match[1], nil)) |
| + if fingerprinted.Code != 200 || !strings.Contains(fingerprinted.Header().Get("Cache-Control"), "immutable") { |
| + t.Fatal("fingerprinted credits style not immutable") |
| + } |
| style := httptest.NewRecorder() |
| app.ServeHTTP(style, httptest.NewRequest(http.MethodGet, "http://example.com"+"/static/credits.css", nil)) |
| if style.Code != http.StatusOK || !strings.HasPrefix(style.Header().Get("Content-Type"), "text/css") || !strings.Contains(style.Body.String(), "[estilo-credits]") { |
| t.Fatal("shared credit styles not served") |
| } |
| + if !strings.Contains(style.Header().Get("Cache-Control"), "no-cache") { |
| + t.Fatal("bare credits CSS can be stale") |
| + } |
| if output := os.Getenv("DOCS_CREDITS_EVIDENCE_DIR"); output != "" { |
| if err := os.MkdirAll(output, 0700); err != nil { |
| t.Fatal(err) |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index e05e23d..52be5a7 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -62,6 +62,7 @@ const ( |
| var Catalog = rastrillo.Catalog{ |
| "docs.credits.title": "Open source", |
| "docs.credits.help": "Help", |
| + "docs.credits.missing": "No notice file is included in this release.", |
| "docs.home.signedout_title": "Signed out of Docs", |
| "docs.home.signedout_body": "You can sign in again with Home.", |
| "docs.home.signedout_action": "Sign in with Home", |
| @@ -606,6 +607,7 @@ func vendoredStaticFS() (fs.FS, error) { |
| return nil, fmt.Errorf("docs: rastrillo ui: theme %q not shipped", dayTheme) |
| } |
| return fstest.MapFS{ |
| + "static/credits.css": {Data: credits.CSS()}, |
| "static/estilo-save-status.css": {Data: savestatus.CSS()}, |
| "static/estilo-save-status.mjs": {Data: savestatus.JS()}, |
| "static/tokens.css": {Data: ui.TokensCSS()}, |
diff --git a/internal/docs/views/credits.html b/internal/docs/views/credits.html| index fdfb4a5..efd5192 100644 |
| --- a/internal/docs/views/credits.html |
| +++ b/internal/docs/views/credits.html |
| @@ -1,4 +1,4 @@ |
| {{define "title"}}{{.Title}}{{end}} |
| {{define "brand"}}<a rst-shell-brand href="/">{{.Brand}}</a>{{end}} |
| -{{define "head"}}{{template "docs/head" .}}<link rel="stylesheet" href="/static/credits.css">{{end}} |
| +{{define "head"}}{{template "docs/head" .}}<link rel="stylesheet" href="{{asset "static/credits.css"}}">{{end}} |
| {{define "content"}}{{template "estilo/credits" .Credits}}{{end}} |
diff --git a/scripts/build-release.sh b/scripts/build-release.sh| index 1869b7e..c2c9475 100755 |
| --- a/scripts/build-release.sh |
| +++ b/scripts/build-release.sh |
| @@ -1,5 +1,6 @@ |
| #!/usr/bin/env bash |
| set -euo pipefail |
| +export GOTOOLCHAIN=go1.26.5 |
| export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}amadan.net/oficina/estilo.git" |
| test "$#" -ge 1 |
diff --git a/scripts/credits.py b/scripts/credits.py| index 82e4f99..967b183 100755 |
| --- a/scripts/credits.py |
| +++ b/scripts/credits.py |
| @@ -11,7 +11,7 @@ from html.parser import HTMLParser |
| ROOT = Path(__file__).resolve().parent.parent |
| OUT = ROOT / 'internal/docs/credits' |
| -ENV = os.environ | {'GOPRIVATE': 'amadan.net/oficina/estilo.git'} |
| +ENV = os.environ | {'GOTOOLCHAIN': 'go1.26.5', 'GOPRIVATE': 'amadan.net/oficina/estilo.git'} |
| EXCLUDE = ','.join(['amadan.net/oficina/estilo.git', 'amadan.net/oficina/home/handoff', 'amadan.net/oficina/home/roster']) |
| @@ -37,7 +37,7 @@ def notices(directory): |
| if path.is_file() and any(lower == word or lower.startswith(word + '.') or lower.startswith(word + '-') for word in ['license', 'licence', 'copying', 'notice']): |
| if path.is_symlink() or path.stat().st_size > 2 * 1024 * 1024: |
| raise ValueError('notice must be a bounded regular file') |
| - text = path.read_text() |
| + text = path.read_bytes().decode('utf-8') |
| if not text.strip(): |
| raise ValueError('empty notice') |
| result.append({'Name': path.name, 'Text': text}) |
| @@ -122,7 +122,7 @@ console.log(JSON.stringify([...inputs])); |
| provenance = json.loads((OUT / 'upstream/yrs-source.json').read_text()) |
| if digest(OUT / 'upstream/yrs-LICENSE') != provenance['sha256']: |
| raise ValueError('yrs original notice checksum changed') |
| - texts = [{'Name': 'LICENSE', 'Text': (OUT / 'upstream/yrs-LICENSE').read_text()}] |
| + texts = [{'Name': 'LICENSE', 'Text': (OUT / 'upstream/yrs-LICENSE').read_bytes().decode('utf-8')}] |
| components.append({'Name': package['name'], 'Version': package['version'], 'SourceURL': 'https://crates.io/crates/' + package['name'] + '/' + package['version'], 'Notices': texts}) |
| sysroot = Path(run(['rustc', '--print', 'sysroot']).decode().strip()) |
| rust_version = run(['rustc', '--version']).decode().split()[1] |