| 1 | // The editor bundle (spec §6.5: ProseMirror stays only because there |
| 2 | // is no build step) is a committed, prebuilt artifact — never built |
| 3 | // by this app or by CI — produced by fixtures/build-bundles.mjs and |
| 4 | // committed under internal/docs/static/. This file embeds that tree |
| 5 | // and serves it, independent of everything render.go's |
| 6 | // vendoredStaticFS already vendors (rastrillo's own design-system |
| 7 | // CSS/JS, an in-memory tree with no files on disk at all). |
| 8 | package docs |
| 9 | |
| 10 | import ( |
| 11 | "bytes" |
| 12 | "embed" |
| 13 | "fmt" |
| 14 | "io/fs" |
| 15 | "net/http" |
| 16 | "strings" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/carlosframework/docs/internal/fonts" |
| 20 | ) |
| 21 | |
| 22 | // brandFS embeds this app's own hand-authored assets — today, the tab |
| 23 | // icon served at iconPath. |
| 24 | // |
| 25 | // A directory of its own, deliberately not static/ below. static/ is |
| 26 | // the editor build's output: .amadan/ci.d/10-gate rebuilds the bundle |
| 27 | // into it and then fails on any dirt at all in that directory, before |
| 28 | // restoring it with git checkout and git clean. So a hand-written file |
| 29 | // there does not merely sit oddly beside generated output — it turns |
| 30 | // the gate red, and the gate's own cleanup then deletes it. That is |
| 31 | // not hypothetical; icon.svg was written to static/ first and this is |
| 32 | // what happened to it. |
| 33 | // |
| 34 | //go:embed brand |
| 35 | var brandFS embed.FS |
| 36 | |
| 37 | // staticFS embeds the editor build's committed output — the vendored |
| 38 | // editor bundle, and nothing else, for the reason brandFS's own doc |
| 39 | // comment gives. internal/docs/static/, not a |
| 40 | // top-level static/: go:embed cannot reach outside (or above) the |
| 41 | // directory containing the .go file that embeds it — no ".." is ever |
| 42 | // allowed in an embed pattern — so this lives under internal/docs/ |
| 43 | // the same way views/*.html (render.go) already does. |
| 44 | // |
| 45 | //go:embed static |
| 46 | var staticFS embed.FS |
| 47 | |
| 48 | // bundleNames maps each committed bundle's name to its filename under |
| 49 | // static/ — "editor" to "editor.f13f5e0ce6fa7467.js", and so on — |
| 50 | // resolved once, at package init, from staticFS. |
| 51 | // |
| 52 | // There are two. "editor" is ProseMirror and Yjs, about 300KB, and |
| 53 | // only a document page needs it; "jumpto" is the keyboard palette |
| 54 | // (fixtures/jump-to.mjs), a few KB, and every signed-in page needs it |
| 55 | // including the list. Keeping them apart is the whole reason this is a |
| 56 | // registry rather than one filename: opening the list should not |
| 57 | // download an editor in order to offer a keyboard shortcut. |
| 58 | // |
| 59 | // Exactly one file must ever be present per name. |
| 60 | // fixtures/build-bundles.mjs's own rmSync loop deletes every previous |
| 61 | // "<name>.<hash>.js" before writing the new one, so a build that |
| 62 | // leaves two behind (a script edited to skip that step, a hand-added |
| 63 | // file, a bad merge) is a mistake — this panics at init rather than |
| 64 | // silently picking one, because a wrong bundle silently served is |
| 65 | // indistinguishable from a correct one until it disagrees with the |
| 66 | // server at runtime. |
| 67 | var bundleNames = mustResolveBundleNames() |
| 68 | |
| 69 | // bundleKinds is every bundle that must exist. Listed rather than |
| 70 | // discovered from the directory: a build that produced no jump-to |
| 71 | // bundle at all would otherwise start cleanly and simply never offer |
| 72 | // the palette, which is the same silent-failure shape the |
| 73 | // exactly-one rule above exists to prevent. |
| 74 | var bundleKinds = []string{"editor", "jumpto", "share"} |
| 75 | |
| 76 | func mustResolveBundleNames() map[string]string { |
| 77 | entries, err := fs.ReadDir(staticFS, "static") |
| 78 | if err != nil { |
| 79 | panic(fmt.Sprintf("docs: read embedded static/: %v", err)) |
| 80 | } |
| 81 | out := make(map[string]string, len(bundleKinds)) |
| 82 | for _, kind := range bundleKinds { |
| 83 | var found []string |
| 84 | for _, e := range entries { |
| 85 | if e.IsDir() || !strings.HasSuffix(e.Name(), ".js") { |
| 86 | continue |
| 87 | } |
| 88 | if strings.HasPrefix(e.Name(), kind+".") { |
| 89 | found = append(found, e.Name()) |
| 90 | } |
| 91 | } |
| 92 | if len(found) != 1 { |
| 93 | panic(fmt.Sprintf("docs: want exactly one %q bundle in static/, found %v — run fixtures/build-bundles.mjs and commit its output", kind, found)) |
| 94 | } |
| 95 | out[kind] = found[0] |
| 96 | } |
| 97 | return out |
| 98 | } |
| 99 | |
| 100 | // bundlePath is the absolute URL path a committed bundle is served at. |
| 101 | // The templates' <script> tags use this rather than a literal, so they |
| 102 | // can never drift from what staticHandler actually serves. |
| 103 | func bundlePath(kind string) string { |
| 104 | name, ok := bundleNames[kind] |
| 105 | if !ok { |
| 106 | // Unreachable while every caller passes a member of |
| 107 | // bundleKinds, which init has already proved resolvable. |
| 108 | panic(fmt.Sprintf("docs: no committed bundle named %q", kind)) |
| 109 | } |
| 110 | return "/static/" + name |
| 111 | } |
| 112 | |
| 113 | // editorScriptPath is the document editor's bundle — views/show.html's |
| 114 | // <script> tag. |
| 115 | func editorScriptPath() string { return bundlePath("editor") } |
| 116 | |
| 117 | // jumpToScriptPath is the keyboard palette's bundle (search.go, |
| 118 | // fixtures/jump-to.mjs), loaded by both the list and a document. |
| 119 | func jumpToScriptPath() string { return bundlePath("jumpto") } |
| 120 | |
| 121 | // shareScriptPath is the share page's Copy button (fixtures/share-link.mjs). |
| 122 | func shareScriptPath() string { return bundlePath("share") } |
| 123 | |
| 124 | // iconPath is the absolute URL path this app's tab icon is served at, |
| 125 | // and views/head.html's <link rel="icon"> href. Fixed rather than |
| 126 | // content-hashed the way the editor bundle above is: a favicon is |
| 127 | // requested by the browser for every page and changes about never, so |
| 128 | // the hash would buy nothing, and a stable path is also what a client |
| 129 | // that ignores the <link> and asks for a conventional location can be |
| 130 | // pointed at. |
| 131 | // |
| 132 | // assets_test.go holds this against what views/head.html actually |
| 133 | // writes, so the two can never drift into a 404 that only ever shows |
| 134 | // up as a missing tab icon nobody files a bug about. |
| 135 | const iconPath = "/static/icon.svg" |
| 136 | |
| 137 | // iconContentType is set explicitly for the same reason |
| 138 | // editorBundleContentType below is: not left to the host's mime |
| 139 | // registry. A browser will not render an icon served as |
| 140 | // text/plain or application/octet-stream. |
| 141 | const iconContentType = "image/svg+xml; charset=utf-8" |
| 142 | |
| 143 | // serveIcon writes the embedded tab icon. A day's cache, not the |
| 144 | // editor bundle's immutable year: the path carries no content hash, |
| 145 | // so a cached copy has to expire on its own for a redrawn icon ever |
| 146 | // to reach anyone. |
| 147 | func serveIcon(w http.ResponseWriter, r *http.Request) { |
| 148 | b, err := fs.ReadFile(brandFS, "brand/icon.svg") |
| 149 | if err != nil { |
| 150 | http.NotFound(w, r) |
| 151 | return |
| 152 | } |
| 153 | w.Header().Set("Content-Type", iconContentType) |
| 154 | w.Header().Set("Cache-Control", "public, max-age=86400") |
| 155 | http.ServeContent(w, r, "icon.svg", time.Time{}, bytes.NewReader(b)) |
| 156 | } |
| 157 | |
| 158 | // editorBundleContentType is set explicitly (http.ServeContent would |
| 159 | // otherwise guess from the ".js" extension via the OS mime registry, |
| 160 | // which on some platforms maps it to "application/javascript" and on |
| 161 | // others to "text/javascript" — RFC 9239's now-preferred type) rather |
| 162 | // than left to vary by host. |
| 163 | const editorBundleContentType = "text/javascript; charset=utf-8" |
| 164 | |
| 165 | // serveEditorBundle writes the embedded bundle with a year-long, |
| 166 | // immutable Cache-Control. That is safe only because the filename |
| 167 | // carries the content hash: the URL changes whenever the bytes do |
| 168 | // (fixtures/build-bundles.mjs), so there is nothing for a stale |
| 169 | // browser cache entry to collide with — see that script's own doc |
| 170 | // comment for the reasoning this depends on. |
| 171 | func serveBundle(w http.ResponseWriter, r *http.Request, fileName string) { |
| 172 | b, err := fs.ReadFile(staticFS, "static/"+fileName) |
| 173 | if err != nil { |
| 174 | http.NotFound(w, r) |
| 175 | return |
| 176 | } |
| 177 | w.Header().Set("Content-Type", editorBundleContentType) |
| 178 | w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| 179 | http.ServeContent(w, r, fileName, time.Time{}, bytes.NewReader(b)) |
| 180 | } |
| 181 | |
| 182 | // staticHandler composes this app's own committed static assets (the |
| 183 | // editor bundle, at its one fixed resolved-at-init path, and the tab |
| 184 | // icon at iconPath) with fallback — render.go's rastrillo.Assets tree (the |
| 185 | // vendored design-system CSS/JS) — into the single handler app.go |
| 186 | // mounts at "/static/*". Both trees share that URL prefix, so this is |
| 187 | // one composed handler rather than two competing route registrations |
| 188 | // (chi does not allow registering the same pattern twice, and a |
| 189 | // second wildcard route would only ever shadow or conflict with the |
| 190 | // first). |
| 191 | // |
| 192 | // Not behind RequireSession: app.go mounts this alongside /signin and |
| 193 | // the other unauthenticated routes, ahead of the r.Group that adds |
| 194 | // RequireSession — a signed-out reader loading the sign-in-adjacent |
| 195 | // shell, or the editor script itself, must not be redirected in a |
| 196 | // loop or 403 on essential assets. |
| 197 | func staticHandler(fallback http.Handler) http.Handler { |
| 198 | // Resolved once, into a path→filename map, rather than compared |
| 199 | // against a growing list of paths on every request. |
| 200 | paths := make(map[string]string, len(bundleNames)) |
| 201 | for _, name := range bundleNames { |
| 202 | paths["/static/"+name] = name |
| 203 | } |
| 204 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 205 | // The committed bundles first, by exact path. A map rather |
| 206 | // than a case per bundle: there are two now (editor and |
| 207 | // jumpto) and the switch below would have to grow a case for |
| 208 | // each, which is the shape that eventually forgets one. |
| 209 | if name, ok := paths[r.URL.Path]; ok { |
| 210 | serveBundle(w, r, name) |
| 211 | return |
| 212 | } |
| 213 | switch r.URL.Path { |
| 214 | case iconPath: |
| 215 | serveIcon(w, r) |
| 216 | return |
| 217 | } |
| 218 | if serveFont(w, r) { |
| 219 | return |
| 220 | } |
| 221 | fallback.ServeHTTP(w, r) |
| 222 | }) |
| 223 | } |
| 224 | |
| 225 | // serveFont answers a request for one of the core font faces |
| 226 | // (internal/fonts) or for the licence they are redistributed under, |
| 227 | // and reports whether it did. |
| 228 | // |
| 229 | // Same immutable caching as the editor bundle above, safe for the same |
| 230 | // reason: internal/fonts puts a content hash in every face's own path, |
| 231 | // so the URL changes whenever the bytes do. The path is matched |
| 232 | // against the registry rather than joined onto a directory, which is |
| 233 | // what makes a traversal attempt ("/static/fonts/../../etc/passwd") |
| 234 | // simply not match anything rather than something to defend against. |
| 235 | // |
| 236 | // Unauthenticated, like everything else under /static: a font is not |
| 237 | // document content, and a signed-out reader on the sign-in page is |
| 238 | // served the same shell. |
| 239 | func serveFont(w http.ResponseWriter, r *http.Request) bool { |
| 240 | if !strings.HasPrefix(r.URL.Path, fonts.PathPrefix) { |
| 241 | return false |
| 242 | } |
| 243 | if r.URL.Path == fonts.LicencePath { |
| 244 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 245 | w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| 246 | http.ServeContent(w, r, "LICENSE-OFL-1.1.txt", time.Time{}, strings.NewReader(fonts.Licence())) |
| 247 | return true |
| 248 | } |
| 249 | face, ok := fonts.Asset(r.URL.Path) |
| 250 | if !ok { |
| 251 | return false |
| 252 | } |
| 253 | w.Header().Set("Content-Type", "font/ttf") |
| 254 | w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| 255 | http.ServeContent(w, r, face.File, time.Time{}, bytes.NewReader(face.Data)) |
| 256 | return true |
| 257 | } |
| 258 | |