aboutsummaryrefslogtreecommitdiffstats
path: root/dashboard/app
diff options
context:
space:
mode:
authorTaras Madan <tarasmadan@google.com>2025-02-04 15:01:41 +0100
committerTaras Madan <tarasmadan@google.com>2025-02-05 14:45:47 +0000
commit577d049b4ea56e459da6e49f4b92fc1981c92440 (patch)
tree7e64eaf42a2032c44aa957a93210da14d594f6a2 /dashboard/app
parent8d34fd8d3a26fa93992edd4432071dd9c249cd61 (diff)
dashboard/app: pre-gzip all responses
Diffstat (limited to 'dashboard/app')
-rw-r--r--dashboard/app/api.go16
-rw-r--r--dashboard/app/handler.go123
-rw-r--r--dashboard/app/handler_test.go66
3 files changed, 171 insertions, 34 deletions
diff --git a/dashboard/app/api.go b/dashboard/app/api.go
index 9e53bcbe2..62b0ac76e 100644
--- a/dashboard/app/api.go
+++ b/dashboard/app/api.go
@@ -111,16 +111,16 @@ func handleJSON(fn JSONHandler) http.Handler {
http.Error(w, err.Error(), status)
return
}
- w.Header().Set("Content-Type", "application/json")
- wJS := w.(io.Writer)
- if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
- w.Header().Set("Content-Encoding", "gzip")
- gw := gzip.NewWriter(w)
- defer gw.Close()
- wJS = gw
- }
+
+ wJS := newGzipResponseWriterCloser(w)
+ defer wJS.Close()
if err := json.NewEncoder(wJS).Encode(reply); err != nil {
log.Errorf(c, "failed to encode reply: %v", err)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if err := wJS.writeResult(r); err != nil {
+ log.Errorf(c, "wJS.writeResult: %s", err.Error())
}
})
}
diff --git a/dashboard/app/handler.go b/dashboard/app/handler.go
index 27817ab8c..77a22e4c0 100644
--- a/dashboard/app/handler.go
+++ b/dashboard/app/handler.go
@@ -5,11 +5,13 @@ package main
import (
"bytes"
+ "compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
+ "io"
"net/http"
"sort"
"strings"
@@ -43,37 +45,45 @@ func handleContext(fn contextHandler) http.Handler {
}
defer backpressureRobots(c, r)()
}
- if err := fn(c, w, r); err != nil {
- hdr := commonHeaderRaw(c, r)
- data := &struct {
- Header *uiHeader
- Error string
- TraceID string
- }{
- Header: hdr,
- Error: err.Error(),
- TraceID: strings.Join(r.Header["X-Cloud-Trace-Context"], " "),
- }
- if err == ErrAccess {
- if hdr.LoginLink != "" {
- http.Redirect(w, r, hdr.LoginLink, http.StatusTemporaryRedirect)
- return
- }
- http.Error(w, "403 Forbidden", http.StatusForbidden)
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ gzw := newGzipResponseWriterCloser(w)
+ defer gzw.Close()
+ err := fn(c, gzw, r)
+ if err == nil {
+ if err = gzw.writeResult(r); err == nil {
return
}
- var redir *ErrRedirect
- if errors.As(err, &redir) {
- http.Redirect(w, r, redir.Error(), http.StatusFound)
+ }
+ hdr := commonHeaderRaw(c, r)
+ data := &struct {
+ Header *uiHeader
+ Error string
+ TraceID string
+ }{
+ Header: hdr,
+ Error: err.Error(),
+ TraceID: strings.Join(r.Header["X-Cloud-Trace-Context"], " "),
+ }
+ if err == ErrAccess {
+ if hdr.LoginLink != "" {
+ http.Redirect(w, r, hdr.LoginLink, http.StatusTemporaryRedirect)
return
}
+ http.Error(w, "403 Forbidden", http.StatusForbidden)
+ return
+ }
+ var redir *ErrRedirect
+ if errors.As(err, &redir) {
+ http.Redirect(w, r, redir.Error(), http.StatusFound)
+ return
+ }
- status := logErrorPrepareStatus(c, err)
- w.WriteHeader(status)
- if err1 := templates.ExecuteTemplate(w, "error.html", data); err1 != nil {
- combinedError := fmt.Sprintf("got err \"%v\" processing ExecuteTemplate() for err \"%v\"", err1, err)
- http.Error(w, combinedError, http.StatusInternalServerError)
- }
+ status := logErrorPrepareStatus(c, err)
+ w.WriteHeader(status)
+ if err1 := templates.ExecuteTemplate(w, "error.html", data); err1 != nil {
+ combinedError := fmt.Sprintf("got err \"%v\" processing ExecuteTemplate() for err \"%v\"", err1, err)
+ http.Error(w, combinedError, http.StatusInternalServerError)
}
})
}
@@ -339,3 +349,64 @@ func encodeCookie(w http.ResponseWriter, cd *cookieData) {
}
var templates = html.CreateGlob("*.html")
+
+// gzipResponseWriterCloser accumulates the gzipped result.
+// In case of error during the handler processing, we'll drop this gzipped data.
+// It allows to call http.Error in the middle of the response generation.
+//
+// For 200 Ok responses we return the compressed data or decompress it depending on the client Accept-Encoding header.
+type gzipResponseWriterCloser struct {
+ w *gzip.Writer
+ plainResponseSize int
+ buf *bytes.Buffer
+ rw http.ResponseWriter
+}
+
+func (g *gzipResponseWriterCloser) Write(p []byte) (n int, err error) {
+ g.plainResponseSize += len(p)
+ return g.w.Write(p)
+}
+
+func (g *gzipResponseWriterCloser) Close() {
+ if g.w != nil {
+ g.w.Close()
+ }
+}
+
+func (g *gzipResponseWriterCloser) Header() http.Header {
+ return g.rw.Header()
+}
+
+func (g *gzipResponseWriterCloser) WriteHeader(statusCode int) {
+ g.rw.WriteHeader(statusCode)
+}
+
+func (g *gzipResponseWriterCloser) writeResult(r *http.Request) error {
+ g.w.Close()
+ g.w = nil
+ clientSupportsGzip := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
+ if clientSupportsGzip {
+ g.rw.Header().Set("Content-Encoding", "gzip")
+ _, err := g.rw.Write(g.buf.Bytes())
+ return err
+ }
+ if g.plainResponseSize > 31<<20 { // 32MB is the AppEngine hard limit for the response size.
+ return fmt.Errorf("len(response) > 31M, try to request gzipped: %w", ErrClientBadRequest)
+ }
+ gzr, err := gzip.NewReader(g.buf)
+ if err != nil {
+ return fmt.Errorf("gzip.NewReader: %w", err)
+ }
+ defer gzr.Close()
+ _, err = io.Copy(g.rw, gzr)
+ return err
+}
+
+func newGzipResponseWriterCloser(w http.ResponseWriter) *gzipResponseWriterCloser {
+ buf := &bytes.Buffer{}
+ return &gzipResponseWriterCloser{
+ w: gzip.NewWriter(buf),
+ buf: buf,
+ rw: w,
+ }
+}
diff --git a/dashboard/app/handler_test.go b/dashboard/app/handler_test.go
new file mode 100644
index 000000000..3eef3e8e8
--- /dev/null
+++ b/dashboard/app/handler_test.go
@@ -0,0 +1,66 @@
+// Copyright 2025 syzkaller project authors. All rights reserved.
+// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
+
+package main
+
+import (
+ "compress/gzip"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGzipResponseWriterCloser_no_compression(t *testing.T) {
+ res := httptest.NewRecorder()
+ gz := newGzipResponseWriterCloser(res)
+ gz.Write([]byte("test"))
+
+ err := gz.writeResult(httpRequestWithAcceptedEncoding(""))
+ assert.NoError(t, err)
+ assert.Equal(t, "test", res.Body.String())
+ assert.Equal(t, "", res.Header().Get("Content-Encoding"))
+}
+
+func TestGzipResponseWriterCloser_with_compression(t *testing.T) {
+ res := httptest.NewRecorder()
+ gz := newGzipResponseWriterCloser(res)
+ gz.Write([]byte("test"))
+
+ err := gz.writeResult(httpRequestWithAcceptedEncoding("gzip"))
+ assert.NoError(t, err)
+ assert.Equal(t, "gzip", res.Header().Get("Content-Encoding"))
+
+ gr, _ := gzip.NewReader(res.Body)
+ gotBytes := make([]byte, 28)
+ n, _ := gr.Read(gotBytes)
+ gotBytes = gotBytes[:n]
+ assert.Equal(t, "test", string(gotBytes))
+}
+
+func TestGzipResponseWriterCloser_headers(t *testing.T) {
+ res := httptest.NewRecorder()
+ gz := newGzipResponseWriterCloser(res)
+
+ gz.Header().Add("key", "val1")
+ gz.Header().Add("key", "val2")
+ err := gz.writeResult(httpRequestWithAcceptedEncoding(""))
+ assert.NoError(t, err)
+ assert.Equal(t, http.Header{
+ "Key": []string{"val1", "val2"},
+ }, res.Header())
+}
+
+func TestGzipResponseWriterCloser_status(t *testing.T) {
+ res := httptest.NewRecorder()
+ gz := newGzipResponseWriterCloser(res)
+
+ gz.WriteHeader(333)
+ gz.writeResult(httpRequestWithAcceptedEncoding("gzip"))
+ assert.Equal(t, 333, res.Code)
+}
+
+func httpRequestWithAcceptedEncoding(encoding string) *http.Request {
+ return &http.Request{Header: http.Header{"Accept-Encoding": []string{encoding}}}
+}