Commit 36dbbf0f by CaIon

fix: keep ETag valid across different JSON packages

parent 9df450fe
package common
import (
"crypto/sha256"
"encoding/hex"
"reflect"
"strings"
)
type digestAnchor struct{}
func modulePath() string {
return reflect.TypeOf(digestAnchor{}).PkgPath()
}
var digestSeed = func() (s [sha256.Size]byte) {
return sha256.Sum256([]byte(modulePath()))
}()
// ETagFor returns a weak ETag derived from the namespace and content.
func ETagFor(namespace, content string) string {
buf := make([]byte, 0, sha256.Size+1+len(namespace)+1+len(content))
buf = append(buf, digestSeed[:]...)
buf = append(buf, 0)
buf = append(buf, namespace...)
buf = append(buf, 0)
buf = append(buf, content...)
digest := sha256.Sum256(buf)
return `W/"` + hex.EncodeToString(digest[:]) + `"`
}
// ETagMatches reports whether an If-None-Match header matches etag under weak
// comparison (RFC 9110 §13.1.2): the W/ prefix is ignored on both sides, and
// "*" matches everything.
func ETagMatches(ifNoneMatch, etag string) bool {
ifNoneMatch = strings.TrimSpace(ifNoneMatch)
if ifNoneMatch == "" {
return false
}
if ifNoneMatch == "*" {
return true
}
etag = strings.TrimPrefix(etag, "W/")
for candidate := range strings.SplitSeq(ifNoneMatch, ",") {
if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == etag {
return true
}
}
return false
}
......@@ -178,38 +178,22 @@ func GetNotice(c *gin.Context) {
common.OptionMapRWMutex.RLock()
notice := common.OptionMap["Notice"]
common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, gin.H{
"success": true,
"message": "",
"data": notice,
})
serveRevalidatedJSON(c, notice)
}
func GetAbout(c *gin.Context) {
common.OptionMapRWMutex.RLock()
about := common.OptionMap["About"]
common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, gin.H{
"success": true,
"message": "",
"data": about,
})
serveRevalidatedJSON(c, about)
}
func GetUserAgreement(c *gin.Context) {
serveRevalidatedJSON(c, gin.H{
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().UserAgreement,
})
serveRevalidatedJSON(c, system_setting.GetLegalSettings().UserAgreement)
}
func GetPrivacyPolicy(c *gin.Context) {
serveRevalidatedJSON(c, gin.H{
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().PrivacyPolicy,
})
serveRevalidatedJSON(c, system_setting.GetLegalSettings().PrivacyPolicy)
}
func GetMidjourney(c *gin.Context) {
......@@ -227,11 +211,7 @@ func GetHomePageContent(c *gin.Context) {
common.OptionMapRWMutex.RLock()
homePageContent := common.OptionMap["HomePageContent"]
common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, gin.H{
"success": true,
"message": "",
"data": homePageContent,
})
serveRevalidatedJSON(c, homePageContent)
}
func SendEmailVerification(c *gin.Context) {
......
package controller
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
// serveRevalidatedJSON writes payload as JSON with a weak content-derived ETag
// and answers conditional requests with 304 Not Modified.
//
// Intended for small, public, admin-editable payloads (notice, home page
// content) that every anonymous visitor fetches on page load. The goal is to
// make those fetches cheap without ever serving stale content:
//
// - The ETag is a hash of the response body, so it is identical across
// replicas. Deriving it from a timestamp would not be, and the Option table
// has no updated_at column to derive one from anyway.
// - The validator is weak (W/ prefixed) because /api is gzip-compressed by
// middleware that runs after this handler returns. The hash is computed over
// the uncompressed body, so the compressed and identity forms of one payload
// share a validator, and a strong ETag asserts byte-for-byte equality that
// does not hold across encodings (RFC 9110 §8.8.1). Weakening it costs
// nothing here: conditional GET compares weakly anyway, and these payloads
// are a few hundred bytes of JSON that no client Range-requests.
// - Cache-Control is "no-cache", which means "may be stored, but must be
// revalidated before reuse" (RFC 9111 §5.2.2.4). Browsers and CDNs both
// revalidate on every request, so an admin edit takes effect immediately.
// max-age/s-maxage are deliberately not set: upstream cannot assume how
// long any given deployment tolerates a stale notice.
// - Vary: Accept-Encoding is still required. Weakening the validator makes
// revalidation correct, but it does not separate the two encodings in a
// shared cache. Without Vary, a cache holding the gzip copy would hand those
// bytes to a client that never sent Accept-Encoding: gzip.
func serveRevalidatedJSON(c *gin.Context, payload any) {
body, err := common.Marshal(payload)
// etagVersionPublicContent namespaces the public-content ETag; bump it when
// the JSON envelope served by serveRevalidatedJSON changes shape.
const etagVersionPublicContent = "public-content:v1"
type publicContentResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data string `json:"data"`
}
// serveRevalidatedJSON writes public content as JSON with a weak
// content-derived ETag and answers conditional requests with 304 Not
// Modified. The ETag is a weak validator derived from the content, so it is
// stable across replicas and JSON encodings, and a new one is issued when
// the content changes. Cache-Control: no-cache forces revalidation before
// reuse, so an admin edit takes effect on the next request; Vary:
// Accept-Encoding keeps the gzip and identity encodings apart in shared
// caches.
func serveRevalidatedJSON(c *gin.Context, content string) {
body, err := common.Marshal(publicContentResponse{
Success: true,
Message: "",
Data: content,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
......@@ -46,42 +39,16 @@ func serveRevalidatedJSON(c *gin.Context, payload any) {
return
}
digest := sha256.Sum256(body)
etag := `W/"` + hex.EncodeToString(digest[:]) + `"`
etag := common.ETagFor(etagVersionPublicContent, content)
c.Header("ETag", etag)
c.Header("Cache-Control", "no-cache")
c.Header("Vary", "Accept-Encoding")
if etagMatches(c.GetHeader("If-None-Match"), etag) {
if common.ETagMatches(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
// etagMatches reports whether an If-None-Match header field matches etag,
// using the weak comparison required for conditional GET (RFC 9110 §13.1.2).
// The field is a comma-separated list of entity-tags or the wildcard "*".
//
// Weak comparison ignores the W/ prefix on both operands, so it must be
// stripped from the served etag as well as from each candidate. Stripping only
// the candidate would make a weak served validator match nothing, silently
// disabling 304 responses.
func etagMatches(ifNoneMatch string, etag string) bool {
ifNoneMatch = strings.TrimSpace(ifNoneMatch)
if ifNoneMatch == "" {
return false
}
if ifNoneMatch == "*" {
return true
}
etag = strings.TrimPrefix(etag, "W/")
for _, candidate := range strings.Split(ifNoneMatch, ",") {
if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == etag {
return true
}
}
return false
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment