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) { ...@@ -178,38 +178,22 @@ func GetNotice(c *gin.Context) {
common.OptionMapRWMutex.RLock() common.OptionMapRWMutex.RLock()
notice := common.OptionMap["Notice"] notice := common.OptionMap["Notice"]
common.OptionMapRWMutex.RUnlock() common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, gin.H{ serveRevalidatedJSON(c, notice)
"success": true,
"message": "",
"data": notice,
})
} }
func GetAbout(c *gin.Context) { func GetAbout(c *gin.Context) {
common.OptionMapRWMutex.RLock() common.OptionMapRWMutex.RLock()
about := common.OptionMap["About"] about := common.OptionMap["About"]
common.OptionMapRWMutex.RUnlock() common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, gin.H{ serveRevalidatedJSON(c, about)
"success": true,
"message": "",
"data": about,
})
} }
func GetUserAgreement(c *gin.Context) { func GetUserAgreement(c *gin.Context) {
serveRevalidatedJSON(c, gin.H{ serveRevalidatedJSON(c, system_setting.GetLegalSettings().UserAgreement)
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().UserAgreement,
})
} }
func GetPrivacyPolicy(c *gin.Context) { func GetPrivacyPolicy(c *gin.Context) {
serveRevalidatedJSON(c, gin.H{ serveRevalidatedJSON(c, system_setting.GetLegalSettings().PrivacyPolicy)
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().PrivacyPolicy,
})
} }
func GetMidjourney(c *gin.Context) { func GetMidjourney(c *gin.Context) {
...@@ -227,11 +211,7 @@ func GetHomePageContent(c *gin.Context) { ...@@ -227,11 +211,7 @@ func GetHomePageContent(c *gin.Context) {
common.OptionMapRWMutex.RLock() common.OptionMapRWMutex.RLock()
homePageContent := common.OptionMap["HomePageContent"] homePageContent := common.OptionMap["HomePageContent"]
common.OptionMapRWMutex.RUnlock() common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, gin.H{ serveRevalidatedJSON(c, homePageContent)
"success": true,
"message": "",
"data": homePageContent,
})
} }
func SendEmailVerification(c *gin.Context) { func SendEmailVerification(c *gin.Context) {
......
package controller package controller
import ( import (
"crypto/sha256"
"encoding/hex"
"net/http" "net/http"
"strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// serveRevalidatedJSON writes payload as JSON with a weak content-derived ETag // etagVersionPublicContent namespaces the public-content ETag; bump it when
// and answers conditional requests with 304 Not Modified. // the JSON envelope served by serveRevalidatedJSON changes shape.
// const etagVersionPublicContent = "public-content:v1"
// Intended for small, public, admin-editable payloads (notice, home page
// content) that every anonymous visitor fetches on page load. The goal is to type publicContentResponse struct {
// make those fetches cheap without ever serving stale content: Success bool `json:"success"`
// Message string `json:"message"`
// - The ETag is a hash of the response body, so it is identical across Data string `json:"data"`
// 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 // serveRevalidatedJSON writes public content as JSON with a weak
// middleware that runs after this handler returns. The hash is computed over // content-derived ETag and answers conditional requests with 304 Not
// the uncompressed body, so the compressed and identity forms of one payload // Modified. The ETag is a weak validator derived from the content, so it is
// share a validator, and a strong ETag asserts byte-for-byte equality that // stable across replicas and JSON encodings, and a new one is issued when
// does not hold across encodings (RFC 9110 §8.8.1). Weakening it costs // the content changes. Cache-Control: no-cache forces revalidation before
// nothing here: conditional GET compares weakly anyway, and these payloads // reuse, so an admin edit takes effect on the next request; Vary:
// are a few hundred bytes of JSON that no client Range-requests. // Accept-Encoding keeps the gzip and identity encodings apart in shared
// - Cache-Control is "no-cache", which means "may be stored, but must be // caches.
// revalidated before reuse" (RFC 9111 §5.2.2.4). Browsers and CDNs both func serveRevalidatedJSON(c *gin.Context, content string) {
// revalidate on every request, so an admin edit takes effect immediately. body, err := common.Marshal(publicContentResponse{
// max-age/s-maxage are deliberately not set: upstream cannot assume how Success: true,
// long any given deployment tolerates a stale notice. Message: "",
// - Vary: Accept-Encoding is still required. Weakening the validator makes Data: content,
// 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)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
...@@ -46,42 +39,16 @@ func serveRevalidatedJSON(c *gin.Context, payload any) { ...@@ -46,42 +39,16 @@ func serveRevalidatedJSON(c *gin.Context, payload any) {
return return
} }
digest := sha256.Sum256(body) etag := common.ETagFor(etagVersionPublicContent, content)
etag := `W/"` + hex.EncodeToString(digest[:]) + `"`
c.Header("ETag", etag) c.Header("ETag", etag)
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Vary", "Accept-Encoding") 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) c.Status(http.StatusNotModified)
return return
} }
c.Data(http.StatusOK, "application/json; charset=utf-8", body) 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