forgejo/modules/httpcache/httpcache.go
Gusted f576a1a21e feat: remove no-transform in Cache-Control header. (#12905)
First, why was this header here in the first place? Cloudflare!
Cloudflare had a optimization setting called "auto-minfy" and would
minify HTML,JS,CSS - this included removing extra whitespaces from
`<code>` elements. That's a problem because files are shown per-line
with a `<code>` element and thus results in indentation being completely
gone. Gitea added a FAQ entry for this [1], but on the same day decided
to add the workaround in Gitea, the `no-transform` header [2].

I can't find a reference of this option and some posts suggests it's
been removed. Thus it no longer serves a need to be present in Forgejo.
That wasn't my intentional motivation to remove this. This header is
also causing that HAProxy will not compress responses [3] from Forgejo
which is not ideal for Codeberg, this behavior cannot be turned off or
be worked around.

Potential risk, some other CDN or some other Cloudflare option might
still do this removal of whitespace in `<code>` HTML tags, it seems
better to disable the feature than to have Forgejo add a header which is
also causing other side-effects. I'm not aware of this another CDN of
Cloudflare option so I don't want to mark it as breaking.

[1]: https://github.com/go-gitea/gitea/pull/20430
[2]: https://github.com/go-gitea/gitea/pull/20432
[3]: https://docs.haproxy.org/3.3/configuration.html#:~:text=the%20response%20contains%20the%20%22no-transform%22%20value%20in%20the%20%22Cache-control%22%20%20%20%20%20header

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12905
Reviewed-by: Otto <otto@codeberg.org>
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
2026-06-03 05:38:47 +02:00

101 lines
3.3 KiB
Go

// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httpcache
import (
"io"
"net/http"
"strconv"
"strings"
"time"
"forgejo.org/modules/setting"
)
// SetCacheControlInHeader sets suitable cache-control headers in the response
func SetCacheControlInHeader(h http.Header, maxAge time.Duration) {
directives := make([]string, 0, 2)
// "max-age=0 + must-revalidate" (aka "no-cache") is preferred instead of "no-store"
// because browsers may restore some input fields after navigate-back / reload a page.
if setting.IsProd {
if maxAge == 0 {
directives = append(directives, "max-age=0", "private", "must-revalidate")
} else {
directives = append(directives, "private", "max-age="+strconv.Itoa(int(maxAge.Seconds())))
}
} else {
directives = append(directives, "max-age=0", "private", "must-revalidate")
// to remind users they are using non-prod setting.
h.Set("X-Gitea-Debug", "RUN_MODE="+setting.RunMode)
h.Set("X-Forgejo-Debug", "RUN_MODE="+setting.RunMode)
}
h.Set("Cache-Control", strings.Join(directives, ", "))
}
func ServeContentWithCacheControl(w http.ResponseWriter, req *http.Request, name string, modTime time.Time, content io.ReadSeeker) {
SetCacheControlInHeader(w.Header(), setting.StaticCacheTime)
http.ServeContent(w, req, name, modTime, content)
}
// HandleGenericETagCache handles ETag-based caching for a HTTP request.
// It returns true if the request was handled.
func HandleGenericETagCache(req *http.Request, w http.ResponseWriter, etag string) (handled bool) {
if len(etag) > 0 {
w.Header().Set("Etag", etag)
if checkIfNoneMatchIsValid(req, etag) {
w.WriteHeader(http.StatusNotModified)
return true
}
}
SetCacheControlInHeader(w.Header(), setting.StaticCacheTime)
return false
}
// checkIfNoneMatchIsValid tests if the header If-None-Match matches the ETag
func checkIfNoneMatchIsValid(req *http.Request, etag string) bool {
ifNoneMatch := req.Header.Get("If-None-Match")
if len(ifNoneMatch) > 0 {
for item := range strings.SplitSeq(ifNoneMatch, ",") {
item = strings.TrimPrefix(strings.TrimSpace(item), "W/") // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#directives
if item == etag {
return true
}
}
}
return false
}
// HandleGenericETagTimeCache handles ETag-based caching with Last-Modified caching for a HTTP request.
// It returns true if the request was handled.
func HandleGenericETagTimeCache(req *http.Request, w http.ResponseWriter, etag string, lastModified *time.Time) (handled bool) {
if len(etag) > 0 {
w.Header().Set("Etag", etag)
}
if lastModified != nil && !lastModified.IsZero() {
// http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat
w.Header().Set("Last-Modified", lastModified.UTC().Format(http.TimeFormat))
}
if len(etag) > 0 {
if checkIfNoneMatchIsValid(req, etag) {
w.WriteHeader(http.StatusNotModified)
return true
}
}
if lastModified != nil && !lastModified.IsZero() {
ifModifiedSince := req.Header.Get("If-Modified-Since")
if ifModifiedSince != "" {
t, err := time.Parse(http.TimeFormat, ifModifiedSince)
if err == nil && lastModified.Unix() <= t.Unix() {
w.WriteHeader(http.StatusNotModified)
return true
}
}
}
SetCacheControlInHeader(w.Header(), setting.StaticCacheTime)
return false
}