| 1 | // Package site assembles fiesta.rastrillo.org: the committed releases |
| 2 | // under their exact versions, a rewrite per major onto its newest |
| 3 | // release, the response headers that let browsers keep what never |
| 4 | // changes, and the pages at the root. |
| 5 | // |
| 6 | // Build is a pure function of the two trees it is handed, so the same |
| 7 | // releases and pages are the same site on anyone's machine. cmd/fiesta |
| 8 | // writes what Build returns, and a static host serves it as is. |
| 9 | package site |
| 10 | |
| 11 | import ( |
| 12 | "bytes" |
| 13 | "crypto/sha256" |
| 14 | "encoding/hex" |
| 15 | "fmt" |
| 16 | "io/fs" |
| 17 | "regexp" |
| 18 | "slices" |
| 19 | "sort" |
| 20 | "strconv" |
| 21 | "strings" |
| 22 | ) |
| 23 | |
| 24 | // sumsName is the file every release carries, one `<hex> <path>` line |
| 25 | // per file, which internal/build writes and Release checks. |
| 26 | const sumsName = "SHA256SUMS" |
| 27 | |
| 28 | // redirectsName is the rules file at the root of a static site, in the |
| 29 | // format Netlify introduced and CARLOS's edge serves. It is the one |
| 30 | // file Build writes that is neither a release nor a page. |
| 31 | const redirectsName = "_redirects" |
| 32 | |
| 33 | // StampName is the file cmd/fiesta leaves at the top of a directory it |
| 34 | // owns, fiesta release's release directories included, so a later run |
| 35 | // knows the directory is its own to empty. It is committed with the |
| 36 | // release and is the one hidden file Release expects: it is skipped, |
| 37 | // never published, and never listed in SHA256SUMS. cmd/fiesta's claim |
| 38 | // uses this constant, so the two cannot disagree. |
| 39 | const StampName = ".fiesta" |
| 40 | |
| 41 | // hidden reports whether any segment of a slash path starts with a |
| 42 | // dot: an editor's swap file, a Finder's .DS_Store, a stamp. |
| 43 | func hidden(p string) bool { |
| 44 | for _, seg := range strings.Split(p, "/") { |
| 45 | if strings.HasPrefix(seg, ".") { |
| 46 | return true |
| 47 | } |
| 48 | } |
| 49 | return false |
| 50 | } |
| 51 | |
| 52 | // strictVersion is X.Y.Z and nothing else: no v, no prerelease, no |
| 53 | // build metadata, no leading zero. A release directory is named this |
| 54 | // way or it is not a release. |
| 55 | var strictVersion = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) |
| 56 | |
| 57 | // allDigits is a first path segment that reserves a major's namespace: |
| 58 | // /1/, /2/ and so on, whether or not that major has a release yet. |
| 59 | var allDigits = regexp.MustCompile(`^[0-9]+$`) |
| 60 | |
| 61 | // version is a parsed X.Y.Z, for ordering. |
| 62 | type version struct { |
| 63 | major, minor, patch int |
| 64 | name string |
| 65 | } |
| 66 | |
| 67 | // parseVersion reads a strict X.Y.Z, or reports that name is not one. |
| 68 | func parseVersion(name string) (version, bool) { |
| 69 | m := strictVersion.FindStringSubmatch(name) |
| 70 | if m == nil { |
| 71 | return version{}, false |
| 72 | } |
| 73 | v := version{name: name} |
| 74 | // A component that does not fit an int is not a version either: Atoi |
| 75 | // saturates on overflow, so two such versions would read as one |
| 76 | // number, and the rewrite could land on the older of them. |
| 77 | var err error |
| 78 | if v.major, err = strconv.Atoi(m[1]); err != nil { |
| 79 | return version{}, false |
| 80 | } |
| 81 | if v.minor, err = strconv.Atoi(m[2]); err != nil { |
| 82 | return version{}, false |
| 83 | } |
| 84 | if v.patch, err = strconv.Atoi(m[3]); err != nil { |
| 85 | return version{}, false |
| 86 | } |
| 87 | return v, true |
| 88 | } |
| 89 | |
| 90 | // The edge's limits on one _redirects file, from CARLOS's |
| 91 | // internal/edge/redirects.go: 2,000 rules, 256 KiB and 2,048 bytes a |
| 92 | // line (which one rule per major cannot reach once every component |
| 93 | // fits an int, but a page's rule could). A site past any of them |
| 94 | // assembles here and is refused by the packer, so it is refused here |
| 95 | // first, naming the figure. |
| 96 | const ( |
| 97 | redirectsMaxRules = 2000 |
| 98 | redirectsMaxBytes = 256 << 10 |
| 99 | redirectsMaxLine = 2048 |
| 100 | ) |
| 101 | |
| 102 | // Rules are what the site's rules files say beyond the release |
| 103 | // rewrites: what the pages ask for, as internal/www works it out from |
| 104 | // the pages it assembled, and which releases are published. |
| 105 | type Rules struct { |
| 106 | // Redirects are _redirects lines, written one a line as given, |
| 107 | // after the release rewrites, so a page's rule never decides a /M/ |
| 108 | // URL first. |
| 109 | Redirects []string |
| 110 | // Immutable are pages, by their address on the site, whose name |
| 111 | // carries a hash of their bytes, so the bytes at that address can |
| 112 | // never change. _headers lets browsers keep each for a year. |
| 113 | Immutable []string |
| 114 | // Published are releases, by version, whose directories are their |
| 115 | // tags', so their bytes can never change: _headers lets browsers |
| 116 | // keep each for a year. Every other release gets /M/'s five |
| 117 | // minutes, because until it is tagged it may be rebuilt under the |
| 118 | // same name, and a browser told it never changes would keep the |
| 119 | // old bytes. Empty is the safe default for every build but the one |
| 120 | // that ships. Build checks only that each is a release it holds; |
| 121 | // that it matches its tag is the caller's to check |
| 122 | // (TagDifferences), since Build reads no repository. |
| 123 | Published []string |
| 124 | } |
| 125 | |
| 126 | // less orders versions numerically, so 1.10.0 is newer than 1.9.3. |
| 127 | func (v version) less(w version) bool { |
| 128 | if v.major != w.major { |
| 129 | return v.major < w.major |
| 130 | } |
| 131 | if v.minor != w.minor { |
| 132 | return v.minor < w.minor |
| 133 | } |
| 134 | return v.patch < w.patch |
| 135 | } |
| 136 | |
| 137 | // Build assembles the site from releases, a tree of X.Y.Z directories |
| 138 | // as fiesta release writes them, and pages, a tree of files for the |
| 139 | // root. It returns every file keyed by its path on the site: |
| 140 | // |
| 141 | // - X.Y.Z/<file> for every file of every release, each checked |
| 142 | // against the release's SHA256SUMS first (Release); |
| 143 | // - _redirects, one rule per major, newest major first, sending |
| 144 | // /M/* to that major's newest release: `/M/* /M.y.z/:splat 200`; |
| 145 | // then rules.Redirects, the pages' rules, one a line as given, so |
| 146 | // a page's rule never decides a /M/ URL first; |
| 147 | // - _headers, a five-minute cache on each major's /M/* and on each |
| 148 | // release's /M.y.z/* that is not in rules.Published, a year's |
| 149 | // immutable one on each that is, all open to other origins, then |
| 150 | // a year's on each of rules.Immutable (headersFor); |
| 151 | // - every page, at the path it has under pages. |
| 152 | // |
| 153 | // Anything else is an error, and the first error is the whole answer: |
| 154 | // a release directory not named strictly X.Y.Z, a loose file at the |
| 155 | // top of releases, a release whose files disagree with its sums, or a |
| 156 | // page that would shadow a version. CARLOS's edge serves a real file |
| 157 | // at the request path before any rewrite that is not forced, so a page |
| 158 | // under an all-digit first segment would answer for /M/*, released or |
| 159 | // not, and a page under a version-shaped segment would answer for that |
| 160 | // release. Both are refused, and so is a page named _redirects or |
| 161 | // _headers, which are the rules files. So is a redirect that is not |
| 162 | // one line of its file, an immutable address that is not a page's, a |
| 163 | // published version that is not a release here or is named twice, and |
| 164 | // rules that take either file past the edge's limits. |
| 165 | func Build(releases fs.FS, pages fs.FS, rules Rules) (map[string][]byte, error) { |
| 166 | out := map[string][]byte{} |
| 167 | entries, err := fs.ReadDir(releases, ".") |
| 168 | if err != nil { |
| 169 | return nil, fmt.Errorf("reading releases: %w", err) |
| 170 | } |
| 171 | var versions []version |
| 172 | for _, e := range entries { |
| 173 | v, ok := parseVersion(e.Name()) |
| 174 | if !ok || !e.IsDir() { |
| 175 | return nil, fmt.Errorf("releases/%s is not a release: a release is a directory named X.Y.Z, nothing else", e.Name()) |
| 176 | } |
| 177 | dir, err := fs.Sub(releases, v.name) |
| 178 | if err != nil { |
| 179 | return nil, err |
| 180 | } |
| 181 | files, err := Release(dir) |
| 182 | if err != nil { |
| 183 | return nil, fmt.Errorf("releases/%s/%w", v.name, err) |
| 184 | } |
| 185 | for name, data := range files { |
| 186 | out[v.name+"/"+name] = data |
| 187 | } |
| 188 | versions = append(versions, v) |
| 189 | } |
| 190 | |
| 191 | // The newest release of each major, majors newest first. |
| 192 | newest := map[int]version{} |
| 193 | for _, v := range versions { |
| 194 | if cur, ok := newest[v.major]; !ok || cur.less(v) { |
| 195 | newest[v.major] = v |
| 196 | } |
| 197 | } |
| 198 | majors := make([]int, 0, len(newest)) |
| 199 | for m := range newest { |
| 200 | majors = append(majors, m) |
| 201 | } |
| 202 | sort.Sort(sort.Reverse(sort.IntSlice(majors))) |
| 203 | if len(majors) > redirectsMaxRules { |
| 204 | return nil, fmt.Errorf("%s would hold %d rules, one per major, and the edge accepts %d", redirectsName, len(majors), redirectsMaxRules) |
| 205 | } |
| 206 | if n := len(majors) + len(rules.Redirects); n > redirectsMaxRules { |
| 207 | return nil, fmt.Errorf("%s would hold %d rules, %d for the releases and %d for the pages, and the edge accepts %d", redirectsName, n, len(majors), len(rules.Redirects), redirectsMaxRules) |
| 208 | } |
| 209 | var redirects bytes.Buffer |
| 210 | for _, m := range majors { |
| 211 | fmt.Fprintf(&redirects, "/%d/* /%s/:splat 200\n", m, newest[m].name) |
| 212 | } |
| 213 | for _, r := range rules.Redirects { |
| 214 | switch { |
| 215 | case strings.ContainsAny(r, "\n\r"): |
| 216 | return nil, fmt.Errorf("the %s rule %q is not one line", redirectsName, r) |
| 217 | case strings.TrimSpace(r) == "": |
| 218 | return nil, fmt.Errorf("a %s rule is empty", redirectsName) |
| 219 | case len(r) > redirectsMaxLine: |
| 220 | return nil, fmt.Errorf("the %s rule %.40q... is %d bytes, and the edge accepts %d a line", redirectsName, r, len(r), redirectsMaxLine) |
| 221 | } |
| 222 | redirects.WriteString(r + "\n") |
| 223 | } |
| 224 | if redirects.Len() > redirectsMaxBytes { |
| 225 | return nil, fmt.Errorf("%s would be %d bytes, and the edge accepts %d", redirectsName, redirects.Len(), redirectsMaxBytes) |
| 226 | } |
| 227 | out[redirectsName] = redirects.Bytes() |
| 228 | |
| 229 | err = fs.WalkDir(pages, ".", func(p string, d fs.DirEntry, err error) error { |
| 230 | if err != nil { |
| 231 | return fmt.Errorf("reading pages: %w", err) |
| 232 | } |
| 233 | if p == "." { |
| 234 | return nil |
| 235 | } |
| 236 | // A hidden page is refused rather than skipped: pages/ is |
| 237 | // committed, so a dotfile there is a mistake someone should |
| 238 | // see, and skipping it quietly would also hide a stamp that |
| 239 | // a wrong -pages pointed at. Directories too, so an empty |
| 240 | // .git or .cache under pages/ is named, not passed over. |
| 241 | if hidden(p) { |
| 242 | return fmt.Errorf("page %s is hidden, and a hidden file is never published; remove it from pages", p) |
| 243 | } |
| 244 | if d.IsDir() { |
| 245 | return nil |
| 246 | } |
| 247 | // Checked per file rather than per directory, so the refusal |
| 248 | // names a page someone wrote and not the directory above it. |
| 249 | first, _, _ := strings.Cut(p, "/") |
| 250 | switch { |
| 251 | case allDigits.MatchString(first): |
| 252 | return fmt.Errorf("page %s would shadow /%s/, the rewrite onto the newest %s.x release, so no page may start with digits alone", p, first, first) |
| 253 | case strictVersion.MatchString(first): |
| 254 | return fmt.Errorf("page %s would shadow the release directory %s/, so no page may start with a version", p, first) |
| 255 | case first == redirectsName || first == headersName: |
| 256 | return fmt.Errorf("page %s is a rules file, which the site writes itself", p) |
| 257 | } |
| 258 | if !d.Type().IsRegular() { |
| 259 | return fmt.Errorf("page %s is not a regular file", p) |
| 260 | } |
| 261 | data, err := fs.ReadFile(pages, p) |
| 262 | if err != nil { |
| 263 | return fmt.Errorf("reading page %s: %w", p, err) |
| 264 | } |
| 265 | out[p] = data |
| 266 | return nil |
| 267 | }) |
| 268 | if err != nil { |
| 269 | return nil, err |
| 270 | } |
| 271 | |
| 272 | published := map[string]bool{} |
| 273 | for _, p := range rules.Published { |
| 274 | switch { |
| 275 | case published[p]: |
| 276 | return nil, fmt.Errorf("the published version %q is named twice", p) |
| 277 | case !slices.ContainsFunc(versions, func(v version) bool { return v.name == p }): |
| 278 | return nil, fmt.Errorf("the published version %q is not a release under releases/", p) |
| 279 | } |
| 280 | published[p] = true |
| 281 | } |
| 282 | headers, err := headersFor(versions, majors, published, rules.Immutable, out) |
| 283 | if err != nil { |
| 284 | return nil, err |
| 285 | } |
| 286 | out[headersName] = headers |
| 287 | return out, nil |
| 288 | } |
| 289 | |
| 290 | // headersFor is the site's _headers: a short cache on each major's |
| 291 | // /M/ and on each release's /M.y.z/ that is not published, a year on |
| 292 | // each that is, all open to other origins, and a year on each |
| 293 | // immutable page, in that order. Every file it does not name keeps the |
| 294 | // edge's no-cache, the pages included, so a deploy shows on the next |
| 295 | // load. |
| 296 | // |
| 297 | // Each immutable address must be a page of the site, not a release's |
| 298 | // file, written so the edge reads it back as that page's path: rooted, |
| 299 | // and free of what a path line reads as a pattern (":name", "*", a |
| 300 | // percent escape), as a query or fragment, or as the line's end |
| 301 | // (white space). Otherwise the rule would name nothing, or something |
| 302 | // else, and the page would quietly keep no-cache. |
| 303 | func headersFor(versions []version, majors []int, published map[string]bool, immutable []string, out map[string][]byte) ([]byte, error) { |
| 304 | ordered := slices.Clone(versions) |
| 305 | slices.SortFunc(ordered, func(a, b version) int { |
| 306 | switch { |
| 307 | case a.less(b): |
| 308 | return -1 |
| 309 | case b.less(a): |
| 310 | return 1 |
| 311 | } |
| 312 | return 0 |
| 313 | }) |
| 314 | ascending := slices.Clone(majors) |
| 315 | slices.Sort(ascending) |
| 316 | |
| 317 | // Majors, then releases, then pages: none of their paths overlap |
| 318 | // today, but where one ever does, the later and more specific rule |
| 319 | // is the one that wins. The two kinds of release never share a |
| 320 | // path, so their order between themselves decides nothing. |
| 321 | majorRules := headerGroup{ |
| 322 | kind: "majors", |
| 323 | why: []string{ |
| 324 | "/M/ moves to each new M.x release, so browsers keep it five minutes.", |
| 325 | "The cross-origin headers let pages on other sites fetch these files,", |
| 326 | "load them as modules and pin them with integrity hashes.", |
| 327 | }, |
| 328 | headers: append([]header{{"Cache-Control", cacheBriefly}}, crossOrigin...), |
| 329 | } |
| 330 | for _, m := range ascending { |
| 331 | majorRules.paths = append(majorRules.paths, fmt.Sprintf("/%d/*", m)) |
| 332 | } |
| 333 | candidateRules := headerGroup{ |
| 334 | kind: "unpublished releases", |
| 335 | why: []string{"An untagged release may be rebuilt under its name, so browsers keep it five minutes."}, |
| 336 | headers: append([]header{{"Cache-Control", cacheBriefly}}, crossOrigin...), |
| 337 | } |
| 338 | releaseRules := headerGroup{ |
| 339 | kind: "published releases", |
| 340 | why: []string{"A published release never changes, so browsers keep it for a year."}, |
| 341 | headers: append([]header{{"Cache-Control", cacheForever}}, crossOrigin...), |
| 342 | } |
| 343 | for _, v := range ordered { |
| 344 | if published[v.name] { |
| 345 | releaseRules.paths = append(releaseRules.paths, "/"+v.name+"/*") |
| 346 | } else { |
| 347 | candidateRules.paths = append(candidateRules.paths, "/"+v.name+"/*") |
| 348 | } |
| 349 | } |
| 350 | pageRules := headerGroup{ |
| 351 | kind: "immutable pages", |
| 352 | why: []string{"Each of these names carries a hash of its bytes, so it never changes."}, |
| 353 | headers: []header{{"Cache-Control", cacheForever}}, |
| 354 | } |
| 355 | for _, p := range immutable { |
| 356 | name, rooted := strings.CutPrefix(p, "/") |
| 357 | first, _, _ := strings.Cut(name, "/") |
| 358 | switch { |
| 359 | case !rooted: |
| 360 | return nil, fmt.Errorf("the immutable address %q is not rooted; write it as the site serves it, from /", p) |
| 361 | case strings.ContainsFunc(p, func(r rune) bool { return r <= ' ' || strings.ContainsRune(":*%?#", r) }): |
| 362 | return nil, fmt.Errorf("the immutable address %q holds a character a %s path line reads as more than a path", p, headersName) |
| 363 | } |
| 364 | if _, ok := out[name]; !ok || strictVersion.MatchString(first) { |
| 365 | return nil, fmt.Errorf("the immutable address %q is not a page of the site", p) |
| 366 | } |
| 367 | pageRules.paths = append(pageRules.paths, p) |
| 368 | } |
| 369 | slices.Sort(pageRules.paths) |
| 370 | return headersFile([]headerGroup{majorRules, candidateRules, releaseRules, pageRules}) |
| 371 | } |
| 372 | |
| 373 | // Release reads one release directory and checks it against its |
| 374 | // SHA256SUMS: every file is listed and its bytes match, and every |
| 375 | // listed file is present. It returns the files keyed by path, the sums |
| 376 | // file included, or the first disagreement. Every error begins with |
| 377 | // the path of the file at fault, relative to dir, so a caller that |
| 378 | // knows where dir is can prefix it. A release that fails this is not |
| 379 | // published, and fiesta release will not replace it as a candidate, |
| 380 | // because it is not what Fiesta wrote. |
| 381 | // |
| 382 | // Only regular files are a release's. A link, or anything else, is |
| 383 | // refused by name, the sums file included: fiesta release writes none, |
| 384 | // and reading through one would check bytes that live somewhere else. |
| 385 | // |
| 386 | // The one file not held to the sums is StampName at the top of the |
| 387 | // release, fiesta release's own ownership mark, which is skipped and |
| 388 | // never returned. Any other hidden file, and a stamp anywhere deeper, |
| 389 | // is refused by name whether or not SHA256SUMS lists it: fiesta |
| 390 | // release writes none, so a listed one was put there by hand, and a |
| 391 | // hidden file is never published. |
| 392 | func Release(dir fs.FS) (map[string][]byte, error) { |
| 393 | if fi, err := fs.Lstat(dir, sumsName); err != nil { |
| 394 | return nil, fmt.Errorf("%s: %w", sumsName, err) |
| 395 | } else if !fi.Mode().IsRegular() { |
| 396 | return nil, fmt.Errorf("%s is not a regular file, so it is not a release file", sumsName) |
| 397 | } |
| 398 | sums, err := fs.ReadFile(dir, sumsName) |
| 399 | if err != nil { |
| 400 | return nil, fmt.Errorf("%s: %w", sumsName, err) |
| 401 | } |
| 402 | listed := map[string]string{} |
| 403 | for i, line := range strings.Split(strings.TrimSuffix(string(sums), "\n"), "\n") { |
| 404 | hash, name, ok := strings.Cut(line, " ") |
| 405 | if !ok || len(hash) != sha256.Size*2 || name == "" { |
| 406 | return nil, fmt.Errorf("%s line %d is not `<sha256> <path>`: %q", sumsName, i+1, line) |
| 407 | } |
| 408 | listed[name] = hash |
| 409 | } |
| 410 | files := map[string][]byte{sumsName: sums} |
| 411 | err = fs.WalkDir(dir, ".", func(p string, d fs.DirEntry, err error) error { |
| 412 | if err != nil { |
| 413 | return err |
| 414 | } |
| 415 | if p == "." || d.IsDir() { |
| 416 | return nil |
| 417 | } |
| 418 | if p == StampName { |
| 419 | return nil |
| 420 | } |
| 421 | if hidden(p) { |
| 422 | return fmt.Errorf("%s is hidden, and a hidden file is never published, so it is not a release file", p) |
| 423 | } |
| 424 | if !d.Type().IsRegular() { |
| 425 | return fmt.Errorf("%s is not a regular file, so it is not a release file", p) |
| 426 | } |
| 427 | if p == sumsName { |
| 428 | return nil |
| 429 | } |
| 430 | want, ok := listed[p] |
| 431 | if !ok { |
| 432 | return fmt.Errorf("%s is not listed in %s", p, sumsName) |
| 433 | } |
| 434 | data, err := fs.ReadFile(dir, p) |
| 435 | if err != nil { |
| 436 | return err |
| 437 | } |
| 438 | sum := sha256.Sum256(data) |
| 439 | if hex.EncodeToString(sum[:]) != want { |
| 440 | return fmt.Errorf("%s does not match its line in %s", p, sumsName) |
| 441 | } |
| 442 | files[p] = data |
| 443 | return nil |
| 444 | }) |
| 445 | if err != nil { |
| 446 | return nil, err |
| 447 | } |
| 448 | for name := range listed { |
| 449 | if _, ok := files[name]; !ok { |
| 450 | return nil, fmt.Errorf("%s is listed in %s and missing", name, sumsName) |
| 451 | } |
| 452 | } |
| 453 | return files, nil |
| 454 | } |
| 455 | |
| 456 | // headersName is the file of response headers by path at the root of |
| 457 | // a static site, in the format Netlify and Cloudflare Pages use and |
| 458 | // CARLOS's edge serves from v0.31.1. Build writes it beside |
| 459 | // redirectsName. |
| 460 | const headersName = "_headers" |
| 461 | |
| 462 | // The edge's limits on one _headers file, from CARLOS's |
| 463 | // internal/edge/headers.go: 100 path lines and 2,048 characters a |
| 464 | // line. Its third limit, 256 KiB, cannot be reached under the other |
| 465 | // two: 90 paths of 2,048 characters, with the few fixed header lines |
| 466 | // under each, come to under 200 KiB. |
| 467 | // |
| 468 | // Build stops at headersStopPaths, ten short of the edge, so the file |
| 469 | // is reshaped while there is room, not on the day a release must ship. |
| 470 | const ( |
| 471 | headersMaxPaths = 100 |
| 472 | headersStopPaths = 90 |
| 473 | headersMaxLine = 2048 |
| 474 | ) |
| 475 | |
| 476 | // The Cache-Control values Build writes. immutable stops a browser |
| 477 | // revalidating even on a reload, so it goes only where the bytes at |
| 478 | // the address can never change: a published release, and a name that |
| 479 | // carries its bytes' hash. A candidate release gets cacheBriefly, as |
| 480 | // /M/ does. |
| 481 | const ( |
| 482 | cacheForever = "public, max-age=31536000, immutable" |
| 483 | cacheBriefly = "public, max-age=300" |
| 484 | headersIntro = "# Response headers for CARLOS's edge, written by fiesta site.\n" + |
| 485 | "# A rule matches the address the browser asked for, before _redirects\n" + |
| 486 | "# rewrites it. Where two rules set a header, the later one wins.\n" + |
| 487 | "# A file no rule names keeps the edge's Cache-Control: no-cache.\n" |
| 488 | ) |
| 489 | |
| 490 | // crossOrigin opens a release's files to pages on other sites. The |
| 491 | // first lets them fetch the strings, load a module script and pin a |
| 492 | // file by its integrity hash; the second lets a page that isolates |
| 493 | // itself (Cross-Origin-Embedder-Policy) load the files at all. The |
| 494 | // gallery's own assets, which only the gallery's pages load, go |
| 495 | // without. |
| 496 | var crossOrigin = []header{ |
| 497 | {"Access-Control-Allow-Origin", "*"}, |
| 498 | {"Cross-Origin-Resource-Policy", "cross-origin"}, |
| 499 | } |
| 500 | |
| 501 | // header is one header line of a _headers rule. |
| 502 | type header struct{ name, value string } |
| 503 | |
| 504 | // headerGroup is a run of _headers rules that set the same headers, |
| 505 | // under a comment that says why. |
| 506 | type headerGroup struct { |
| 507 | kind string // what the paths are, for a refusal's count |
| 508 | why []string // the comment's lines |
| 509 | paths []string |
| 510 | headers []header |
| 511 | } |
| 512 | |
| 513 | // headerChecks are the headers CARLOS's edge lets a site set, by the |
| 514 | // name Build writes, each with the values the edge accepts. carlos |
| 515 | // ship refuses a file with any other, so Build refuses it first: the |
| 516 | // headers are constants here, and one edited into something the edge |
| 517 | // refuses fails the build, not the ship. Cache-Control is held a |
| 518 | // little tighter than the edge holds it: no quoted values, which |
| 519 | // Build never writes. |
| 520 | var headerChecks = map[string]func(string) bool{ |
| 521 | "Cache-Control": func(v string) bool { return tokenList(v, true) }, |
| 522 | "Access-Control-Allow-Origin": func(v string) bool { return v == "*" }, |
| 523 | "Access-Control-Expose-Headers": func(v string) bool { return tokenList(v, false) }, |
| 524 | "Cross-Origin-Resource-Policy": func(v string) bool { |
| 525 | return v == "same-origin" || v == "same-site" || v == "cross-origin" |
| 526 | }, |
| 527 | "Timing-Allow-Origin": func(v string) bool { return v == "*" }, |
| 528 | } |
| 529 | |
| 530 | // tokenList reports whether v is a comma-separated list of HTTP tokens |
| 531 | // (RFC 9110), each optionally token=token when assign is set. |
| 532 | func tokenList(v string, assign bool) bool { |
| 533 | for _, item := range strings.Split(v, ",") { |
| 534 | item = strings.Trim(item, " ") |
| 535 | if name, val, ok := strings.Cut(item, "="); ok && assign { |
| 536 | if !isToken(name) || !isToken(val) { |
| 537 | return false |
| 538 | } |
| 539 | } else if !isToken(item) { |
| 540 | return false |
| 541 | } |
| 542 | } |
| 543 | return true |
| 544 | } |
| 545 | |
| 546 | // isToken reports whether s is one or more of the characters an HTTP |
| 547 | // header or directive name may hold. |
| 548 | func isToken(s string) bool { |
| 549 | if s == "" { |
| 550 | return false |
| 551 | } |
| 552 | for i := 0; i < len(s); i++ { |
| 553 | c := s[i] |
| 554 | if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0) { |
| 555 | return false |
| 556 | } |
| 557 | } |
| 558 | return true |
| 559 | } |
| 560 | |
| 561 | // headersFile writes groups as a _headers file, in order, or refuses |
| 562 | // what the edge would refuse: more than headersStopPaths paths, a line |
| 563 | // over headersMaxLine, or a header outside headerChecks. |
| 564 | func headersFile(groups []headerGroup) ([]byte, error) { |
| 565 | var counts []string |
| 566 | n := 0 |
| 567 | for _, g := range groups { |
| 568 | if len(g.paths) > 0 { |
| 569 | n += len(g.paths) |
| 570 | counts = append(counts, fmt.Sprintf("%d %s", len(g.paths), g.kind)) |
| 571 | } |
| 572 | } |
| 573 | if n > headersStopPaths { |
| 574 | return nil, fmt.Errorf("%s would name %d paths (%s), and the build stops at %d, short of the edge's limit of %d; "+ |
| 575 | "give the gallery's hashed assets a directory of their own so one rule covers them, or ask CARLOS for a higher limit", |
| 576 | headersName, n, strings.Join(counts, ", "), headersStopPaths, headersMaxPaths) |
| 577 | } |
| 578 | var b bytes.Buffer |
| 579 | b.WriteString(headersIntro) |
| 580 | for _, g := range groups { |
| 581 | if len(g.paths) == 0 { |
| 582 | continue |
| 583 | } |
| 584 | b.WriteString("\n") |
| 585 | for _, line := range g.why { |
| 586 | b.WriteString("# " + line + "\n") |
| 587 | } |
| 588 | for _, p := range g.paths { |
| 589 | if len(p) > headersMaxLine { |
| 590 | return nil, fmt.Errorf("the %s path %.40q... is %d characters, and the edge accepts %d a line", headersName, p, len(p), headersMaxLine) |
| 591 | } |
| 592 | b.WriteString(p + "\n") |
| 593 | for _, h := range g.headers { |
| 594 | if check := headerChecks[h.name]; check == nil || !check(h.value) { |
| 595 | return nil, fmt.Errorf("%s would set %s: %q on %s, and the edge refuses it; "+ |
| 596 | "set only the headers in headerChecks, with the values it allows", headersName, h.name, h.value, p) |
| 597 | } |
| 598 | b.WriteString(" " + h.name + ": " + h.value + "\n") |
| 599 | } |
| 600 | } |
| 601 | } |
| 602 | return b.Bytes(), nil |
| 603 | } |
| 604 | |