Add cache control to Files API static assets

Brand assets served at /api/files/v1/static had no cache headers.
Introduce brand.Assets to own the embedded filesystem, content-hash
ETags, and HTTP serving. Responses now carry Cache-Control and ETag
so clients can cache and revalidate; stable email URLs stay
revalidatable (max-age=3600, no immutable).

Replace hardcoded Default*Path constants with StaticPathPrefix,
logical filename constants, and StaticPath(). NewAssets validates
required assets at startup so a rename fails fast instead of 404ing
in sent emails. The files handler keeps routing and 404 rendering;
ServeAssets sets cache headers and serves the file.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-12 12:14:40 +02:00
parent c33540637d
commit dbfd191bc5
8 changed files with 208 additions and 68 deletions

View File

@@ -22,7 +22,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/server/jsonutil"
"go.probo.inc/probo/pkg/server/jsonx"
)
func newCORSMiddleware(logger *log.Logger, cookieBannerSvc *cookiebanner.Service) func(http.Handler) http.Handler {
@@ -37,32 +37,32 @@ func newCORSMiddleware(logger *log.Logger, cookieBannerSvc *cookiebanner.Service
bannerIDStr := chi.URLParam(r, "bannerID")
if bannerIDStr == "" {
jsonutil.RenderForbidden(w)
jsonx.RenderForbidden(w)
return
}
bannerID, err := gid.ParseGID(bannerIDStr)
if err != nil {
jsonutil.RenderForbidden(w)
jsonx.RenderForbidden(w)
return
}
banner, err := cookieBannerSvc.GetActiveCookieBanner(r.Context(), bannerID)
if err != nil {
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
jsonutil.RenderForbidden(w)
jsonx.RenderForbidden(w)
return
}
logger.ErrorCtx(r.Context(), "cannot load cookie banner for CORS check", log.Error(err))
jsonutil.RenderInternalServerError(w)
jsonx.RenderInternalServerError(w)
return
}
canonicalOrigin := cookiebanner.CanonicalizeOrigin(origin)
if banner.Origin != canonicalOrigin {
jsonutil.RenderForbidden(w)
jsonx.RenderForbidden(w)
return
}

View File

@@ -31,7 +31,7 @@ import (
"go.probo.inc/probo/pkg/geoloc"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/server/api/clientip"
"go.probo.inc/probo/pkg/server/jsonutil"
"go.probo.inc/probo/pkg/server/jsonx"
"go.probo.inc/probo/pkg/uri"
)
@@ -69,7 +69,7 @@ func NewMux(
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
if err != nil {
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
return
}
@@ -81,17 +81,17 @@ func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
config, err := h.cookieBannerSvc.GetActiveBannerConfig(r.Context(), bannerID, lang, regulation, sdkVersion)
if err != nil {
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
jsonutil.RenderNotFound(w, fmt.Errorf("banner not found"))
jsonx.RenderNotFound(w, fmt.Errorf("banner not found"))
return
}
if errors.Is(err, cookiebanner.ErrNoPublishedVersion) {
jsonutil.RenderNotFound(w, fmt.Errorf("no published version"))
jsonx.RenderNotFound(w, fmt.Errorf("no published version"))
return
}
h.logger.ErrorCtx(r.Context(), "cannot get banner config", log.Error(err), log.String("sdk_version", sdkVersion))
jsonutil.RenderInternalServerError(w)
jsonx.RenderInternalServerError(w)
return
}
@@ -124,25 +124,25 @@ func (h *Handler) resolveCountryCode(r *http.Request) *coredata.CountryCode {
func (h *Handler) handleGetConsent(w http.ResponseWriter, r *http.Request) {
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
if err != nil {
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
return
}
visitorID := chi.URLParam(r, "visitorID")
if visitorID == "" {
jsonutil.RenderBadRequest(w, fmt.Errorf("missing visitor id"))
jsonx.RenderBadRequest(w, fmt.Errorf("missing visitor id"))
return
}
consent, err := h.cookieBannerSvc.GetVisitorConsent(r.Context(), bannerID, visitorID)
if err != nil {
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
jsonutil.RenderNotFound(w, fmt.Errorf("banner not found"))
jsonx.RenderNotFound(w, fmt.Errorf("banner not found"))
return
}
if errors.Is(err, cookiebanner.ErrConsentNotFound) {
jsonutil.RenderNotFound(w, fmt.Errorf("consent not found"))
jsonx.RenderNotFound(w, fmt.Errorf("consent not found"))
return
}
@@ -152,7 +152,7 @@ func (h *Handler) handleGetConsent(w http.ResponseWriter, r *http.Request) {
log.Error(err),
log.String("sdk_version", sdkVersionFromContext(r.Context())),
)
jsonutil.RenderInternalServerError(w)
jsonx.RenderInternalServerError(w)
return
}
@@ -179,13 +179,13 @@ type (
func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) {
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
if err != nil {
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
return
}
var body postConsentBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid request body"))
jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body"))
return
}
@@ -214,17 +214,17 @@ func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) {
record, err := h.cookieBannerSvc.RecordConsent(r.Context(), bannerID, req)
if err != nil {
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
jsonutil.RenderNotFound(w, fmt.Errorf("banner not found"))
jsonx.RenderNotFound(w, fmt.Errorf("banner not found"))
return
}
if errors.Is(err, cookiebanner.ErrVersionNotFound) || errors.Is(err, cookiebanner.ErrVersionNotPublished) {
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid version"))
jsonx.RenderBadRequest(w, fmt.Errorf("invalid version"))
return
}
h.logger.ErrorCtx(r.Context(), "cannot record consent", log.Error(err), log.String("sdk_version", sdkVersion))
jsonutil.RenderInternalServerError(w)
jsonx.RenderInternalServerError(w)
return
}
@@ -290,23 +290,23 @@ func sanitizeInitiatorURL(raw *string) *string {
func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Request) {
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
if err != nil {
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
return
}
var body reportDetectedCookiesBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid request body"))
jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body"))
return
}
if len(body.Cookies) == 0 {
jsonutil.RenderBadRequest(w, fmt.Errorf("cookies list is empty"))
jsonx.RenderBadRequest(w, fmt.Errorf("cookies list is empty"))
return
}
if len(body.Cookies) > maxDetectedCookiesPerRequest {
jsonutil.RenderBadRequest(w, fmt.Errorf("too many cookies, maximum is %d", maxDetectedCookiesPerRequest))
jsonx.RenderBadRequest(w, fmt.Errorf("too many cookies, maximum is %d", maxDetectedCookiesPerRequest))
return
}
@@ -340,7 +340,7 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req
}
if len(detected) == 0 {
jsonutil.RenderBadRequest(w, fmt.Errorf("no valid cookie names provided"))
jsonx.RenderBadRequest(w, fmt.Errorf("no valid cookie names provided"))
return
}
@@ -350,7 +350,7 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req
if err := h.cookieBannerSvc.ReportDetectedCookies(r.Context(), bannerID, req); err != nil {
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
jsonutil.RenderNotFound(w, fmt.Errorf("banner not found"))
jsonx.RenderNotFound(w, fmt.Errorf("banner not found"))
return
}
@@ -360,7 +360,7 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req
log.Error(err),
log.String("sdk_version", sdkVersionFromContext(r.Context())),
)
jsonutil.RenderInternalServerError(w)
jsonx.RenderInternalServerError(w)
return
}
@@ -392,24 +392,24 @@ const maxDetectedTrackersPerRequest = 100
func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Request) {
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
if err != nil {
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
return
}
var body reportDetectedTrackersBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid request body"))
jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body"))
return
}
total := len(body.Cookies) + len(body.Storage) + len(body.Resources)
if total == 0 {
jsonutil.RenderBadRequest(w, fmt.Errorf("no items provided"))
jsonx.RenderBadRequest(w, fmt.Errorf("no items provided"))
return
}
if total > maxDetectedTrackersPerRequest {
jsonutil.RenderBadRequest(w, fmt.Errorf("too many items, maximum is %d", maxDetectedTrackersPerRequest))
jsonx.RenderBadRequest(w, fmt.Errorf("too many items, maximum is %d", maxDetectedTrackersPerRequest))
return
}
@@ -533,18 +533,18 @@ func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Re
}
if len(req.Cookies)+len(req.Storage)+len(req.Resources) == 0 {
jsonutil.RenderBadRequest(w, fmt.Errorf("no valid items provided"))
jsonx.RenderBadRequest(w, fmt.Errorf("no valid items provided"))
return
}
if err := h.cookieBannerSvc.ReportDetectedTrackers(r.Context(), bannerID, req); err != nil {
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
jsonutil.RenderNotFound(w, fmt.Errorf("banner not found"))
jsonx.RenderNotFound(w, fmt.Errorf("banner not found"))
return
}
h.logger.ErrorCtx(r.Context(), "cannot report detected trackers", log.Error(err), log.String("sdk_version", sdkVersionFromContext(r.Context())))
jsonutil.RenderInternalServerError(w)
jsonx.RenderInternalServerError(w)
return
}

View File

@@ -17,7 +17,6 @@ package files_v1
import (
"errors"
"fmt"
"io/fs"
"net/http"
"time"
@@ -31,7 +30,7 @@ import (
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/jsonutil"
"go.probo.inc/probo/pkg/server/jsonx"
)
const presignedURLExpiry = 1 * time.Hour
@@ -41,6 +40,7 @@ type Handler struct {
fileSvc *filemanager.Service
probo *probo.Service
iamSvc *iam.Service
assets *brand.Assets
}
func NewMux(
@@ -56,6 +56,7 @@ func NewMux(
fileSvc: fileSvc,
probo: proboSvc,
iamSvc: iamSvc,
assets: brand.NewAssets(),
}
r := chi.NewRouter()
@@ -77,12 +78,15 @@ func NewMux(
func (h *Handler) handleGetStaticFile(w http.ResponseWriter, r *http.Request) {
file := chi.URLParam(r, "file")
if _, statErr := fs.Stat(brand.Assets, file); statErr == nil {
http.ServeFileFS(w, r, brand.Assets, file)
if _, statErr := h.assets.Stat(file); statErr != nil {
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
return
}
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
// ServeAssets honors the ETag header we set above for If-None-Match (and
// If-Range), so it emits 304 Not Modified and handles range requests without
// any extra conditional logic here.
h.assets.ServeAssets(w, r, file)
}
func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
@@ -90,14 +94,14 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
fileID, err := gid.ParseGID(fileIDStr)
if err != nil {
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
return
}
file, err := h.fileSvc.GetPublicFile(r.Context(), fileID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
return
}
@@ -107,7 +111,7 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
log.Error(err),
log.String("file_id", fileIDStr),
)
jsonutil.RenderInternalServerError(w)
jsonx.RenderInternalServerError(w)
return
}
@@ -120,7 +124,7 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
log.Error(err),
log.String("file_id", fileIDStr),
)
jsonutil.RenderInternalServerError(w)
jsonx.RenderInternalServerError(w)
return
}
@@ -133,7 +137,7 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) {
fileID, err := gid.ParseGID(fileIDStr)
if err != nil {
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
return
}
@@ -153,19 +157,19 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) {
scope, err := h.iamSvc.Authorizer.Authorize(ctx, params)
if err != nil {
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
return
}
f, err := h.probo.Files.Get(ctx, scope, fileID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
return
}
h.logger.ErrorCtx(ctx, "cannot get file", log.Error(err), log.String("file_id", fileIDStr))
jsonutil.RenderInternalServerError(w)
jsonx.RenderInternalServerError(w)
return
}
@@ -173,7 +177,7 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) {
presignedURL, err := h.fileSvc.GeneratePresignedURL(ctx, f, presignedURLExpiry)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot generate file URL", log.Error(err), log.String("file_id", fileIDStr))
jsonutil.RenderInternalServerError(w)
jsonx.RenderInternalServerError(w)
return
}

View File

@@ -19,10 +19,12 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/securecookie"
)
@@ -67,6 +69,43 @@ func TestHandleGetFile_InvalidGID(t *testing.T) {
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestHandleGetStaticFile(t *testing.T) {
t.Parallel()
mux := NewMux(
log.NewLogger(log.WithOutput(io.Discard)),
nil,
nil,
nil,
securecookie.Config{},
"test-secret",
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/probo.png", nil)
mux.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Header().Get("Cache-Control"), "max-age=3600")
etag := rec.Header().Get("ETag")
require.NotEmpty(t, etag)
require.True(t, strings.HasPrefix(etag, `"`) && strings.HasSuffix(etag, `"`))
recNotModified := httptest.NewRecorder()
reqNotModified := httptest.NewRequest(http.MethodGet, "/static/probo.png", nil)
reqNotModified.Header.Set("If-None-Match", etag)
mux.ServeHTTP(recNotModified, reqNotModified)
require.Equal(t, http.StatusNotModified, recNotModified.Code)
recMissing := httptest.NewRecorder()
reqMissing := httptest.NewRequest(http.MethodGet, "/static/does-not-exist.png", nil)
mux.ServeHTTP(recMissing, reqMissing)
require.Equal(t, http.StatusNotFound, recMissing.Code)
}
func TestHandleGetFile_UnauthenticatedReturns401(t *testing.T) {
t.Parallel()

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package jsonutil
package jsonx
import (
"fmt"