diff --git a/NOTICE.txt b/NOTICE.txt index 988c1df6d50..fce1a681ee9 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -10633,6 +10633,41 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- +## molecule-man/go-brrr + +This product contains 'molecule-man/go-brrr' by Andrii Berezhynskyi. + +Pure Go Brotli compressor + +* HOMEPAGE: + * https://github.com/molecule-man/go-brrr + +* LICENSE: MIT + +MIT License + +Copyright (c) 2026 Andrii Berezhynskyi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + ## monaco-editor This product contains 'monaco-editor'. diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js index 852934bbee0..b18dcff9f26 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js @@ -277,6 +277,34 @@ describe('Environment', () => { }); }); + describe('Web server mode - compression', () => { + let originalWebserverMode; + + before(() => { + cy.apiGetConfig().then(({config}) => { + originalWebserverMode = config.ServiceSettings.WebserverMode; + }); + }); + + after(() => { + cy.apiUpdateConfig({ServiceSettings: {WebserverMode: originalWebserverMode}}); + }); + + it('can be set back to gzip and persists', () => { + cy.visit('/admin_console/environment/web_server'); + + cy.findByTestId('ServiceSettings.WebserverModedropdown').select('gzip'); + cy.get('#saveSetting').click().wait(TIMEOUTS.ONE_SEC); + + cy.reload(); + cy.findByTestId('ServiceSettings.WebserverModedropdown').should('have.value', 'gzip'); + + cy.apiGetConfig().then(({config}) => { + expect(config.ServiceSettings.WebserverMode).to.equal('gzip'); + }); + }); + }); + function waitForAlert(message) { cy.waitUntil(() => cy.get('.alert').scrollIntoView().should('be.visible').then((alert) => { return alert[0].innerText === message; diff --git a/e2e-tests/playwright/lib/src/server/default_feature_flags.ts b/e2e-tests/playwright/lib/src/server/default_feature_flags.ts index 38759682d9c..d2dd136efe3 100644 --- a/e2e-tests/playwright/lib/src/server/default_feature_flags.ts +++ b/e2e-tests/playwright/lib/src/server/default_feature_flags.ts @@ -19,6 +19,7 @@ export const defaultFeatureFlags: Record = { DiscoverableChannels: 'false', EnableAIPluginBridge: 'false', EnableAIRecaps: 'false', + EnableBrotliCompression: 'false', EnableDocs: 'false', EnableMFIPluginSignaturePublicKey: 'true', EnableMattermostEntry: 'true', diff --git a/server/channels/api4/brotli.go b/server/channels/api4/brotli.go new file mode 100644 index 00000000000..73e2ca4869c --- /dev/null +++ b/server/channels/api4/brotli.go @@ -0,0 +1,320 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "bufio" + "fmt" + "net" + "net/http" + "strconv" + "strings" + + "github.com/klauspost/compress/gzhttp" + brrr "github.com/molecule-man/go-brrr" + + "github.com/mattermost/mattermost/server/public/shared/mlog" +) + +// go-brrr was benchmarked against github.com/andybalholm/brotli (already an indirect +// dependency) at the same compression level: andybalholm/brotli was 2-2.7x slower and +// allocated far more per call (e.g. 22MB vs 12MB on a 50k-channel payload), eroding most +// of the speed advantage brotli is meant to offer here over gzip. See compression_bench_test.go. + +// brotliCompressionLevel is chosen for its cost/benefit ratio: benchmarked across +// response sizes from a few hundred bytes to tens of megabytes, it consistently beats +// gzip's default level on ratio and speed while keeping memory use close to (small/medium +// responses) or a bounded multiple of (large responses) gzip's own footprint. Higher +// levels buy only marginal ratio gains for a steep rise in memory and CPU cost. +const brotliCompressionLevel = 2 + +// brotliMinSize reuses gzhttp's own minimum-size threshold so both compression +// paths in compressionHandler apply the same "is this worth compressing" policy. +const brotliMinSize = gzhttp.DefaultMinSize + +// bodyAllowedForStatus reports whether a given response status code permits a +// body, mirroring gzhttp's own helper of the same name (RFC 7230, section 3.3). +func bodyAllowedForStatus(status int) bool { + switch { + case status >= 100 && status <= 199: + return false + case status == http.StatusNoContent: + return false + case status == http.StatusNotModified: + return false + } + return true +} + +// brotliContentTypeAllowed mirrors gzhttp.DefaultContentTypeFilter: content +// that's already compressed (video, audio, most images) gains nothing from +// brotli and isn't worth buffering entirely into memory, e.g. for the file, +// thumbnail, and preview download handlers. +func brotliContentTypeAllowed(contentType string) bool { + return gzhttp.DefaultContentTypeFilter(contentType) +} + +// brotliResponseWriter mirrors gzhttp.GzipResponseWriter's own Write/Close +// algorithm (buffer up to minSize, decide once whether to compress, then +// stream everything else directly into the compressor) so brotli responses +// get the same behavior gzip responses already have in this codebase: small +// or incompressible bodies pass through untouched, and large bodies are +// streamed rather than fully buffered in memory. go-brrr (unlike gzhttp for +// gzip/zstd) is a bare compressor with no such policy layer, so this type +// provides it. +type brotliResponseWriter struct { + http.ResponseWriter + bw *brrr.Writer // set once compression starts; nil until then + code int // pending status code; 0 means WriteHeader wasn't called yet + buf []byte // holds the response until minSize is reached or Close + ignore bool // true once we've decided not to compress + hijacked bool // true once the connection has been hijacked +} + +func newBrotliResponseWriter(w http.ResponseWriter) *brotliResponseWriter { + return &brotliResponseWriter{ResponseWriter: w} +} + +// WriteHeader just saves the response code, mirroring gzhttp.GzipResponseWriter: +// the real header commit happens once Write/Close decides compress vs. passthrough. +func (b *brotliResponseWriter) WriteHeader(code int) { + if code >= 100 && code <= 199 { + b.ResponseWriter.WriteHeader(code) + return + } + if b.code == 0 { + b.code = code + } +} + +// statusCode returns the pending status code, defaulting to 200 if the +// handler never called WriteHeader, matching net/http's own default. +func (b *brotliResponseWriter) statusCode() int { + if b.code == 0 { + return http.StatusOK + } + return b.code +} + +func (b *brotliResponseWriter) Write(p []byte) (int, error) { + if b.bw != nil { + return b.bw.Write(p) + } + if b.ignore { + return b.ResponseWriter.Write(p) + } + + // Matches net/http: the first Write without a prior WriteHeader locks in + // 200, and any WriteHeader call after that is a no-op on the real + // ResponseWriter — so it must be a no-op here too. + if b.code == 0 { + b.code = http.StatusOK + } + + toAdd := len(p) + if len(b.buf)+toAdd > brotliMinSize { + toAdd = brotliMinSize - len(b.buf) + } + b.buf = append(b.buf, p[:toAdd]...) + remain := p[toAdd:] + + if len(b.buf) < brotliMinSize { + // Not enough data yet to decide; wait for more Writes or Close. + return len(p), nil + } + + if err := b.startCompression(remain); err != nil { + return 0, err + } + return len(p), nil +} + +// startCompression commits headers, decides compress vs. passthrough based on +// content-type, and (if compressing) writes the buffered bytes plus remain +// through the brotli writer. Mirrors gzhttp's startCompression. +func (b *brotliResponseWriter) startCompression(remain []byte) error { + h := b.ResponseWriter.Header() + h.Add("Vary", "Accept-Encoding") + + code := b.statusCode() + + // A handler that already declared its own Content-Encoding has either + // pre-compressed the body itself or opted out of transparent compression; + // compressing it again would corrupt the response, so pass it through as-is. + alreadyEncoded := h.Get("Content-Encoding") != "" + + // A ranged response (e.g. from http.ServeContent) has Content-Range set; + // compressing it would corrupt the byte range the client asked for, since + // the range refers to the uncompressed body. Matches gzhttp's own check. + isRanged := h.Get("Content-Range") != "" + + _, hasContentType := h["Content-Type"] + ct := h.Get("Content-Type") + if !hasContentType && bodyAllowedForStatus(code) && len(b.buf) > 0 { + // Detect Content-Type from the buffered plain bytes before compressing — + // once Content-Encoding is set, net/http's own sniffing (which normally + // runs on Write when Content-Type is unset) would otherwise sniff the + // compressed bytes instead, matching gzhttp's own behavior. Checking the + // header key's presence (not just whether its value is "") distinguishes + // "never set" from a handler explicitly setting an empty Content-Type to + // opt out of sniffing — the same distinction gzhttp itself makes. + ct = http.DetectContentType(b.buf) + h.Set("Content-Type", ct) + } + + if alreadyEncoded || isRanged || !bodyAllowedForStatus(code) || !brotliContentTypeAllowed(ct) { + return b.startPlain(remain) + } + + bw, err := brrr.NewWriter(b.ResponseWriter, brotliCompressionLevel) + if err != nil { + mlog.Warn("Failed to create brotli writer, sending uncompressed response", mlog.Err(err)) + return b.startPlain(remain) + } + b.bw = bw + + h.Set("Content-Encoding", "br") + // If the handler already set an explicit Content-Length (e.g. compliance.go), + // it describes the uncompressed size and must not survive into a compressed + // response, or the client will misread the body boundary. + h.Del("Content-Length") + // http.ServeContent sets Accept-Ranges on any seekable response; once the + // body is compressed, a later byte-range request against it would have to + // be resolved against the compressed stream, which this writer doesn't + // support, so advertise no range support. Matches gzhttp's own default. + h.Del("Accept-Ranges") + b.ResponseWriter.WriteHeader(code) + + if len(b.buf) > 0 { + if _, err := b.bw.Write(b.buf); err != nil { + return err + } + b.buf = nil + } + if len(remain) > 0 { + if _, err := b.bw.Write(remain); err != nil { + return err + } + } + return nil +} + +// startPlain commits headers and writes any buffered bytes through unmodified, +// mirroring gzhttp's startPlain. Only reachable before headers are committed +// (Content-Encoding is never set on this path), so header mutation is still safe. +func (b *brotliResponseWriter) startPlain(remain []byte) error { + b.ResponseWriter.WriteHeader(b.statusCode()) + b.ignore = true + if len(b.buf) > 0 { + if _, err := b.ResponseWriter.Write(b.buf); err != nil { + return err + } + b.buf = nil + } + if len(remain) > 0 { + if _, err := b.ResponseWriter.Write(remain); err != nil { + return err + } + } + return nil +} + +// Close finalizes the response. If compression never started because the whole +// body stayed under minSize, decide now: too small to bother, so send it plain. +// Otherwise close the brotli stream. Mirrors gzhttp.GzipResponseWriter.Close. +func (b *brotliResponseWriter) Close() error { + if b.hijacked || b.ignore { + return nil + } + if b.bw == nil { + if len(b.buf) < brotliMinSize { + return b.startPlain(nil) + } + return b.startCompression(nil) + } + return b.bw.Close() +} + +// Flush flushes the underlying brotli writer (if compression has started) and +// then the underlying ResponseWriter, matching gzhttp.GzipResponseWriter.Flush. +// If not enough data has been written to decide compress-vs-passthrough, this +// starts compression now so buffered data is actually sent, exactly like gzhttp. +func (b *brotliResponseWriter) Flush() { + if b.hijacked { + return + } + if b.bw == nil && !b.ignore { + if len(b.buf) == 0 { + // Nothing written yet. + return + } + if err := b.startCompression(nil); err != nil { + mlog.Warn("Failed to start brotli compression on flush", mlog.Err(err)) + return + } + } + if b.bw != nil { + if err := b.bw.Flush(); err != nil { + mlog.Warn("Failed to flush brotli writer", mlog.Err(err)) + } + } + if f, ok := b.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Hijack implements http.Hijacker by delegating to the underlying ResponseWriter, +// the same pattern gzhttp.GzipResponseWriter uses — without it, WebSocket upgrades +// routed through this writer would fail to take over the connection. +func (b *brotliResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hj, ok := b.ResponseWriter.(http.Hijacker); ok { + conn, rw, err := hj.Hijack() + if err == nil { + b.hijacked = true + } + return conn, rw, err + } + return nil, nil, fmt.Errorf("http.Hijacker interface is not supported") +} + +// acceptsBrotli reports whether the client's Accept-Encoding header accepts +// the "br" coding, honoring q-values (e.g. "br;q=0" means "not accepted") +// rather than doing a naive substring match. +func acceptsBrotli(r *http.Request) bool { + header := r.Header.Get("Accept-Encoding") + for token := range strings.SplitSeq(header, ",") { + token = strings.TrimSpace(token) + if token == "" { + continue + } + coding, params, _ := strings.Cut(token, ";") + coding = strings.TrimSpace(coding) + if !strings.EqualFold(coding, "br") { + continue + } + if params == "" { + return true + } + return acceptEncodingQAllowsCompression(params) + } + return false +} + +// acceptEncodingQAllowsCompression parses the ";q=..." parameter portion of an +// Accept-Encoding token and reports whether it permits use of that coding +// (i.e. is absent, unparsable, or a non-zero quality value). +func acceptEncodingQAllowsCompression(params string) bool { + for param := range strings.SplitSeq(params, ";") { + name, value, found := strings.Cut(param, "=") + if !found || !strings.EqualFold(strings.TrimSpace(name), "q") { + continue + } + q, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return true + } + return q > 0 + } + return true +} diff --git a/server/channels/api4/cloud.go b/server/channels/api4/cloud.go index cc8b03cebcd..bc39496f0da 100644 --- a/server/channels/api4/cloud.go +++ b/server/channels/api4/cloud.go @@ -32,7 +32,7 @@ func (api *API) InitCloud() { // GET /api/v4/cloud/subscription api.BaseRoutes.Cloud.Handle("/subscription", api.APISessionRequired(getSubscription)).Methods(http.MethodGet) api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.APISessionRequired(getInvoicesForSubscription)).Methods(http.MethodGet) - api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:[_A-Za-z0-9]+}/pdf", api.APISessionRequired(getSubscriptionInvoicePDF)).Methods(http.MethodGet) + api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:[_A-Za-z0-9]+}/pdf", api.APISessionRequiredGzip(getSubscriptionInvoicePDF)).Methods(http.MethodGet) // GET /api/v4/cloud/validate-business-email api.BaseRoutes.Cloud.Handle("/validate-business-email", api.APISessionRequired(validateBusinessEmail)).Methods(http.MethodPost) diff --git a/server/channels/api4/compression.go b/server/channels/api4/compression.go new file mode 100644 index 00000000000..1f222de5ce2 --- /dev/null +++ b/server/channels/api4/compression.go @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "net/http" + + "github.com/klauspost/compress/gzhttp" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" +) + +// apiCompressionHandler wraps h with compressionHandler using the server's current +// config, negotiating Brotli only for handlers that opt in via allowBrotli — large +// file downloads stay on gzip regardless of the feature flag, since Brotli's memory +// footprint scales with response size and buys little over gzip for already-large bodies. +func apiCompressionHandler(cfg *model.Config, h http.Handler, allowBrotli bool) http.Handler { + useBrotli := allowBrotli && cfg.ServiceSettings.CompressResponsesWithBrotli(cfg.FeatureFlags.EnableBrotliCompression) + return compressionHandler(h, cfg.ServiceSettings.CompressResponses(), useBrotli) +} + +// compressionHandler wraps h to prefer Brotli when the client supports it, +// falling back to gzip, and passing through uncompressed otherwise. +func compressionHandler(h http.Handler, useCompression, useBrotli bool) http.Handler { + gzipWrapped := h + if useCompression { + gzipWrapped = gzhttp.GzipHandler(h) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if useBrotli && acceptsBrotli(r) { + bbw := newBrotliResponseWriter(w) + defer func() { + if err := bbw.Close(); err != nil { + mlog.Warn("Failed to close brotli response writer", mlog.Err(err)) + } + }() + h.ServeHTTP(bbw, r) + return + } + gzipWrapped.ServeHTTP(w, r) + }) +} diff --git a/server/channels/api4/compression_bench_test.go b/server/channels/api4/compression_bench_test.go new file mode 100644 index 00000000000..54a909bde2e --- /dev/null +++ b/server/channels/api4/compression_bench_test.go @@ -0,0 +1,208 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +// gzip-6 (production default) vs go-brrr-2 (brotliCompressionLevel, brotli.go): +// +// fixture time/mem/ratio (gzip-6) time/mem/ratio (go-brrr-2) +// tiny_ping_like_5_fields 22.7us / 814KB / 1.34x 1.6us / 361B / 1.37x +// small_single_resource_50_fields 36.0us / 818KB / 2.06x 9.4us / 1.5KB / 2.05x +// medium_post_list_page_800_fields 576.3us / 879KB / 2.23x 118.0us / 45KB / 2.36x +// large_realistic_1078_channels 2.55ms / 1.1MB / 8.24x 782.5us / 185KB / 10.49x +// xlarge_old_hardcoded_limit_10000_channels 23.52ms / 2.9MB / 8.33x 8.32ms / 3.2MB / 8.96x +// adversarial_50000_channels 134.11ms / 17.6MB / 8.37x 46.66ms / 12.9MB / 8.96x +// +// Level 2 was picked over 1-11: it's faster and smaller than gzip-6 at every size +// above, and levels 3+ buy at most a marginal ratio gain for a steep memory jump. +import ( + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "testing" + + brrr "github.com/molecule-man/go-brrr" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" +) + +// buildFlatMapFixture mimics a small, flat JSON response like GET /system/ping. +func buildFlatMapFixture(fieldCount int) []byte { + m := make(map[string]string, fieldCount) + for i := range fieldCount { + m[fmt.Sprintf("field_%d", i)] = fmt.Sprintf("value_%d_%s", i, model.NewId()) + } + body, err := json.Marshal(m) + if err != nil { + panic(err) + } + return body +} + +// Mirrors the aggregate API's DTOs so the fixture keeps the same serialized +// size without this package depending on them. +type benchChannel struct { + Id string `json:"id"` + CreateAt int64 `json:"create_at,omitempty"` + UpdateAt int64 `json:"update_at,omitempty"` + DeleteAt int64 `json:"delete_at,omitempty"` + TeamId string `json:"team_id"` + Type model.ChannelType `json:"type"` + DisplayName string `json:"display_name"` + Name string `json:"name"` + LastPostAt int64 `json:"last_post_at"` + TotalMsgCount int64 `json:"total_msg_count"` + CreatorId string `json:"creator_id,omitempty"` + GroupConstrained *bool `json:"group_constrained"` + Shared *bool `json:"shared"` + TotalMsgCountRoot int64 `json:"total_msg_count_root,omitempty"` + LastRootPostAt int64 `json:"last_root_post_at,omitempty"` + PolicyEnforced bool `json:"policy_enforced,omitempty"` + MemberCount int64 `json:"member_count,omitempty"` +} + +type benchChannelMember struct { + ChannelId string `json:"channel_id"` + UserId string `json:"user_id"` + Roles string `json:"roles"` + LastViewedAt int64 `json:"last_viewed_at"` + NotifyProps model.StringMap `json:"notify_props"` + MsgCount int64 `json:"msg_count"` + MentionCount int64 `json:"mention_count"` + MentionCountRoot int64 `json:"mention_count_root"` + UrgentMentionCount int64 `json:"urgent_mention_count"` + MsgCountRoot int64 `json:"msg_count_root"` + LastUpdateAt int64 `json:"last_update_at"` + SchemeGuest bool `json:"scheme_guest"` + SchemeUser bool `json:"scheme_user"` + SchemeAdmin bool `json:"scheme_admin"` + AutoTranslationDisabled bool `json:"autotranslation_disabled,omitempty"` +} + +func buildAggregateChannelsFixture(channelCount int) []byte { + channels := make([]*benchChannel, 0, channelCount) + members := make([]*benchChannelMember, 0, channelCount) + for i := range channelCount { + id := model.NewId() + channels = append(channels, &benchChannel{ + Id: id, + CreateAt: 1700000000000, + UpdateAt: 1700000000000 + int64(i), + TeamId: model.NewId(), + Type: model.ChannelTypeOpen, + DisplayName: fmt.Sprintf("channel-display-name-%d", i), + Name: fmt.Sprintf("channel-name-%d", i), + LastPostAt: 1700000000000, + TotalMsgCount: int64(i % 5000), + TotalMsgCountRoot: int64(i % 5000), + }) + members = append(members, &benchChannelMember{ + ChannelId: id, + UserId: model.NewId(), + Roles: "channel_user", + NotifyProps: model.StringMap{ + "desktop": "default", + "mark_unread": "all", + "push": "default", + "email": "default", + "ignore_channel_mentions": "default", + }, + LastViewedAt: 1700000000000, + MsgCount: int64(i % 5000), + MentionCount: int64(i % 5), + MentionCountRoot: int64(i % 5), + LastUpdateAt: 1700000000000, + SchemeUser: true, + }) + } + + payload := struct { + Channels []*benchChannel `json:"channels"` + Members []*benchChannelMember `json:"channel_members"` + }{Channels: channels, Members: members} + + body, err := json.Marshal(payload) + if err != nil { + panic(err) + } + return body +} + +type compressionFixture struct { + name string + body []byte +} + +func allCompressionFixtures() []compressionFixture { + return []compressionFixture{ + {"tiny_ping_like_5_fields", buildFlatMapFixture(5)}, + {"small_single_resource_50_fields", buildFlatMapFixture(50)}, + {"medium_post_list_page_800_fields", buildFlatMapFixture(800)}, + {"large_realistic_1078_channels", buildAggregateChannelsFixture(1078)}, + {"xlarge_old_hardcoded_limit_10000_channels", buildAggregateChannelsFixture(10000)}, + {"adversarial_50000_channels", buildAggregateChannelsFixture(50000)}, + } +} + +func compressGzip(level int, body []byte) int { + var out bytes.Buffer + gw, _ := gzip.NewWriterLevel(&out, level) + _, _ = gw.Write(body) + _ = gw.Close() + return out.Len() +} + +func compressGoBrrr(level int, body []byte) int { + var out bytes.Buffer + w, err := brrr.NewWriter(&out, level) + if err != nil { + panic(err) + } + _, _ = w.Write(body) + _ = w.Close() + return out.Len() +} + +// BenchmarkCompression compares gzip's production default against go-brrr at the +// shipped level (brotliCompressionLevel, brotli.go). Run with: +// +// go test ./channels/api4/... -bench BenchmarkCompression -benchmem -run '^$' +func BenchmarkCompression(b *testing.B) { + for _, fx := range allCompressionFixtures() { + b.Run(fx.name, func(b *testing.B) { + b.Run("gzip", func(b *testing.B) { + var compressedSize int + b.ReportAllocs() + for range b.N { + compressedSize = compressGzip(gzip.DefaultCompression, fx.body) + } + b.ReportMetric(float64(len(fx.body))/float64(compressedSize), "ratio") + }) + b.Run("go_brrr", func(b *testing.B) { + var compressedSize int + b.ReportAllocs() + for range b.N { + compressedSize = compressGoBrrr(brotliCompressionLevel, fx.body) + } + b.ReportMetric(float64(len(fx.body))/float64(compressedSize), "ratio") + }) + }) + } +} + +func TestGoBrrrRoundTripCorrectness(t *testing.T) { + body := buildAggregateChannelsFixture(1078) + + var out bytes.Buffer + w, err := brrr.NewWriter(&out, brotliCompressionLevel) + require.NoError(t, err) + _, err = w.Write(body) + require.NoError(t, err) + require.NoError(t, w.Close()) + + decompressed, err := brrr.Decompress(out.Bytes()) + require.NoError(t, err, "go-brrr output failed to decompress") + require.True(t, bytes.Equal(decompressed, body), "go-brrr round-trip did not reproduce the original payload") +} diff --git a/server/channels/api4/compression_test.go b/server/channels/api4/compression_test.go new file mode 100644 index 00000000000..7f5b97fa9d9 --- /dev/null +++ b/server/channels/api4/compression_test.go @@ -0,0 +1,416 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "bufio" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + brrr "github.com/molecule-man/go-brrr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func decompressBrotli(t *testing.T, data []byte) []byte { + t.Helper() + decompressed, err := brrr.Decompress(data) + require.NoError(t, err) + return decompressed +} + +// hijackableResponseWriter adds http.Hijacker support to httptest.ResponseRecorder, +// which doesn't implement it, so tests can exercise the real WebSocket upgrade path. +type hijackableResponseWriter struct { + *httptest.ResponseRecorder + conn net.Conn +} + +func (w *hijackableResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return w.conn, bufio.NewReadWriter(bufio.NewReader(w.conn), bufio.NewWriter(w.conn)), nil +} + +// handlerForBrotli writes a body large enough that compression is worthwhile. +func handlerForBrotli(t *testing.T) func(*Context, http.ResponseWriter, *http.Request) { + return func(_ *Context, w http.ResponseWriter, _ *http.Request) { + var body [1400]byte + _, err := w.Write(body[:]) + require.NoError(t, err) + } +} + +func TestCompressionHandlerBrotli(t *testing.T) { + innerHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + var body [1400]byte + _, err := w.Write(body[:]) + require.NoError(t, err) + }) + + t.Run("serves brotli when Accept-Encoding contains br and compression enabled", func(t *testing.T) { + h := compressionHandler(innerHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br, gzip") + + h.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "br", resp.Header().Get("Content-Encoding")) + + // Verify the body is valid brotli-compressed data. + decompressed := decompressBrotli(t, resp.Body.Bytes()) + assert.Len(t, decompressed, 1400) + }) + + t.Run("falls back to gzip when Accept-Encoding does not contain br", func(t *testing.T) { + h := compressionHandler(innerHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "gzip") + + h.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "gzip", resp.Header().Get("Content-Encoding")) + }) + + t.Run("no compression when compression disabled even if client accepts br", func(t *testing.T) { + h := compressionHandler(innerHandler, false, false) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br, gzip") + + h.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "", resp.Header().Get("Content-Encoding")) + }) + + t.Run("serves gzip when brotli is disabled even if client accepts br", func(t *testing.T) { + h := compressionHandler(innerHandler, true, false) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br, gzip") + + h.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "gzip", resp.Header().Get("Content-Encoding")) + }) + + t.Run("WebSocket upgrade can hijack the connection through the brotli path", func(t *testing.T) { + // brotliResponseWriter implements http.Hijacker by delegating to the underlying + // ResponseWriter, so WS upgrades no longer need to bypass the compression path. + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + hw := &hijackableResponseWriter{ + ResponseRecorder: httptest.NewRecorder(), + conn: serverConn, + } + + wsUpgradeHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hj, ok := w.(http.Hijacker) + require.True(t, ok, "ResponseWriter passed to the handler must implement http.Hijacker") + conn, _, err := hj.Hijack() + require.NoError(t, err) + assert.Same(t, serverConn, conn) + }) + h := compressionHandler(wsUpgradeHandler, true, true) + req := httptest.NewRequest(http.MethodGet, "/api/v4/websocket", nil) + req.Header.Set("Accept-Encoding", "br") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + + h.ServeHTTP(hw, req) + + // Close() must not write to the ResponseRecorder once the connection has + // been hijacked — the recorder is orphaned at that point, and writing to + // it (or to the real connection) would be incorrect. + assert.Empty(t, hw.Body.Bytes()) + assert.Empty(t, hw.Header().Get("Content-Encoding")) + }) + + t.Run("br;q=0 is treated as declining brotli", func(t *testing.T) { + h := compressionHandler(innerHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br;q=0, gzip") + + h.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "gzip", resp.Header().Get("Content-Encoding")) + }) + + t.Run("Vary: Accept-Encoding is set on brotli responses", func(t *testing.T) { + h := compressionHandler(innerHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, "Accept-Encoding", resp.Header().Get("Vary")) + }) + + t.Run("204 response is not brotli-encoded and Content-Encoding is left unset", func(t *testing.T) { + noContentHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + h := compressionHandler(noContentHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNoContent, resp.Code) + assert.Empty(t, resp.Header().Get("Content-Encoding")) + assert.Empty(t, resp.Body.Bytes()) + }) + + t.Run("small response below the min-size threshold is not compressed", func(t *testing.T) { + smallHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte("tiny body")) + require.NoError(t, err) + }) + h := compressionHandler(smallHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Empty(t, resp.Header().Get("Content-Encoding")) + assert.Equal(t, "tiny body", resp.Body.String()) + }) + + t.Run("JPEG content type is not brotli-encoded even when large", func(t *testing.T) { + jpegHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + var body [2000]byte + _, err := w.Write(body[:]) + require.NoError(t, err) + }) + h := compressionHandler(jpegHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Empty(t, resp.Header().Get("Content-Encoding")) + assert.Len(t, resp.Body.Bytes(), 2000) + }) + + t.Run("no encoding when no Accept-Encoding header and compression enabled", func(t *testing.T) { + h := compressionHandler(innerHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + + h.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "", resp.Header().Get("Content-Encoding")) + }) + + t.Run("response larger than minSize is split across multiple Write calls and still decodes correctly", func(t *testing.T) { + // Exercises the streaming path: the handler writes in chunks straddling + // the minSize threshold, so some bytes go through the buffer-then-decide + // path and some go directly through the already-initialized brotli writer. + multiWriteHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + chunk := make([]byte, 512) + for range 5 { + _, err := w.Write(chunk) + require.NoError(t, err) + } + }) + h := compressionHandler(multiWriteHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, "br", resp.Header().Get("Content-Encoding")) + decompressed := decompressBrotli(t, resp.Body.Bytes()) + assert.Len(t, decompressed, 512*5) + }) + + t.Run("Flush before minSize is reached starts compression and sends buffered data", func(t *testing.T) { + flushingHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte("small but flushed")) + require.NoError(t, err) + w.(http.Flusher).Flush() + }) + h := compressionHandler(flushingHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, "br", resp.Header().Get("Content-Encoding")) + decompressed := decompressBrotli(t, resp.Body.Bytes()) + assert.Equal(t, "small but flushed", string(decompressed)) + }) + + t.Run("Content-Length set by the handler is removed once compression starts", func(t *testing.T) { + clHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + var body [1400]byte + w.Header().Set("Content-Length", "1400") + _, err := w.Write(body[:]) + require.NoError(t, err) + }) + h := compressionHandler(clHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, "br", resp.Header().Get("Content-Encoding")) + assert.Empty(t, resp.Header().Get("Content-Length"), + "a Content-Length describing the uncompressed size must not survive into a compressed response") + }) + + t.Run("a handler that already set Content-Encoding is passed through unchanged", func(t *testing.T) { + preEncodedHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + var body [1400]byte + w.Header().Set("Content-Encoding", "identity") + _, err := w.Write(body[:]) + require.NoError(t, err) + }) + h := compressionHandler(preEncodedHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, "identity", resp.Header().Get("Content-Encoding"), + "must not overwrite or double-compress a response that already declared its own Content-Encoding") + assert.Len(t, resp.Body.Bytes(), 1400) + }) + + t.Run("Content-Type is detected from the buffered body when the handler never sets it", func(t *testing.T) { + noContentTypeHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte("" + strings.Repeat("x", 1100) + "")) + require.NoError(t, err) + }) + h := compressionHandler(noContentTypeHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, "br", resp.Header().Get("Content-Encoding")) + assert.Contains(t, resp.Header().Get("Content-Type"), "text/html", + "Content-Type must be sniffed from the plain buffered bytes before compression starts") + }) + + t.Run("explicitly empty Content-Type is preserved and not overwritten by sniffing", func(t *testing.T) { + emptyContentTypeHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "") + _, err := w.Write([]byte(strings.Repeat("x", 1100))) + require.NoError(t, err) + }) + h := compressionHandler(emptyContentTypeHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, "", resp.Header().Get("Content-Type"), + "a handler-set empty Content-Type must not be overwritten by sniffing") + }) + + t.Run("brotli writer is still closed when the handler panics", func(t *testing.T) { + panicHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte("partial body under minSize")) + require.NoError(t, err) + panic("boom") + }) + h := compressionHandler(panicHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + func() { + defer func() { + r := recover() + require.NotNil(t, r, "the handler's panic must still propagate") + }() + h.ServeHTTP(resp, req) + }() + + // Close() must have run despite the panic (via defer) and flushed the + // buffered, under-minSize body plainly, proving it wasn't skipped. + assert.Equal(t, "partial body under minSize", resp.Body.String()) + }) + + t.Run("WriteHeader after a buffered Write cannot override the implicit 200", func(t *testing.T) { + lateWriteHeaderHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(strings.Repeat("x", 1100))) + require.NoError(t, err) + w.WriteHeader(http.StatusInternalServerError) + }) + h := compressionHandler(lateWriteHeaderHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code, + "a WriteHeader call after Write has already started buffering must be a no-op, matching net/http") + }) + + t.Run("a ranged response with Content-Range set is not brotli-encoded", func(t *testing.T) { + rangedHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Content-Range", "bytes 0-1099/2000") + w.WriteHeader(http.StatusPartialContent) + _, err := w.Write([]byte(strings.Repeat("x", 1100))) + require.NoError(t, err) + }) + h := compressionHandler(rangedHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, "", resp.Header().Get("Content-Encoding"), + "a ranged response must not be brotli-encoded, since the range refers to the uncompressed body") + assert.Len(t, resp.Body.Bytes(), 1100) + }) + + t.Run("Accept-Ranges is removed once compression starts", func(t *testing.T) { + seekableHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + var body [1400]byte + w.Header().Set("Accept-Ranges", "bytes") + _, err := w.Write(body[:]) + require.NoError(t, err) + }) + h := compressionHandler(seekableHandler, true, true) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br") + + h.ServeHTTP(resp, req) + + assert.Equal(t, "br", resp.Header().Get("Content-Encoding")) + assert.Empty(t, resp.Header().Get("Accept-Ranges"), + "a client must not be told ranges are supported on a response it received compressed") + }) +} diff --git a/server/channels/api4/file.go b/server/channels/api4/file.go index a9c4410fb83..d1082e02827 100644 --- a/server/channels/api4/file.go +++ b/server/channels/api4/file.go @@ -32,16 +32,16 @@ const maxMultipartFormDataBytes = 10 * 1024 // 10Kb func (api *API) InitFile() { api.BaseRoutes.Files.Handle("", api.APISessionRequired(uploadFileStream, handlerParamFileAPI)).Methods(http.MethodPost) - api.BaseRoutes.File.Handle("", api.APISessionRequiredTrustRequester(getFile)).Methods(http.MethodGet, http.MethodHead) - api.BaseRoutes.File.Handle("/thumbnail", api.APISessionRequiredTrustRequester(getFileThumbnail)).Methods(http.MethodGet, http.MethodHead) + api.BaseRoutes.File.Handle("", api.APISessionRequiredTrustRequesterGzip(getFile)).Methods(http.MethodGet, http.MethodHead) + api.BaseRoutes.File.Handle("/thumbnail", api.APISessionRequiredTrustRequesterGzip(getFileThumbnail)).Methods(http.MethodGet, http.MethodHead) api.BaseRoutes.File.Handle("/link", api.APISessionRequired(getFileLink)).Methods(http.MethodGet) - api.BaseRoutes.File.Handle("/preview", api.APISessionRequiredTrustRequester(getFilePreview)).Methods(http.MethodGet, http.MethodHead) + api.BaseRoutes.File.Handle("/preview", api.APISessionRequiredTrustRequesterGzip(getFilePreview)).Methods(http.MethodGet, http.MethodHead) api.BaseRoutes.File.Handle("/info", api.APISessionRequired(getFileInfo)).Methods(http.MethodGet) api.BaseRoutes.Team.Handle("/files/search", api.APISessionRequiredDisableWhenBusy(searchFilesInTeam)).Methods(http.MethodPost) api.BaseRoutes.Files.Handle("/search", api.APISessionRequiredDisableWhenBusy(searchFilesInAllTeams)).Methods(http.MethodPost) - api.BaseRoutes.PublicFile.Handle("", api.APIHandler(getPublicFile)).Methods(http.MethodGet, http.MethodHead) + api.BaseRoutes.PublicFile.Handle("", api.APIHandlerGzip(getPublicFile)).Methods(http.MethodGet, http.MethodHead) } func parseMultipartRequestHeader(req *http.Request) (boundary string, err error) { diff --git a/server/channels/api4/handlers.go b/server/channels/api4/handlers.go index fb4265b76c1..dbf4cabebf1 100644 --- a/server/channels/api4/handlers.go +++ b/server/channels/api4/handlers.go @@ -6,8 +6,6 @@ package api4 import ( "net/http" - "github.com/klauspost/compress/gzhttp" - "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/channels/app" @@ -39,10 +37,27 @@ func (api *API) APIHandler(h handlerFunc, opts ...APIHandlerOption) http.Handler } setHandlerOpts(handler, opts...) - if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" { - return gzhttp.GzipHandler(handler) + return apiCompressionHandler(api.srv.Config(), handler, true) +} + +// APIHandlerGzip is APIHandler without brotli: large file downloads (e.g. public +// file links) compress about as well under gzip as under brotli, and brotli's +// memory footprint scales with response size, so these stay on gzip rather than +// paying that cost. +func (api *API) APIHandlerGzip(h handlerFunc, opts ...APIHandlerOption) http.Handler { + handler := &web.Handler{ + Srv: api.srv, + HandleFunc: h, + HandlerName: web.GetHandlerName(h), + RequireSession: false, + TrustRequester: false, + RequireMfa: false, + IsStatic: false, + IsLocal: false, } - return handler + setHandlerOpts(handler, opts...) + + return apiCompressionHandler(api.srv.Config(), handler, false) } // APISessionRequired provides a handler for API endpoints which require the user to be logged in in order for access to @@ -60,10 +75,27 @@ func (api *API) APISessionRequired(h handlerFunc, opts ...APIHandlerOption) http } setHandlerOpts(handler, opts...) - if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" { - return gzhttp.GzipHandler(handler) + return apiCompressionHandler(api.srv.Config(), handler, true) +} + +// APISessionRequiredGzip is APISessionRequired without brotli: large file downloads +// (e.g. log files, invoice PDFs) compress about as well under gzip as under brotli, +// and brotli's memory footprint scales with response size, so these stay on gzip +// rather than paying that cost. +func (api *API) APISessionRequiredGzip(h handlerFunc, opts ...APIHandlerOption) http.Handler { + handler := &web.Handler{ + Srv: api.srv, + HandleFunc: h, + HandlerName: web.GetHandlerName(h), + RequireSession: true, + TrustRequester: false, + RequireMfa: true, + IsStatic: false, + IsLocal: false, } - return handler + setHandlerOpts(handler, opts...) + + return apiCompressionHandler(api.srv.Config(), handler, false) } // CloudAPIKeyRequired provides a handler for webhook endpoints to access Cloud installations from CWS @@ -81,10 +113,7 @@ func (api *API) CloudAPIKeyRequired(h handlerFunc, opts ...APIHandlerOption) htt } setHandlerOpts(handler, opts...) - if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" { - return gzhttp.GzipHandler(handler) - } - return handler + return apiCompressionHandler(api.srv.Config(), handler, true) } // RemoteClusterTokenRequired provides a handler for remote cluster requests to /remotecluster endpoints. @@ -103,10 +132,7 @@ func (api *API) RemoteClusterTokenRequired(h handlerFunc, opts ...APIHandlerOpti } setHandlerOpts(handler, opts...) - if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" { - return gzhttp.GzipHandler(handler) - } - return handler + return apiCompressionHandler(api.srv.Config(), handler, true) } // APISessionRequiredMfa provides a handler for API endpoints which require a logged-in user session but when accessed, @@ -125,10 +151,7 @@ func (api *API) APISessionRequiredMfa(h handlerFunc, opts ...APIHandlerOption) h } setHandlerOpts(handler, opts...) - if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" { - return gzhttp.GzipHandler(handler) - } - return handler + return apiCompressionHandler(api.srv.Config(), handler, true) } // APIHandlerTrustRequester provides a handler for API endpoints which do not require the user to be logged in and are @@ -147,10 +170,7 @@ func (api *API) APIHandlerTrustRequester(h handlerFunc, opts ...APIHandlerOption } setHandlerOpts(handler, opts...) - if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" { - return gzhttp.GzipHandler(handler) - } - return handler + return apiCompressionHandler(api.srv.Config(), handler, true) } // APISessionRequiredTrustRequester provides a handler for API endpoints which do require the user to be logged in and @@ -168,10 +188,27 @@ func (api *API) APISessionRequiredTrustRequester(h handlerFunc, opts ...APIHandl } setHandlerOpts(handler, opts...) - if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" { - return gzhttp.GzipHandler(handler) + return apiCompressionHandler(api.srv.Config(), handler, true) +} + +// APISessionRequiredTrustRequesterGzip is APISessionRequiredTrustRequester without +// brotli: large file downloads (e.g. files, thumbnails, previews, exports) compress +// about as well under gzip as under brotli, and brotli's memory footprint scales +// with response size, so these stay on gzip rather than paying that cost. +func (api *API) APISessionRequiredTrustRequesterGzip(h handlerFunc, opts ...APIHandlerOption) http.Handler { + handler := &web.Handler{ + Srv: api.srv, + HandleFunc: h, + HandlerName: web.GetHandlerName(h), + RequireSession: true, + TrustRequester: true, + RequireMfa: true, + IsStatic: false, + IsLocal: false, } - return handler + setHandlerOpts(handler, opts...) + + return apiCompressionHandler(api.srv.Config(), handler, false) } // DisableWhenBusy provides a handler for API endpoints which should be disabled when the server is under load, @@ -190,10 +227,7 @@ func (api *API) APISessionRequiredDisableWhenBusy(h handlerFunc, opts ...APIHand } setHandlerOpts(handler, opts...) - if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" { - return gzhttp.GzipHandler(handler) - } - return handler + return apiCompressionHandler(api.srv.Config(), handler, true) } // APILocal provides a handler for API endpoints to be used in local @@ -213,10 +247,7 @@ func (api *API) APILocal(h handlerFunc, opts ...APIHandlerOption) http.Handler { } setHandlerOpts(handler, opts...) - if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" { - return gzhttp.GzipHandler(handler) - } - return handler + return apiCompressionHandler(api.srv.Config(), handler, true) } func (api *API) RateLimitedHandler(apiHandler http.Handler, settings model.RateLimitSettings) http.Handler { diff --git a/server/channels/api4/handlers_test.go b/server/channels/api4/handlers_test.go index 3e324fb1997..b1ae4d895c5 100644 --- a/server/channels/api4/handlers_test.go +++ b/server/channels/api4/handlers_test.go @@ -68,7 +68,7 @@ func testAPIHandlerNoGzipMode(t *testing.T, name string, h http.Handler, token s }) } -func TestAPIHandlersWithGzip(t *testing.T) { +func TestAPIHandlersWithCompression(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) @@ -76,8 +76,13 @@ func TestAPIHandlersWithGzip(t *testing.T) { require.NoError(t, err) session, _ := th.App.GetSession(th.Client.AuthToken) + th.ConfigStore.SetReadOnlyFF(false) + t.Run("with WebserverMode == \"gzip\"", func(t *testing.T) { - th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.WebserverMode = "gzip" }) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.WebserverMode = "gzip" + cfg.FeatureFlags.EnableBrotliCompression = false + }) testAPIHandlerGzipMode(t, "ApiHandler", api.APIHandler(handlerForGzip(t)), "") testAPIHandlerGzipMode(t, "ApiSessionRequired", api.APISessionRequired(handlerForGzip(t)), session.Token) @@ -86,6 +91,21 @@ func TestAPIHandlersWithGzip(t *testing.T) { testAPIHandlerGzipMode(t, "ApiSessionRequiredTrustRequester", api.APISessionRequiredTrustRequester(handlerForGzip(t)), session.Token) }) + t.Run("with WebserverMode == \"gzip\" and EnableBrotliCompression off, Accept-Encoding: br falls back to gzip", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.WebserverMode = "gzip" + cfg.FeatureFlags.EnableBrotliCompression = false + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br, gzip") + api.APIHandler(handlerForGzip(t)).ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "gzip", resp.Header().Get("Content-Encoding")) + }) + t.Run("with WebserverMode == \"uncompressed\"", func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.WebserverMode = "uncompressed" }) @@ -95,4 +115,93 @@ func TestAPIHandlersWithGzip(t *testing.T) { testAPIHandlerNoGzipMode(t, "ApiHandlerTrustRequester", api.APIHandlerTrustRequester(handlerForGzip(t)), "") testAPIHandlerNoGzipMode(t, "ApiSessionRequiredTrustRequester", api.APISessionRequiredTrustRequester(handlerForGzip(t)), session.Token) }) + + t.Run("with EnableBrotliCompression and Accept-Encoding: br", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.WebserverMode = "gzip" + cfg.FeatureFlags.EnableBrotliCompression = true + }) + + for _, tc := range []struct { + name string + handler http.Handler + }{ + {"ApiHandler", api.APIHandler(handlerForBrotli(t))}, + {"ApiSessionRequired", api.APISessionRequired(handlerForBrotli(t))}, + {"ApiSessionRequiredMfa", api.APISessionRequiredMfa(handlerForBrotli(t))}, + {"ApiHandlerTrustRequester", api.APIHandlerTrustRequester(handlerForBrotli(t))}, + {"ApiSessionRequiredTrustRequester", api.APISessionRequiredTrustRequester(handlerForBrotli(t))}, + } { + t.Run(tc.name, func(t *testing.T) { + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br, gzip") + if tc.name != "ApiHandler" && tc.name != "ApiHandlerTrustRequester" { + req.Header.Set(model.HeaderAuth, "Bearer "+session.Token) + } + tc.handler.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "br", resp.Header().Get("Content-Encoding"), "handler %s should use brotli", tc.name) + }) + } + }) + + t.Run("with EnableBrotliCompression and Accept-Encoding: gzip", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.WebserverMode = "gzip" + cfg.FeatureFlags.EnableBrotliCompression = true + }) + + for _, tc := range []struct { + name string + handler http.Handler + }{ + {"ApiHandler", api.APIHandler(handlerForGzip(t))}, + {"ApiSessionRequired", api.APISessionRequired(handlerForGzip(t))}, + {"ApiSessionRequiredMfa", api.APISessionRequiredMfa(handlerForGzip(t))}, + {"ApiHandlerTrustRequester", api.APIHandlerTrustRequester(handlerForGzip(t))}, + {"ApiSessionRequiredTrustRequester", api.APISessionRequiredTrustRequester(handlerForGzip(t))}, + } { + t.Run(tc.name, func(t *testing.T) { + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "gzip") + if tc.name != "ApiHandler" && tc.name != "ApiHandlerTrustRequester" { + req.Header.Set(model.HeaderAuth, "Bearer "+session.Token) + } + tc.handler.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "gzip", resp.Header().Get("Content-Encoding"), "handler %s should fall back to gzip", tc.name) + }) + } + }) + + t.Run("with EnableBrotliCompression, the Gzip handler variants never use brotli", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.WebserverMode = "gzip" + cfg.FeatureFlags.EnableBrotliCompression = true + }) + + for _, tc := range []struct { + name string + handler http.Handler + }{ + {"ApiHandlerGzip", api.APIHandlerGzip(handlerForGzip(t))}, + {"ApiSessionRequiredGzip", api.APISessionRequiredGzip(handlerForGzip(t))}, + {"ApiSessionRequiredTrustRequesterGzip", api.APISessionRequiredTrustRequesterGzip(handlerForGzip(t))}, + } { + t.Run(tc.name, func(t *testing.T) { + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil) + req.Header.Set("Accept-Encoding", "br, gzip") + req.Header.Set(model.HeaderAuth, "Bearer "+session.Token) + + tc.handler.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "gzip", resp.Header().Get("Content-Encoding"), + "handler %s must fall back to gzip even when the client accepts brotli", tc.name) + }) + } + }) } diff --git a/server/channels/api4/job.go b/server/channels/api4/job.go index 02ab5e8f091..a32b9ae8c9b 100644 --- a/server/channels/api4/job.go +++ b/server/channels/api4/job.go @@ -24,7 +24,7 @@ func (api *API) InitJob() { api.BaseRoutes.Jobs.Handle("", api.APISessionRequired(getJobs)).Methods(http.MethodGet) api.BaseRoutes.Jobs.Handle("", api.APISessionRequired(createJob)).Methods(http.MethodPost) api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}", api.APISessionRequired(getJob)).Methods(http.MethodGet) - api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/download", api.APISessionRequiredTrustRequester(downloadJob)).Methods(http.MethodGet) + api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/download", api.APISessionRequiredTrustRequesterGzip(downloadJob)).Methods(http.MethodGet) api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/cancel", api.APISessionRequired(cancelJob)).Methods(http.MethodPost) api.BaseRoutes.Jobs.Handle("/type/{job_type:[A-Za-z0-9_-]+}", api.APISessionRequired(getJobsByType)).Methods(http.MethodGet) api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/status", api.APISessionRequired(updateJobStatus)).Methods(http.MethodPatch) diff --git a/server/channels/api4/system.go b/server/channels/api4/system.go index 62018aaf71f..25c04408709 100644 --- a/server/channels/api4/system.go +++ b/server/channels/api4/system.go @@ -55,7 +55,7 @@ func (api *API) InitSystem() { api.BaseRoutes.APIRoot.Handle("/caches/invalidate", api.APISessionRequired(invalidateCaches)).Methods(http.MethodPost) api.BaseRoutes.APIRoot.Handle("/logs", api.APISessionRequired(getLogs)).Methods(http.MethodGet) - api.BaseRoutes.APIRoot.Handle("/logs/download", api.APISessionRequired(downloadLogs)).Methods(http.MethodGet) + api.BaseRoutes.APIRoot.Handle("/logs/download", api.APISessionRequiredGzip(downloadLogs)).Methods(http.MethodGet) api.BaseRoutes.APIRoot.Handle("/logs/query", api.APISessionRequired(queryLogs)).Methods(http.MethodPost) api.BaseRoutes.APIRoot.Handle("/logs", api.APIHandler(postLog)).Methods(http.MethodPost) diff --git a/server/channels/app/platform/testdata/support_packet/sanitized_config.json b/server/channels/app/platform/testdata/support_packet/sanitized_config.json index 00eb07c0cb3..b7693ed5120 100644 --- a/server/channels/app/platform/testdata/support_packet/sanitized_config.json +++ b/server/channels/app/platform/testdata/support_packet/sanitized_config.json @@ -760,6 +760,7 @@ "PostDeliveryTracking": false, "EnforceLogPathRoot": false, "HealthDashboard": false, + "EnableBrotliCompression": false, "OpenIdAttributeSync": false } } \ No newline at end of file diff --git a/server/channels/web/handlers.go b/server/channels/web/handlers.go index dc0a02774c1..7e045faeed2 100644 --- a/server/channels/web/handlers.go +++ b/server/channels/web/handlers.go @@ -556,7 +556,7 @@ func (w *Web) APIHandler(h func(*Context, http.ResponseWriter, *http.Request)) h IsStatic: false, IsLocal: false, } - if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" { + if w.srv.Config().ServiceSettings.CompressResponses() { return gzhttp.GzipHandler(handler) } return handler @@ -576,7 +576,7 @@ func (w *Web) APIHandlerTrustRequester(h func(*Context, http.ResponseWriter, *ht IsStatic: false, IsLocal: false, } - if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" { + if w.srv.Config().ServiceSettings.CompressResponses() { return gzhttp.GzipHandler(handler) } return handler @@ -595,7 +595,7 @@ func (w *Web) APISessionRequired(h func(*Context, http.ResponseWriter, *http.Req IsStatic: false, IsLocal: false, } - if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" { + if w.srv.Config().ServiceSettings.CompressResponses() { return gzhttp.GzipHandler(handler) } return handler diff --git a/server/channels/web/static.go b/server/channels/web/static.go index 042c4efd394..e9fe4291e4c 100644 --- a/server/channels/web/static.go +++ b/server/channels/web/static.go @@ -40,7 +40,7 @@ func (w *Web) InitStatic() { pluginHandler := staticFilesHandler(http.StripPrefix(path.Join(subpath, "static", "plugins"), http.FileServer(http.Dir(*w.srv.Config().PluginSettings.ClientDirectory)))) docsHandler := NewDocsHandler(staticDir, subpath) - if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" { + if w.srv.Config().ServiceSettings.CompressResponses() { staticHandler = gzhttp.GzipHandler(staticHandler) pluginHandler = gzhttp.GzipHandler(pluginHandler) docsHandler = gzhttp.GzipHandler(docsHandler) diff --git a/server/go.mod b/server/go.mod index a3b9970154f..8552cca6403 100644 --- a/server/go.mod +++ b/server/go.mod @@ -55,6 +55,7 @@ require ( github.com/mholt/archives v0.1.5 github.com/microcosm-cc/bluemonday v1.0.27 github.com/minio/minio-go/v7 v7.4.1 + github.com/molecule-man/go-brrr v1.3.0 github.com/opensearch-project/opensearch-go/v4 v4.8.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.25.0 diff --git a/server/go.sum b/server/go.sum index 6d9dd7efbf2..c7a1ed5d973 100644 --- a/server/go.sum +++ b/server/go.sum @@ -394,6 +394,8 @@ github.com/minio/minio-go/v7 v7.4.1 h1:PtU/zIOfQRWZ+QydXAZysc+xZYHdG+BBR4Z6TUvy7 github.com/minio/minio-go/v7 v7.4.1/go.mod h1:nQnQi7zTfXR4lREtYXZQWqo5aw0Rqv+ZqCGrTORygRk= github.com/minio/minlz v1.2.2 h1:o3YoP6f3VG0oY3Ykhx2mHrpCywsoobsFPVWslgfKcbY= github.com/minio/minlz v1.2.2/go.mod h1:xHhZ+MJmC2U4ssK/rSENsoyCfv0VFQo1mT6AdCA2RJ8= +github.com/molecule-man/go-brrr v1.3.0 h1:KEGPkUk9PYFw5+Sv/evCOSU5OmMDXCGFqQ9YjRB7GeU= +github.com/molecule-man/go-brrr v1.3.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= diff --git a/server/platform/shared/web/files_test.go b/server/platform/shared/web/files_test.go new file mode 100644 index 00000000000..bd9e5b7f6c3 --- /dev/null +++ b/server/platform/shared/web/files_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package web + +import ( + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestWriteFileResponseContentLengthHeaders(t *testing.T) { + body := "some file contents" + + for mode, compressed := range map[string]bool{ + "gzip": true, + "uncompressed": false, + "disabled": false, + } { + t.Run(mode, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/file", nil) + + WriteFileResponse("f.txt", "text/plain", int64(len(body)), time.Unix(0, 0), mode, strings.NewReader(body), false, w, r) + + size := strconv.Itoa(len(body)) + if compressed { + assert.Equal(t, size, w.Header().Get("X-Uncompressed-Content-Length")) + } else { + assert.Empty(t, w.Header().Get("X-Uncompressed-Content-Length")) + assert.Equal(t, size, w.Header().Get("Content-Length")) + } + }) + } +} diff --git a/server/public/model/config.go b/server/public/model/config.go index 36998e819dc..0ed430714cb 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -532,6 +532,18 @@ type ServiceSettings struct { var MattermostGiphySdkKey string +func (s *ServiceSettings) CompressResponses() bool { + return *s.WebserverMode == "gzip" +} + +// CompressResponsesWithBrotli reports whether API responses should negotiate +// Brotli compression with clients that advertise support for it, falling back +// to gzip otherwise. Requires both gzip compression and the EnableBrotliCompression +// feature flag. +func (s *ServiceSettings) CompressResponsesWithBrotli(enableBrotliCompression bool) bool { + return s.CompressResponses() && enableBrotliCompression +} + func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.EnableEmailInvitations == nil { // If the site URL is also not present then assume this is a clean install diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index 93ee9dcc24e..6a3503cf6b5 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -416,7 +416,7 @@ func TestServiceSettingsIsValid(t *testing.T) { }, "WebserverMode is invalid": { ServiceSettings: ServiceSettings{ - WebserverMode: new("brotli"), + WebserverMode: new("unknown"), }, ExpectError: true, }, @@ -639,6 +639,26 @@ func TestEmailSettingsIsValid(t *testing.T) { } } +func TestServiceSettingsCompressResponses(t *testing.T) { + for mode, expected := range map[string]struct { + compress bool + brotli bool + }{ + "gzip": {compress: true, brotli: true}, + "uncompressed": {compress: false, brotli: false}, + "disabled": {compress: false, brotli: false}, + } { + t.Run(mode, func(t *testing.T) { + ss := ServiceSettings{WebserverMode: new(mode)} + + assert.Equal(t, expected.compress, ss.CompressResponses()) + assert.Equal(t, expected.brotli, ss.CompressResponsesWithBrotli(true)) + assert.False(t, ss.CompressResponsesWithBrotli(false), + "brotli must stay off regardless of WebserverMode when the feature flag is off") + }) + } +} + func TestServiceSettingsHardenedModeMigration(t *testing.T) { t.Run("defaults to disabled without the deprecated setting", func(t *testing.T) { ss := ServiceSettings{} diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index ed15847daac..8cbcb18f484 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -187,6 +187,11 @@ type FeatureFlags struct { // Health Dashboard. Off by default. HealthDashboard bool + // EnableBrotliCompression negotiates Brotli compression for API responses with + // clients that advertise support for it, falling back to gzip otherwise. Requires + // WebserverMode to be "gzip". + EnableBrotliCompression bool + // FEATURE_FLAG_REMOVAL: OpenIdAttributeSync - Remove this kill switch once // OpenID Connect attribute sync is proven in production. Gates syncing user // attributes from OpenID Connect claims at sign-in: when off, no claims are @@ -268,6 +273,9 @@ func (f *FeatureFlags) SetDefaults() { f.EnforceLogPathRoot = false f.HealthDashboard = false + + f.EnableBrotliCompression = false + f.OpenIdAttributeSync = true } diff --git a/webapp/channels/src/components/admin_console/admin_definition_constants.tsx b/webapp/channels/src/components/admin_console/admin_definition_constants.tsx index 16f7ce6ee76..72b3ab56def 100644 --- a/webapp/channels/src/components/admin_console/admin_definition_constants.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition_constants.tsx @@ -147,7 +147,7 @@ export const WEBSERVER_MODE_HELP_TEXT = (

diff --git a/webapp/channels/src/components/admin_console/admin_definition_webserver_mode.test.tsx b/webapp/channels/src/components/admin_console/admin_definition_webserver_mode.test.tsx new file mode 100644 index 00000000000..85834adfaa7 --- /dev/null +++ b/webapp/channels/src/components/admin_console/admin_definition_webserver_mode.test.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {RESOURCE_KEYS} from 'mattermost-redux/constants/permissions_sysconsole'; + +import AdminDefinition from './admin_definition'; +import type {AdminDefinitionSetting, AdminDefinitionSubSection, ConsoleAccess} from './types'; + +type DisabledCheck = (config: object, state: object, license: undefined, enterpriseReady: boolean, consoleAccess: ConsoleAccess) => boolean; + +const WEBSERVER_MODE_KEY = 'ServiceSettings.WebserverMode'; + +function settingsOf(subsection: AdminDefinitionSubSection): AdminDefinitionSetting[] { + const schema = subsection.schema; + + return schema && 'settings' in schema && schema.settings ? schema.settings : []; +} + +function writeAccessTo(resourceKey: string): ConsoleAccess { + return {read: {}, write: {[resourceKey]: true}}; +} + +describe('AdminDefinition - Webserver Mode setting', () => { + const getSetting = () => settingsOf(AdminDefinition.environment.subsections.web_server). + find((setting) => setting.key === WEBSERVER_MODE_KEY); + + const getOptions = () => { + const setting = getSetting(); + return setting && 'options' in setting ? setting.options : undefined; + }; + + test('is rendered as a dropdown on the Environment > Web Server page', () => { + const setting = getSetting(); + + expect(setting).toBeDefined(); + expect(setting?.type).toBe('dropdown'); + expect(setting?.label).toBeDefined(); + expect(setting?.help_text).toBeDefined(); + }); + + test('offers every supported mode, ordered by descending compression capability', () => { + expect(getOptions()?.map((option) => option.value)).toEqual(['gzip', 'uncompressed', 'disabled']); + }); + + test('gives every mode a display name', () => { + const options = getOptions(); + + expect(options).toHaveLength(3); + options?.forEach((option) => { + expect(option.display_name).toBeDefined(); + }); + }); + + test('is gated on write access to the Web Server console resource', () => { + const isDisabled = getSetting()?.isDisabled as DisabledCheck; + expect(typeof isDisabled).toBe('function'); + + expect(isDisabled({}, {}, undefined, true, writeAccessTo(RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER))).toBe(false); + expect(isDisabled({}, {}, undefined, true, writeAccessTo(RESOURCE_KEYS.EXPERIMENTAL.FEATURES))).toBe(true); + }); +}); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index d8a4f0299c1..abf02ce56e4 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -17756,7 +17756,7 @@ "description": "Description of the 'gzip' option in the Webserver Mode dropdown on the System Console Web Server page; gzip is a compression format (keep name)." }, "admin.webserverModeHelpText": { - "defaultMessage": "gzip compression applies to static content files. It is recommended to enable gzip to improve performance unless your environment has specific restrictions, such as a web proxy that distributes gzip files poorly.", + "defaultMessage": "gzip compression applies to static content files. It is recommended to enable compression to improve performance unless your environment has specific restrictions, such as a web proxy that distributes compressed files poorly.", "description": "Help paragraph shown below the Webserver Mode option table in the admin console; explains when to use gzip compression." }, "admin.webserverModeTitle": {