| 1 | // Package busy carries rastrillo's busy rule to the Oficina apps (Paul, |
| 2 | // 2026-09-29): a submit button's label gives way to a spinner for at |
| 3 | // least 650ms, the button disables, its form refuses a second submit, and |
| 4 | // Back hands the form back. |
| 5 | // |
| 6 | // The rule is rastrillo's (ui/busy.js). The apps vendor older rastrillo |
| 7 | // files, and moving every app to a newer rastrillo would carry far more |
| 8 | // than this, so estilo carries three byte-exact copies instead, each |
| 9 | // recorded in provenance.json and checked against it: |
| 10 | // |
| 11 | // - ShimJS: rastrillo.js as it is now the busy rule has left it. An app |
| 12 | // replaces its vendored static/rastrillo.js with these bytes — its old |
| 13 | // copy still carries the old busy rule, and two busy rules on one |
| 14 | // page fight over every submit. |
| 15 | // - JS: busy.js, the rule itself. |
| 16 | // - CSS: the spinner-in-place block from rastrillo's tokens.css, for a |
| 17 | // tokens.css that predates it. Link it after tokens.css. |
| 18 | // |
| 19 | // When an app next re-vendors rastrillo for real, it takes all three from |
| 20 | // there and drops this package. |
| 21 | package busy |
| 22 | |
| 23 | import ( |
| 24 | "crypto/sha256" |
| 25 | _ "embed" |
| 26 | "encoding/hex" |
| 27 | "net/http" |
| 28 | "strconv" |
| 29 | "strings" |
| 30 | ) |
| 31 | |
| 32 | //go:embed rastrillo.js |
| 33 | var shimJS []byte |
| 34 | |
| 35 | //go:embed busy.js |
| 36 | var busyJS []byte |
| 37 | |
| 38 | //go:embed busy.css |
| 39 | var busyCSS []byte |
| 40 | |
| 41 | // ShimJS is rastrillo.js without the busy rule, for an app's static/. |
| 42 | func ShimJS() []byte { return append([]byte(nil), shimJS...) } |
| 43 | |
| 44 | // JS is busy.js. |
| 45 | func JS() []byte { return append([]byte(nil), busyJS...) } |
| 46 | |
| 47 | // CSS is the spinner-in-place block. |
| 48 | func CSS() []byte { return append([]byte(nil), busyCSS...) } |
| 49 | |
| 50 | // Prefix is the URL path Handler serves under. Its version segment is the |
| 51 | // two files' own digest, so new bytes are always a new URL: both are |
| 52 | // cached for a year, immutably, and no one has to remember to bump it. |
| 53 | var Prefix = func() string { |
| 54 | sum := sha256.Sum256(append(append([]byte(nil), busyJS...), busyCSS...)) |
| 55 | return "/static/estilo/busy/" + hex.EncodeToString(sum[:6]) + "/" |
| 56 | }() |
| 57 | |
| 58 | // Handler serves GET for Prefix + "busy.js" and Prefix + "busy.css", and |
| 59 | // nothing else. Public and sessionless: the sign-in form needs it too. |
| 60 | func Handler() http.Handler { |
| 61 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 62 | var body []byte |
| 63 | var kind string |
| 64 | switch strings.TrimPrefix(r.URL.Path, Prefix) { |
| 65 | case "busy.js": |
| 66 | body, kind = busyJS, "text/javascript; charset=utf-8" |
| 67 | case "busy.css": |
| 68 | body, kind = busyCSS, "text/css; charset=utf-8" |
| 69 | } |
| 70 | if body == nil || !strings.HasPrefix(r.URL.Path, Prefix) { |
| 71 | http.NotFound(w, r) |
| 72 | return |
| 73 | } |
| 74 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 75 | w.Header().Set("Allow", "GET, HEAD") |
| 76 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| 77 | return |
| 78 | } |
| 79 | w.Header().Set("Content-Type", kind) |
| 80 | w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| 81 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 82 | w.Header().Set("Content-Length", strconv.Itoa(len(body))) |
| 83 | if r.Method == http.MethodHead { |
| 84 | return |
| 85 | } |
| 86 | _, _ = w.Write(body) |
| 87 | }) |
| 88 | } |
| 89 | |