Work the design critique down: chapter format, colour law, search, strip, a11y #11
+1
-1
@@ -179,7 +179,7 @@ func newRouter(store *Store, cfg Config) http.Handler {
|
|||||||
web.register(mux)
|
web.register(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
return withCORS(cfg.AllowedOrigins, guardEmptyUserscriptToken(mux))
|
return withCORS(cfg.AllowedOrigins, withGzip(guardEmptyUserscriptToken(mux)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect:
|
// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"compress/gzip"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -196,3 +198,46 @@ func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
|
|||||||
t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum)
|
t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGzipCompressesTextNotFonts(t *testing.T) {
|
||||||
|
srv, _ := newWebTestServer(t, webConfig())
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"/static/style.css", true},
|
||||||
|
{"/static/filter.js", true},
|
||||||
|
{"/static/htmx.min.js", true},
|
||||||
|
{"/static/fonts/dm-sans-var-latin.woff2", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d, want 200", tc.path, rr.Code)
|
||||||
|
}
|
||||||
|
got := rr.Header().Get("Content-Encoding") == "gzip"
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("GET %s Content-Encoding gzip = %v, want %v", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
if got {
|
||||||
|
zr, err := gzip.NewReader(rr.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET %s: body is not gzip: %v", tc.path, err)
|
||||||
|
}
|
||||||
|
if _, err := io.ReadAll(zr); err != nil {
|
||||||
|
t.Fatalf("GET %s: gzip body did not decode: %v", tc.path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client that does not ask still gets plain bytes.
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/static/style.css", nil))
|
||||||
|
if enc := rr.Header().Get("Content-Encoding"); enc != "" {
|
||||||
|
t.Errorf("Content-Encoding without Accept-Encoding = %q, want empty", enc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"compress/gzip"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,6 +27,71 @@ func withAuth(token string, next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gzipWriter compresses the body and drops Content-Length, which no longer
|
||||||
|
// describes what goes on the wire. WriteHeader is where the decision is made:
|
||||||
|
// only then is Content-Type known.
|
||||||
|
type gzipWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
gz *gzip.Writer
|
||||||
|
decided bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// compressible covers what this server actually serves in bulk: HTML, CSS, JS
|
||||||
|
// and JSON. Fonts are woff2, which is already compressed — gzipping them costs
|
||||||
|
// CPU to add bytes.
|
||||||
|
func compressible(contentType string) bool {
|
||||||
|
ct, _, _ := strings.Cut(contentType, ";")
|
||||||
|
switch strings.TrimSpace(ct) {
|
||||||
|
case "text/html", "text/css", "text/javascript", "application/javascript",
|
||||||
|
"application/json", "text/plain":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *gzipWriter) WriteHeader(status int) {
|
||||||
|
if !w.decided {
|
||||||
|
w.decided = true
|
||||||
|
if compressible(w.Header().Get("Content-Type")) {
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
w.Header().Del("Content-Length")
|
||||||
|
w.gz = gzip.NewWriter(w.ResponseWriter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *gzipWriter) Write(b []byte) (int, error) {
|
||||||
|
if !w.decided {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
if w.gz != nil {
|
||||||
|
return w.gz.Write(b)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withGzip compresses text responses for clients that ask. The templates,
|
||||||
|
// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter
|
||||||
|
// of that gzipped, which is the difference between a fast and a slow first load
|
||||||
|
// on mobile data.
|
||||||
|
func withGzip(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Add("Vary", "Accept-Encoding")
|
||||||
|
gw := &gzipWriter{ResponseWriter: w}
|
||||||
|
defer func() {
|
||||||
|
if gw.gz != nil {
|
||||||
|
gw.gz.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(gw, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// withCORS reflects the request Origin only when it is in allowed, answers
|
// withCORS reflects the request Origin only when it is in allowed, answers
|
||||||
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
||||||
// auth middleware so preflight (which carries no Authorization header) is never
|
// auth middleware so preflight (which carries no Authorization header) is never
|
||||||
|
|||||||
Reference in New Issue
Block a user