Add Brotli response compression behind a new webserver mode (#38637)

* Add Brotli response compression behind a new webserver mode

Add a "brotli" value to ServiceSettings.WebserverMode, selectable from the
System Console, that compresses API responses with Brotli for clients that
advertise it and falls back to gzip for those that do not. The default stays
"gzip", so Brotli is opt-in. brotli is a superset of gzip rather than an
alternative, so static files and file downloads keep gzip; two
ServiceSettings helpers replace the exact WebserverMode == "gzip" checks to
keep that true.

* Fix TestBulkImportKeepsLinesAfterNonFatalWorkerError password length for FIPS

FIPS mode enforces a 14-character minimum password length. The test's
collider-user fixture used a 9-character password, which only surfaces
as a failure when FIPS-mode CI actually runs (gated on go.mod changes).
Reuses the 16-character password already used elsewhere in this file.

* Fix TestImportUsernamRemap password length for FIPS

* Delete Accept-Ranges once brotli compression starts

Matches gzhttp's own default behavior: once a response is compressed,
a client must not be told ranges are supported on it, since a later
range request would have to be resolved against the compressed
stream. http.ServeContent sets Accept-Ranges unconditionally on any
seekable response, so this only mattered once compression actually
started.

* Keep file downloads on gzip instead of brotli

Large file downloads (logs, invoice PDFs, uploaded files) compress
about as well under gzip as under brotli, and brotli's memory
footprint scales with response size, so file-serving routes now use
APISessionRequiredGzip / APISessionRequiredTrustRequesterGzip, which
force brotli off regardless of WebserverMode.

* Keep the public file link on gzip instead of brotli

getPublicFile still used APIHandler, which is brotli-eligible, so a
brotli-capable client could still get a brotli-compressed response
from the public file link despite the earlier fix for the
session-authenticated file routes. Adds APIHandlerGzip alongside the
other two Gzip variants and switches the route to it.

* Gate Brotli compression behind a feature flag and split brotli.go out

Brotli is now negotiated automatically whenever ServiceSettings.WebserverMode
is "gzip" and the EnableBrotliCompression feature flag is on, instead of
requiring a separate "brotli" webserver mode in the System Console. The
Brotli-specific response writer and Accept-Encoding handling move into their
own brotli.go, leaving compression.go with just the shared gzip/Brotli
dispatch logic. go-brrr is bumped to v1.3.0.

* Remove remaining brotli WebserverMode references

WriteFileResponse still special-cased webserverMode == "brotli" for the
X-Uncompressed-Content-Length header, and the Cypress web server mode spec
still exercised selecting "brotli" from the dropdown. Neither is reachable
anymore since "brotli" is no longer a valid WebserverMode value.

* Regenerate Playwright default feature flags

EnableBrotliCompression was missing from the generated Playwright
default_feature_flags.ts, which make check-generated validates in CI.

* Add EnableBrotliCompression to the support packet golden fixture

TestSupportPacketMarshalGolden/config does a strict comparison against
this checked-in fixture, which didn't account for the new feature flag.

---------

Co-authored-by: Jesse Hallam <jesse@mattermost.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
authored and GitHub committed 2026-10-22 01:40:41 +08:00
1 parent 27700f99a3
commit 269bdfd0f0
25 files changed
+1388 -51

No files matched your search

+35
View File
@@ -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'.
@@ -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;
@@ -19,6 +19,7 @@ export const defaultFeatureFlags: Record<string, string> = {
DiscoverableChannels: 'false',
EnableAIPluginBridge: 'false',
EnableAIRecaps: 'false',
EnableBrotliCompression: 'false',
EnableDocs: 'false',
EnableMFIPluginSignaturePublicKey: 'true',
EnableMattermostEntry: 'true',
+320
View File
@@ -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
}
+1 -1
View File
@@ -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)
+45
View File
@@ -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)
})
}
@@ -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")
}
+416
View File
@@ -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("<html><body>" + strings.Repeat("x", 1100) + "</body></html>"))
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")
})
}
+4 -4
View File
@@ -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) {
+66 -35
View File
@@ -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 {
+111 -2
View File
@@ -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)
})
}
})
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
@@ -760,6 +760,7 @@
"PostDeliveryTracking": false,
"EnforceLogPathRoot": false,
"HealthDashboard": false,
"EnableBrotliCompression": false,
"OpenIdAttributeSync": false
}
}
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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)
+1
View File
@@ -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
+2
View File
@@ -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=
+39
View File
@@ -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"))
}
})
}
}
+12
View File
@@ -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
+21 -1
View File
@@ -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{}
+8
View File
@@ -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
}
@@ -147,7 +147,7 @@ export const WEBSERVER_MODE_HELP_TEXT = (
<p className='help-text'>
<FormattedMessage
id='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.'
/>
</p>
</div>
@@ -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);
});
});
+1 -1
View File
@@ -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": {